В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.
Тактические паттерны DDD дают структуру для организации этой логики. Каждый паттерн отвечает на конкретный вопрос: что является объектом с уникальной идентичностью, что — просто значением, где граница консистентности, как части системы общаются через события.
Entity — объект с идентичностью
Представьте пользователя в системе. У него есть имя и email — они могут меняться. Но пользователь остаётся тем же пользователем, даже если поменял всё. Его идентичность — это идентификатор, а не набор полей.
Entity — объект, у которого есть уникальный идентификатор. Два объекта с одинаковыми полями, но разными ID — разные сущности.
Ключевые свойства:
- Есть стабильный ID (не меняется в течение жизни объекта).
- Состояние может меняться — но только через методы, которые проверяют бизнес-правила.
- Равенство по ID, не по полям.
- Содержит поведение, а не только данные.
Частые примеры: User, Order, Invoice, Shipment, Contract.
Типичная ошибка — «анемичная» Entity: класс с геттерами и сеттерами без правил. Тогда бизнес-логика утекает в сервисы, а объект становится структурой данных.
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 void changeEmail(Email newEmail) {
if (!active) throw new IllegalStateException("Inactive user cannot change email");
this.email = Objects.requireNonNull(newEmail);
}
public void deactivate() { this.active = false; }
@Override
public boolean equals(Object o) {
if (!(o instanceof User user)) return false;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() { return Objects.hash(id); }
}
Метод changeEmail — не просто сеттер. Он проверяет бизнес-правило: неактивный пользователь не может менять email. Это ключевое отличие от простой записи.
Value Object — объект без идентичности
Деньги — хороший пример. Две купюры по 100 рублей одинаковы — нас не интересует, «та же» ли это купюра или другая. Главное — сумма и валюта.
Value Object — объект, у которого нет собственного ID. Равенство определяется по значениям полей. После создания не меняется (иммутабельный).
Ключевые свойства:
- Нет ID.
- Иммутабельный: вместо изменения создаётся новый объект.
- Равенство по всем значимым полям.
- Проверяет свои инварианты при создании.
Частые примеры: Money, Email, PhoneNumber, Address, DateRange.
Зачем нужны Value Object: они убирают «примитивную одержимость» — когда вместо типа Email везде ходит строка String. Правила формата, валидация и операции живут внутри типа, а не разбросаны по коду.
public final class Money {
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(2, RoundingMode.HALF_UP);
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Money m)) return false;
return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
}
@Override
public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
}
Метод add возвращает новый объект — исходный не меняется. Проверка совместимости валют встроена в операцию, а не лежит где-то снаружи.
Aggregate — граница консистентности
Вот реальная проблема: у заказа есть строки. Можно ли изменить строку напрямую, минуя заказ? Формально — да. Но тогда заказ оказывается в некорректном состоянии: например, сумма не пересчитана, или добавили строку в уже подтверждённый заказ.
Aggregate — это кластер Entity и Value Object с единой границей консистентности. Снаружи виден только корень (Aggregate Root) — только через него можно менять состояние агрегата.
Правила:
- Все изменения внутри агрегата идут через корень.
- Нельзя хранить прямые ссылки на внутренние объекты другого агрегата — только ID.
- Агрегат должен быть небольшим. «Бог-агрегат» с десятками объектов внутри — антипаттерн.
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status;
public void addLine(ProductId productId, int qty, Money price) {
ensureDraft();
if (qty <= 0) throw new IllegalArgumentException("qty must be > 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() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.zero(Currency.RUB), Money::add);
}
private void ensureDraft() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("Order is not editable");
}
}
OrderLine — внутренний объект агрегата. Снаружи его напрямую не трогают — только через методы Order.
Domain Event — что произошло в домене
Когда заказ оплачен, нужно: отправить письмо, уведомить склад, начислить бонусы. Один способ — вызвать всё это прямо в методе pay(). Но тогда Order знает про email, склад и бонусы — это нарушает границы ответственности.
Domain Event — объект, который фиксирует факт: что-то значимое произошло в домене. Агрегат публикует событие, а другие части системы реагируют самостоятельно.
Характеристики:
- Иммутабельный — факт в прошлом нельзя изменить.
- Именование в прошедшем времени:
OrderPaid,UserRegistered, неPayOrder. - Несёт контекст:
orderId,amount,paidAt— не просто маркер. - Агрегат накапливает события и отдаёт их после сохранения.
public abstract class DomainEvent {
private final UUID eventId = UUID.randomUUID();
private final Instant 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) {
this.orderId = orderId;
this.amount = amount;
}
}
public abstract class AggregateRoot {
private final List<DomainEvent> domainEvents = new ArrayList<>();
protected void registerEvent(DomainEvent event) { domainEvents.add(event); }
public List<DomainEvent> getDomainEvents() { return Collections.unmodifiableList(domainEvents); }
public void clearEvents() { domainEvents.clear(); }
}
Агрегат вызывает registerEvent(new OrderPaid(...)) при изменении состояния. После сохранения агрегата репозиторий забирает накопленные события и публикует их — это гарантирует, что события уходят только после успешной записи.
Внутренние события живут внутри одного bounded context, часто в одной транзакции. Внешние пересекают границы через брокер сообщений (Kafka, RabbitMQ) и требуют сериализации.
Repository — доступ к агрегатам
Как правило, когда нужно сохранить заказ, пишут SQL прямо в сервисе. Запросы выходят за пределы бизнес-логики, привязываются к конкретной БД, и поменять хранилище становится дорого.
Repository — абстракция доступа к хранилищу агрегатов. Он создаёт иллюзию коллекции объектов в памяти: найти, сохранить. Интерфейс живёт в слое домена, реализация — в инфраструктуре.
Ключевые правила:
- Один репозиторий — один агрегат.
- Загружает и сохраняет агрегат целиком.
- Методы в терминах домена:
findById,save— не SQL. - Интерфейс в домене, реализация в инфраструктуре (инверсия зависимостей).
// Интерфейс — в домене
interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
// Реализация — в инфраструктуре, публикует события после сохранения
public class OrderRepositoryImpl implements OrderRepository {
private final OrderDao dao;
private final EventPublisher eventPublisher;
@Override
public void save(Order order) {
dao.save(order);
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearEvents();
}
}
Разница с DAO: Repository работает с агрегатом целиком и думает на языке домена. DAO работает с таблицей и думает на языке хранилища.
Domain Service — логика между агрегатами
Иногда операция не принадлежит ни одному агрегату. Перевод денег между счетами: нужно снять с одного и зачислить на другой. Ни Account-от-отправителя, ни Account-получателя не должны знать друг о друге.
Domain Service — место для бизнес-логики, которая координирует несколько агрегатов или объектов домена.
Правило: сначала попробуй поместить логику в Entity или Aggregate Root. Domain Service — если логика действительно не принадлежит ни одному из них.
class TransferService {
void transfer(Account from, Account to, Money amount) {
from.withdraw(amount);
to.deposit(amount);
}
}
Domain Service живёт в слое домена и знает только про доменные объекты. Не путать с Application Service — тот живёт в слое приложения и оркестрирует: загрузить агрегат, вызвать доменную логику, сохранить.
Factory — создание агрегатов
Иногда создание агрегата — это не просто new. Нужно проверить бизнес-правила, сгенерировать ID, собрать объект из нескольких частей.
Factory инкапсулирует логику создания агрегата. Если конструктор справляется — фабрика не нужна.
class OrderFactory {
Order createNew(Customer customer) {
if (customer.isBlocked())
throw new IllegalStateException("Blocked customer cannot place orders");
return new Order(OrderId.generate(), customer.id());
}
}
Здесь проверяется бизнес-правило («заблокированный клиент не может оформить заказ») до создания агрегата. Сам конструктор Order не знает про Customer — это разделение обязанностей.
Коротко
- Entity — объект с уникальным ID; равенство по ID; содержит поведение и проверяет инварианты.
- Value Object — нет ID, иммутабельный, равенство по значениям; убирает «примитивную одержимость».
- Aggregate — кластер объектов с единой границей консистентности; снаружи виден только корень.
- Domain Event — иммутабельный факт в прошедшем времени; агрегат накапливает события, репозиторий публикует их после сохранения.
- Repository — абстракция хранилища агрегатов; интерфейс в домене, реализация в инфраструктуре.
- Domain Service — бизнес-логика, которая не принадлежит ни одному агрегату; крайнее средство, не первый выбор.
- Factory — создание агрегата со сложной логикой; если конструктор справляется — не нужна.
- Не нужно применять все паттерны сразу. Начни с Entity и Value Object, добавляй остальное по необходимости.
Что почитать дальше
- Что такое DDD и зачем он нужен — стратегический уровень: bounded context, ubiquitous language.
- Стратегические паттерны DDD — как делить систему на контексты и выстраивать отношения между ними.
- Интеграционные паттерны DDD — как bounded contexts общаются между собой.