Олег Марков
Слой app app layer в архитектуре приложений
Введение
Слой app (application layer, app-layer) — это уровень приложения, который отвечает за координацию бизнес-логики, работу сценариев (use cases) и взаимодействие между доменным слоем и внешними интерфейсами (HTTP API, очереди, CLI, gRPC и так далее).
Если говорить проще, домен отвечает на вопрос «что делать», а слой app — «когда, в каком порядке и с чем именно это делать». Здесь вы описываете прикладные сценарии: создать заказ, изменить профиль пользователя, сформировать отчет, обработать событие и так далее.
В этой статье я покажу вам:
- какую роль выполняет app-layer и зачем он нужен;
- как отделить его от домена и инфраструктуры;
- какие структуры, интерфейсы и паттерны чаще всего используются;
- как реализовать слой app на примерах (на Go, но принципы одинаковы почти для любого языка);
- какие типичные ошибки возникают и как их избежать.
Роль слоя app в архитектуре
Где находится app-layer в многослойной архитектуре
Чаще всего архитектура выглядит так:
- UI / интерфейсы:
- HTTP API
- gRPC
- CLI
- воркеры очередей
- App layer:
- use cases
- application services
- фасады для внешнего мира
- Domain layer:
- сущности (entities)
- агрегаты
- доменные сервисы
- value-объекты
- Infrastructure:
- базы данных
- клиенты внешних сервисов
- логирование
- реализация репозиториев и адаптеров
Смотрите, здесь важно разделить ответственности:
- домен определяет правила и инварианты бизнеса;
- app-layer оркестрирует выполнение доменных операций, дергает нужные сервисы, репозитории, интеграции;
- инфраструктура только предоставляет технические средства для реализации.
Задачи app-layer
Основные задачи, которые выносите в слой app:
Реализация прикладных сценариев (use cases)
Примеры:- зарегистрировать пользователя;
- создать заказ;
- отменить заказ;
- отправить письмо с подтверждением;
- обработать входящее событие из очереди.
Координация доменных операций
App-layer решает:- в каком порядке вызывать методы доменных сервисов;
- какие зависимости нужны (репозитории, внешние API);
- какие транзакции использовать.
Управление транзакционными границами
Обычно именно здесь вы:- начинаете и завершаете транзакции;
- решаете, когда делать commit и rollback;
- объединяете несколько доменных вызовов в одну логическую операцию.
Преобразование данных между слоями
App-layer часто:- принимает DTO / команды от интерфейсов;
- преобразует их в доменные объекты или параметры доменных методов;
- получает доменные сущности и маппит их в DTO для ответа.
Организация взаимодействия с внешними системами
Например:- внешний сервис оплаты;
- сервис уведомлений;
- сторонние API.
Слой app решает, когда именно вызывать эти интеграции в рамках сценария.
Организация кросс-сценарной логики
Например:- аудит действий пользователя;
- метрики;
- ретраи при временных ошибках;
- компенсационные операции.
Чем app-layer отличается от domain и инфраструктуры
Разделение ответственности
Давайте разберемся на простом примере: сценарий «оформление заказа».
Domain:
- сущность Order знает свои инварианты: нельзя заказать отрицательное количество товаров, нельзя подтвердить заказ без оплаты;
- доменный сервис, например OrderService, реализует методы вроде CalculateTotal, ValidateOrder и тому подобное.
App-layer:
- сценарий PlaceOrder:
- валидирует входящие данные на уровне приложения;
- вызывает доменный сервис для создания заказа;
- сохраняет заказ через репозиторий;
- вызывает внешний платежный сервис;
- при успехе обновляет статус заказа;
- публикует событие «Заказ создан» в очередь.
- сценарий PlaceOrder:
Инфраструктура:
- реализация репозитория OrderRepository с использованием конкретной БД;
- клиент платежного провайдера;
- клиент брокера сообщений.
Ключевая идея: домен не знает, что такое HTTP, транзакции и внешние API. App-layer знает, но старается не привязываться к конкретной реализации (работает через интерфейсы).
Что не должно попадать в app-layer
Чтобы слой app не превратился в «свалку», полезно помнить, что там не должно быть:
- сложной бизнес-логики и инвариантов;
- SQL-запросов;
- конкретных HTTP-клиентов и SDK (они живут в инфраструктуре);
- UI-логики (формирование HTML, работа с шаблонами, конкретные фреймворки).
В app-layer можно держать базовую валидацию входных данных на уровне сценария (например, проверка обязательных полей или формата), но все, что связано с бизнес-правилами, лучше отправлять в домен.
Структура app-layer в проекте
Пример структуры каталогов
Покажу вам один из часто используемых подходов (на примере Go):
- internal
- app
- orders
- commands.go
- queries.go
- service.go
- handler_http.go (опционально если вы делаете адаптеры рядом)
- users
- commands.go
- queries.go
- service.go
- orders
- domain
- orders
- order.go
- repository.go (интерфейсы)
- users
- user.go
- orders
- infra
- db
- ordersrepopg.go
- usersrepopg.go
- http
- email_client.go
- payment_client.go
- db
- app
Слой app здесь — это папка app и ее подпапки (orders, users и так далее).
Обычно внутри каждой подсистемы в app-layer вы выделяете:
- команды и запросы (commands / queries);
- application services или handlers, которые эти команды и запросы обрабатывают;
- интерфейсы репозиториев, клиентов и других зависимостей, с которыми он будет работать (часто они определяются в домене, но иногда часть интерфейсов выносится в app, если они специфичны именно для сценариев).
Команды и запросы в app-layer
Многим разработчикам удобно описывать сценарии через паттерн Command / Query (часто в сочетании с CQRS).
Команды (Commands)
Команда — это объект, описывающий намерение изменить состояние системы.
Примеры команд:
- RegisterUserCommand
- PlaceOrderCommand
- CancelOrderCommand
- ChangeUserEmailCommand
Каждая команда:
- содержит входные данные;
- не возвращает большие модели (чаще всего только идентификатор, статус, ошибку);
обрабатывается отдельным handler’ом, который:
- получает команду;
- выполняет шаги сценария;
- вызывает домен;
- сохраняет изменения.
Посмотрим, как это может выглядеть на практике.
Пример команды в app-layer
// RegisterUserCommand описывает данные для регистрации пользователя
type RegisterUserCommand struct {
Email string // email будущего пользователя
Password string // сырой пароль (храним только в момент регистрации)
Name string // имя пользователя
}
// RegisterUserHandler обрабатывает RegisterUserCommand
type RegisterUserHandler struct {
usersRepo UsersRepository // интерфейс репозитория пользователей
passwordHasher PasswordHasher // интерфейс хеширования пароля
}
// Handle выполняет сценарий регистрации пользователя
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
// 1. Простая валидация на уровне приложения
if cmd.Email == "" || cmd.Password == "" {
return "", ErrInvalidInput // ошибка уровня приложения
}
// 2. Проверяем, что пользователь еще не существует
_, err := h.usersRepo.FindByEmail(ctx, cmd.Email)
if err == nil {
return "", ErrUserAlreadyExists
}
if !errors.Is(err, ErrUserNotFound) {
// Любая другая ошибка - техническая ошибка
return "", err
}
// 3. Хешируем пароль
hash, err := h.passwordHasher.Hash(cmd.Password)
if err != nil {
return "", err
}
// 4. Создаем доменную сущность User
user, err := domain.NewUser(cmd.Email, hash, cmd.Name)
if err != nil {
// домен может вернуть бизнес-ошибку (например, неверный email)
return "", err
}
// 5. Сохраняем пользователя в репозиторий
if err := h.usersRepo.Save(ctx, user); err != nil {
return "", err
}
// 6. Возвращаем идентификатор созданного пользователя
return user.ID().String(), nil
}
Обратите внимание, как разделены обязанности:
- app-layer:
- координирует шаги;
- решает порядок действий;
- знает про репозиторий и хеширование;
- domain:
- через конструктор NewUser проверяет инварианты сущности.
Запросы (Queries)
Запросы — это операции чтения, которые не изменяют состояние системы.
Примеры запросов:
- GetUserProfileQuery
- ListUserOrdersQuery
- GetOrderDetailsQuery
Особенности запросов в app-layer:
- они могут использовать оптимизированные SQL-запросы;
- часто возвращают DTO, а не доменные сущности (например, «плоские» структуры для UI);
- могут обходить часть доменного слоя, если нужно просто отрисовать данные (особенно в CQRS-подходах).
Пример запроса в app-layer
// GetUserProfileQuery описывает вход для получения профиля пользователя
type GetUserProfileQuery struct {
UserID string // идентификатор пользователя
}
// UserProfileDTO - данные, которые нужны внешнему миру
type UserProfileDTO struct {
ID string
Email string
Name string
}
// GetUserProfileHandler обрабатывает запрос профиля пользователя
type GetUserProfileHandler struct {
usersReadRepo UsersReadRepository // специализированный репозиторий для чтения
}
// Handle получает профиль пользователя по его идентификатору
func (h *GetUserProfileHandler) Handle(ctx context.Context, q GetUserProfileQuery) (*UserProfileDTO, error) {
// 1. Проверяем, что идентификатор не пустой
if q.UserID == "" {
return nil, ErrInvalidInput
}
// 2. Запрашиваем данные в read-репозитории
user, err := h.usersReadRepo.GetByID(ctx, q.UserID)
if err != nil {
return nil, err
}
// 3. Маппим данные в DTO
dto := &UserProfileDTO{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}
return dto, nil
}
Здесь логика простая, но вы видите философию: app-layer получает запрос, дергает нужный репозиторий, формирует DTO.
Application services и фасады
Вместо явных команд и запросов иногда используют «application service» — сервис уровня приложения, в котором методы прямо соответствуют сценариям.
Пример application service
// OrdersService - сервис уровня приложения для работы с заказами
type OrdersService struct {
ordersRepo OrdersRepository // интерфейс репозитория заказов
payment PaymentGateway // интерфейс платежного сервиса
eventBus EventBus // интерфейс для публикации событий
txManager TxManager // абстракция над транзакциями
}
// PlaceOrderInput - входные данные для сценария оформления заказа
type PlaceOrderInput struct {
UserID string
Items []OrderItemInput
}
// PlaceOrder создает новый заказ и запускает процесс оплаты
func (s *OrdersService) PlaceOrder(ctx context.Context, in PlaceOrderInput) (string, error) {
// Запускаем транзакцию
return s.txManager.WithinTransaction(ctx, func(txCtx context.Context) (string, error) {
// 1. Создаем доменный агрегат заказа
order, err := domain.NewOrder(in.UserID, mapItems(in.Items))
if err != nil {
// домен сообщает о нарушении бизнес-правил
return "", err
}
// 2. Сохраняем заказ в репозиторий
if err := s.ordersRepo.Save(txCtx, order); err != nil {
return "", err
}
// 3. Вызываем платежный сервис
if err := s.payment.Charge(txCtx, order.ID(), order.TotalAmount()); err != nil {
return "", err
}
// 4. Обновляем статус заказа после успешной оплаты
if err := order.MarkPaid(); err != nil {
return "", err
}
if err := s.ordersRepo.Save(txCtx, order); err != nil {
return "", err
}
// 5. Публикуем событие "OrderPlaced"
if err := s.eventBus.Publish(txCtx, NewOrderPlacedEvent(order)); err != nil {
return "", err
}
// 6. Возвращаем идентификатор заказа
return order.ID().String(), nil
})
}
Здесь вы видите сразу несколько важных аспектов app-layer:
- управление транзакцией через TxManager;
- взаимодействие с платежным сервисом;
- публикация событий;
- координация нескольких доменных вызовов.
Все это типичные «обязанности» application service.
Управление транзакциями в app-layer
Почему транзакции не в домене
Доменные сущности не должны знать:
- как именно вы храните данные;
- что такое транзакция и какие у нее границы.
Эта информация относится к техническим деталям реализации и чаще всего находится в app-layer (инициирование транзакции) и в инфраструктуре (конкретная реализация).
Подход с TxManager
Смотрите, я покажу вам распространенный подход: вы описываете интерфейс TxManager в app-layer, а конкретную реализацию делаете в инфраструктуре.
// TxManager определяет абстракцию для выполнения кода в транзакции
type TxManager interface {
WithinTransaction(
ctx context.Context,
fn func(ctx context.Context) (string, error),
) (string, error)
}
Реализация в infra-слое может использовать, к примеру, *sql.Tx. App-layer не знает, как именно это сделано, но может уверенно работать с транзакцией как с «черным ящиком».
Пример выше в OrdersService уже демонстрировал этот подход.
Взаимодействие app-layer с UI и адаптерами
Контроллеры как клиенты app-layer
Как правило, HTTP-обработчики, gRPC-сервисы или фоновые воркеры находятся либо в отдельном слое, либо в инфраструктуре. Они используют app-layer как API приложения.
Давайте посмотрим, как это выглядит на примере HTTP-хендлера.
// OrdersHTTPHandler - HTTP адаптер, который использует OrdersService
type OrdersHTTPHandler struct {
ordersService *OrdersService // сервис уровня приложения
}
// HandlePlaceOrder - HTTP endpoint POST /orders
func (h *OrdersHTTPHandler) HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var input PlaceOrderInput
// 1. Десериализуем JSON в структуру input
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// 2. Вызываем app-layer
orderID, err := h.ordersService.PlaceOrder(ctx, input)
if err != nil {
// 3. Маппим ошибки приложения в HTTP-коды
if errors.Is(err, domain.ErrInvalidOrder) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// 4. Формируем HTTP-ответ
resp := map[string]string{"order_id": orderID}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Здесь HTTP-хендлер:
- занимается протоколом (HTTP, JSON, коды ответов);
- использует app-layer как «бизнес API».
App-layer при этом ничего не знает о HTTP, что делает его переиспользуемым, например, для gRPC или CLI.
Ошибки и обработка ошибок в app-layer
Типы ошибок
На уровне app-layer обычно встречаются несколько типов ошибок:
Ошибки валидации входных данных
Например, обязательное поле не передано, некорректный формат.Бизнес-ошибки (из домена)
Нарушения бизнес-правил:- недостаточно средств;
- товар недоступен;
- нельзя отменить оплаченный заказ.
Технические ошибки
Проблемы инфраструктуры:- БД недоступна;
- таймаут внешнего API.
App-layer:
- различает эти типы;
- маппит их на понятные для адаптеров коды и сообщения.
Пример структуры ошибок
// AppError - тип ошибки уровня приложения с кодом
type AppError struct {
Code string // машинно-читаемый код
Message string // человекочитаемое сообщение
Err error // исходная ошибка
}
// Error реализует интерфейс error
func (e *AppError) Error() string {
return e.Message
}
// WrapValidationError оборачивает ошибку валидации
func WrapValidationError(err error) *AppError {
return &AppError{
Code: "validation_error",
Message: err.Error(),
Err: err,
}
}
В UI-слое вы потом можете по Code понять, какой HTTP-статус вернуть.
События и асинхронные сценарии в app-layer
Доменные события и события приложения
Иногда вы разделяете:
- доменные события:
- OrderPaid
- UserRegistered
- события приложения:
- EmailConfirmationRequested
- LoyaltyPointsAccrued
App-layer может:
- слушать доменные события (которые поднялись из домена);
- реагировать на них, вызывая внешние сервисы;
- публиковать события приложения во внешние очереди.
Пример обработки события
Представим, что домен генерирует событие UserRegisteredDomainEvent, а app-layer должен отправить приветственное письмо.
// WelcomeEmailHandler - обработчик доменного события на уровне приложения
type WelcomeEmailHandler struct {
emailSender EmailSender // интерфейс отправки email
}
// Handle обрабатывает доменное событие регистрации пользователя
func (h *WelcomeEmailHandler) Handle(ctx context.Context, e domain.UserRegisteredDomainEvent) error {
// Формируем текст письма
subject := "Welcome to our service"
body := "Hello " + e.UserName + ", thanks for registering"
// Отправляем письмо
if err := h.emailSender.Send(ctx, e.Email, subject, body); err != nil {
return err
}
return nil
}
Как видите, этот код выполняет асинхронный сценарий, который логически относится к приложению, но при этом использует доменное событие, чтобы понять, когда его запускать.
Границы ответственности app-layer по данным
DTO и маппинг
В app-layer часто создаются отдельные структуры для:
- команд и запросов (входящие данные);
- DTO для выдачи данных наружу.
Почему это делается:
- форма данных в UI и домене редко совпадает;
- вы можете скрывать часть информации из домена (например, технические поля);
- легче эволюционировать API (менять DTO) без ломки домена.
Пример маппинга
// OrderDTO - представление заказа для внешнего мира
type OrderDTO struct {
ID string
Status string
Total float64
}
// MapOrderToDTO - функция маппинга доменной сущности в DTO
func MapOrderToDTO(o *domain.Order) OrderDTO {
return OrderDTO{
ID: o.ID().String(), // берем идентификатор из доменной сущности
Status: o.Status().String(),// статус может быть типом enum в домене
Total: o.TotalAmount(), // домен считает сумму
}
}
Такие функции удобно держать именно в app-layer, так как они часто завязаны на нужды внешних клиентов (UI, API).
Типичные ошибки при проектировании app-layer
Смешивание домена и инфраструктуры
Распространенная проблема: в application service добавляются:
- SQL-запросы;
- специфичные структуры ORM;
- код HTTP-вызовов.
Это приводит к сильной связанности и сложной поддержке. Лучше:
- вынести конкретные реализации в infra;
- использовать интерфейсы в app-layer.
Затащили всю бизнес-логику в app-layer
Иногда разработчики:
- пишут большинство проверок и правил прямо в командах и сервисах;
- держат домен «тонким» и слабо используемым.
В результате:
- домен трудно переиспользовать;
- тестировать логику сложнее (много зависимостей от инфраструктуры).
Лучше:
- формулировать бизнес-правила в домене (конструкторы, методы сущностей, доменные сервисы);
- в app-layer оставлять только композицию этих правил в сценарии.
Отсутствие четких сценариев
Иногда в app-layer появляется «general purpose» сервис с методами вроде DoSomething, Process, Handle и так далее. Через время:
- становится непонятно, какие сценарии у приложения;
- сложно отследить границы транзакций;
- тесты с трудом покрывают поведение.
Рекомендуется:
- давать методам и командам имена, отражающие сценарий (CreateOrder, ConfirmPayment, ChangeEmail);
- не бояться создавать отдельные handler’ы под каждый кейс.
Тестирование слоя app
Юнит-тесты с моками
Так как app-layer часто зависит от интерфейсов (репозиториев, клиентов, шины событий), его удобно тестировать с моками. Вы можете:
- подменить репозитории на in-memory реализации;
- эмулировать ответы внешних сервисов;
- проверять, что application service вызывает нужные методы в правильном порядке.
Пример эскиза теста (без фреймворка):
// mockUsersRepo - простая мок-реализация репозитория
type mockUsersRepo struct {
saved []*domain.User // сюда будем складывать сохраненных пользователей
}
func (m *mockUsersRepo) Save(ctx context.Context, u *domain.User) error {
m.saved = append(m.saved, u)
return nil
}
func (m *mockUsersRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, ErrUserNotFound
}
// В тесте вы создаете RegisterUserHandler с этим мок-репозиторием
// и проверяете, что при вызове Handle создается пользователь
// и вызывается метод Save без ошибок.
Интеграционные тесты сценариев
Отдельно полезно писать тесты, которые:
- поднимают часть инфраструктуры (например, тестовую БД);
- вызывают application service или handler через реальный репозиторий;
- проверяют, что сценарий отрабатывает целиком.
Это особенно важно для сложных сценариев с несколькими шагами и транзакциями.
Заключение
Слой app (app-layer) — это центр координации поведения приложения. Он:
- реализует прикладные сценарии и use cases;
- оркестрирует вызовы доменного слоя и инфраструктуры;
- управляет транзакциями;
- преобразует данные между доменом и внешними интерфейсами;
- обрабатывает доменные события и реализует асинхронные процессы.
Если вы будете четко разделять:
- домен — про правила и инварианты;
- app-layer — про сценарии и координацию;
- инфраструктуру — про технические детали,
то ваше приложение станет:
- легче для тестирования;
- устойчивее к изменениям инфраструктуры и UI;
- проще в сопровождении и развитии.
Частозадаваемые технические вопросы по теме
Как правильно передавать контекст (context) между слоями
Передавайте context из внешнего слоя вниз во все вызовы:
- контроллер получает контекст из HTTP запроса;
- передает его в app-layer (в handler или service);
- тот в свою очередь передает контекст дальше в домен и инфраструктуру.
Не создавайте новый context в app-layer без крайней необходимости, иначе потеряете таймауты и отмену запросов.
Где лучше размещать интерфейсы репозиториев — в домене или в app-layer
Если репозиторий работает с доменными сущностями и отражает бизнес-понятия (OrderRepository, UserRepository) — интерфейс лучше размещать в домене.
Если интерфейс описывает чисто прикладной кейс чтения, оптимизированный под UI (например, OrdersReadRepository с джойнами и специфичными полями) — его можно определить в app-layer.
Можно ли вызывать внешний HTTP сервис напрямую из домена
Нет, лучше избегать этого. Домен не должен знать о протоколах и внешних интеграциях.
Создайте интерфейс клиента во внешнем слое (чаще в app-layer или отдельном «портовом» модуле), а реализацию клиента — в инфраструктуре. Домен может, при необходимости, работать с абстрактным интерфейсом, не зная, что за ним HTTP.
Как быть с валидацией — что валидировать в app, а что в домене
В app-layer удобно делать:
- синтаксическую валидацию (непустые поля, формат e-mail, длину строки);
- проверку, что запрос корректен с точки зрения протокола.
В домене держите:
- бизнес-валидацию (можно ли создать такой объект, допустимы ли такие значения);
- инварианты сущностей и агрегатов.
Часто данные проходят обе ступени валидации — это нормально.
Надо ли выделять отдельный слой для хендлеров команд и запросов или можно писать все в одном сервисе
Оба подхода рабочие. Если сценариев немного, проще иметь один application service с понятными методами.
Если сценариев много, они сложные и вы используете CQRS, удобнее заводить отдельные типы Command/Query и соответствующие handler’ы.
Ориентируйтесь на читаемость кода и прозрачность сценариев — как только один сервис разрастается, разбивайте его на отдельные обработчики.