← назад к разделу · ступень 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 и пожалели
Правило «уровень повышается легко, понижается тяжело» здесь особенно верно, и признать ошибку стоит раньше, чем позже. Признаки, что уровень выбран не по делу:
- Агрегаты без правил. Методы агрегата — это присваивания полей, проверок внутри нет. Значит правил в домене не оказалось, и вся конструкция — обёртка над записью в таблицу.
- Объекты-значения без инвариантов. Класс вокруг одного поля, который ничего не проверяет и ничего не запрещает.
- События, которые никто не слушает. Публикуются, пишутся, вычитываются — и ни одного подписчика уже полгода.
- Порты с единственной реализацией, которую никогда не меняли и не собираются. Интерфейс в ядре и один адаптер — это не изоляция, это лишний файл.
- Правка одного поля требует изменений в шести файлах. И ни одно из них не про смысл.
Один-два признака — нормально, это просто неравномерность. Четыре и больше — уровень не тот.
Что делать, и чего не делать. Понижать уровень всему сервису разом не стоит: это большая работа с риском регрессий ради упрощения, которое само по себе ничего не приносит. Рабочий порядок — обратный тому, которым поднимались:
- Остановить распространение. Новые операции делать на уровне 2: команда, обработчик, репозиторий, без агрегата и портов. Два уровня в одном сервисе — нормально, и об этом прямо сказано выше.
- Схлопнуть то, что схлопывается дёшево. Объект-значение без инвариантов заменить на примитив; порт с единственной реализацией — на прямое использование адаптера; события без подписчиков — удалить вместе с механизмом публикации.
- Агрегаты без правил превратить в обычные сущности. Метод, который только присваивает, переезжает в обработчик, и агрегат становится структурой данных. Это самая объёмная часть, и делают её по одному агрегату, когда его всё равно трогают.
- Не трогать то, где правила есть. Если два агрегата из семи держат настоящие инварианты — они остаются на уровне 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, дешёвые упрощения сразу, агрегаты по одному.
Что почитать дальше
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Domain Event, Repository.
- Стратегические паттерны DDD — Bounded Context, Context Map.
- Гексагональная архитектура — детальный разбор портов и адаптеров.
- Распределённые паттерны — Outbox, Saga, Idempotent Consumer.
- Паттерны отказоустойчивости — что должно быть в адаптере выходного порта.