Паттерны DDD — Entity, Aggregate, Bounded Context — отвечают на вопрос «что строить». Эта статья о другом: как думать при проектировании. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в один паттерн, но определяют качество всей архитектуры.
Знание должно жить в коде, а не в SQL
Когда команда начинает строить систему, бизнес-правила часто оседают там, где удобнее прямо сейчас: в SQL-запросах, в контроллерах, в скриптах. Через год никто не помнит, откуда берётся скидка для «золотого» клиента — это WHERE в запросе или условие в сервисе?
Эванс называет этот процесс Knowledge Crunching — перемалывание знаний. Суть: модель должна расти из диалога с людьми, которые понимают домен. И знание должно оставаться в доменном коде, а не утекать вовне.
Как выглядит утечка знаний:
// Знание спрятано в SQL — кто это прочитает через год?
class OrderDao {
RiskRating riskFor(OrderId id) {
// SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
return RiskRating.LOW;
}
}
// Копия структуры БД вместо модели — никакого поведения
class OrderRecord {
Long id;
BigDecimal total_amount;
String status_code;
}
Как выглядит явная доменная концепция:
class CustomerTierResolver {
CustomerTier resolve(Customer customer) {
if (customer.ordersInLastYear() >= 20) return CustomerTier.GOLD;
if (customer.ordersInLastYear() >= 5) return CustomerTier.SILVER;
return CustomerTier.BRONZE;
}
}
Правило читается как правило — без SQL, без магических чисел в комментариях.
Главная ловушка: зафиксировать модель слишком рано или скопировать структуру базы данных как доменную модель. Ранние модели всегда наивны — и это нормально, их нужно активно пересматривать.
Модель должна жить в коде, а не только на вики
Представьте: архитектор нарисовал красивую диаграмму на доске. Разработчики посмотрели и пошли писать код по-своему. Модель и реализация расходятся, и через полгода диаграмма описывает воображаемую систему, а не реальную.
Эванс называет это Model-Driven Design: модель должна быть выражена прямо в коде. Если модель существует только в голове аналитика или на вики — это не Model-Driven Design.
Самая частая проблема — анемичная модель: объект только с полями и геттерами, вся логика в отдельном сервисе. Выглядит аккуратно, но бизнес-правила начинают утекать в сервисы, контроллеры, маппинги — и со временем непонятно, где искать правду.
// Анемичная модель: сущность без поведения
class Order {
Long id;
OrderStatus status;
// только get/set
}
// Вся логика в сервисе
class OrderService {
void confirm(Order order) {
if (order.getStatus() != OrderStatus.DRAFT) throw new RuntimeException();
order.setStatus(OrderStatus.CONFIRMED);
}
}
В Model-Driven Design поведение живёт там, где данные:
class Order {
private OrderStatus status;
void confirm() {
if (!canBeConfirmed())
throw new IllegalStateException("Order cannot be confirmed");
status = OrderStatus.CONFIRMED;
}
void markAsPaid(PaymentId paymentId) {
status = OrderStatus.PAID;
events.add(new OrderPaidEvent(id, paymentId));
}
}
Теперь правило «нельзя подтвердить неподходящий заказ» живёт внутри Order и никуда не утекает.
Домен не должен зависеть от фреймворков
Классическая проблема: разработчик пишет доменный объект и сразу вешает на него @Entity, @Table, аннотации JPA. Потом оказывается, что этот объект нельзя протестировать без базы данных, нельзя переиспользовать без Spring, нельзя изменить схему без переписывания домена.
Решение — слоистая архитектура с чёткими правилами: UI → Application → Domain → Infrastructure. Домен никогда не импортирует то, что снаружи него.
// Application Service координирует сценарий, домен принимает решения
class OrderAppService {
private final OrderRepository repo;
void confirmOrder(OrderId id) {
Order order = repo.byId(id);
order.confirm(); // решение — внутри домена
repo.save(order);
}
}
// Интерфейс репозитория объявлен в домене
interface OrderRepository {
Order byId(OrderId id);
void save(Order order);
}
// Реализация — в инфраструктурном слое, домен о ней не знает
class JpaOrderRepository implements OrderRepository {
public Order byId(OrderId id) { /* JPA */ }
public void save(Order order) { /* JPA */ }
}
Антипаттерны:
// Доменный объект с инфраструктурными аннотациями
@Entity
@Table(name = "orders")
class Order { /* ORM прямо в домене */ }
// Application Service работает напрямую с инфраструктурой
class OrderAppService {
@PersistenceContext EntityManager em; // инфраструктура в слое приложения
}
Скрытые концепции стоит сделать видимыми
Когда в коде появляется длинное условие без имени — это признак, что в домене есть важная концепция, которую никто не назвал.
// Невыразительная логика — что это означает?
if (amount.compareTo(limit) <= 0 && !customer.isBlocked() && customer.age() >= 18) {
// разрешить операцию
}
Кандидаты на выделение — политики, роли, временные периоды, доменные события:
// Политика — именованное правило
class CreditApprovalPolicy {
boolean allows(Customer customer, Money amount) {
return !customer.isBlocked()
&& customer.isAdult()
&& customer.creditLimit().remaining().isGreaterThan(amount);
}
}
// Период — доменная концепция вместо двух дат
record BillingPeriod(LocalDate from, LocalDate to) {
boolean includes(LocalDate date) {
return !date.isBefore(from) && !date.isAfter(to);
}
}
// Событие — явный факт домена
record OrderExpired(OrderId orderId, Instant expiredAt) implements DomainEvent {}
Явные типы упрощают тестирование и повторное использование. Правило с именем можно обсудить с экспертом, поменять, протестировать отдельно.
Иногда явная концепция переворачивает модель — Эванс называет это прорывом. До прорыва: скидка — это просто флаг boolean hasDiscount. После прорыва: скидка — это объект Discount с правилами применения. Код становится проще и выразительнее.
API должен рассказывать, что происходит
Плохой признак — метод, название которого не говорит ничего:
order.updateStatus(2); // что такое 2?
order.process(true, false); // что делают эти boolean?
Хороший публичный API говорит что происходит, а не как это сделано внутри:
order.confirm();
order.cancelByCustomer(reason);
order.markAsPaid(paymentId);
Эванс называет этот принцип Intention-Revealing Interfaces — интерфейсы, раскрывающие намерение. Читая вызов метода, понимаешь смысл операции без заглядывания в реализацию.
Вычисления не должны менять состояние
Ещё один принцип из раздела Supple Design — Side-Effect-Free Functions. Если метод что-то вычисляет, он не должен одновременно что-то менять. Это делает код предсказуемым и простым для тестирования.
// Вычисление + побочный эффект — опасная смесь
class PricingService {
Money calculateAndApplyDiscount(Order order) {
Money discount = computeDiscount(order);
order.setTotal(order.getTotal().subtract(discount)); // мутирует заказ!
return discount;
}
}
Лучше разделить:
// Чистое вычисление — ничего не меняет
class TaxCalculator {
Money taxFor(Order order) {
return order.subtotal().multiply(TAX_RATE);
}
}
// Команда — меняет состояние, ничего не возвращает
class Order {
void applyDiscount(Discount discount) {
this.total = discount.applyTo(this.total);
events.add(new DiscountAppliedEvent(id, discount));
}
}
Инварианты проверяются там, где меняется состояние
Инвариант — это правило, которое всегда должно выполняться. Если объект может оказаться в недопустимом состоянии — это рано или поздно приведёт к ошибке, которую трудно поймать.
// Молча позволяет отрицательный баланс
class Account {
void withdraw(Money amount) {
balance = balance.subtract(amount); // а если amount > balance?
}
}
Инварианты проверяются там, где происходит изменение:
class Account {
void withdraw(Money amount) {
if (amount.isNegative()) throw new IllegalArgumentException("Сумма должна быть положительной");
if (balance.isLessThan(amount)) throw new IllegalStateException("Недостаточно средств");
balance = balance.subtract(amount);
}
}
Эванс называет этот принцип Assertions — утверждения о состоянии. Ошибки ловятся близко к источнику, а не всплывают в неожиданном месте.
Паттерны GoF помогают выразить домен
Strategy, Factory, Specification — технические паттерны оправданы, когда они подчёркивают смысл домена, а не просто добавляют уровни абстракции.
// Strategy — для вариативного поведения
interface ShippingPolicy {
Money costFor(Shipment shipment);
}
class ExpressShipping implements ShippingPolicy {
public Money costFor(Shipment shipment) { /* ... */ }
}
// Factory — для создания с доменными правилами
class ShippingPolicyFactory {
ShippingPolicy forOrder(Order order) {
return order.isExpress() ? new ExpressShipping() : new StandardShipping();
}
}
// Specification — для переиспользуемых правил
class CanConfirmOrder implements Specification<Order> {
public boolean isSatisfiedBy(Order order) {
return order.isPaid() && order.hasItems();
}
}
Антипаттерн — паттерн ради паттерна, без доменного смысла:
// Синглтон ConfigManager не имеет отношения к домену
class ConfigManager {
static ConfigManager INSTANCE = new ConfigManager();
}
Рефакторинг в DDD — не техническое упражнение, а инструмент углубления модели. Каждое изменение должно приближать код к языку домена.
Коротко
- Знание в коде — бизнес-правила не должны прятаться в SQL-запросах или вспомогательных объектах без поведения.
- Модель в коде — анемичная модель (объект с полями + сервис со всей логикой) — антипаттерн; поведение живёт там, где данные.
- Изоляция домена — домен не зависит от фреймворков; зависимости направлены внутрь: UI → Application → Domain ← Infrastructure.
- Явные концепции — политики, роли, периоды и события заслуживают собственных типов, а не живут внутри
if/else. - Intention-Revealing Interfaces —
confirm()вместоupdateStatus(2); читая вызов, понимаешь смысл. - Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние.
- Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте.
- Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв.
Что почитать дальше
- Что такое DDD и зачем он нужен — отправная точка, если ещё не читали.
- Стратегические паттерны DDD — Bounded Context, Context Map, Ubiquitous Language.
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Repository.