← назад к разделу · ступень 3 из четырёх (0–3) · предыдущая: уровень 2

На Уровне 2 появились отдельные Handler-классы для каждой операции — и это уже большой шаг. Но бизнес-правила по-прежнему живут прямо в Handler-ах, а код базы данных и внешних вызовов перемешан с логикой. Когда правил много и они важны, это становится проблемой. Уровень 3 решает её двумя идеями сразу: DDD (домен с явными правилами) и Hexagonal (инфраструктура за портами).

Обязательно

Какую боль решает

Представьте, что в вашем сервисе оплаты есть правило: нельзя оплатить уже отменённый заказ. Если это правило проверяется прямо в PayOrderHandler, рано или поздно появится второй Handler — например, RetryPaymentHandler — и кто-то забудет скопировать туда проверку. Баг незаметно проходит ревью.

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

Уровень 3 решает оба сценария: правила переезжают в агрегат, а инфраструктура прячется за портами.

Агрегат — хранитель правилспросят на собеседовании

Aggregate Root (корень агрегата) — это обычный класс, который держит внутри себя все бизнес-правила, относящиеся к одному объекту. Все изменения состояния проходят только через его методы — не через прямую запись в поля.

Пример: Order знает, что оплата в отменённом заказе невозможна. Это правило описано один раз — внутри Order.pay(). Сколько бы Handler-ов ни вызывали оплату, ни один не сможет его обойти.

public class Order {
    private final OrderId id;
    private OrderStatus status;
    private Money total;
    private final List<DomainEvent> events = new ArrayList<>();

    public void pay(Money amount) {
        if (status == OrderStatus.CANCELLED) {
            throw new IllegalStateException("Нельзя оплатить отменённый заказ");
        }
        if (!amount.equals(total)) {
            throw new IllegalArgumentException("Сумма не совпадает");
        }
        this.status = OrderStatus.PAID;
        events.add(new OrderPaid(this.id, amount));
    }

    public List<DomainEvent> pullEvents() {
        var copy = List.copyOf(events);
        events.clear();
        return copy;
    }
}
type Order struct {
	id     OrderID
	status OrderStatus
	total  Money
	events []DomainEvent
}

func (o *Order) Pay(amount Money) error {
	if o.status == StatusCancelled {
		return errors.New("нельзя оплатить отменённый заказ")
	}
	if !amount.Equals(o.total) {
		return errors.New("сумма не совпадает")
	}
	o.status = StatusPaid
	o.events = append(o.events, OrderPaid{OrderID: o.id, Amount: amount})
	return nil
}

func (o *Order) PullEvents() []DomainEvent {
	events := o.events
	o.events = nil
	return events
}
export class Order {
  private status: OrderStatus = OrderStatus.NEW;
  private readonly events: DomainEvent[] = [];

  constructor(readonly id: OrderId, private readonly total: Money) {}

  pay(amount: Money): void {
    if (this.status === OrderStatus.CANCELLED) {
      throw new Error('Нельзя оплатить отменённый заказ');
    }
    if (!amount.equals(this.total)) {
      throw new Error('Сумма не совпадает');
    }
    this.status = OrderStatus.PAID;
    this.events.push(new OrderPaid(this.id, amount));
  }

  pullEvents(): DomainEvent[] {
    return this.events.splice(0);
  }
}
class Order:
    def __init__(self, id: OrderId, total: Money) -> None:
        self._id = id
        self._status = OrderStatus.NEW
        self._total = total
        self._events: list[DomainEvent] = []

    def pay(self, amount: Money) -> None:
        if self._status is OrderStatus.CANCELLED:
            raise ValueError("Нельзя оплатить отменённый заказ")
        if amount != self._total:
            raise ValueError("Сумма не совпадает")
        self._status = OrderStatus.PAID
        self._events.append(OrderPaid(self._id, amount))

    def pull_events(self) -> list[DomainEvent]:
        events, self._events = self._events, []
        return events

pullEvents называется именно «pull», а не «get», потому что забирает события насовсем: список внутри агрегата после вызова пуст. Зовите его ровно один раз за операцию — второй вызов вернёт пустоту, и события просто никуда не уйдут. На этом чаще всего и ломаются повторные публикации.

Handler при этом превращается в оркестратор: загрузил агрегат → вызвал метод → сохранил → опубликовал события. Никакой логики «нельзя» — она вся внутри агрегата.

@Component
public final class PayOrderHandler implements UseCaseHandler<PayOrderCommand, Void> {

    private final OrderRepository repo;
    private final DomainEventPublisher publisher;

    @Override
    @Transactional
    public Void handle(PayOrderCommand cmd) {
        Order order = repo.findById(cmd.orderId()).orElseThrow();
        order.pay(cmd.amount());       // правила: статус, сумма, регистрация события
        repo.save(order);
        publisher.publishAll(order.pullEvents());
        return null;
    }
}
type PayOrderHandler struct {
    repo      OrderRepository
    publisher DomainEventPublisher
}

func (h *PayOrderHandler) Handle(cmd PayOrderCommand) error {
    order, err := h.repo.FindByID(cmd.OrderID)
    if err != nil {
        return err
    }
    if err := order.Pay(cmd.Amount); err != nil {
        return err
    }
    if err := h.repo.Save(order); err != nil {
        return err
    }
    return h.publisher.PublishAll(order.PullEvents())
}
export class PayOrderHandler implements UseCaseHandler<PayOrderCommand, void> {
  constructor(
    private readonly repo: OrderRepository,
    private readonly publisher: DomainEventPublisher,
  ) {}

  async handle(cmd: PayOrderCommand): Promise<void> {
    const order = await this.repo.findById(cmd.orderId);
    order.pay(cmd.amount);
    await this.repo.save(order);
    await this.publisher.publishAll(order.pullEvents());
  }
}
from dataclasses import dataclass

@dataclass
class PayOrderHandler:
    repo: OrderRepository
    publisher: DomainEventPublisher

    def handle(self, cmd: PayOrderCommand) -> None:
        order = self.repo.find_by_id(cmd.order_id)
        order.pay(cmd.amount)
        self.repo.save(order)
        self.publisher.publish_all(order.pull_events())

В Java-варианте на handle стоит @Transactional, в Go обработчик заворачивает работу в uow.Within, в Python в async with uow, в Node в tx.run — и это не для красоты. Сохранение заказа и его события должны попасть в базу одной транзакцией: если заказ сохранился, а событие потерялось, бонусы не спишутся и письмо не уйдёт, а по базе всё выглядит нормально. Поэтому publishAll обычно не шлёт в брокер напрямую — он пишет события в служебную таблицу рядом с заказом, а отдельный процесс уже вычитывает её и отправляет.

Как агрегат сохраняется

repo.save(order) в примере выше выглядит как одна строка, и за ней прячется самый болезненный вопрос уровня 3. Агрегат — это не строка таблицы, а граф: корень, сущности внутри, объекты-значения. Загрузить и сохранить его целиком — отдельная работа.

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

Отсюда и форма интерфейса порта: он работает с корнем и оперирует целым агрегатом.

public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
}
type OrderRepository interface {
	FindByID(ctx context.Context, id OrderID) (*Order, error)
	Save(ctx context.Context, order *Order) error
}
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
class OrderRepository(Protocol):
    async def find_by_id(self, id: OrderId) -> Order | None: ...
    async def save(self, order: Order) -> None: ...

Ни findItemsByOrderId, ни updateStatus, ни saveItem. Запросы для чтения, которым не нужен агрегат, — это не репозиторий, а отдельный порт чтения со своими структурами ответа.

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

Практические приёмы для промежуточных случаев:

  • Отложенная загрузка внутри агрегата — плохая идея. Она означает, что агрегат снаружи выглядит целым, а внутри может не быть данных, и правило проверится на неполном состоянии. Лучше честно разделить агрегаты, чем подгружать по надобности.
  • Метод порта под сценарий. Если для одной операции действительно нужен «заказ без истории», это отдельный метод порта с явным именем, а не флаг. Но два разных представления одного агрегата — повод пересмотреть границу.
  • Чтение мимо агрегата — нормально. Витрина заказов в интерфейсе не должна собираться из агрегатов; она читается отдельным запросом. Агрегат нужен для изменения, а не для показа.

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

СпособКак работаетКогда
Сверказагруженное состояние сравнивается с текущим, пишутся различияинструмент отображения делает это сам; удобно, пока агрегат небольшой
Полная перезапись вложенноговнутренние сущности удаляются и вставляются зановопросто и предсказуемо; годится при десятках строк, не при тысячах

Первый способ требует, чтобы адаптер помнил исходное состояние; второй — чтобы вложенных данных было мало. Выбор записывают в техническом разделе спеки, потому что от него зависит поведение под нагрузкой.

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

  • у корня агрегата есть поле версии;
  • сохранение пишет с условием «версия та же, что при загрузке», и увеличивает её;
  • не совпало — сохранение не произошло, и вызывающий получает отказ.

Что делать при конфликте — решение операции, а не инфраструктуры, и оно записывается в карточке:

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

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

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

Value Object — тип вместо примитиваспросят на собеседовании

Деньги хранить голым числом опасно: в одном месте складывают, в другом сравнивают без учёта валюты. Value Object — это небольшой неизменяемый класс, который несёт правила своего типа сам.

Money знает, что нельзя складывать разные валюты. Email проверяет адрес по тому правилу, которое принято в проекте, — и дальше по коду ходит уже проверенный адрес, а не любая строка. OrderId исключает путаницу «передали userId вместо orderId» — компилятор не пропустит.

Value Object сравнивают по значению, не по идентичности. У них нет идентификатора — две Money(100, "RUB") равны, даже если это разные объекты.

Domain Event — факт, который случилсяспросят на собеседовании

Когда заказ оплачен, об этом надо знать нескольким частям системы: списать бонусы, отправить письмо, уведомить логистику. Вместо того чтобы вызывать их всех из Handler-а напрямую, агрегат регистрирует Domain Event — OrderPaid.

Событие называется глаголом в прошедшем времени: OrderPaid, RefundIssued, ItemShipped. Это факт, который уже произошёл. Публикует его Handler после сохранения агрегата.

Доменные исключения и граница

В примере агрегат бросает общие исключения языка, и это компромисс ради краткости. В работающем сервисе так делать не стоит, и причина конкретная: на границе надо различать, какой это отказ, а по типу общего исключения различить нельзя.

Правило простое: агрегат бросает доменные исключения со своими типами и кодами, а перевод их в ответ клиенту живёт в одном месте на границе.

public class OrderAlreadyCancelled extends DomainException {
    public OrderAlreadyCancelled(OrderId id) {
        super("ORDER_ALREADY_CANCELLED", "Заказ " + id.value() + " уже отменён");
    }
}
type OrderAlreadyCancelled struct{ ID OrderID }

func (e OrderAlreadyCancelled) Error() string {
	return "Заказ " + e.ID.String() + " уже отменён"
}

func (OrderAlreadyCancelled) Code() string { return "ORDER_ALREADY_CANCELLED" }
export class OrderAlreadyCancelled extends DomainError {
  constructor(id: OrderId) {
    super('ORDER_ALREADY_CANCELLED', `Заказ ${id.value} уже отменён`);
  }
}
class OrderAlreadyCancelled(DomainError):
    def __init__(self, id: OrderId) -> None:
        super().__init__("ORDER_ALREADY_CANCELLED", f"Заказ {id.value} уже отменён")

Что это даёт по пунктам:

  • Код ошибки — часть контракта. Он записан в карточке команды, в поле «Ошибки», и клиент ветвится по нему, а не по тексту. Текст меняется, код — нет.
  • Статус ответа выводится из типа, а не из текста. «Не найдено», «нарушено правило», «нет прав» — разные типы и разные статусы. С общим исключением всё это становится одним ответом «что-то пошло не так».
  • Ядро не знает про протокол. Доменное исключение не содержит статуса ответа: соответствие «код ошибки — статус» живёт в адаптере входа, потому что для очереди сообщений статуса нет вовсе.
  • Гейт может сверить список. Коды из карточек против объявленных типов — формальная проверка, которая ловит забытую ошибку.

Три вида отказов и три разных места:

ВидГде возникаетПримерЧто на границе
Нарушение инвариантавнутри агрегата«нельзя оплатить отменённый»конфликт состояния
Непройденное предусловиев обработчике до агрегата«заказа не существует», «нет прав»не найдено или запрещено
Отказ инфраструктурыв адаптере выходашлюз недоступен, база не отвечаетошибка на нашей стороне

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

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

Порты — граница с внешним миромспросят на собеседовании

Hexagonal Architecture (гексагональная архитектура, она же «порты и адаптеры») отвечает на вопрос: как изолировать код базы данных и внешних сервисов от бизнес-логики?

Идея простая: логика работает через интерфейсы (порты), а реализации (адаптеры) живут отдельно. Хотите заменить Stripe на другой шлюз — меняете только адаптер, логика не трогается.

Структура папок отражает это разделение:

core/
    domain/        ← агрегаты, value objects, события
    usecase/       ← UseCase + Handler
    port/          ← интерфейсы (OrderRepository, PaymentGateway, ...)
adapter/
    in/rest/       ← REST-контроллер вызывает UseCase
    in/kafka/      ← Kafka-потребитель вызывает тот же UseCase
    out/postgres/  ← реализация OrderRepository через базу данных
    out/payment/   ← реализация PaymentGateway через внешний сервис

Главное правило: core/ не знает ничего об инфраструктуре. Никаких аннотаций фреймворка, никаких SQL, никакого HTTP в core/domain/ или core/usecase/. Зависимости текут строго внутрь — от адаптеров к ядру, не наоборот.

Один UseCase при этом можно вызывать из нескольких точек входа: из REST, из Kafka и из планировщика — он сам об этом не знает.

Что лежит в адаптере, кроме реализации порта

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

Правило: структуры внешнего представления живут в адаптере, вместе с преобразованием. Ни одна из них не пересекает границу ядра.

adapter/
  in/rest/
    OrderController.java          ← принимает запрос
    CreateOrderRequest.java       ← структура тела запроса
    OrderResponse.java            ← структура ответа
    OrderRestMapper.java          ← запрос → команда, результат → ответ
  out/payment/
    HttpPaymentGateway.java       ← реализация порта
    PaymentRequestDto.java        ← формат чужого API
    PaymentResponseDto.java       ← формат чужого ответа
    PaymentMapper.java            ← домен → формат партнёра и обратно
    PaymentGatewayException.java  ← отказ партнёра в своём типе

Почему именно так:

  • Формат внешнего API меняется по чужим причинам. Партнёр переименовал поле, добавил обязательный параметр, сменил версию. Если его структуры лежат в ядре, чужое изменение меняет ядро.
  • Один порт — несколько адаптеров, у каждого свой формат. Два платёжных провайдера принимают разные тела запроса; общая структура для обоих неизбежно станет объединением двух, и ядро начнёт знать про оба.
  • Преобразование — это тоже код, который надо тестировать. Оно живёт рядом с тем, что преобразует, и тестируется отдельно от бизнес-правил.

Кто делает преобразование. На входе — адаптер входа: из структуры запроса в команду. На выходе — адаптер выхода: из доменных типов в формат партнёра и обратно. Обработчик преобразованиями не занимается вовсе: он получает команду и отдаёт результат в доменных типах.

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

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

Проверка границ в CI

Дисциплина «не импортировать инфраструктуру в ядро» легко нарушается под давлением дедлайна. Поэтому границы проверяют автоматически:

  • Java — ArchUnit: noClasses().that().resideInAPackage("..core..").should().dependOnClassesThat().resideInAPackage("..adapter..")
  • Go — go-arch-lint
  • Node/TypeScript — dependency-cruiser
  • Python — import-linter

Таких правил нужно немного: пары штук про направление зависимостей обычно хватает, чтобы поймать случай, когда кто-то дотянулся до базы прямо из Handler-а.

Когда брать этот уровень

Уровень 3 оправдан, когда выполняется хотя бы несколько условий:

  • Есть сложные правила, которые нельзя нарушить (отрицательный баланс, платёж в закрытой сессии).
  • Много внешних интеграций, которые нужно подменять в тестах.
  • Один UseCase вызывается из нескольких точек входа (REST + Kafka + cron).
  • Продукт долгоживущий — бизнес-логика переживёт несколько смен инфраструктуры.

Когда не стоит брать:

  • CRUD-сервис без нетривиальных правил.
  • Ранняя стадия продукта, логика ещё нестабильна.
  • Небольшая команда без опыта DDD — кривая обучения велика.

Уровень 3 заметно увеличивает количество классов по сравнению с Уровнем 2 — на каждую операцию добавляются агрегат, объекты-значения, события и порты — и требует дисциплины на ревью. Это инвестиция, которая окупается на сложном долгосрочном домене. В одном сервисе разные модули могут жить на разных уровнях: ядро бизнеса — на 3-м, простые справочники — на 1-м.

Если взяли уровень 3 и пожалели

Правило «уровень повышается легко, понижается тяжело» здесь особенно верно, и признать ошибку стоит раньше, чем позже. Признаки, что уровень выбран не по делу:

  • Агрегаты без правил. Методы агрегата — это присваивания полей, проверок внутри нет. Значит правил в домене не оказалось, и вся конструкция — обёртка над записью в таблицу.
  • Объекты-значения без инвариантов. Класс вокруг одного поля, который ничего не проверяет и ничего не запрещает.
  • События, которые никто не слушает. Публикуются, пишутся, вычитываются — и ни одного подписчика уже полгода.
  • Порты с единственной реализацией, которую никогда не меняли и не собираются. Интерфейс в ядре и один адаптер — это не изоляция, это лишний файл.
  • Правка одного поля требует изменений в шести файлах. И ни одно из них не про смысл.

Один-два признака — нормально, это просто неравномерность. Четыре и больше — уровень не тот.

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

  1. Остановить распространение. Новые операции делать на уровне 2: команда, обработчик, репозиторий, без агрегата и портов. Два уровня в одном сервисе — нормально, и об этом прямо сказано выше.
  2. Схлопнуть то, что схлопывается дёшево. Объект-значение без инвариантов заменить на примитив; порт с единственной реализацией — на прямое использование адаптера; события без подписчиков — удалить вместе с механизмом публикации.
  3. Агрегаты без правил превратить в обычные сущности. Метод, который только присваивает, переезжает в обработчик, и агрегат становится структурой данных. Это самая объёмная часть, и делают её по одному агрегату, когда его всё равно трогают.
  4. Не трогать то, где правила есть. Если два агрегата из семи держат настоящие инварианты — они остаются на уровне 3. Понижение не обязано быть сплошным.

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

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

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

Глубже: граница агрегата: что внутрь, что по идентификаторурасширенное

Агрегат назван хранителем правил, и самое дорогое решение уровня 3, что положить внутрь, а что оставить снаружи по идентификатору, в статье не разобрано. Ошибка в обе стороны стоит одинаково: слишком большой агрегат грузится целиком ради одной правки и конфликтует сам с собой, слишком мелкий не может проверить своё правило.

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

Что это значит в спеке и коде. В файле агрегата раздел «Доменная модель» перечисляет корень и сущности внутри, а всё внешнее записано как <other>_id, и это правило распространяется на ER-схему: внешних ключей между агрегатами нет, только логические ссылки. Проверка внутри агрегата пишется в его метод; проверка, которой нужны данные другого агрегата («покупатель не заблокирован»), либо переезжает в предусловие команды, которое обработчик проверяет до вызова агрегата, читая другой агрегат, либо становится сагой, если данные меняются независимо и гонка возможна.

Как границу двигают задним числом. Признаки ошибки: конфликты оптимистичной блокировки у пользователей, которые не трогали общего; загрузка тысяч строк на одну операцию; правило, для которого постоянно не хватает данных. Сдвиг это отдельный срез: новый агрегат со своей таблицей, перелив данных, переезд команд по одной, и спека меняется первой документом изменения. Дешевле всего это делать, пока агрегатов три, а не тридцать, поэтому на уровень 3 идут с названными вслух инвариантами, о чём статья про сценарии.

Глубже: как правило попадает в корпусрасширенное

Проверка границ в CI показывает, как правило применяется. Откуда оно берётся и как проверяется до того, как станет обязательным, это отдельная процедура, и без неё корпус превращается в список пожеланий.

Рождение. Правило формулируют из повторяющейся находки: три ревью подряд ловят одно и то же (Instant.now() в агрегате, транзакция вокруг HTTP-вызова), и это кандидат. Формулировка требования с идентификатором, тремя обязательными полями и примером: что требуется, чем ловится (гейт: тест на архитектуру, линтер, скрипт сверки спеки, или только ревью), что не ловит (честное перечисление случаев, которые пройдут мимо гейта), и покрытие по языкам.

Проверка на существующем коде. До включения правило прогоняют по текущему коду и считают нарушения. Ноль нарушений при известной проблеме означает, что гейт не работает; сотни нарушений означают, что правило включают с базовым списком существующих и ловят только новые, а старые выносят задачами. Правило без гейта помечают так явно: «ловится только ревью», и это самый слабый класс, которого в корпусе больше половины.

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

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

Коротко

  • На Уровне 3 бизнес-правила переезжают из Handler-а в агрегат — нарушить их снаружи невозможно. Value Object оборачивает примитив и несёт его правила: Money, Email, OrderId. Domain Event — глагол прошедшего времени (OrderPaid); агрегат регистрирует факт, Handler публикует. Repository — интерфейс в core/port/, реализация в adapter/out/.
  • Handler на этом уровне — оркестратор: загрузил → вызвал метод агрегата → сохранил → опубликовал события. core/ не знает об инфраструктуре; направление зависимостей — только внутрь.
  • Один UseCase вызывается из REST, Kafka и cron — он не знает, кто его вызывает. Границы проверяются в CI статическим анализом (ArchUnit, go-arch-lint, dependency-cruiser, import-linter).
  • Это не «лучший» уровень — это «нужный при сложном домене и долгом горизонте». Граница агрегата решается тремя вопросами: какое правило держится в транзакции, кто и когда меняет, сколько весит; внешнее по идентификатору без внешних ключей, чужие данные в предусловии команды или в саге, сдвиг границы это отдельный срез со спекой первой.
  • Правило попадает в корпус из повторяющейся находки с полями «чем ловится» и «что не ловит», проверяется на существующем коде, получает гейт вместе с собой и измеряется по срабатываниям; правило без гейта помечено явно.
  • Один репозиторий — один корень агрегата: внутренние сущности сохраняются вместе с корнем, иначе инвариант обходится мимо агрегата; чтение для витрин идёт мимо агрегата отдельным портом.
  • У корня есть версия: сохранение пишет с условием «версия та же», а что делать при конфликте — повторить, отказать или признать границу слишком широкой — решает карточка операции.
  • Агрегат бросает доменные исключения со своими кодами, перевод в ответ живёт в одном месте на границе, а отказ внешней системы адаптер переводит в исключение рядом с портом.
  • Структуры внешних форматов и преобразования лежат в адаптерах: через порт ходят только типы ядра, а имя внешней структуры внутри ядра — признак пробитой границы.
  • Если уровень взят зря (агрегаты без правил, объекты-значения без инвариантов, события без подписчиков, порты с единственной реализацией), понижают не сплошняком: новые операции на уровне 2, дешёвые упрощения сразу, агрегаты по одному.

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