Тактические паттерны DDD
Тактические паттерны DDD: Entity, Value Object, Aggregate, Repository, Domain Event.
Тактические паттерны решают конкретную проблему: бизнес-логика размазывается по сервисам, контроллерам и утилитам. Со временем становится непонятно, где живут правила, кто за что отвечает, и почему изменение в одном месте ломает другое.
Паттерны ниже дают структуру: что является объектом с идентичностью, что — значением, где граница консистентности, кто хранит агрегаты, кто их создаёт, и как части системы общаются через события.
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), всегда асинхронные, требуют сериализации и гарантий доставки.
Реализация в 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 — по необходимости.
Главный критерий — код должен говорить на языке домена. Если бизнес-эксперт может прочитать имена классов и методов и понять, что происходит — вы на правильном пути.