← назад к разделу · уровень 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 EventOrderPaid.

Событие называется глаголом в прошедшем времени: 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..")
  • Gogo-arch-lint
  • Node/TypeScriptdependency-cruiser
  • Pythonimport-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).
  • Это не «лучший» уровень — это «нужный при сложном домене и долгом горизонте».

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