Onion Architecture

Суть

Hexagonal говорит "используй порты и адаптеры". Onion идёт дальше: внутри домена тоже есть слои. Кольца, как у луковицы. Зависимости направлены строго внутрь. Автор — Jeffrey Palermo, 2008.

text
┌────────────────────────────────────┐ │ Infrastructure │ │ (postgres, http, redis, kafka) │ │ ┌──────────────────────────────┐ │ │ │ Application Services │ │ │ │ (use cases, оркестрация) │ │ │ │ ┌────────────────────────┐ │ │ │ │ │ Domain Services │ │ │ │ │ │ (логика между entity) │ │ │ │ │ │ ┌──────────────────┐ │ │ │ │ │ │ │ Domain Model │ │ │ │ │ │ │ │ (entity, VO) │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ └────────────────────────┘ │ │ │ └──────────────────────────────┘ │ └────────────────────────────────────┘

Domain Model в центре не импортирует ничего. Application — только Domain. Infrastructure — может импортировать всё внутри, но никто не импортирует Infrastructure.

Зачем разбивать домен на слои

В Hexagonal "ядро" — единый кусок. Доменные правила, оркестрация сценариев, сериализация в репозиторий — всё перемешано в OrderService. Через год сервис на 2000 строк, и непонятно, где правило бизнеса, а где техническая склейка.

Onion разделяет:

  • Что система знает — Domain Model (entity, value objects, инварианты)
  • Что система умеет с собственным состоянием — Domain Services (логика, не помещающаяся в одну entity)
  • Какие сценарии система выполняет — Application Services (use case)
  • Как это делается технически — Infrastructure (БД, HTTP, очереди)

Каждый слой имеет свою ответственность и тестируется отдельно.

Четыре слоя

1. Domain Model (центр)

Что: entities, value objects, доменные ошибки, инварианты.

Что НЕ импортирует: ничего внешнего. Ни ORM, ни json, ни logger.

go
// domain/order.go package domain import "errors" type OrderID string type OrderStatus int const ( StatusDraft OrderStatus = iota StatusPaid StatusCancelled ) type Order struct { id OrderID items []Item status OrderStatus total Money } func NewOrder(id OrderID, items []Item) (*Order, error) { if len(items) == 0 { return nil, errors.New("order must have items") } return &Order{id: id, items: items, status: StatusDraft, total: calcTotal(items)}, nil } // Cancel — инвариант: нельзя отменить оплаченный заказ func (o *Order) Cancel() error { if o.status == StatusPaid { return errors.New("paid order cannot be cancelled") } o.status = StatusCancelled return nil } func (o *Order) Pay() error { if o.status != StatusDraft { return errors.New("only draft orders can be paid") } o.status = StatusPaid return nil }

Инкапсуляция: поля приватные, изменения через методы, внутри — проверка инвариантов. Это богатая модель (rich domain model), а не DTO.

Value Object:

go
// domain/money.go type Money struct { amount int64 // в копейках currency string } func NewMoney(amount int64, currency string) (Money, error) { if amount < 0 { return Money{}, errors.New("negative amount") } return Money{amount: amount, currency: currency}, nil } func (m Money) Add(other Money) (Money, error) { if m.currency != other.currency { return Money{}, errors.New("currency mismatch") } return Money{amount: m.amount + other.amount, currency: m.currency}, nil }

Value object иммутабельный, сравнивается по значению, проверяет свои инварианты.

2. Domain Services

Что: логика, которая работает с несколькими entity или не вписывается в одну. Чисто доменная, без оркестрации.

Когда нужен: перевод денег между двумя счетами (не помещается ни в Account.Send, ни в Account.Receive — обе entity участвуют).

go
// domain/transfer_service.go package domain type TransferService struct{} // TransferMoney — чистая доменная логика, без БД и транзакций func (s *TransferService) Transfer(from, to *Account, amount Money) error { if err := from.Withdraw(amount); err != nil { return err } if err := to.Deposit(amount); err != nil { // компенсация — снова положить _ = from.Deposit(amount) return err } return nil }

TransferService ничего не знает про БД и транзакции. Только бизнес-правило: "снять с одного, положить на другой".

3. Application Services (Use Cases)

Что: оркестрация сценария use case. Тянет данные из репо, дёргает домен, сохраняет, шлёт события.

Когда нужен: "перевести деньги по REST-запросу" — это не доменная операция, это сценарий: загрузить два счёта, дёрнуть TransferService, сохранить оба, опубликовать событие.

go
// application/transfer_money.go package application import "myapp/domain" type AccountRepo interface { FindByID(ctx context.Context, id domain.AccountID) (*domain.Account, error) Save(ctx context.Context, a *domain.Account) error } type EventBus interface { Publish(ctx context.Context, event any) error } type TransferMoneyHandler struct { repo AccountRepo bus EventBus transfer *domain.TransferService tx UnitOfWork } func (h *TransferMoneyHandler) Execute(ctx context.Context, fromID, toID domain.AccountID, amount domain.Money) error { return h.tx.Run(ctx, func(ctx context.Context) error { from, err := h.repo.FindByID(ctx, fromID) if err != nil { return err } to, err := h.repo.FindByID(ctx, toID) if err != nil { return err } if err := h.transfer.Transfer(from, to, amount); err != nil { return err } if err := h.repo.Save(ctx, from); err != nil { return err } if err := h.repo.Save(ctx, to); err != nil { return err } return h.bus.Publish(ctx, MoneyTransferred{From: fromID, To: toID, Amount: amount}) }) }

Application Service:

  • Знает порядок шагов
  • Управляет транзакцией
  • Дёргает Domain
  • Не содержит бизнес-правил (правила — в Account.Withdraw и TransferService.Transfer)

4. Infrastructure

Что: реализации портов. Postgres, http-handler, kafka-producer, redis-cache.

go
// infrastructure/postgres/account_repo.go package postgres type AccountRepo struct{ db *pgxpool.Pool } func (r *AccountRepo) FindByID(ctx context.Context, id domain.AccountID) (*domain.Account, error) { row := r.db.QueryRow(ctx, "SELECT id, balance FROM accounts WHERE id = $1", id) var dto accountRow if err := row.Scan(&dto.ID, &dto.Balance); err != nil { return nil, err } return mapToDomain(dto), nil // маппинг row → entity на границе }
go
// infrastructure/http/transfer_handler.go package http type TransferHandler struct { handler *application.TransferMoneyHandler } func (h *TransferHandler) Handle(c echo.Context) error { var req TransferRequest if err := c.Bind(&req); err != nil { return c.JSON(400, err) } if err := h.handler.Execute(c.Request().Context(), req.From, req.To, req.Amount); err != nil { return c.JSON(500, err) } return c.NoContent(204) }

Domain Service vs Application Service

Самая частая путаница. Различие критичное.

Domain ServiceApplication Service
Зачембизнес-правило между entityоркестрация шагов use case
Знает протолько domainrepo, event bus, tx
Транзакциинетда
Внешние вызовынетда (HTTP, БД)
ИменаTransferService.TransferTransferMoneyHandler.Execute
Тестюнит, чистый Goюнит с моками портов

Правило: если убрать БД, очереди, HTTP — Domain Service остаётся. Application Service — пропадает.

Что уходит в Domain:

  • "Заказ нельзя отменить после оплаты" → метод Order.Cancel()
  • "Скидка считается так-то для VIP-клиента" → DiscountPolicy.Calculate()
  • "Перевести деньги между счетами" → TransferService.Transfer()

Что уходит в Application:

  • "При отмене заказа: снять hold с карты, отправить email" → CancelOrderHandler
  • "Загрузить заказ из БД, применить скидку, сохранить" → ApplyDiscountHandler
  • "По REST-запросу: создать заказ, опубликовать event" → CreateOrderHandler

Domain Events

Механизм связи между агрегатами без прямой ссылки. Когда что-то значимое произошло в домене — публикуется событие. Подписчики реагируют.

go
// domain/events.go package domain type OrderPaid struct { OrderID OrderID Amount Money PaidAt time.Time }

Entity накапливает события:

go
type Order struct { // ... events []any } func (o *Order) Pay() error { if o.status != StatusDraft { return errors.New("only draft can be paid") } o.status = StatusPaid o.events = append(o.events, OrderPaid{OrderID: o.id, Amount: o.total, PaidAt: time.Now()}) return nil } func (o *Order) PullEvents() []any { e := o.events o.events = nil return e }

Application Service публикует после сохранения:

go
func (h *PayOrderHandler) Execute(ctx context.Context, id OrderID) error { order, _ := h.repo.FindByID(ctx, id) if err := order.Pay(); err != nil { return err } if err := h.repo.Save(ctx, order); err != nil { return err } for _, e := range order.PullEvents() { h.bus.Publish(ctx, e) } return nil }

Подписчик в другом контексте слушает OrderPaid и стартует доставку, начисляет бонусы, шлёт email — без прямого вызова из Order.

Структура проекта (Screaming Architecture)

Robert Martin: "архитектура должна кричать о домене, а не о фреймворке". В Onion это работает: верхние папки — про бизнес, не про технологии.

Плохо (кричит про Spring/Echo):

text
controllers/ services/ repositories/ entities/

Хорошо (кричит про OrderService / Billing):

text
internal/ order/ ← bounded context domain/ order.go events.go transfer_service.go application/ create_order.go cancel_order.go pay_order.go infrastructure/ postgres/ order_repo.go http/ order_handler.go billing/ ← другой context domain/ application/ infrastructure/ cmd/ server/ main.go

Открыл репу — видишь "Order, Billing, Shipping". Видишь домен. Не видишь "controllers, services, dao".

Production-границы в Onion

Onion легко испортить, если воспринимать слои как названия папок. В продакшене проверяют не красоту дерева, а направление знания:

  • Domain Model знает правила и термины предметной области, но не знает HTTP, SQL, Redis, Kafka, logger и config.
  • Domain Service содержит правило, которое не принадлежит одной entity, но всё ещё не ходит в сеть и БД.
  • Application Service оркестрирует сценарий: загружает агрегаты, вызывает domain, сохраняет результат, пишет outbox, управляет транзакцией.
  • Infrastructure переводит внешний мир в понятные ядру интерфейсы и модели.

Хороший запах: доменный пакет можно собрать и протестировать отдельно, без поднятого окружения.

Плохой запах:

go
package conversion type Quote struct { ID string `db:"id" json:"id"` TraceID string `json:"trace_id"` CreatedAt time.Time } func (q Quote) LogValue() slog.Value { /* ... */ }

Здесь доменная модель знает о DB tags, JSON contract и logging. Каждый из этих контрактов меняется по другой причине, поэтому им место на границе: DB row в repository adapter, response DTO в transport adapter, log fields в middleware/usecase boundary.

Практическая проверка для review:

  • можно ли объяснить каждый import из domain как часть предметной области?
  • можно ли заменить Echo на gRPC без изменения domain?
  • можно ли заменить Postgres на in-memory fake в use case test?
  • не лежат ли context.Value, logger или tracer внутри entity как скрытая зависимость?

Pitfalls

Domain импортирует Infrastructure. Самая частая ошибка. Entity использует gorm.Model или маршалит в JSON через теги. Лечится: модели БД отдельные, маппинг на границе.

Domain Service вместо метода entity. Логику "Order.Cancel()" вынесли в OrderService.CancelOrder(o *Order). Анемичная модель. Если правило про одну entity — клади метод в entity.

Application Service с бизнес-логикой. В CreateOrderHandler 200 строк проверок: VIP-скидка, лимит, валидация. Это правила домена, должны быть в entity или Domain Service.

Anaemic Domain Model. Структуры с публичными полями, без методов, вся логика в сервисах. Это процедурный код в Onion-обёртке.

Утечка events наружу. Domain event сериализуется как DTO для внешнего API. Это два разных события: внутреннее (доменное) и внешнее (контракт). Разделяй.

Переусложнение. На простом CRUD заводят 4 слоя, где Domain Service пуст, Application Service просто зовёт repo. Если домена нет — Onion не нужен. Делай Layered/Hexagonal без внутренних колец.

Импорты вверх. Domain импортирует Application, потому что "удобно". Это рушит весь смысл — направление зависимостей нарушено.

God Aggregate. Один Order тянет всё: items, payment, shipping, customer, history. Делите по транзакционным границам.

Сравнение с альтернативами

Без Onion (Hexagonal):

  • Один "сервис" на сценарий
  • Логика и оркестрация перемешаны
  • Подходит для среднего размера

Onion:

  • Чёткое разделение Domain / Application / Infrastructure
  • Подходит для сложного домена
  • Структура папок отражает контексты

Clean Architecture:

  • То же + явные Use Case-боундари + DTO между слоями
  • Формализовано Robert Martin
  • Дополнительный boilerplate ради ещё большей изоляции

DDD:

  • Onion часто идёт в комплекте с DDD (агрегаты, ubiquitous language, bounded contexts)
  • Но Onion работает и без DDD

Чек-лист ревью

  • domain/ не импортирует ничего из application/ или infrastructure/
  • Поля entity приватные, изменения через методы
  • Инварианты проверяются в методах entity, а не в сервисах
  • Value objects иммутабельны и валидируют себя в конструкторе
  • Domain Service используется только когда логика про несколько entity
  • Application Service не содержит бизнес-правил (только оркестрация)
  • Транзакции только в Application слое
  • Domain events публикуются после сохранения, не во время
  • Структура папок отражает домен, а не технологию (screaming architecture)
  • Маппинг DB-моделей в Domain entity на границе (в repo)

Вопросы для интервью

Q: Что такое Onion Architecture? A: Архитектура с концентрическими слоями вокруг Domain Model. Зависимости направлены строго внутрь: Infrastructure → Application → Domain. Автор — Jeffrey Palermo, 2008. Развитие Hexagonal: добавляет явные слои внутри ядра.

Q: Сколько слоёв в Onion? A: Обычно 4: Domain Model (entities, value objects), Domain Services (логика между entity), Application Services (use cases, оркестрация), Infrastructure (БД, HTTP, очереди). Можно объединять — но направление зависимостей сохраняется.

Q: Чем Domain Service отличается от Application Service? A: Domain Service содержит бизнес-логику, работающую с несколькими entity, без БД и транзакций. Application Service — оркестрация сценария: загрузить из repo, дёрнуть domain, сохранить, опубликовать событие. Если убрать инфраструктуру, Domain Service остаётся, Application — нет.

Q: Что такое богатая (rich) и анемичная (anaemic) модель? A: Rich — у entity приватные поля и методы с инвариантами (order.Cancel()). Anaemic — публичные поля без методов, логика в сервисах. Onion и DDD требуют rich. Anaemic — антипаттерн Фаулера.

Q: Зачем нужны Domain Events? A: Чтобы агрегаты общались между собой без прямых вызовов. Order.Pay() публикует OrderPaid, на это подписан Billing-контекст. Связность падает, можно асинхронно, можно для аудита (event sourcing).

Q: Что такое Screaming Architecture? A: Идея Robert Martin: структура папок должна "кричать" о домене (order/, billing/), а не о фреймворке (controllers/, services/). Onion поддерживает это естественно — bounded context на верхнем уровне.

Q: Можно ли в Domain слое использовать стандартную библиотеку Go? A: Да — time, errors, fmt, strings это часть языка. Запрет на внешние пакеты, особенно инфраструктурные (sql, http, kafka, gorm). Стандартная библиотека — серая зона: time.Now() в домене затруднит тесты, лучше инжектить через интерфейс Clock.

Q: Чем Onion отличается от Hexagonal? A: Hexagonal — про порты и адаптеры (как ядро общается наружу). Onion добавляет структуру внутри ядра: Domain Model, Domain Services, Application Services. По сути Hexagonal с разделением логики и оркестрации.

Q: Где проводить транзакционную границу? A: На уровне Application Service, обычно через UnitOfWork или функцию tx.Run(func(ctx)). Domain не знает о транзакциях. Граница транзакции = граница агрегата.

Q: Когда не надо Onion? A: Простой CRUD без бизнес-правил — слои будут пустыми. Прототип — лишний boilerplate. Маленький сервис на 1-2 endpoint. Если нет домена, Onion навредит, не поможет.


Практика

Quiz+10 XP

Куда должны быть направлены зависимости в Onion Architecture?

  • От domain к infrastructure
  • От infrastructure к database
  • От внешних колец к внутренним
  • В любую сторону, если нет import cycle
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func canImport(from, to string) bool { rank := map[string]int{ "domain": 0, "application": 1, "infrastructure": 2, } return rank[from] >= rank[to] } func main() { fmt.Println(canImport("infrastructure", "domain")) fmt.Println(canImport("domain", "infrastructure")) }
Задача+20 XP

Реализуй RingDependency: верни "ok", если зависимость идёт внутрь или остаётся в том же кольце.