ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ Onion Architecture

Обязательно

Домен в центре, зависимости только внутрь

Layers · Dependency inversion · Use cases

Кольца изолируют бизнес-правила от базы, фреймворка и внешних интеграций.

1. Доменная модель не знает о внешнем мире

1 / 5

Центр содержит сущности, value objects и доменные правила. SQL, HTTP и фреймворк не импортируются внутрь.

  • Типы домена независимы.
  • Ошибки выражаются смыслом.
  • Изменение инфраструктуры не меняет правила.
DomainPostgreSQLHTTP API

2. Application Service координирует use case

2 / 5

Сервис приложения принимает команду, открывает транзакцию и вызывает модель. Он не забирает доменное правило себе.

  • Граница транзакции явная.
  • Модель выполняет изменение.
  • Координация не равна бизнес-логике.
PayOrderHandlertransaction boundaryOrder aggregatedomain behaviorOutboxsame transaction

3. Порт объявляется внутри

3 / 5

Домен или приложение называет необходимый интерфейс. Инфраструктура реализует его снаружи и подставляется через composition root.

  • Направление зависимости обращено.
  • Реализация заменяется.
  • Тест получает fake порт.
Core portdomain interfaceOuter adapterComposition rootwires implementation

4. Адаптеры переводят форматы

4 / 5

HTTP request превращается в команду, доменный результат - в ответ. SQL adapter переводит ошибки драйвера в доменные.

  • DTO не проникает внутрь.
  • Маппер не содержит правил.
  • Внешняя ошибка имеет доменный смысл.
External formatDTO + driverAdaptermap onlyDomain formattypes + errors

5. Onion и Hexagonal разделяют акцент

5 / 5

Onion подчёркивает кольца и размещение модели, Hexagonal - порты и адаптеры. Общее правило: домен не зависит от инфраструктуры.

  • Кольца не являются целью сами по себе.
  • Порты делают границу проверяемой.
  • Сложность добавляется по необходимости.
Onionconcentric layersdomain at centerHexagonalports + adaptersexplicit boundaries

Луковая архитектура (Onion Architecture)

← назад к разделу

В классической трёхслойной архитектуре слои идут сверху вниз: UI → бизнес-логика → слой доступа к данным. Зависимости направлены вниз: бизнес-логика зависит от слоя данных, тот — от базы. В итоге домен намертво сцеплен с конкретной СУБД и фреймворком: нельзя протестировать логику без поднятой базы, а смена хранилища тянет правки в бизнес-код. Луковая архитектура (Onion) переворачивает это: в центре — чистый домен, а всё инфраструктурное вынесено наружу и зависит от центра, а не наоборот.

Что такое луковая архитектура

Луковую архитектуру описал Джеффри Палермо в 2008 году. Название буквальное: представьте луковицу в разрезе — концентрические кольца, в самом центре доменная модель.

Четыре вложенных кольца: в центре доменная модель, вокруг доменные сервисы, дальше сервисы приложения (use cases), снаружи инфраструктура, UI, БД и фреймворки, стрелки внутрь

Что на схеме: четыре вложенных кольца. В самом центре — доменная модель, вокруг неё доменные сервисы, дальше сервисы приложения, снаружи инфраструктура.

Кольца от центра наружу:

  • Доменная модель — сущности и value-объекты с бизнес-правилами. Чистый код: никаких импортов БД, HTTP, фреймворка.
  • Доменные сервисы — логика, которая охватывает несколько сущностей и не помещается в одну.
  • Сервисы приложения (use cases) — оркестрация: принять запрос, дёрнуть домен, сохранить результат.
  • Внешнее кольцо — всё остальное: база, очереди, внешние API, веб-контроллеры, конфигурация фреймворка.

Правило зависимостей

Главное правило одно: зависимости направлены только внутрь. Внешнее кольцо знает о внутренних, внутренние о внешнем — нет.

Как это возможно технически, ведь сервису приложения нужно сохранить данные в базу (внешнее кольцо)? Через инверсию зависимостей: внутреннее кольцо объявляет интерфейс (например OrderRepository), а внешнее кольцо его реализует (PostgresOrderRepository). Домен зависит от интерфейса, который сам же и определил; конкретная реализация живёт снаружи и подставляется при сборке приложения. Так стрелка зависимости разворачивается внутрь, хотя поток данных идёт наружу.

Вот как это выглядит в файлах — шесть строк, из которых видно, где физически лежит какой код:

// domain/order/Order.java — центр: ни одного импорта инфраструктуры
public class Order {
    private OrderStatus status;
    public void confirm() {
        if (status != DRAFT) throw new IllegalTransition(status, CONFIRM);
        status = CONFIRMED;
    }
}

// domain/order/OrderRepository.java — интерфейс объявлен ВНУТРИ, рядом с доменом
public interface OrderRepository {
    Optional<Order> byId(OrderId id);
    void save(Order order);
}

// application/order/ConfirmOrderService.java — кольцо сервисов приложения
public class ConfirmOrderService {
    private final OrderRepository orders;             // зависит от интерфейса из центра

    public void confirm(OrderId id) {
        Order order = orders.byId(id).orElseThrow(OrderNotFound::new);
        order.confirm();                              // решение принимает домен
        orders.save(order);
    }
}

// infrastructure/persistence/PostgresOrderRepository.java — внешнее кольцо
public class PostgresOrderRepository implements OrderRepository {   // реализует интерфейс центра
    private final DSLContext dsl;
    // SQL, маппинг, версии — всё техническое живёт здесь
}
// domain/order/order.go — центр: ни одного импорта инфраструктуры
type Order struct{ status Status }

func (o *Order) Confirm() error {
	if o.status != Draft {
		return &IllegalTransition{From: o.status, Action: "confirm"}
	}
	o.status = Confirmed
	return nil
}

// domain/order/repository.go — интерфейс объявлен ВНУТРИ, рядом с доменом
type Repository interface {
	ByID(ctx context.Context, id ID) (*Order, error) // ErrNotFound, если заказа нет
	Save(ctx context.Context, order *Order) error
}

// application/order/confirm.go — кольцо сервисов приложения
type ConfirmService struct {
	orders order.Repository // зависит от интерфейса из центра
}

func (s *ConfirmService) Confirm(ctx context.Context, id order.ID) error {
	o, err := s.orders.ByID(ctx, id)
	if err != nil {
		return err
	}
	if err := o.Confirm(); err != nil { // решение принимает домен
		return err
	}
	return s.orders.Save(ctx, o)
}

// infrastructure/persistence/postgres_order_repository.go — внешнее кольцо
type PostgresOrderRepository struct{ pool *pgxpool.Pool } // реализует интерфейс центра
// SQL, маппинг, версии — всё техническое живёт здесь
// domain/order/order.js — центр: ни одного импорта инфраструктуры
export class Order {
  #status;
  confirm() {
    if (this.#status !== OrderStatus.DRAFT) throw new IllegalTransition(this.#status, 'confirm');
    this.#status = OrderStatus.CONFIRMED;
  }
}

// domain/order/order-repository.js — интерфейс объявлен ВНУТРИ, рядом с доменом
export class OrderRepository {
  async byId(id) { throw new Error('порт: реализует адаптер'); }   // null, если заказа нет
  async save(order) { throw new Error('порт: реализует адаптер'); }
}

// application/order/confirm-order-service.js — кольцо сервисов приложения
export class ConfirmOrderService {
  constructor(orders) { this.orders = orders; }  // зависит от интерфейса из центра

  async confirm(id) {
    const order = await this.orders.byId(id);
    if (!order) throw new OrderNotFound(id);
    order.confirm();                              // решение принимает домен
    await this.orders.save(order);
  }
}

// infrastructure/persistence/postgres-order-repository.js — внешнее кольцо
export class PostgresOrderRepository extends OrderRepository {   // реализует интерфейс центра
  constructor(pool) { super(); this.pool = pool; }
  // SQL, маппинг, версии — всё техническое живёт здесь
}
# domain/order/order.py — центр: ни одного импорта инфраструктуры
class Order:
    def confirm(self) -> None:
        if self._status is not OrderStatus.DRAFT:
            raise IllegalTransition(self._status, "confirm")
        self._status = OrderStatus.CONFIRMED


# domain/order/repository.py — интерфейс объявлен ВНУТРИ, рядом с доменом
class OrderRepository(Protocol):
    def by_id(self, id: OrderId) -> Order | None: ...
    def save(self, order: Order) -> None: ...


# application/order/confirm_order.py — кольцо сервисов приложения
class ConfirmOrderService:
    def __init__(self, orders: OrderRepository):    # зависит от интерфейса из центра
        self._orders = orders

    def confirm(self, id: OrderId) -> None:
        order = self._orders.by_id(id)
        if order is None:
            raise OrderNotFound(id)
        order.confirm()                              # решение принимает домен
        self._orders.save(order)


# infrastructure/persistence/postgres_order_repository.py — внешнее кольцо
class PostgresOrderRepository:                       # реализует интерфейс центра
    def __init__(self, session: Session):
        self._session = session
    # SQL, маппинг, версии — всё техническое живёт здесь

Главное в этом листинге — где лежит интерфейс. OrderRepository находится в пакете домена, а не в пакете инфраструктуры: домен объявляет, что ему нужно, а внешнее кольцо подчиняется. Если переставить интерфейс в infrastructure, правило зависимостей нарушится, хотя код будет компилироваться и выглядеть похоже — и это самая частая ошибка при первом применении.

Onion, гексагональная и Clean Architecture — одно семейство

Это все варианты одной идеи: держать домен чистым и развернуть зависимости к центру. Различаются словарь и акценты:

  • Гексагональная (Ports & Adapters) — домен в центре, вокруг него порты (интерфейсы) и адаптеры (реализации). Подчёркивает симметрию входящих и исходящих границ.
  • Луковая (Onion) — те же зависимости-внутрь, но поданы как концентрические кольца с доменной моделью в самом центре; акцент на слоистости.
  • Clean Architecture (Роберт Мартин) — то же самое кольцами «entities → use cases → interface adapters → frameworks» с тем же правилом зависимостей.

Различия всё-таки не только в словаре. Луковая архитектура говорит, как устроен сам домен внутри: доменная модель → доменные сервисы → сервисы приложения. Гексагональная про внутренность домена молчит, зато требует симметрии: входящие порты и исходящие описываются одинаково. Так что если вам важна слоистость самого домена — берите словарь луковицы, если важны границы с внешним миром — гексагональный. На этом сайте подробно разобран гексагональный вариант (ядро, порты, адаптеры, тесты архитектуры) — если нужна реализация по шагам, начните с него.

Когда применять

Луковая архитектура оправдана, когда домен богатый и живёт долго, инфраструктур несколько (БД + очередь + внешние API), а тестируемость важна. Для простого CRUD-сервиса без сложных правил концентрические кольца — избыточная церемония: получится много интерфейсов ради одного-двух вызовов.

Признаки, по которым решают

«Домен богатый и живёт долго» — не признак, а настроение. Измеримые признаки, и достаточно трёх из пяти:

  • Правил больше, чем полей. В главной сущности есть хотя бы пять правил, которые можно нарушить (условия переходов, ограничения, вычисления), — то есть есть что защищать. Если объект — набор данных с формой редактирования, защищать нечего.
  • Больше одной инфраструктуры. База плюс брокер, или база плюс внешний сервис, или два хранилища. Одна база и ничего больше — интерфейсы ради одной реализации.
  • Жизнь дольше года и сменяемость инфраструктуры реальна: сервис переживёт замену хранилища, брокера или внешнего поставщика. Если проект на три месяца, инверсия зависимостей не окупится.
  • Тесты правил должны быть быстрыми и массовыми. Больше сотни тестов на правила — и тогда выигрыш от «домен без базы» измеряется минутами на каждом прогоне.
  • Несколько входов в одну логику. HTTP плюс обработчик очереди плюс задача по расписанию вызывают один и тот же сценарий. Один вход — одна причина меньше.

Меньше трёх признаков — берите трёхслойную, и это не поражение: у неё меньше файлов, меньше церемонии и она понятна любому. Признаки считают на текущем состоянии, а не на предполагаемом будущем: «а вдруг вырастет» — плохое основание, потому что если вырастет, границы можно провести тогда (см. следующий раздел), а вот выкинуть лишние слои сложнее.

Дополнительно: при первом чтении можно пропустить

Глубже: чем доменный сервис отличается от сервиса приложениярасширенное

Два кольца, которые на слух почти одинаковы, и граница между ними — место, где ошибаются чаще всего. Разница на примере:

// ДОМЕННЫЙ сервис: чистое правило, охватывающее несколько объектов.
// Ни репозиториев, ни транзакций, ни внешних вызовов — только вход и результат.
public class VolumeDiscountPolicy {
    public Money discountFor(List<Order> customerOrders, CustomerTier tier) {
        Money total = customerOrders.stream().map(Order::total).reduce(Money.zero(), Money::add);
        return tier.discountRate().applyTo(total);      // решение, а не действие
    }
}

// СЕРВИС ПРИЛОЖЕНИЯ: сценарий. Достал, позвал домен, сохранил, отдал наружу.
public class PlaceOrderService {
    private final OrderRepository orders;
    private final CustomerRepository customers;
    private final VolumeDiscountPolicy discountPolicy;   // доменный сервис как зависимость
    private final OutboxRepository outbox;

    @Transactional
    public OrderId place(PlaceOrderCommand command) {
        Customer customer = customers.byId(command.customerId()).orElseThrow();
        List<Order> history = orders.byCustomer(customer.id());

        Money discount = discountPolicy.discountFor(history, customer.tier());
        Order order = Order.place(command.lines(), discount);

        orders.save(order);
        outbox.save(new OrderPlaced(order.id()));
        return order.id();
    }
}
// ДОМЕННЫЙ сервис: чистое правило, охватывающее несколько объектов.
// Ни репозиториев, ни транзакций, ни внешних вызовов — только вход и результат.
type VolumeDiscountPolicy struct{}

func (VolumeDiscountPolicy) DiscountFor(customerOrders []Order, tier CustomerTier) Money {
	total := MoneyZero
	for _, o := range customerOrders {
		total = total.Add(o.Total())
	}
	return tier.DiscountRate().ApplyTo(total) // решение, а не действие
}

// СЕРВИС ПРИЛОЖЕНИЯ: сценарий. Достал, позвал домен, сохранил, отдал наружу.
type PlaceOrderService struct {
	orders         OrderRepository
	customers      CustomerRepository
	discountPolicy VolumeDiscountPolicy // доменный сервис как зависимость
	outbox         OutboxRepository
	tx             TxRunner
}

func (s *PlaceOrderService) Place(ctx context.Context, cmd PlaceOrderCommand) (id OrderID, err error) {
	err = s.tx.RunInTx(ctx, func(ctx context.Context) error {
		customer, err := s.customers.ByID(ctx, cmd.CustomerID)
		if err != nil {
			return err
		}
		history, err := s.orders.ByCustomer(ctx, customer.ID())
		if err != nil {
			return err
		}

		discount := s.discountPolicy.DiscountFor(history, customer.Tier())
		order := PlaceOrder(cmd.Lines, discount)

		if err := s.orders.Save(ctx, order); err != nil {
			return err
		}
		id = order.ID()
		return s.outbox.Save(ctx, OrderPlaced{OrderID: id})
	})
	return id, err
}
// ДОМЕННЫЙ сервис: чистое правило, охватывающее несколько объектов.
// Ни репозиториев, ни транзакций, ни внешних вызовов — только вход и результат.
export class VolumeDiscountPolicy {
  discountFor(customerOrders, tier) {
    const total = customerOrders.reduce((sum, o) => sum.add(o.total()), Money.zero());
    return tier.discountRate().applyTo(total);      // решение, а не действие
  }
}

// СЕРВИС ПРИЛОЖЕНИЯ: сценарий. Достал, позвал домен, сохранил, отдал наружу.
export class PlaceOrderService {
  constructor({ orders, customers, discountPolicy, outbox, tx }) {
    Object.assign(this, { orders, customers, discountPolicy, outbox, tx }); // discountPolicy — доменный сервис как зависимость
  }

  place(command) {
    return this.tx.runInTx(async () => {
      const customer = await this.customers.byId(command.customerId);
      const history = await this.orders.byCustomer(customer.id);

      const discount = this.discountPolicy.discountFor(history, customer.tier);
      const order = Order.place(command.lines, discount);

      await this.orders.save(order);
      await this.outbox.save(new OrderPlaced(order.id));
      return order.id;
    });
  }
}
# ДОМЕННЫЙ сервис: чистое правило, охватывающее несколько объектов.
# Ни репозиториев, ни транзакций, ни внешних вызовов — только вход и результат.
class VolumeDiscountPolicy:
    def discount_for(self, customer_orders: list[Order], tier: CustomerTier) -> Money:
        total = sum((o.total() for o in customer_orders), Money.zero())
        return tier.discount_rate().apply_to(total)      # решение, а не действие


# СЕРВИС ПРИЛОЖЕНИЯ: сценарий. Достал, позвал домен, сохранил, отдал наружу.
class PlaceOrderService:
    def __init__(self, uow: UnitOfWork, discount_policy: VolumeDiscountPolicy):
        self._uow = uow
        self._discount_policy = discount_policy          # доменный сервис как зависимость

    def place(self, command: PlaceOrderCommand) -> OrderId:
        with self._uow:                                  # граница транзакции
            customer = self._uow.customers.by_id(command.customer_id)
            history = self._uow.orders.by_customer(customer.id)

            discount = self._discount_policy.discount_for(history, customer.tier)
            order = Order.place(command.lines, discount)

            self._uow.orders.save(order)
            self._uow.outbox.save(OrderPlaced(order.id))
            self._uow.commit()
            return order.id

Разница в трёх признаках, и по ним легко проверить свой код:

Доменный сервисСервис приложения
Что делаетпринимает решение по правилам доменаведёт сценарий: достать, позвать, сохранить
Знает про репозиториинетда
Транзакции, права, события наружунетда
Данные получаетпараметрамисам достаёт
Отвечает на вопрос«сколько скидка?»«оформить заказ»
Имяправило: VolumeDiscountPolicy, ShippingCostCalculatorсценарий: PlaceOrderService, ConfirmOrderService

Две ошибки, которые из этой путаницы растут. Доменный сервис с репозиторием внутри — самая частая: как только он сам достаёт данные, он перестаёт быть правилом и становится сценарием, а домен получает зависимость от инфраструктуры. Сервис приложения с бизнес-правилом внутри — обратная: условие «если заказов больше пяти, скидка 10 %» оказалось в сценарии, и теперь правило живёт снаружи домена, где его не найдут.

И практическое замечание: доменных сервисов должно быть мало. Если правило принадлежит одному объекту, его место — в этом объекте (order.confirm()), а не в отдельном сервисе. Доменный сервис появляется, только когда правило действительно про несколько объектов и ни одному из них не принадлежит.

Прямое следствие — домен тестируется без базы и фреймворка: интерфейсы подменяются заглушками.

Глубже: когда кольца объявлены, а импорты ходят наружурасширенное

Самая распространённая беда — не отказ от луковицы, а её имитация: пакеты названы domain, application, infrastructure, а зависимости идут как попало. Такой сервис хуже честного трёхслойного, и стоит объяснить, почему.

Как это выглядит в коде:

  • в доменном классе импорт аннотации хранения или фреймворка («так удобнее, всё равно же одна база»);
  • интерфейс репозитория лежит в пакете инфраструктуры, а домен импортирует его оттуда — стрелка развёрнута наружу, хотя интерфейс есть;
  • доменный сервис держит репозиторий и сам достаёт данные;
  • доменный класс возвращает структуру для ответа наружу (с полями под формат HTTP);
  • бизнес-правило живёт в обработчике запроса или в запросе к базе, а домен — набор данных без поведения.

Почему это хуже трёхслойной архитектуры, а не «немного не дотянули»: у трёхслойной есть работающее обещание («логика в среднем слое, доступ к данным в нижнем»), и по ней можно ориентироваться. У имитации обещание нарушено, но структура делает вид, что соблюдено: новый человек читает domain и думает, что там правила, а половина правил снаружи. Плюс платится полная цена структуры (больше пакетов, больше интерфейсов, больше файлов на одну задачу) без выигрыша: домен всё равно не тестируется без базы, смена хранилища всё равно задевает правила.

Что с этим делают:

Проверять машиной, а не глазами. Правило зависимостей — то, что элементарно проверяется тестом: «ни один класс из domain не импортирует ничего из infrastructure и ничего из фреймворка». Один такой тест превращает соглашение в факт. Как его написать — в статье про тесты архитектуры (Java, Go, Node, Python).

Или честно применить частично. Если кольца полностью выдержать не получается (сроки, небольшой сервис, команда не готова), лучше осознанно взять часть: например, интерфейсы репозиториев в домене и чистые правила в сущностях, но с аннотациями хранения на них. Это компромисс, у которого есть имя и границы, — и он несравнимо честнее «луковицы с дырами». Разбор частичного применения — в статье про то, когда брать гексагональную архитектуру (Java, Go, Node, Python).

Правило простое: кольцо, которое не проверяется, через полгода проницаемо. Либо тест, либо не объявляйте кольца.

Глубже: переход из трёхслойного сервисарасширенное

У читателя обычно уже есть работающий сервис: контроллеры, сервисы, репозитории на аннотациях хранения. Переписывать его целиком не надо — порядок такой, и каждый шаг полезен сам по себе.

Шаг 1. Один интерфейс в домене. Выбираете одну главную сущность и объявляете для неё интерфейс репозитория в пакете домена — с методами на языке домена (byId, save, byCustomer), а не findAllByStatusInAndCreatedAtBetween. Существующая реализация остаётся, просто начинает реализовывать интерфейс. Работа на час, а стрелка зависимости уже развернулась.

Шаг 2. Правила — внутрь сущности. Берёте самое живое правило из сервиса («отменить можно только до отгрузки») и переносите в метод сущности, убрав публичные сеттеры. Сервис теперь зовёт order.cancel(). Повторяете для нескольких правил — не для всех сразу, а по мере того, как к ним приходят задачи.

Шаг 3. Сценарий отделяется от правил. Ваш прежний сервис распадается на два: сценарий (достать, позвать, сохранить) и правила (уже внутри сущности или в доменном сервисе). Обычно на этом шаге и становится видно, сколько бизнес-логики жило в обработчиках.

Шаг 4. Тест зависимостей. Как только пакет domain существует, ставится проверка «домен не импортирует инфраструктуру». С этого момента структура перестаёт размываться, и дальше можно двигаться спокойно.

Шаг 5. Чистые доменные классы — только если нужно. Полное отделение доменного класса от записи базы (два класса и преобразователь) — самый дорогой шаг, и он не обязателен: до него доходят, когда мешают аннотации хранения (ленивые связи ломают правила, тесты требуют контекста). Разбор цены этого шага — в статье про принципы DDD.

Что важно в этом порядке: после каждого шага сервис работает и выкатывается, и каждый шаг делается на живой задаче, а не отдельным проектом. Начинать с шага 5 («сначала разделим модель и записи, потом всё остальное») — верный способ потратить месяц и не получить ни одного видимого улучшения.

Коротко

  • Луковая архитектура — концентрические кольца с доменной моделью в центре; зависимости направлены только внутрь.
  • Домен не знает о базе, HTTP и фреймворке — снаружи подставляются реализации интерфейсов, объявленных внутри (инверсия зависимостей).
  • Главный выигрыш — домен тестируется без инфраструктуры, а смена БД/фреймворка не задевает бизнес-логику.
  • Onion, гексагональная и Clean Architecture — одно семейство с общим правилом зависимостей; луковица добавляет слоистость внутри домена, гексагональная — симметрию портов.
  • Для простого CRUD — избыточно; применяйте при богатом, долгоживущем домене.
  • Интерфейс репозитория лежит в пакете домена, а не инфраструктуры: домен объявляет, что ему нужно, внешнее кольцо реализует; перенос интерфейса наружу ломает правило, хотя код компилируется.
  • Доменный сервис принимает решение по правилам и получает данные параметрами; сервис приложения ведёт сценарий, знает репозитории, транзакции и события наружу — доменный сервис с репозиторием внутри уже не доменный.
  • Кольца без проверки машиной становятся проницаемы: тест «домен не импортирует инфраструктуру» или честное частичное применение, но не «луковица с дырами».
  • Решают по пяти признакам, достаточно трёх: пять нарушаемых правил в сущности, больше одной инфраструктуры, жизнь дольше года, сотня тестов правил, несколько входов в одну логику.
  • Переход из трёхслойного идёт шагами, каждый полезен сам: интерфейс в домене, правила внутрь сущности, сценарий отдельно от правил, тест зависимостей, и только при необходимости — отдельные доменные классы.

Что почитать дальше