Тактические паттерны DDD

Тактические паттерны DDD: Entity, Value Object, Aggregate, Repository, Domain Event.

Статья внедрена в скилл AI-агента ucp-ddd-tactical-review / ucp-ddd-tactical-design Эталонная библиотека к статье ddd-building-blocks тактические паттерны DDD

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

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

diagram

Entity (сущность)

Суть: есть уникальная идентичность, состояние может меняться.

Ключевые свойства Entity

  • Есть стабильный идентификатор (в рамках контекста).
  • Жизненный цикл: создаётся, изменяется, может удаляться/деактивироваться.
  • Изменяемое состояние допустимо (под контролем бизнес-правил).
  • Равенство по ID, а не по всем полям.
  • Содержит бизнес-поведение, а не только данные.

Частые Entity в реальных системах

  • User, Customer, Employee
  • Order, Invoice, Payment
  • Shipment, Ticket, Contract

Ошибки при использовании

  • Сравнивать сущности по всем полям вместо ID.
  • Пихать всю логику в сервисы, оставляя Entity "анемичной".
  • Давать менять поля напрямую (setters без инвариантов).
  • Менять ID после создания.
  • Путать границы агрегата и раздавать наружу внутреннее изменяемое состояние.
import java.util.Objects;
import java.util.UUID;

public class User {
    private final UUID id;
    private Email email;
    private String name;
    private boolean active;

    public User(UUID id, Email email, String name) {
        if (id == null) throw new IllegalArgumentException("id is required");
        if (email == null) throw new IllegalArgumentException("email is required");
        if (name == null || name.isBlank()) throw new IllegalArgumentException("name is required");
        this.id = id;
        this.email = email;
        this.name = name;
        this.active = true;
    }

    public UUID id() { return id; }

    public void changeEmail(Email newEmail) {
        if (!active) throw new IllegalStateException("Inactive user cannot change email");
        this.email = Objects.requireNonNull(newEmail);
    }

    public void rename(String newName) {
        if (newName == null || newName.isBlank()) throw new IllegalArgumentException("name is required");
        this.name = newName;
    }

    public void deactivate() { this.active = false; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User user)) return false;
        return Objects.equals(id, user.id);
    }

    @Override
    public int hashCode() { return Objects.hash(id); }
}

Value Object (значимый объект)

Суть: нет идентичности, сравнение по значениям, чаще всего immutable.

Ключевые свойства Value Object

  • Нет собственного ID (в отличие от Entity).
  • Неизменяемый (immutable) — после создания не меняется.
  • Равенство по значениям (equals/hashCode по всем значимым полям).
  • Самопроверка инвариантов при создании (валидное состояние всегда).
  • Часто маленький и концептуально "атомарный" для домена.

Зачем нужны в DDD

  • Убирают "примитивную одержимость" (String email, BigDecimal amount везде по коду).
  • Локализуют бизнес-правила (валидация/формат/операции) внутри типа.
  • Снижают количество ошибок и сайд-эффектов (из-за immutable).
  • Делают модель выразительной и ближе к языку домена.

Частые Value Objects в реальных системах

  • Email, PhoneNumber, FullName
  • Money, Price, Percentage
  • DateRange, Period
  • Address, GeoPoint

Ошибки при использовании

  • Делать VO изменяемым.
  • Хранить VO как "мешок геттеров" без правил.
  • Смешивать VO и Entity (добавлять ID и lifecycle, где не нужно).
  • Сравнивать VO по ссылке вместо значений.
public final class Money {
    private static final int SCALE = 2;
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        if (amount == null) throw new IllegalArgumentException("amount is required");
        if (currency == null) throw new IllegalArgumentException("currency is required");
        this.amount = amount.setScale(SCALE, RoundingMode.HALF_UP);
        this.currency = currency;
    }

    public static Money of(String amount, Currency currency) {
        return new Money(new BigDecimal(amount), currency);
    }

    public Money add(Money other) {
        ensureSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money subtract(Money other) {
        ensureSameCurrency(other);
        return new Money(this.amount.subtract(other.amount), this.currency);
    }

    public Money multiply(BigDecimal factor) {
        if (factor == null) throw new IllegalArgumentException("factor is required");
        return new Money(this.amount.multiply(factor), this.currency);
    }

    public boolean isNegative() { return amount.signum()  lines = new ArrayList<>();
    private OrderStatus status;

    public void addLine(ProductId productId, int qty, Money price) {
        ensureDraft();
        if (qty  0");
        lines.add(new OrderLine(productId, qty, price));
    }

    public void confirm() {
        ensureDraft();
        if (lines.isEmpty()) throw new IllegalStateException("Order has no lines");
        status = OrderStatus.CONFIRMED;
    }

    public Money total() {
        // считаем из lines, не храним дублирующие данные без нужды
    }

    private void ensureDraft() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Order is not editable");
        }
    }
}

Частые ошибки

  • Делать aggregate слишком большим ("God Aggregate") -> блокировки, медленно, сложно.
  • Разрешать прямую модификацию внутренних объектов извне.
  • Хранить жёсткие object references на другие агрегаты.
  • Анемичная модель: root с геттерами/сеттерами без поведения.

Domain Service (доменный сервис)

Суть: логика домена, которая не принадлежит ни одной конкретной сущности или агрегату.

Признаки того, что нужен Domain Service: операция работает с двумя и более агрегатами; логика значима для домена, но не принадлежит конкретной сущности; бизнес-эксперт может назвать эту операцию ("перевод денег", "расчёт скидки").

Domain Service vs Application Service

Domain Service живёт в слое домена, содержит бизнес-логику между агрегатами, знает только про доменные объекты. Пример: расчёт стоимости доставки.

Application Service живёт в слое приложения (use case), оркестрация: загрузить, вызвать, сохранить. Знает про домен + инфраструктуру. Пример: обработка команды "Оформить заказ".

Правило: сначала попробуй положить логику в Entity или Aggregate Root. Domain Service — запасной вариант.

class TransferService {
    void transfer(Account from, Account to, Money amount) {
        from.withdraw(amount);
        to.deposit(amount);
    }
}

Domain Event (доменное событие)

Domain Event — это объект, который фиксирует факт того, что в домене произошло что-то значимое для бизнеса. Вместо того чтобы агрегат Order знал про email, склад и бонусы, он публикует факт — OrderPaid — а подписчики реагируют, каждый в своей зоне ответственности.

Характеристики

Иммутабельность. Событие — факт в прошлом. Нельзя изменить или отменить. Можно создать компенсирующее событие (OrderRefunded), но не удалить OrderPaid.

Именование в прошедшем времени: OrderPaid, не PayOrder. UserRegistered, не RegisterUser.

Несёт контекст. OrderPaid — не просто маркер, а объект с orderId, amount, paidAt, paymentMethod.

Порождается агрегатом. Событие возникает в момент изменения состояния. Order публикует OrderPaid, когда переходит в статус PAID.

Внутренние vs внешние события

Внутренние (internal) живут внутри одного bounded context, часто в одной транзакции. Внешние (external) пересекают границы через брокер (Kafka, RabbitMQ), всегда асинхронные, требуют сериализации и гарантий доставки.

diagram

Реализация в Java/Spring

Модель события

public abstract class DomainEvent {
    private final UUID eventId;
    private final Instant occurredAt;

    protected DomainEvent() {
        this.eventId = UUID.randomUUID();
        this.occurredAt = Instant.now();
    }

    public UUID getEventId() { return eventId; }
    public Instant getOccurredAt() { return occurredAt; }
}
public class OrderPaid extends DomainEvent {
    private final UUID orderId;
    private final BigDecimal amount;

    public OrderPaid(UUID orderId, BigDecimal amount) {
        super();
        this.orderId = orderId;
        this.amount = amount;
    }

    public UUID getOrderId() { return orderId; }
    public BigDecimal getAmount() { return amount; }
}

Публикация из агрегата

public abstract class AggregateRoot {
    private final List domainEvents = new ArrayList<>();

    protected void registerEvent(DomainEvent event) { domainEvents.add(event); }

    public List getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearEvents() { domainEvents.clear(); }
}
public class Order extends AggregateRoot {
    private UUID id;
    private OrderStatus status;
    private BigDecimal amount;

    public void pay() {
        if (this.status != OrderStatus.CONFIRMED) {
            throw new IllegalStateException("Cannot pay order in status " + status);
        }
        this.status = OrderStatus.PAID;
        registerEvent(new OrderPaid(this.id, this.amount));
    }
}
@Component
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {
    private final JooqOrderDao dao;
    private final ApplicationEventPublisher eventPublisher;

    @Override
    public void save(Order order) {
        dao.save(order);
        order.getDomainEvents().forEach(eventPublisher::publishEvent);
        order.clearEvents();
    }
}

Подписчики

@EventListener — выполняется синхронно, в той же транзакции. Если упадёт — откатит всё. Для критичных эффектов (чек, списание со склада).

@Component
public class ReceiptOnOrderPaid {
    @EventListener
    public void handle(OrderPaid event) {
        // формируем чек — если упадёт, откатится вся транзакция
    }
}

@TransactionalEventListener — выполняется после коммита. Для некритичных действий (email, push, аналитика).

@Component
public class NotificationOnOrderPaid {
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void handle(OrderPaid event) {
        // отправляем email — заказ уже оплачен, если упадёт здесь — не откатится
    }
}

Подводные камни

Потеря события при AFTER_COMMIT — решения: Outbox pattern или retry-механизм. Порядок подписчиков — Spring не гарантирует, используй @Order. Синхронность по умолчанию@Async для тяжёлых подписчиков, но теряешь транзакционный контекст. Циклические события — подписчик порождает событие, на которое подписан другой → бесконечный цикл.


Repository (репозиторий)

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

Ключевые правила

  • Один репозиторий — один агрегат.
  • Загружает и сохраняет агрегат целиком.
  • Интерфейс в домене, реализация в инфраструктуре (Dependency Inversion).
  • Методы в терминах домена (findById, save), а не SQL.

Repository vs DAO

Repository: уровень домена, работает с агрегатом целиком, абстракция коллекции. DAO: уровень инфраструктуры, работает с таблицей/записью, абстракция доступа к данным.

interface OrderRepository {
    Optional findById(UUID id);
    void save(Order order);
}

Factory (фабрика)

Суть: инкапсулирует сложную логику создания агрегатов. Нужна, когда создание требует валидации бизнес-правил за рамками конструктора, или нужно собрать агрегат из нескольких частей. Если конструктор справляется — фабрика не нужна.

class OrderFactory {
    Order createNew(Customer customer) {
        if (customer.isBlocked()) throw new IllegalStateException();
        return new Order(UUID.randomUUID(), customer);
    }
}

Module (модуль)

Суть: организация доменных объектов в пакеты по смыслу, а не по техническому типу.

Типичная проблема — группировка по типу (entity/, service/, repository/): 30 классов из разных доменов в одном пакете, связанные вещи разбросаны.

Решение: группировка по домену

core/
    domain/
        aggregate/
            Order.java
        entity/
            OrderTicket.java
        valueobject/
            Money.java
        event/
            OrderConfirmed.java
            OrderCancelled.java
        repository/
            OrderRepository.java
    usecase/
        command/
            order/
                CreateOrderCommand.java
                CreateOrderCommandHandler.java
        query/
            order/
                GetOrderByIdQuery.java
                GetOrderByIdQueryHandler.java
adapter/
    in/rest/
        OrderController.java
    out/postgres/
        order/
            JooqOrderRepository.java
    out/sber/
        SberClientAdapter.java

Доменные события выносятся в отдельный пакет event/ — они являются контрактами между модулями и должны быть доступны.


Specification (спецификация)

Суть: инкапсуляция бизнес-правила в отдельный объект. Полезна, когда одно и то же условие проверяется в нескольких местах или правила нужно комбинировать (spec1.and(spec2).or(spec3)).

public class LargeConfirmedOrderSpec implements Specification {
    private final Money threshold;

    public LargeConfirmedOrderSpec(Money threshold) {
        this.threshold = threshold;
    }

    @Override
    public boolean isSatisfiedBy(Order order) {
        return order.getStatus() == OrderStatus.CONFIRMED
            && order.getTotal().compareTo(threshold.amount()) > 0;
    }
}

На практике — самый нишевый паттерн. Если правило простое и используется в одном месте — if достаточно.


Итого

Тактические паттерны DDD — не догма, а набор инструментов. Не нужно применять их все сразу. Начни с малого: выдели Entity и Value Object, оберни в Aggregate с чёткими инвариантами, спрячь хранение за Repository. Domain Events добавляй, когда появятся побочные эффекты. Factory и Specification — по необходимости.

Главный критерий — код должен говорить на языке домена. Если бизнес-эксперт может прочитать имена классов и методов и понять, что происходит — вы на правильном пути.