Уровень 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)?