← назад к разделу

В большинстве проектов бизнес-логика постепенно размазывается по сервисам, контроллерам и вспомогательным классам. Через год становится непонятно, где живут правила, кто за что отвечает и почему изменение в одном месте ломает другое.

Тактические паттерны 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, добавляй остальное по необходимости.

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