Onion Architecture
Суть
Hexagonal говорит "используй порты и адаптеры". Onion идёт дальше: внутри домена тоже есть слои. Кольца, как у луковицы. Зависимости направлены строго внутрь. Автор — Jeffrey Palermo, 2008.
┌────────────────────────────────────┐
│ 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.
// 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:
// 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 участвуют).
// 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, сохранить оба, опубликовать событие.
// 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.
// 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 на границе
}
// 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 Service | Application Service | |
|---|---|---|
| Зачем | бизнес-правило между entity | оркестрация шагов use case |
| Знает про | только domain | repo, event bus, tx |
| Транзакции | нет | да |
| Внешние вызовы | нет | да (HTTP, БД) |
| Имена | TransferService.Transfer | TransferMoneyHandler.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
Механизм связи между агрегатами без прямой ссылки. Когда что-то значимое произошло в домене — публикуется событие. Подписчики реагируют.
// domain/events.go
package domain
type OrderPaid struct {
OrderID OrderID
Amount Money
PaidAt time.Time
}
Entity накапливает события:
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 публикует после сохранения:
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):
controllers/
services/
repositories/
entities/
Хорошо (кричит про OrderService / Billing):
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 переводит внешний мир в понятные ядру интерфейсы и модели.
Хороший запах: доменный пакет можно собрать и протестировать отдельно, без поднятого окружения.
Плохой запах:
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 навредит, не поможет.
Практика
Куда должны быть направлены зависимости в Onion Architecture?
Что выведет этот код?
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"))
}
Реализуй RingDependency: верни "ok", если зависимость идёт внутрь или остаётся в том же кольце.