Слой app app layer в архитектуре приложений

05 января 2026
Автор

Олег Марков

Введение

Слой 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:

  1. Реализация прикладных сценариев (use cases)
    Примеры:

    • зарегистрировать пользователя;
    • создать заказ;
    • отменить заказ;
    • отправить письмо с подтверждением;
    • обработать входящее событие из очереди.
  2. Координация доменных операций
    App-layer решает:

    • в каком порядке вызывать методы доменных сервисов;
    • какие зависимости нужны (репозитории, внешние API);
    • какие транзакции использовать.
  3. Управление транзакционными границами
    Обычно именно здесь вы:

    • начинаете и завершаете транзакции;
    • решаете, когда делать commit и rollback;
    • объединяете несколько доменных вызовов в одну логическую операцию.
  4. Преобразование данных между слоями
    App-layer часто:

    • принимает DTO / команды от интерфейсов;
    • преобразует их в доменные объекты или параметры доменных методов;
    • получает доменные сущности и маппит их в DTO для ответа.
  5. Организация взаимодействия с внешними системами
    Например:

    • внешний сервис оплаты;
    • сервис уведомлений;
    • сторонние API.
      Слой app решает, когда именно вызывать эти интеграции в рамках сценария.
  6. Организация кросс-сценарной логики
    Например:

    • аудит действий пользователя;
    • метрики;
    • ретраи при временных ошибках;
    • компенсационные операции.

Чем app-layer отличается от domain и инфраструктуры

Разделение ответственности

Давайте разберемся на простом примере: сценарий «оформление заказа».

  • Domain:

    • сущность Order знает свои инварианты: нельзя заказать отрицательное количество товаров, нельзя подтвердить заказ без оплаты;
    • доменный сервис, например OrderService, реализует методы вроде CalculateTotal, ValidateOrder и тому подобное.
  • App-layer:

    • сценарий 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
    • domain
      • orders
        • order.go
        • repository.go (интерфейсы)
      • users
        • user.go
    • infra
      • db
        • ordersrepopg.go
        • usersrepopg.go
      • http
        • email_client.go
        • payment_client.go

Слой 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 обычно встречаются несколько типов ошибок:

  1. Ошибки валидации входных данных
    Например, обязательное поле не передано, некорректный формат.

  2. Бизнес-ошибки (из домена)
    Нарушения бизнес-правил:

    • недостаточно средств;
    • товар недоступен;
    • нельзя отменить оплаченный заказ.
  3. Технические ошибки
    Проблемы инфраструктуры:

    • БД недоступна;
    • таймаут внешнего 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’ы.
Ориентируйтесь на читаемость кода и прозрачность сценариев — как только один сервис разрастается, разбивайте его на отдельные обработчики.

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий