Луковая архитектура (Onion Architecture)
В классической трёхслойной архитектуре слои идут сверху вниз: UI → бизнес-логика → слой доступа к данным. Зависимости направлены вниз: бизнес-логика зависит от слоя данных, тот — от базы. В итоге домен намертво сцеплен с конкретной СУБД и фреймворком: нельзя протестировать логику без поднятой базы, а смена хранилища тянет правки в бизнес-код. Луковая архитектура (Onion) переворачивает это: в центре — чистый домен, а всё инфраструктурное вынесено наружу и зависит от центра, а не наоборот.
Что такое луковая архитектура
Луковую архитектуру описал Джеффри Палермо в 2008 году. Название буквальное: представьте луковицу в разрезе — концентрические кольца, в самом центре доменная модель.
Что на схеме: четыре вложенных кольца. В самом центре — доменная модель, вокруг неё доменные сервисы, дальше сервисы приложения, снаружи инфраструктура.
Кольца от центра наружу:
- Доменная модель — сущности и 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 — избыточно; применяйте при богатом, долгоживущем домене.
- Интерфейс репозитория лежит в пакете домена, а не инфраструктуры: домен объявляет, что ему нужно, внешнее кольцо реализует; перенос интерфейса наружу ломает правило, хотя код компилируется.
- Доменный сервис принимает решение по правилам и получает данные параметрами; сервис приложения ведёт сценарий, знает репозитории, транзакции и события наружу — доменный сервис с репозиторием внутри уже не доменный.
- Кольца без проверки машиной становятся проницаемы: тест «домен не импортирует инфраструктуру» или честное частичное применение, но не «луковица с дырами».
- Решают по пяти признакам, достаточно трёх: пять нарушаемых правил в сущности, больше одной инфраструктуры, жизнь дольше года, сотня тестов правил, несколько входов в одну логику.
- Переход из трёхслойного идёт шагами, каждый полезен сам: интерфейс в домене, правила внутрь сущности, сценарий отдельно от правил, тест зависимостей, и только при необходимости — отдельные доменные классы.
Что почитать дальше
- Гексагональная архитектура — тот же принцип в варианте ports & adapters, с реализацией по шагам.
- CQRS — разделение команд и запросов поверх чистого домена.
- Domain-Driven Design — что именно живёт в центре луковицы.