Паттерны DDD — Entity, Aggregate, Bounded Context — отвечают на вопрос «что строить». Эта статья о другом: как думать при проектировании. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в один паттерн, но определяют качество всей архитектуры.
Самый заметный из этих принципов — про то, где живут бизнес-правила. Решение принимается один раз, в первый день, а расплачиваться за него приходится на каждом следующем сценарии.
Решение принимается один раз, а последствия приходят с каждым новым сценарием. В варианте A правило переписывают в каждом сервисе — к третьему разу копия проверяет уже не то, и уже подтверждённый заказ подтверждается второй раз. В варианте B новый сценарий не пишет проверку заново, а вызывает order.confirm(): место с правилом одно, и правят его там же.
Знание должно жить в коде, а не в SQL
Когда команда начинает строить систему, бизнес-правила часто оседают там, где удобнее прямо сейчас: в SQL-запросах, в контроллерах, в скриптах. Через год никто не помнит, откуда берётся скидка для «золотого» клиента — это WHERE в запросе или условие в сервисе?
Эванс называет этот процесс Knowledge Crunching — перемалывание знаний. Суть: модель должна расти из диалога с людьми, которые понимают домен. И знание должно оставаться в доменном коде, а не утекать вовне.
Как это выглядит по календарю
«Модель растёт из диалога» — правда, из которой непонятно, что делать в понедельник. Практика такая.
Первый заход — событийный штурм, полдня. Собираются эксперты и инженеры и выкладывают процесс целиком. Это самый быстрый способ добыть понятия, связи и — главное — противоречия. Формат, ход по шагам и что делать с результатом разбирает статья про событийный штурм; здесь важно, что перемалывание начинается именно с него, а не с чтения требований.
Дальше — час в неделю, регулярно. Не «когда понадобится»: регулярность важнее длительности. На встречу приносят конкретные вопросы из кода, а не «давайте обсудим модель»: «вот здесь мы решили, что отменить можно до отгрузки, — а что с частично отгруженным?», «в коде статус называется HOLD, а вы говорите „на согласовании“ — это одно и то же?». Час такого разговора даёт больше, чем день абстрактного проектирования.
Что приносят на встречу. Три вещи, каждая на одну страницу: список изменившихся или непонятных правил; список слов, где код и разговор расходятся; и — самое полезное — реальные случаи, которые модель не объяснила. Последнее обычно приходит из поддержки и из аварий: «пришёл заказ, у которого две оплаты, а модель это не предусматривает».
Как понять, что модель устарела и пора пересматривать. Признаки наблюдаемые, а не интуитивные:
- Новое требование не укладывается без флага или поля-исключения. Первый флаг — случайность, третий — модель отстала от домена.
- Эксперт говорит словами, которых нет в коде. Появился «отложенный заказ», а в модели только
PENDING— значит, домен ушёл вперёд. - Разработчики спрашивают одно и то же дважды. Повторяющийся вопрос означает, что в модели нет места для ответа.
- В коде много условий про «особый случай», каждый из которых когда-то был отдельной просьбой бизнеса.
- Тесты названы по номеру задачи, а не по правилу:
testJira1234— признак, что правило никто не смог назвать.
И честная оговорка про доступность. Перемалывание требует людей, которые знают домен, и требует их регулярно. Если эксперт доступен раз в квартал на полчаса, модель будет расти из догадок — и это надо назвать вслух как риск, а не делать вид, что DDD применяется. Цена доступа к экспертам — часть цены входа.
Как выглядит утечка знаний:
// Знание спрятано в 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 final OrderId id;
private OrderStatus status;
private final List<DomainEvent> events = new ArrayList<>();
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 и никуда не утекает.
Когда анемичная модель — правильный выбор
Названо антипаттерном — и это верно для контекста с правилами. Но статья первой части фазы прямо говорит, что для CRUD-контекста DDD избыточен, и здесь то же самое: есть места, где модель без поведения дешевле и честнее.
Где анемичная модель нормальна:
- CRUD-контекст без правил. Справочники, настройки, каталог с формой редактирования. Правил нет, «поведения» тоже нет — единственное поведение состоит в «сохранить то, что ввели». Доменный класс с методами здесь превратится в набор сеттеров с другими именами.
- Поддерживающий контекст, который скоро заменят. Кусок, который через год планируют отдать внешнему сервису: вложение в модель не вернётся.
- Слой чтения. Структуры для отчётов и списков — это по определению данные без поведения, и им поведение противопоказано. Такие объекты и не должны быть доменными.
- Перенос данных между границами. Структуры запроса и ответа, сообщения, записи в очередь — данные, и всё.
Как отличить оправданную анемичность от ленивой, одним вопросом: есть ли в этом контексте правила, которые можно нарушить? Если объект нельзя привести в недопустимое состояние (любая комбинация полей законна), то защищать нечего, и модель с поведением защищает воздух. Если можно («сумма заказа не совпадает с суммой позиций», «отменённый нельзя оплатить»), то анемичная модель означает, что правила лежат снаружи и будут разъезжаться.
И вторая проверка, по симптомам: сервис, который правит поля объекта в трёх разных местах по-разному, — вот это уже ленивая анемичность, и её надо лечить. Сервис, который читает и сохраняет, — нормально.
Практическое следствие для проекта: в одном приложении уживаются оба стиля, и это не непоследовательность. Ядро с правилами — модель с поведением; справочники и отчёты — простые структуры. Попытка сделать «как в книжке» везде даёт больше кода без выигрыша ровно там, где выигрыша не бывает.
Домен не должен зависеть от фреймворков
Классическая проблема: разработчик пишет доменный объект и сразу вешает на него @Entity, @Table, аннотации JPA. Потом оказывается, что этот объект нельзя протестировать без базы данных, нельзя переиспользовать без Spring, нельзя изменить схему без переписывания домена.
Решение — слоистая архитектура с чёткими правилами: UI → Application → Domain ← Infrastructure. Стрелка здесь означает «ссылается на»: и приложение, и инфраструктура смотрят на домен, а домен не смотрит ни на кого. Он не импортирует ни Spring, ни JPA, ни соседний слой — интерфейс репозитория объявлен в домене, а реализация лежит снаружи и приходит к нему сама.
// 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; // инфраструктура в слое приложения
}
Сколько стоит эта изоляция и когда она не нужна
Схема выше требует двух наборов классов: доменный Order и запись для базы, плюс преобразование между ними. Это лишний код, и решение «стоит ли он того» принимается по размеру, а не по принципу.
Что именно добавляется. На каждую сущность: класс домена, класс записи базы, преобразователь в обе стороны, тесты преобразователя. Порядок — 50–150 строк на сущность плюс работа при каждом изменении полей (поправить надо в двух местах и в преобразователе).
Что за это получают. Домен, который тестируется без базы за миллисекунды; свобода менять схему хранения, не трогая правила; невозможность случайно «сохранить» изменение, которое домен не разрешал (ленивые связи и грязная проверка не дотягиваются до правил); и — самое недооценённое — доменные классы, которые читаются как домен, без технических полей и связей ради связей.
Когда изоляция оправдана:
- контекст с настоящими правилами, который живёт годами (ядро);
- правила, которые надо проверять в тестах массово (тогда скорость тестов без базы решает всё);
- модель, где доменная структура не совпадает со схемой хранения: агрегат в трёх таблицах, значения в отдельных колонках, история в соседней таблице;
- несколько контекстов работают с одними таблицами по-разному.
Когда переплата:
- сервис на несколько сущностей с простыми правилами;
- контекст, где доменная структура один в один совпадает с таблицей (тогда преобразователь — чистая переписка полей);
- прототип или то, что через год перепишут.
Промежуточный вариант, который стоит знать, потому что он закрывает большинство случаев: один класс, но с честными правилами. Сущность с аннотациями хранения, но с приватными полями, без публичных сеттеров, с методами-переходами и с проверками внутри. Домен формально зависит от библиотеки хранения, зато правила защищены и кода вдвое меньше. Цена: тесты требуют хотя бы минимального контекста хранения, и легко случайно вытащить наружу технические детали (ленивые связи). Для поддерживающих контекстов и небольших сервисов это разумный компромисс, и называть его ошибкой неправильно — важно, чтобы он был выбран, а не получился.
Правило, по которому стоит решать: чем дольше жизнь контекста и чем больше в нём правил, тем сильнее нужна изоляция. И обратное: изоляция, введённая «потому что так правильно» в сервисе из четырёх таблиц, — это тот самый лишний слой, за который потом ругают DDD.
Скрытые концепции стоит сделать видимыми
Когда в коде появляется длинное условие без имени — это признак, что в домене есть важная концепция, которую никто не назвал.
// Невыразительная логика — что это означает?
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().isAtLeast(amount);
}
}
// Период — доменная концепция вместо двух дат
record BillingPeriod(LocalDate from, LocalDate to) {
boolean includes(LocalDate date) {
return !date.isBefore(from) && !date.isAfter(to);
}
}
// Событие — явный факт домена
record OrderExpired(UUID eventId, Instant occurredAt, OrderId orderId) 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 — интерфейсы, раскрывающие намерение. Читая вызов метода, понимаешь смысл операции без заглядывания в реализацию.
Вычисления не должны менять состояние
Метод calculateTotal() посчитал сумму и заодно записал её в базу — и теперь его нельзя вызвать «просто посмотреть», а тест на него требует базы. Правило простое: если метод что-то вычисляет, он не должен одновременно что-то менять. Эванс называет это 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 {
private final OrderId id;
private final List<DomainEvent> events = new ArrayList<>();
private Discount discount = Discount.none();
void applyDiscount(Discount discount) {
this.discount = discount;
events.add(new DiscountAppliedEvent(id, discount));
}
}
Скидка здесь запоминается, а итог по-прежнему считается из строк заказа — так же, как в тактических паттернах. Переписать хранимый total было бы соблазнительно, но тогда правило «итого = сумма строк со скидкой» пришлось бы поддерживать руками в каждом методе, который трогает заказ.
Инварианты проверяются там, где меняется состояние
Инвариант — это правило, которое всегда должно выполняться. Если объект может оказаться в недопустимом состоянии — это рано или поздно приведёт к ошибке, которую трудно поймать.
// Молча позволяет отрицательный баланс
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 — утверждения о состоянии. Ошибки ловятся близко к источнику, а не всплывают в неожиданном месте.
Прорыв: чем за него платят
В разговоре про эволюцию модели встречается слово прорыв (breakthrough) — момент, когда постепенные улучшения складываются в новое понимание, и модель нужно переделать целиком. Классический пример из этой же статьи: флаг hasDiscount превращается в объект Discount со своими правилами, и вслед за ним меняется половина модели, потому что скидка оказывается полноценным понятием домена.
Что важно сказать про это прямо: прорыв — это переписывание работающей модели, и решение о нём принимают, а не ждут вдохновения.
Как он выглядит. Сначала накапливается неудобство: каждое новое требование про скидки требует ещё одного флага, условия расползаются, тесты называются по номерам задач. Потом кто-то произносит правильное слово («это не признак заказа, это отдельная сущность со своей жизнью»), и становится видно, что модель можно сделать проще и точнее. Это и есть прорыв: не новая функциональность, а другое устройство того же самого.
Чем платят. Честный список, потому что решение принимается по нему:
- Работа без видимого результата. Неделя-две, после которых поведение системы не изменилось. Бизнесу это надо объяснять заранее, а не после.
- Риск регрессии. Переписывается то, что работало; тесты, написанные под старую модель, придётся частично выбросить — и именно они защищали от ошибок.
- Остановка изменений в этой области. Делать прорыв и одновременно добавлять функциональность в том же месте нельзя: получатся оба изменения, перемешанные в одном пул-реквесте.
- Заражение соседей. Новое понятие обычно вылезает за границы: появившийся
Discountменяет контракт, событие, отчёты.
Когда на это идут. Три условия вместе: неудобство повторяется (не один раз показалось, а третье требование подряд не укладывается); новое понимание названо словами и его подтверждает эксперт домена (а не «мне кажется, тут нужна абстракция»); и область активно меняется — в замороженный кусок прорыв вкладывать незачем.
Когда не идут. Контекст поддерживающий и стабильный; понимание есть, но нет времени довести до конца (половина прорыва хуже его отсутствия: в коде будут жить обе модели); или главная мотивация — «стало некрасиво».
Как делают, чтобы не растянулось на квартал. Отдельная ветка на два-три дня для проверки идеи на самом трудном случае (не на простом!); затем решение «идём или нет» по результату; затем полная замена в один-два пул-реквеста с зелёными тестами на каждом шаге, а не долгое сосуществование двух моделей. И тесты на правила, а не на структуру, готовятся заранее — они и есть страховка при переписывании: если тесты сформулированы через поведение («отменённый нельзя оплатить»), они переживут смену модели и поймают регрессию.
Что получают, если сделали. Не «красивее», а измеримое: следующее требование того же класса укладывается в модель без флага, число условий падает, а понятие получает имя, которым пользуются и бизнес, и код. Если после прорыва этого не случилось — это была перестройка ради перестройки, и в следующий раз стоит требовать более твёрдого критерия.
Паттерны 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.status() == OrderStatus.DRAFT && order.hasLines();
}
}
Антипаттерн — паттерн ради паттерна, без доменного смысла:
// Синглтон ConfigManager не имеет отношения к домену
class ConfigManager {
static final ConfigManager INSTANCE = new ConfigManager();
private ConfigManager() {}
}
Рефакторинг в DDD — не техническое упражнение, а инструмент углубления модели. Каждое изменение должно приближать код к языку домена.
Остальные приёмы гибкого дизайна
Три приёма выше (говорящие интерфейсы, функции без побочных эффектов, явные проверки) — часть набора, который Эванс называет гибким дизайном (supple design): код, который легко менять, потому что он говорит о домене. Остальные три приёма встречаются реже в статьях и чаще в жизни.
Границы понятий (conceptual contours). Идея: разрезать код по естественным границам домена, а не по удобству реализации. Признак того, что граница неестественная: одно изменение требования всегда требует правок в двух местах, а два независимых требования всегда меняют один и тот же класс. Практическая проверка — посмотреть историю изменений: файлы, которые всегда меняются вместе, хотят быть одним; файл, который меняется по трём несвязанным причинам, хочет быть тремя. Это та же мысль, что про границы контекстов, только на уровне классов.
Самодостаточные классы (standalone classes). Чем меньше зависимостей у класса, тем легче его понять: класс, для чтения которого нужно держать в голове ещё пять, дорог в обслуживании. Приём — вынимать из класса всё, что можно выразить значениями: вместо ссылки на Customer внутри расчёта — CustomerTier как значение; вместо зависимости от репозитория — переданные на вход данные. Крайний и самый полезный случай — класс, у которого зависимостей нет вовсе: чистый расчёт, который тестируется одним вызовом. Правило на практике: если класс можно сделать самодостаточным, не потеряв смысла, — сделайте, это самое дешёвое упрощение из существующих.
Замкнутость операций (closure of operations). Операция, которая принимает и возвращает тот же тип, не тянет за собой новых понятий и складывается в цепочки. Money.add(Money): Money — ровно это: сложение денег не требует знать ни про заказ, ни про базу, и результат можно складывать дальше. То же с DateRange.intersect(DateRange): DateRange, Specification.and(Specification): Specification. Почему это важно: такие операции компонуются, то есть из простых правил собираются сложные без нового кода — и именно поэтому денежная сумма и период получаются такими удобными, а «сервис, который считает сумму по заказу» — нет.
Как этим пользоваться, не превращая в теорию: три вопроса к своему коду. Что здесь меняется вместе, а что нет (границы понятий). Что можно передать значением вместо ссылки (самодостаточность). Какие операции можно сделать «тип → тот же тип» (замкнутость). Каждый из них даёт конкретную правку, и все три дешёвые.
Зачем правило отдельным объектом
Specification упомянут в примерах, и стоит ответить на прямой вопрос: чем new CanConfirmOrder().isSatisfiedBy(order) лучше метода order.canBeConfirmed()? Метод почти всегда лучше — пока не появляется одна из четырёх причин.
Причина 1: правило нужно и в памяти, и в запросе к базе. «Активный клиент — купивший за 90 дней и не заблокированный» проверяется на объекте в памяти и используется для выборки из базы. Метод на объекте для выборки не годится: нельзя же поднять миллион клиентов и отфильтровать. Спецификация умеет обе роли: у неё есть проверка объекта и способ превратиться в условие запроса. Это её главное оправдание.
Причина 2: правил много и они складываются. Когда условий пять и их сочетают по-разному (в одном месте нужны первое и третье, в другом — первое или второе), получается либо десять методов, либо параметризованный метод с флагами. Спецификации складываются операциями and, or, not — и это ровно та замкнутость операций из раздела выше:
var eligible = confirmed.and(fullyPaid).and(notCancelled.or(cancelledByAdmin));
Причина 3: правило выбирается во время работы. Условия акции, настройки тарифа, критерии подбора, которые задаёт не программист, а пользователь в интерфейсе: правило становится данными, и тогда ему нужен объект, который можно собрать из настроек и сохранить.
Причина 4: одно правило живёт в нескольких местах жизненного цикла. Классический набор: одна и та же спецификация проверяет объект перед сохранением (проверка), выбирает подходящие из базы (выборка) и создаёт объект, удовлетворяющий условию (построение). Третья роль встречается редко, первые две — постоянно.
Почему метод обычно лучше. Он короче, читается прямо в классе, не требует лишнего типа и не размазывает правило по двум местам. Правило «отменить можно до отгрузки» должно быть методом order.canBeCancelled() — и всё. Спецификации, введённые «на всякий случай», дают классы OrderIsConfirmedSpecification на одну строку кода и ощущение, что DDD — это про создание классов.
Практическая ловушка про базу. Обещание «одна спецификация работает и в памяти, и в запросе» стоит денег: спецификация должна уметь превратиться в условие запроса, а это либо ручной метод («вот такое условие»), либо библиотека построения запросов. Сложные условия (по связанным сущностям, с агрегатами) в такую схему укладываются плохо, и появляется соблазн держать два варианта одного правила — в памяти и в запросе, — а это ровно то расхождение, от которого спецификация должна была спасти. Поэтому правило простое: в спецификацию выносят то, что действительно нужно с двух сторон, и не больше.
Глубже: Specification: правило как объектрасширенное
CanConfirmOrder в листинге выше это спецификация, и стоит показать, зачем правило выносят в отдельный объект, если можно написать if.
Причин три. Правило нужно в нескольких местах: «заказ можно подтвердить» проверяется при подтверждении, показывается кнопкой в интерфейсе и фильтрует список «готовые к подтверждению»; три if в трёх местах разъедутся через месяц. Правило составное: «клиент надёжный» это «нет просрочек» и «больше трёх заказов» или «в списке VIP», и составлять его из частей удобнее, чем переписывать условие. И правило должно работать в двух мирах: в памяти, над загруженным агрегатом, и в запросе к базе, чтобы отобрать подходящие строки без загрузки всех.
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) { return c -> isSatisfiedBy(c) && other.isSatisfiedBy(c); }
default Specification<T> or(Specification<T> other) { return c -> isSatisfiedBy(c) || other.isSatisfiedBy(c); }
default Specification<T> not() { return c -> !isSatisfiedBy(c); }
}
public final class NoOverdueInvoices implements Specification<Customer> {
public boolean isSatisfiedBy(Customer c) { return c.invoices().stream().noneMatch(Invoice::isOverdue); }
}
Для второго мира у спецификации появляется вторая обязанность: перевести себя в условие запроса. В jOOQ это метод toCondition(), который возвращает Condition, и репозиторий чтения добавляет его в WHERE; and и or при этом переводятся в Condition.and и or. Две реализации одного правила обязаны совпадать, и на это пишут тест: набор клиентов, для каждого проверка в памяти и через запрос, результаты равны.
Где спецификация вредна. Правило из одного условия в одном месте: if честнее объекта. Правило, которому нужны данные не из агрегата (курс валюты, остаток на складе): это доменный сервис, а не спецификация. И «спецификация на всё» превращает домен в конструктор условий, который никто не читает; берут её для правил, которые повторяются и составляются, обычно это единицы на контекст.
Глубже: как удержать принципы: тесты инвариантов, событий и ArchUnitрасширенное
Принципы выше держатся на дисциплине, а дисциплина не переживает третьего разработчика. Держат их три вида тестов, и все три быстрые.
Тесты инвариантов без базы и без Spring. Агрегат это обычный объект, и его правила проверяются обычным JUnit: собрать заказ, подтвердить, попробовать добавить строку, получить исключение; собрать заказ на сумму больше лимита, получить отказ при подтверждении. Один тест на каждое правило из глоссария, с именем, повторяющим правило: confirmed_order_rejects_new_lines. Эти тесты идут за миллисекунды, их сотни, и они же документация модели; если для теста агрегата понадобился контекст Spring или контейнер базы, домен зависит от инфраструктуры, и это находка.
Тесты событий. Агрегат после операции отдаёт список событий, и тест проверяет, что там ровно ожидаемое: order.confirm() оставляет одно событие OrderConfirmed с нужной суммой, а повторное подтверждение не добавляет второго. Так проверяют, что события отражают факты, а не «вызов метода», и что их не публикуют при неудаче.
Тесты на архитектуру. ArchUnit читает байткод и проверяет правила о зависимостях, которые ревью пропускает: пакет домена не импортирует Spring, jOOQ, Jackson и HTTP; интерфейсы репозиториев лежат в домене, реализации в инфраструктуре; классы с именем *Service в домене не зависят от адаптеров; агрегаты не ссылаются на другие агрегаты объектами, только идентификаторами. Правила пишут по одному, начиная с самого нарушаемого, и ставят в сборку рядом с остальными тестами; сломанное правило это не «предупреждение», а красная сборка. Подробно об этом статья про архитектурные тесты в разделе гексагональной архитектуры.
Вместе три вида закрывают то, ради чего принципы писались: знание живёт в коде и проверяется каждым запуском, а не хранится в голове того, кто внедрял.
Коротко
- Знание в коде — бизнес-правила не должны прятаться в SQL-запросах или вспомогательных объектах без поведения. Модель в коде — анемичная модель (объект с полями + сервис со всей логикой) — антипаттерн; поведение живёт там, где данные.
- Изоляция домена — домен не зависит от фреймворков; зависимости направлены внутрь: UI → Application → Domain ← Infrastructure. Явные концепции — политики, роли, периоды и события заслуживают собственных типов, а не живут внутри
if/else. - Intention-Revealing Interfaces —
confirm()вместоupdateStatus(2); читая вызов, понимаешь смысл. Side-Effect-Free Functions — вычисления и команды разделены; метод либо считает, либо меняет состояние. - Инварианты — проверяются там, где происходит изменение, а не потом в случайном месте. Паттерны GoF — Strategy, Factory, Specification оправданы, когда подчёркивают домен, а не когда добавляют слои ради слоёв. Спецификация выносит правило в объект, когда оно повторяется, составляется из частей и нужно и в памяти, и в запросе (
isSatisfiedByиtoCondition, с тестом на совпадение); для одногоifв одном месте она лишняя. Принципы держат тесты: инварианты агрегата без Spring и базы по одному на правило, события после операции ровно ожидаемые, ArchUnit на направление зависимостей в сборке. - Перемалывание знаний по календарю: событийный штурм на старте, потом час в неделю с конкретными вопросами из кода; модель устарела, если требование не укладывается без флага, эксперт говорит словами, которых нет в коде, а тесты названы по номерам задач.
- Анемичная модель нормальна в CRUD-контексте, слое чтения и структурах переноса данных; проверка одна — есть ли правила, которые можно нарушить; два стиля в одном приложении — норма, а не непоследовательность.
- Изоляция домена от библиотеки хранения стоит 50–150 строк на сущность и оправдана для ядра с правилами и расходящихся структур; для небольших контекстов есть выбранный компромисс — один класс, но с приватными полями и переходами вместо сеттеров.
- Прорыв — переписывание работающей модели: идут на него при повторяющемся неудобстве, названном словами понимании и активной области; платят неделями без видимого результата и риском регрессии, поэтому тесты на правила готовят заранее.
- Гибкий дизайн — это ещё границы понятий (что меняется вместе), самодостаточные классы (значение вместо ссылки) и замкнутость операций (тип → тот же тип), из которой и получаются складываемые правила.
- Правило выносят в отдельный объект по четырём причинам: нужно и в памяти, и в запросе; правил много и они складываются; правило задаётся данными; одно правило работает на нескольких шагах жизненного цикла. Иначе метод на агрегате лучше.
Что почитать дальше
- Что такое DDD и зачем он нужен — отправная точка, если ещё не читали.
- Стратегические паттерны DDD — Bounded Context, Context Map, Ubiquitous Language.
- Тактические паттерны DDD — Entity, Value Object, Aggregate, Repository.
- Онтология и доменная модель показывает, откуда берётся сама модель, к которой применяют эти принципы.