В классической трёхслойной архитектуре слои идут сверху вниз: 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, маппинг, версии — всё техническое живёт здесь
}
Главное в этом листинге — где лежит интерфейс. OrderRepository находится в пакете домена, а не в пакете инфраструктуры: домен объявляет, что ему нужно, а внешнее кольцо подчиняется. Если переставить интерфейс в infrastructure, правило зависимостей нарушится, хотя код будет компилироваться и выглядеть похоже — и это самая частая ошибка при первом применении.
Чем доменный сервис отличается от сервиса приложения
Два кольца, которые на слух почти одинаковы, и граница между ними — место, где ошибаются чаще всего. Разница на примере:
// ДОМЕННЫЙ сервис: чистое правило, охватывающее несколько объектов.
// Ни репозиториев, ни транзакций, ни внешних вызовов — только вход и результат.
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();
}
}
Разница в трёх признаках, и по ним легко проверить свой код:
| Доменный сервис | Сервис приложения | |
|---|---|---|
| Что делает | принимает решение по правилам домена | ведёт сценарий: достать, позвать, сохранить |
| Знает про репозитории | нет | да |
| Транзакции, права, события наружу | нет | да |
| Данные получает | параметрами | сам достаёт |
| Отвечает на вопрос | «сколько скидка?» | «оформить заказ» |
| Имя | правило: VolumeDiscountPolicy, ShippingCostCalculator | сценарий: PlaceOrderService, ConfirmOrderService |
Две ошибки, которые из этой путаницы растут. Доменный сервис с репозиторием внутри — самая частая: как только он сам достаёт данные, он перестаёт быть правилом и становится сценарием, а домен получает зависимость от инфраструктуры. Сервис приложения с бизнес-правилом внутри — обратная: условие «если заказов больше пяти, скидка 10 %» оказалось в сценарии, и теперь правило живёт снаружи домена, где его не найдут.
И практическое замечание: доменных сервисов должно быть мало. Если правило принадлежит одному объекту, его место — в этом объекте (order.confirm()), а не в отдельном сервисе. Доменный сервис появляется, только когда правило действительно про несколько объектов и ни одному из них не принадлежит.
Прямое следствие — домен тестируется без базы и фреймворка: интерфейсы подменяются заглушками.
Onion, гексагональная и Clean Architecture — одно семейство
Это все варианты одной идеи: держать домен чистым и развернуть зависимости к центру. Различаются словарь и акценты:
- Гексагональная (Ports & Adapters) — домен в центре, вокруг него порты (интерфейсы) и адаптеры (реализации). Подчёркивает симметрию входящих и исходящих границ.
- Луковая (Onion) — те же зависимости-внутрь, но поданы как концентрические кольца с доменной моделью в самом центре; акцент на слоистости.
- Clean Architecture (Роберт Мартин) — то же самое кольцами «entities → use cases → interface adapters → frameworks» с тем же правилом зависимостей.
Различия всё-таки не только в словаре. Луковая архитектура говорит, как устроен сам домен внутри: доменная модель → доменные сервисы → сервисы приложения. Гексагональная про внутренность домена молчит, зато требует симметрии: входящие порты и исходящие описываются одинаково. Так что если вам важна слоистость самого домена — берите словарь луковицы, если важны границы с внешним миром — гексагональный. На этом сайте подробно разобран гексагональный вариант (ядро, порты, адаптеры, тесты архитектуры) — если нужна реализация по шагам, начните с него.
Когда применять
Луковая архитектура оправдана, когда домен богатый и живёт долго, инфраструктур несколько (БД + очередь + внешние API), а тестируемость важна. Для простого CRUD-сервиса без сложных правил концентрические кольца — избыточная церемония: получится много интерфейсов ради одного-двух вызовов.
Когда кольца объявлены, а импорты ходят наружу
Самая распространённая беда — не отказ от луковицы, а её имитация: пакеты названы domain, application, infrastructure, а зависимости идут как попало. Такой сервис хуже честного трёхслойного, и стоит объяснить, почему.
Как это выглядит в коде:
- в доменном классе импорт аннотации хранения или фреймворка («так удобнее, всё равно же одна база»);
- интерфейс репозитория лежит в пакете инфраструктуры, а домен импортирует его оттуда — стрелка развёрнута наружу, хотя интерфейс есть;
- доменный сервис держит репозиторий и сам достаёт данные;
- доменный класс возвращает структуру для ответа наружу (с полями под формат HTTP);
- бизнес-правило живёт в обработчике запроса или в запросе к базе, а домен — набор данных без поведения.
Почему это хуже трёхслойной архитектуры, а не «немного не дотянули»: у трёхслойной есть работающее обещание («логика в среднем слое, доступ к данным в нижнем»), и по ней можно ориентироваться. У имитации обещание нарушено, но структура делает вид, что соблюдено: новый человек читает domain и думает, что там правила, а половина правил снаружи. Плюс платится полная цена структуры (больше пакетов, больше интерфейсов, больше файлов на одну задачу) без выигрыша: домен всё равно не тестируется без базы, смена хранилища всё равно задевает правила.
Что с этим делают:
Проверять машиной, а не глазами. Правило зависимостей — то, что элементарно проверяется тестом: «ни один класс из domain не импортирует ничего из infrastructure и ничего из фреймворка». Один такой тест превращает соглашение в факт. Как его написать — в статье про тесты архитектуры.
Или честно применить частично. Если кольца полностью выдержать не получается (сроки, небольшой сервис, команда не готова), лучше осознанно взять часть: например, интерфейсы репозиториев в домене и чистые правила в сущностях, но с аннотациями хранения на них. Это компромисс, у которого есть имя и границы, — и он несравнимо честнее «луковицы с дырами». Разбор частичного применения — в статье про то, когда брать гексагональную архитектуру.
Правило простое: кольцо, которое не проверяется, через полгода проницаемо. Либо тест, либо не объявляйте кольца.
Признаки, по которым решают
«Домен богатый и живёт долго» — не признак, а настроение. Измеримые признаки, и достаточно трёх из пяти:
- Правил больше, чем полей. В главной сущности есть хотя бы пять правил, которые можно нарушить (условия переходов, ограничения, вычисления), — то есть есть что защищать. Если объект — набор данных с формой редактирования, защищать нечего.
- Больше одной инфраструктуры. База плюс брокер, или база плюс внешний сервис, или два хранилища. Одна база и ничего больше — интерфейсы ради одной реализации.
- Жизнь дольше года и сменяемость инфраструктуры реальна: сервис переживёт замену хранилища, брокера или внешнего поставщика. Если проект на три месяца, инверсия зависимостей не окупится.
- Тесты правил должны быть быстрыми и массовыми. Больше сотни тестов на правила — и тогда выигрыш от «домен без базы» измеряется минутами на каждом прогоне.
- Несколько входов в одну логику. HTTP плюс обработчик очереди плюс задача по расписанию вызывают один и тот же сценарий. Один вход — одна причина меньше.
Меньше трёх признаков — берите трёхслойную, и это не поражение: у неё меньше файлов, меньше церемонии и она понятна любому. Признаки считают на текущем состоянии, а не на предполагаемом будущем: «а вдруг вырастет» — плохое основание, потому что если вырастет, границы можно провести тогда (см. следующий раздел), а вот выкинуть лишние слои сложнее.
Переход из трёхслойного сервиса
У читателя обычно уже есть работающий сервис: контроллеры, сервисы, репозитории на аннотациях хранения. Переписывать его целиком не надо — порядок такой, и каждый шаг полезен сам по себе.
Шаг 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 — что именно живёт в центре луковицы.