Onion Architecture (Jeffrey Palermo, 2008): concentric rings with domain model at center, surrounded by domain services, application services, infrastructure outside. Dependencies only inward. Pre-cursor to Clean Architecture, sister pattern to Hexagonal.
Onion Architecture — формулировка Jeffrey Palermo (2008) того же принципа dependency inversion, что и Hexagonal (Cockburn, 2005) и Clean (Uncle Bob, 2012): бизнес-домен в центре, технические детали — снаружи. Все три паттерна изоморфны — одна идея, разная упаковка. Onion исторически выросла в .NET-сообществе и подчёркивает DDD-affinity: domain model в центре, окружённая domain services, application services, infrastructure снаружи.
Зачем знать именно Onion, если есть Clean? Во-первых, словарь: половина .NET-кодбаз называет себя "onion-shaped", и без знания терминологии (rings, domain services vs application services) ревьюить чужой код тяжело. Во-вторых, Onion даёт минимум прескриптивных правил — это плюс для DDD-команд, которые уже знают что делать, и минус для juniors, которым нужен checklist (тогда лучше Clean).
Луковица: domain model в центре. Зависимости только внутрь — внешние кольца знают о внутренних, но не наоборот.
Четыре кольца от центра к периферии:
@Entity, никаких ORM-аннотаций, никакого import "postgres".Dependency rule: стрелки только внутрь. Application service не импортирует Postgres-драйвер; он объявляет IOrderRepository, а Postgres-имплементация инжектится снаружи через DI.
Канвас — четыре концентрические группы (Infrastructure → Application Services → Domain Services → Domain Model), плюс отдельная test-нода вне всех колец.
domain-model): entity-order, vo-money (Money value object), agg-customer (Customer aggregate). Иконка code — это pure-language классы.domain-services): svc-pricing, svc-discount. Pure-language, без I/O.app-services): app-create-order, app-pay. Use cases.infra): http-ctrl, postgres-repo, stripe-client, email-sender.test): unit test, обращается напрямую к entity-order — доказательство, что центр действительно не требует инфры.Все edges идут внутрь: http-ctrl → app-create-order → svc-pricing → entity-order. Edges от app-service к infra (app-create-order → postgres-repo) подписаны именем интерфейса (IOrderRepo, IPaymentGw, INotifier) — это и есть инверсия: application объявляет contract, infra реализует.
Outer-to-inner traversal: HTTP-контроллер вызывает application service, тот orchestrates domain service + entity, ответ возвращается тем же путём. Видно, что каждое кольцо вызывает только следующее внутрь — нет skip-уровней (controller не дёргает entity напрямую).
Главный сценарий. Application service вызывает postgres-repo, но интерфейс IOrderRepository принадлежит inner ring — Postgres лишь его реализует. То же с IPaymentGateway (Stripe) и INotifier (SMTP). Application не знает про конкретные технологии; их подменяет DI. Тесты подкладывают InMemoryOrderRepo без единого изменения в application-коде.
Unit test создаёт Order напрямую, без БД, без HTTP, без application services. 5ms тест без I/O и моков. Сценарий flash-подсвечивает infra-ноды красным — они не загружены и не нужны для теста. Это litmus-test: если домен нельзя протестировать без инфры — луковица сломана.
Тот же поток через словарь трёх паттернов: Onion 2008 (rings) ↔ Hexagonal 2005 (ports/adapters) ↔ Clean 2012 (4 named layers). Один и тот же app-create-order → entity-order зовётся по-разному, но семантика идентична. Решение в ADR-001 — выбор по vocabulary команды, не по силе паттерна.
Самая частая ошибка: @Entity JPA-аннотация на доменном классе, или import postgres внутрь entity. Edge entity-order → postgres-repo загорается красным — inner ring зависит от outer, dependency rule нарушен. Следствие: domain test нельзя запустить без БД, юниты деградируют до интеграционных. Fix — вынести mapping в repository (DataMapper), keep domain pure.
ADR-001 на ноде entity-order фиксирует выбор Onion vs Clean vs Hexagonal. Decision tree:
| Условие команды | Брать | Почему |
|---|---|---|
| DDD-fluent (aggregates, VO, events в речи) | Onion | Domain vocabulary совпадает с rings — нулевой mismatch |
| Несколько delivery channels (REST + gRPC + queue) | Hexagonal | Симметрия driving/driven ports окупается, in-memory тесты бесплатные |
| Junior-heavy, нужны explicit правила | Clean | 4 named layers = checklist, проще ревью PR |
За Onion:
Против Onion:
domain/ + application/ + infrastructure/ это ровно она@Entity JPA на доменном классе, [Table("orders")] на C# record. Сценарий anti-pattern выше про это.class CreateOrderService { create(dto) { return repo.save(dto); } }. Use case вырождается в controller-плюс-один-вызов.DataRow.OrderService на 1000 строк со всеми use cases. Разбивайте: CreateOrderUseCase, CancelOrderUseCase.getOrdersWithCustomersAndPayments(). Это read-side задача, выносите в repository / CQRS read model.order.cancel(reason)).User в три разных места. Active Record + контроллеры быстрее.fetchAndMerge() лучше луковицы.handler.ts + db.ts лучше четырёх папок.Эвристика: если сложность домена < сложности инфраструктуры — Onion не нужен. Если наоборот — почти всегда нужен.
Источники: