Onion Architecture
Palermo (2008): концентрические слои с domain-model в центре. Отличия от Hexagonal и Clean. Как ложится на DDD tactical.
TL;DR
- Onion акцентирует domain-model как центр, всё остальное — периферия.
- Три слоя изнутри наружу: Domain Model → Domain Services → Application Services. Infrastructure — снаружи всего.
- От Hexagonal отличается риторикой: Palermo пишет о слоях, Cockburn — о портах. Практика та же.
- Хорошо ложится на DDD tactical: entities/VO/aggregates внутри, application services снаружи.
Зачем ещё одна архитектура
Onion появилась через 3 года после Hexagonal — в 2008. Palermo не отрицал Cockburn’а, а переформулировал похожие идеи в терминах слоёв. Ключевой мотив: показать, что domain-model — центр, а не «просто ещё один пакет».
The most important rule: all coupling is toward the center. This means that we can layer up on the outside and easily have multiple applications talk to the same core.
Это то же самое правило зависимостей, что у Clean, но сказанное на 4 года раньше.
Три круга Palermo
Domain Model — самое ядро. Entities, Value Objects, Aggregates. Ничего не знает, ни от кого не зависит.
Domain Services — вокруг ядра. Логика, которая пересекает несколько aggregate. Знает про Domain Model.
Application Services — оркестрация сценариев. Use case, Application Service, Interactor — это здесь. Знает про Domain Services и Model.
Infrastructure — снаружи всего. UI, БД, брокеры, кэш. Знает про всех, все не знают про неё.
Правило одно: зависимости к центру. Каждый круг знает всё, что внутри него; ничего — снаружи.
Отличия от Hexagonal
На практике мало. Разница риторическая, но она влияет на организацию папок и мышление команды.
| Аспект | Hexagonal | Onion |
|---|---|---|
| Ключевое слово | Port / Adapter | Слой |
| Центр (как формулируется) | Application core | Domain Model |
| Форма мышления | «Что снаружи, что внутри» | «Где живёт правило» |
| Явное для DDD | Косвенно | Прямо (entities в центре) |
| Число «слоёв» | 2 (core + adapters) | 4 (Domain / DomainSvc / AppSvc / Infra) |
Одна и та же система, спроектированная по Hexagonal и по Onion, будет практически идентичной. Разница — только в том, как её описывают в документации.
Где Domain Services
Onion явно выделяет Domain Services как отдельный слой. У Cockburn их нет — всё, что не UI/DB, попадает в core. У Palermo есть чёткая позиция:
Логика, которая семантически принадлежит домену, но не помещается ни в одну entity — потому что пересекает границы aggregate.
Пример: правило кросс-агрегатной оплаты.
class PricingPolicy:
"""Рассчитывает финальную цену с учётом скидок клиента и региональных налогов."""
def calculate_final(
self,
order: Order,
customer: Customer,
region: Region,
) -> Money:
base = order.subtotal
discount = customer.discount_for(base)
taxed = region.apply_taxes(base - discount)
return taxed
Живёт в domain-слое. Не имеет состояния (stateless). Принимает aggregate’ы, возвращает Money. Знает про Order, Customer, Region — но не про repository, БД или HTTP.
▸ Domain Service vs Application Service — как отличить
- Domain Service отвечает на вопрос «Как?» — как считается, как складывается, как проверяется. Оперирует доменными понятиями.
- Application Service отвечает на вопрос «Что?» — что делает use case: загрузить, применить, сохранить, опубликовать. Оперирует use case’ами.
Тест: если сервис вызывает repository.save(...) — это Application. Если только оперирует aggregate’ами — Domain.
Пример: Onion-структура
app/
├── domain/ # Круг 1 — Domain Model
│ ├── model/
│ │ ├── order.py
│ │ ├── customer.py
│ │ ├── money.py
│ │ └── events.py
│ ├── services/ # Круг 2 — Domain Services
│ │ ├── pricing_policy.py
│ │ └── inventory_reservation.py
│ └── ports/ # Определения интерфейсов, реализуемых Infra
│ ├── order_repository.py
│ └── event_publisher.py
├── application/ # Круг 3 — Application Services
│ ├── commands/
│ │ ├── place_order.py
│ │ └── cancel_order.py
│ └── queries/
│ └── list_orders_for_customer.py
└── infrastructure/ # Внешний круг
├── http/
├── db/
├── amqp/
└── di/
Ключевые правила:
domain/не импортируетapplication/иinfrastructure/.application/импортируетdomain/, но неinfrastructure/.infrastructure/импортирует всё.
▸ Где живут Ports
У Palermo Ports (интерфейсы репозиториев) — в domain-слое. Аргумент: контракт репозитория — часть контракта домена.
Cockburn кладёт Ports рядом с use case (в application-слое). Аргумент: use case диктует, что ему нужно от инфраструктуры.
Оба варианта работают. Практически: если ваш port сильно доменный (InventoryReservationPort) — в domain/. Если инфраструктурный (EmailSenderPort) — в application/.
Как правильно: application service
@dataclass(frozen=True)
class PlaceOrderCommand:
customer_id: CustomerId
items: list[OrderItemInput]
class PlaceOrderCommandHandler:
def __init__(
self,
orders: OrderRepositoryPort,
customers: CustomerRepositoryPort,
pricing: PricingPolicy,
events: EventPublisherPort,
) -> None:
self._orders = orders
self._customers = customers
self._pricing = pricing
self._events = events
async def execute(self, cmd: PlaceOrderCommand) -> OrderId:
customer = await self._customers.find_by_id(cmd.customer_id)
if customer is None:
raise CustomerNotFound(cmd.customer_id)
order = Order.place(customer_id=cmd.customer_id, items=cmd.items)
final_price = self._pricing.calculate_final(order, customer, customer.region)
order.apply_final_price(final_price)
await self._orders.save(order)
await self._events.publish(order.pull_events())
return order.id
- Application Service координирует.
Order.place(...)— фабричный метод внутри aggregate.PricingPolicy— доменный сервис. Инжектится как зависимость.- Порт репозитория — из
domain/(по Palermo) или изapplication/(по Cockburn) — важно только, чтобы он был.
Как не надо
1. Инвертированные слои
from app.infrastructure.db.tax_repository import TaxRepository
class PricingPolicy:
def calculate_final(self, order, customer, region):
taxes = TaxRepository().get_for_region(region)
...
Domain Service импортирует repository напрямую из infrastructure. Onion разрушен: центр знает про периферию.
Правильно: TaxRepositoryPort в domain (или application), infrastructure реализует. Или PricingPolicy принимает taxes как параметр — application-service загрузит их через repository и передаст.
2. Application Service содержит бизнес-логику
class PlaceOrderCommandHandler:
async def execute(self, cmd):
# оркестрация
customer = await self._customers.find_by_id(cmd.customer_id)
# !! Бизнес-правило внутри app-service, а не в domain
if customer.status == 'blocked':
raise ValueError('cannot place order for blocked customer')
if sum(i.quantity for i in cmd.items) > 100:
raise ValueError('too many items')
order = Order(customer_id=cmd.customer_id, items=cmd.items)
await self._orders.save(order)
«Заблокированному нельзя», «больше 100 items — нельзя» — доменные инварианты. Место — в Order.place() или Customer.can_place_order(). Через полгода тот же инвариант появится ещё в CancelOrderCommandHandler — забудут.
Правильно: Order.place(customer) внутри проверяет customer.can_place_order(). Бросает DomainError. Application Service только загружает и передаёт.
3. Domain знает про Application
from app.application.commands.place_order import PlaceOrderCommand
class Order:
@classmethod
def from_command(cls, cmd: PlaceOrderCommand) -> 'Order':
...
Domain импортирует Application-класс (Command). Стрелка перевёрнута — центр знает про внешний круг.
Правильно: конвертация Command → Order — задача Application Service, не domain.
Trade-offs
| Ситуация | Onion / Hexagonal / Clean оправданы | Что-то проще |
|---|---|---|
| Сервис с богатым доменом (много aggregate) | ✓ | |
| Хочется явное разделение Domain / Domain Services / Application | ✓ (Onion) | |
| Команда мыслит через слои, не через ports | ✓ (Onion) | |
| CRUD-сервис на 2 таблицы | ✓ | |
| Скрипт-обработчик | ✓ |
По сути — те же trade-offs, что у Hexagonal. Разница только в стиле мышления.
В твоём же коде
Анонимизированный микросервис из case study формально Hexagonal (порты через Protocol, отдельный слой ports/). Но легко переформулировать как Onion:
- Domain Model: пуст (сервис-оркестратор).
- Domain Services: нет.
- Application Services:
OrderStandardizeUseCase. - Infrastructure:
MarketplaceAdapter,PostgresItemRepository,RabbitEventPublisher.
Отсутствие domain layer — характеристика этого конкретного сервиса, а не Onion. Как только появятся собственные инварианты (например, «нельзя нормализовать больше N характеристик за раз» или «характеристика без единицы измерения не нормализуется»), они станут доменной моделью.
Дальнейшее чтение
- Jeffrey Palermo. The Onion Architecture, Parts 1–4. Blog series, 2008–2012:
- Herberto Graça. Explicit Architecture. Синтез Onion с Hexagonal, Clean, CQRS.
- Vaughn Vernon. Implementing Domain-Driven Design. Chapter 4. Onion как основа для DDD tactical.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Ключевое правило Onion Architecture — это...
Q2. Domain Service отличается от Application Service тем, что...
Q3. В чём практическая разница между Hexagonal и Onion?
Q4. Что означает «Onion разрушен»?
Q5. Где по Palermo лежат интерфейсы репозиториев (Ports)?