Уровень 2 · Стили Глава 05 10 мин

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.

— Jeffrey Palermo The Onion Architecture, Part 1, 2008

Это то же самое правило зависимостей, что у 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

На практике мало. Разница риторическая, но она влияет на организацию папок и мышление команды.

АспектHexagonalOnion
Ключевое словоPort / AdapterСлой
Центр (как формулируется)Application coreDomain Model
Форма мышления«Что снаружи, что внутри»«Где живёт правило»
Явное для DDDКосвенноПрямо (entities в центре)
Число «слоёв»2 (core + adapters)4 (Domain / DomainSvc / AppSvc / Infra)

Одна и та же система, спроектированная по Hexagonal и по Onion, будет практически идентичной. Разница — только в том, как её описывают в документации.

Где Domain Services

Onion явно выделяет Domain Services как отдельный слой. У Cockburn их нет — всё, что не UI/DB, попадает в core. У Palermo есть чёткая позиция:

Определение Domain Service (доменный сервис)

Логика, которая семантически принадлежит домену, но не помещается ни в одну entity — потому что пересекает границы aggregate.

— Evans, DDD, 2003

Пример: правило кросс-агрегатной оплаты.

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:
    • Part 1 — базовые принципы.
    • Part 2 — пример.
    • Part 3 — типичные вопросы.
    • Part 4 — 4 года спустя.
  • Herberto Graça. Explicit Architecture. Синтез Onion с Hexagonal, Clean, CQRS.
  • Vaughn Vernon. Implementing Domain-Driven Design. Chapter 4. Onion как основа для DDD tactical.

Проверьте себя

Мини-quiz · закрепить

Проверьте себя

  1. Q1. Ключевое правило Onion Architecture — это...

  2. Q2. Domain Service отличается от Application Service тем, что...

  3. Q3. В чём практическая разница между Hexagonal и Onion?

  4. Q4. Что означает «Onion разрушен»?

  5. Q5. Где по Palermo лежат интерфейсы репозиториев (Ports)?