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

В классической трёхслойной архитектуре слои идут сверху вниз: 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, маппинг, версии — всё техническое живёт здесь
}

Главное в этом листинге — где лежит интерфейс. 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 — избыточно; применяйте при богатом, долгоживущем домене.
  • Интерфейс репозитория лежит в пакете домена, а не инфраструктуры: домен объявляет, что ему нужно, внешнее кольцо реализует; перенос интерфейса наружу ломает правило, хотя код компилируется.
  • Доменный сервис принимает решение по правилам и получает данные параметрами; сервис приложения ведёт сценарий, знает репозитории, транзакции и события наружу — доменный сервис с репозиторием внутри уже не доменный.
  • Кольца без проверки машиной становятся проницаемы: тест «домен не импортирует инфраструктуру» или честное частичное применение, но не «луковица с дырами».
  • Решают по пяти признакам, достаточно трёх: пять нарушаемых правил в сущности, больше одной инфраструктуры, жизнь дольше года, сотня тестов правил, несколько входов в одну логику.
  • Переход из трёхслойного идёт шагами, каждый полезен сам: интерфейс в домене, правила внутрь сущности, сценарий отдельно от правил, тест зависимостей, и только при необходимости — отдельные доменные классы.

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