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

Паттерны DDD — Entity, Aggregate, Bounded Context — отвечают на вопрос «что строить». Эта статья о другом: как думать при проектировании. Здесь собраны принципы из книги Эрика Эванса, которые не укладываются в один паттерн, но определяют качество всей архитектуры.

Самый заметный из этих принципов — про то, где живут бизнес-правила. Решение принимается один раз, в первый день, а расплачиваться за него приходится на каждом следующем сценарии.

одно правило: подтвердить можно только черновик вариант A: поля + сервис Order поля и геттеры, поведения нет CheckoutService if status == DRAFT CashierServiceif status == DRAFT ImportServiceif status != CANCELLED правило записано в 1 месте правило записано в 2 местах правило записано в 3 местахтретья копия уже разошлась вариант B: поведение в Order Order.confirm() if status == DRAFT CheckoutService order.confirm() CashierServiceorder.confirm() ImportServiceorder.confirm() правило записано в 1 месте новый сценарий его вызывает правило меняется: черновик или оплаченный3 правки, одну забудут1 правка

Решение принимается один раз, а последствия приходят с каждым новым сценарием. В варианте 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, без магических чисел в комментариях.

CustomerTier WHERE в SQL порог суммы в запросе if в сервисе та же граница ещё раз фильтр на фронте третья копия правила одно место правки меняем один раз

Слева три места, где сегодня живёт одно правило про скидку; справа тот же смысл, собранный в доменный тип, и одна точка правки.

Главная ловушка: зафиксировать модель слишком рано или скопировать структуру базы данных как доменную модель. Ранние модели всегда наивны — и это нормально, их нужно активно пересматривать.

Модель должна жить в коде, а не только на вики

Представьте: архитектор нарисовал красивую диаграмму на доске. Разработчики посмотрели и пошли писать код по-своему. Модель и реализация расходятся, и через полгода диаграмма описывает воображаемую систему, а не реальную.

Эванс называет это 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 строк на сущность и оправдана для ядра с правилами и расходящихся структур; для небольших контекстов есть выбранный компромисс — один класс, но с приватными полями и переходами вместо сеттеров.
  • Прорыв — переписывание работающей модели: идут на него при повторяющемся неудобстве, названном словами понимании и активной области; платят неделями без видимого результата и риском регрессии, поэтому тесты на правила готовят заранее.
  • Гибкий дизайн — это ещё границы понятий (что меняется вместе), самодостаточные классы (значение вместо ссылки) и замкнутость операций (тип → тот же тип), из которой и получаются складываемые правила.
  • Правило выносят в отдельный объект по четырём причинам: нужно и в памяти, и в запросе; правил много и они складываются; правило задаётся данными; одно правило работает на нескольких шагах жизненного цикла. Иначе метод на агрегате лучше.

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