← назад к разделу · уровень 3 из 3 · предыдущий: уровень 2
На Уровне 2 появились отдельные Handler-классы для каждой операции — и это уже большой шаг. Но бизнес-правила по-прежнему живут прямо в Handler-ах, а код базы данных и внешних вызовов перемешан с логикой. Когда правил много и они важны, это становится проблемой. Уровень 3 решает её двумя идеями сразу: DDD (домен с явными правилами) и Hexagonal (инфраструктура за портами).
Какую боль решает
Представьте, что в вашем сервисе оплаты есть правило: нельзя оплатить уже отменённый заказ. Если это правило проверяется прямо в PayOrderHandler, рано или поздно появится второй Handler — например, RetryPaymentHandler — и кто-то забудет скопировать туда проверку. Баг незаметно проходит ревью.
Вторая проблема: платёжный шлюз сегодня — Stripe, завтра заказчик хочет заменить на другой. В коде нет границы «здесь заканчивается логика, здесь начинается Stripe». Замена превращается в поиск по всему проекту.
Уровень 3 решает оба сценария: правила переезжают в агрегат, а инфраструктура прячется за портами.
Агрегат — хранитель правил
Aggregate Root (корень агрегата) — это обычный класс, который держит внутри себя все бизнес-правила, относящиеся к одному объекту. Все изменения состояния проходят только через его методы — не через прямую запись в поля.
Пример: Order знает, что оплата в отменённом заказе невозможна. Это правило описано один раз — внутри Order.pay(). Сколько бы Handler-ов ни вызывали оплату, ни один не сможет его обойти.
public class Order {
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;
}
}
Handler при этом превращается в оркестратор: загрузил агрегат → вызвал метод → сохранил → опубликовал события. Никакой логики «нельзя» — она вся внутри агрегата.
public final class PayOrderHandler implements UseCaseHandler<PayOrderCommand, Void> {
private final OrderRepository repo;
private final DomainEventPublisher publisher;
@Override
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())
Value Object — тип вместо примитива
Деньги хранить числом BigDecimal опасно: в одном месте складывают, в другом сравнивают без учёта валюты. Value Object — это небольшой неизменяемый класс, который несёт правила своего типа сам.
Money знает, что нельзя складывать разные валюты. Email знает, что строка должна содержать @. OrderId исключает путаницу «передали userId вместо orderId» — компилятор не пропустит.
Value Object сравнивают по значению, не по идентичности. У них нет идентификатора — две Money(100, "RUB") равны, даже если это разные объекты.
Domain Event — факт, который случился
Когда заказ оплачен, об этом надо знать нескольким частям системы: списать бонусы, отправить письмо, уведомить логистику. Вместо того чтобы вызывать их всех из Handler-а напрямую, агрегат регистрирует Domain Event — OrderPaid.
Событие называется глаголом в прошедшем времени: OrderPaid, RefundIssued, ItemShipped. Это факт, который уже произошёл. Публикует его Handler после сохранения агрегата.
Порты — граница с внешним миром
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 и из планировщика — он сам об этом не знает.
Проверка границ в CI
Дисциплина «не импортировать инфраструктуру в ядро» легко нарушается под давлением дедлайна. Поэтому границы проверяют автоматически:
- Java — ArchUnit:
noClasses().that().resideInAPackage("..core..").should().dependOnClassesThat().resideInAPackage("..adapter..") - Go —
go-arch-lint - Node/TypeScript —
dependency-cruiser - Python —
import-linter
Достаточно 3–5 правил, чтобы поймать 90% случаев, когда кто-то случайно дотягивается до базы прямо из Handler-а.
Когда брать этот уровень
Уровень 3 оправдан, когда выполняется хотя бы несколько условий:
- Есть сложные правила, которые нельзя нарушить (отрицательный баланс, платёж в закрытой сессии).
- Много внешних интеграций, которые нужно подменять в тестах.
- Один UseCase вызывается из нескольких точек входа (REST + Kafka + cron).
- Продукт долгоживущий — бизнес-логика переживёт несколько смен инфраструктуры.
Когда не стоит брать:
- CRUD-сервис без нетривиальных правил.
- Ранняя стадия продукта, логика ещё нестабильна.
- Небольшая команда без опыта DDD — кривая обучения велика.
Уровень 3 вдвое увеличивает количество классов и требует дисциплины на ревью. Это инвестиция, которая окупается на сложном долгосрочном домене. В одном сервисе разные модули могут жить на разных уровнях: ядро бизнеса — на 3-м, простые справочники — на 1-м.
Коротко
- На Уровне 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).
- Это не «лучший» уровень — это «нужный при сложном домене и долгом горизонте».
Что почитать дальше
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Domain Event, Repository.
- Стратегические паттерны DDD — Bounded Context, Context Map.
- Гексагональная архитектура — детальный разбор портов и адаптеров.
- Распределённые паттерны — Outbox, Saga, Idempotent Consumer.
- Паттерны отказоустойчивости — что должно быть в адаптере выходного порта.