Тактический DDD: 5 паттернов, 4 факапа, 1 правило
Содержание
TL;DR: Тактические паттерны DDD – инструменты под конкретные боли, а не чеклист для Core-домена. Value Object убирает целые классы багов бесплатно. Aggregate – это граница транзакционной консистентности, а не “класс с детками”. Generic Repository<T> – не абстракция, а отказ от проектирования. Domain Service – для логики, которая объективно не принадлежит ни одному агрегату. Если у паттерна нет боли, которую он решает, в коде ему делать нечего.
Открываю IDE. Ищу SearchService.Search(). Строка 1. Строка 50. Строка 120. Прокручиваю быстрее. Строка 310. Дожимаю Ctrl+End – строка 437. Один метод. Внутри – вызовы трёх провайдеров, дедупликация, наценки, корпоративные политики, пересчёт валют, кэш, фолбэки. Класс целиком – около двух с половиной тысяч строк.
Это был travel-проект, 2018 год. Сервис поиска по отельным агрегаторам – когда-то тонкий адаптер, за полтора года превратившийся в то, что я открыл. Никто специально не проектировал его таким. Просто каждую новую фичу проще было дописать в конец Search(), чем выделять в отдельный объект. Через полтора года “проще” накопилось до двух с половиной тысяч строк.
Поменять ранжирование – PR на 15 файлов, потому что логика сортировки лежала рядом с вызовом HotelBook. Добавить новый фильтр страшно: ты не уверен, не сломаешь ли наценки. Баг “в поиске показался несуществующий отель” – час на bisection по шагам: кэш? провайдер? дедупликация? наценка? Тесты требовали моков на три провайдера, Redis и конфиг комиссий, проще было руками в dev-стенде проверить.
Этот сервис был не Core-доменом. Поиск по внешним агрегаторам – это Supporting, ближе к Generic. Никакого DDD там применять не требовалось, и я не собирался. Но даже без DDD размазывать пять разных ответственностей в один метод – плохо.
В прошлой статье мы разобрались, где применять DDD, а где его не трогать. 80% пользы – в стратегической части: bounded contexts, ubiquitous language, Core/Supporting/Generic. Тактические паттерны – опция. Но если ваш домен попадает в ту половину проектов, где тактика окупается, пора говорить о коде.
Сегодня – без пересказа Эванса. Пять паттернов, четыре истории и одно правило: если у паттерна нет боли, которую он решает, в коде ему делать нечего.
Анемичная модель: почему все так пишут
Начну с того, что пишут все. Включая меня, пятнадцать лет назад.
Анемичная модель – это когда Entity у вас ничего не умеет, кроме как хранить данные. Все бизнес-правила живут в *Service рядом. Получается два слоя: один с геттерами и сеттерами, другой – с процедурами.
// Entity
public class Booking
{
public Guid Id { get; set; }
public string Status { get; set; }
public decimal TotalPrice { get; set; }
public string Currency { get; set; }
public List<Segment> Segments { get; set; } = [];
}
// Service – "вся логика здесь"
public class BookingService
{
public void Cancel(Booking booking, DateTime now)
{
if (booking.Status == "Confirmed")
{
var firstDeparture = booking.Segments.Min(s => s.DepartureDate);
var daysLeft = (firstDeparture - now).TotalDays;
if (daysLeft > 30)
booking.TotalPrice = 0;
else if (daysLeft > 14)
booking.TotalPrice *= 0.5m;
// а если < 14 дней? забыли. баг.
booking.Status = "Cancelled";
// кто отправит событие? кто обновит бюджет?
// кто уведомит согласующего?
}
}
}
Фаулер написал про это отдельное эссе ещё в 2003 году – суть в том, что данные отдельно, а поведение отдельно противоречит самой идее ООП, которая в том, чтобы объединить их в одном месте. Но вся индустрия всё равно пишет анемично. И у этого есть причины.
Фреймворки учат так писать. EF Core scaffolding генерирует вам DTO из таблицы. Spring Data тоже. Туториалы по любому современному фреймворку начинаются с “создадим модель и сервис”. Никто не учит начинать с бизнес-правил и двигаться к модели.
Это путь наименьшего сопротивления. Новая фича? Добавь метод в *Service. Новое поле? Добавь свойство в Entity. За два года у вас BookingService на две тысячи строк и Booking с сорока свойствами. Тот самый сервис поиска из вступления – его старшая версия для доменного слоя.
Теперь про AI. Спросите у AI-агента: “сгенерируй модель бронирования с отменой”. Он выдаст ровно то, что выше. Класс с полями, сервис с методом Cancel. Со всеми его багами: забытой веткой “меньше 14 дней”, отсутствием событий, mutable state. AI не знает ваш домен. Он воспроизводит самый частый паттерн из обучающей выборки, а самый частый – именно анемичный. Если вы не проектируете модель осознанно, AI закрепит анемичный подход – просто быстрее, чем раньше.
Важный момент: анемичная модель – не всегда плохо.
Для Generic subdomain – это лучший выбор. Рассылка писем, загрузка файлов, логирование. Там нет доменных правил, там инфраструктура. Писать NotificationAggregate с Domain Events – как варить суп в скороварке для установки гвоздя.
Для Supporting – тоже чаще норма. Простые CRUD-экраны, отчётность, личный кабинет. Transaction Script или Table Module – подходит.
Проблема начинается на Core, когда правил становится много, и они живут не в одном месте. Скидочная политика, которую применяют поиск, оформление и биллинг. Правило расчёта штрафа за отмену, которое знает фронт (показывает), бэк (применяет) и бухгалтерия (проверяет). Когда одно и то же правило живёт в трёх *Service с тремя разными реализациями – вы получаете рассогласование. Клиент видит на фронте “штраф 500”, платит 1000, бухгалтерия начисляет возврат как будто штрафа не было.
Это момент, когда модель хочет перестать быть анемичной. Не потому что “так сказал Эванс”. А потому что правило, написанное дважды, будет написано по-разному.
Вернёмся к моему сервису поиска. Я его не переписывал “в DDD” – у него не было повода. Но я постепенно выделял куски: адаптеры провайдеров, потом HotelDeduplicator, потом PricingPolicy (наценки), потом ProviderSelectionPolicy (какого провайдера брать для какой страны). Пять объектов вместо одного. Каждый понятно, что делает. Тестируется изолированно. Меняется безопасно.
Через полгода примерно 80% сервиса уже было разобрано на отдельные объекты. Это был не DDD, а обычное выделение ответственностей – то, что должно происходить задолго до разговоров про агрегаты.
Итого: анемичная модель – нормальный старт. Опасно оставлять её, когда домен становится сложным. Дальше по статье – как выглядит обратная крайность и в какой момент она нужна.
Value Object: самый дешёвый паттерн с самой большой отдачей
Начнём с самого маленького кирпичика. И парадоксально – с единственного тактического паттерна, который я советую вообще всем, независимо от того, делаете ли вы “полный DDD”.
Value Object – это объект, который определяется своими значениями, а не идентичностью. Два объекта с одинаковыми полями – это один и тот же объект. Money на 500 рублей равна любой другой Money на 500 рублей. Это не “конкретная монета”, это значение.
Правило проверки: “два объекта с одинаковыми полями – один объект? Если да – VO”.
В C# для VO существует record – он делает structural equality бесплатно:
public record Money(decimal Amount, Currency Currency)
{
public static Money Zero(Currency currency) => new(0, currency);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new CurrencyMismatchException(Currency, other.Currency);
return this with { Amount = Amount + other.Amount };
}
}
Четыре строки кода, и вот что вы получили даром.
Невозможно случайно сложить рубли с долларами. В анемичной модели TotalPrice – это decimal, а Currency отдельное поле string. Сложили два бронирования в разных валютах, получили бессмысленное число. Поймали это в продакшне через месяц. С Money такой код не скомпилируется: decimal + decimal работает, Money + Money проверяет валюту, Money + decimal вызывает ошибку.
Равенство работает как надо. new Money(500, RUB) == new Money(500, RUB) возвращает true. Для обычного класса – false, потому что это разные ссылки. В анемичной модели для сравнения приходится писать .Equals вручную или забывать про него и получать странные баги в тестах.
Хеш-код корректен из коробки. Money можно использовать как ключ Dictionary<Money, ...> и не думать.
record отлично работает, когда достаточно структурного равенства. Но часто при создании VO нужна валидация – отбить невалидный код аэропорта сразу, а не в середине бизнес-логики. Тогда пишем record с явным телом:
public record AirportCode
{
public string Value { get; }
public AirportCode(string value)
{
if (value.Length != 3 || !value.All(char.IsLetter))
throw new InvalidAirportCodeException(value);
Value = value.ToUpperInvariant();
}
}
Теперь new AirportCode("MOW") работает, new AirportCode("moscow") падает с понятной ошибкой. В анемичной модели аэропорт это string. Кто-то положит “moscow” вместо “MOW”, кто-то “SVO ” с пробелом, кто-то лишнюю точку. Половина IATA-парсеров в проекте не потому, что это сложно, а потому, что валидация размазана.
Ещё полезный приём – VO с фиксированным набором значений:
public sealed record FareClass(string Code)
{
public static readonly FareClass Economy = new("Y");
public static readonly FareClass Business = new("C");
public static readonly FareClass First = new("F");
}
Enum на стероидах со structural equality. Формально кто угодно может сделать new FareClass("ZZ") – это публичный конструктор. На практике команде хватает договорённости работать через константы; если хочется жёстче – сделать конструктор internal, закрыв создание вне модуля доменных типов. В анемичной модели это поле string FareClass, и в каком-нибудь reporting-модуле рано или поздно появляется строка “PremiumEconomy”, которую никто не обрабатывает.
И наконец – VO из других VO:
public record FlightSegment(
AirportCode Origin,
AirportCode Destination,
DateTimeOffset Departure,
FareClass Fare,
Money Price);
FlightSegment – тоже VO. Перелёт SVO → LHR 15 февраля в 10:00 за 45000₽ бизнес-классом – это значение, не “конкретный перелёт с идентичностью”.
Итого: VO исключают целые классы багов бесплатно. Невалидные аэропорты, смешение валют, неожиданные классы обслуживания, неправильное сравнение, забытые валидации. В C# это стоит одной строки – record вместо class. Не нужен полный DDD, чтобы начать применять. Это моя единственная универсальная рекомендация из тактических паттернов.
Теперь – к тому, что требует повода.
Aggregate: самый опасный паттерн DDD
Если VO – это самое дешёвое, то агрегат – самое дорогое по ошибкам. Я видел больше проваленных проектов из-за неправильно выбранных границ агрегата, чем из-за всего остального DDD вместе взятого.
Определение: агрегат – это кластер объектов с одним корнем (Aggregate Root), доступ к которым только через корень. Внешний мир не трогает внутренности напрямую.
Но определение ничего не объясняет. Главное про агрегат – это граница транзакционной консистентности. Правило одно: одна транзакция = один агрегат. Всё, что должно меняться атомарно – внутри одного агрегата. Всё, что может меняться асинхронно – в разных.
И теперь – история, которая это правило ломает.
На travel-платформе, где я работал позже, у нас был агрегат Booking. Он выглядел… исчерпывающе:
// Один агрегат на всё бронирование – звучит логично
public class Booking : AggregateRoot
{
public List<TripSegment> Segments { get; } = [];
public List<PaymentAttempt> PaymentAttempts { get; } = [];
public List<Refund> Refunds { get; } = [];
public List<StatusChange> StatusHistory { get; } = [];
public List<AuditEntry> AuditLog { get; } = [];
public List<ManagerComment> Comments { get; } = [];
// ... и методы, которые всё это меняют
}
Сегменты поездки, попытки оплаты (и успешные, и неудачные), история возвратов, отмены и причины, смены статуса, audit log, комментарии менеджера корпоративного клиента. Один контракт с пользователем – одна штука в коде. В книжке-то так и написано.
А потом пришла жизнь.
Сценарий первый. Пользователь платит картой. Invalid CVV – попытка добавлена в Booking, статус PaymentFailed. Вторая – то же. Третья – успех. Три PaymentAttempt внутри одного агрегата, три сохранения целого Booking в базу.
Сценарий второй. Пользователь жмёт “оплатить”, и параллельно в другой вкладке – “добавить отель”. Оба изменения пытаются сохраниться в один Booking. OptimisticConcurrencyException. Лечили ретраями. Попадали, когда деньги уже списались до ретрая.
Сценарий третий. Клиент отменяет бронирование через месяц. Для возврата нужен один объект – последний успешный платёж. Вместо этого загружается весь Booking целиком: сегменты, попытки, история, audit, комментарии. 2–3 мегабайта на одно бронирование.
Сценарий четвёртый, самый болезненный. Ночная сверка платежей с провайдерами (reconciliation – это когда мы сравниваем, что у нас в базе числится как оплаченное, с тем, что реально прошло у банков, и ищем расхождения). Job читает все активные Booking целиком, чтобы достать из каждого список платежей. Гигабайты данных через сеть, часы работы, нагрузка на базу. А нужно-то было поле “успешный платёж” по каждому бронированию.
Диагноз: мы сложили в один агрегат вещи, которые не обязаны меняться атомарно. Сегменты бронирования и история попыток оплаты – разные инварианты. Сегменты защищают правила маршрута (нельзя добавить перелёт после даты отправления). Платежи защищают правила денег (нельзя провести успешный платёж после отмены). Это не одна транзакция. Это две, связанные событиями.
Решение:
public class Booking : AggregateRoot
{
public BookingId Id { get; }
public BookingStatus Status { get; private set; }
public TravelerId TravelerId { get; }
public PaymentStatus PaymentStatus { get; private set; } // сводное поле
private readonly List<TripSegment> _segments = [];
public IReadOnlyList<TripSegment> Segments => _segments;
// История платежей, возвраты, комментарии – не здесь
public void AddSegment(TripSegment segment) { /* инварианты маршрута */ }
public void Cancel(IClock clock, CancellationPolicy policy)
{
Guard.Against(Status != BookingStatus.Confirmed,
"Can only cancel confirmed bookings");
var penalty = policy.CalculatePenalty(_segments, clock.UtcNow);
Status = BookingStatus.Cancelled;
AddDomainEvent(new BookingCancelled(Id, TravelerId, penalty, clock.UtcNow));
}
}
public class PaymentAttempt : AggregateRoot // отдельный агрегат
{
public PaymentAttemptId Id { get; }
public BookingId BookingId { get; } // связь по Id, не по объекту
public Money Amount { get; }
public PaymentStatus Status { get; private set; }
public DateTimeOffset AttemptedAt { get; }
}
Booking теперь знает текущий статус оплаты (сводное поле PaymentStatus), но не хранит историю попыток. Каждая попытка оплаты – отдельный маленький агрегат, связанный с Booking по BookingId. Когда приходит уведомление об успешной оплате, мы создаём новый PaymentAttempt, а Booking обновляем через domain event.
Что изменилось на практике:
- Concurrency-конфликтов между платежами и изменениями маршрута больше нет. Это разные транзакции.
- Возврат при отмене – не читаем
Bookingцеликом. Запрос кPaymentAttemptс фильтром “последний успешный дляBookingId”. - Ночная сверка – читает только
PaymentAttempt, не трогаяBooking. Те же данные, в сто раз меньше объёма. - Добавление отеля в бронирование не блокируется висящей попыткой оплаты.
Правило, которое я с тех пор повторяю себе перед каждым новым агрегатом: “какие данные ОБЯЗАНЫ меняться в одной транзакции?”. Не “какие логически связаны”. Не “что пользователь видит в одной форме”. Именно – что обязано атомарно. Всё остальное – отдельные агрегаты, связанные по Id и через события.
Вторая типичная ошибка: прямые ссылки между агрегатами. Соблазн написать Booking.Traveler вместо Booking.TravelerId огромный. У вас же ORM есть, навигационные свойства. Но это тихо ломает границы: изменил Traveler, затронул Booking. Сверка данных между агрегатами? Загрузится пол-базы через навигационные ссылки. Правило: между агрегатами только Id. Если нужны данные другого агрегата, читаем явно через репозиторий.
Помните оранжевые и жёлтые стикеры из Event Storming? Жёлтые были агрегатами. Оранжевые – доменными событиями. Жёлтые в коде у нас теперь есть. Дальше оранжевые.
Domain Events в коде
Короткий раздел. Distributed events, CQRS, Saga – следующая статья. Сегодня – только in-process.
Domain Event – это факт, который произошёл. Прошедшее время. BookingCancelled, не CancelBooking. SegmentAdded, не AddSegment. Это важно: команды можно отклонить, события – уже случились, их можно только принять.
public record BookingCancelled(
BookingId BookingId,
TravelerId TravelerId,
Money CancellationPenalty,
DateTimeOffset CancelledAt
) : IDomainEvent;
Агрегат добавляет события в свой внутренний список при изменениях:
public void Cancel(IClock clock, CancellationPolicy policy)
{
// ... бизнес-логика ...
AddDomainEvent(new BookingCancelled(Id, TravelerId, penalty, clock.UtcNow));
}
Events диспатчатся при сохранении агрегата. Внутри одного процесса это обычно значит “после коммита транзакции”. Обработчики – отдельные классы:
internal sealed class SendCancellationEmailHandler : IEventHandler<BookingCancelled>
{
public async Task HandleAsync(BookingCancelled @event)
{
await _emails.Send(@event.TravelerId, new CancellationEmail(@event));
}
}
Зачем это всё? Decoupling. Booking не знает про email, биллинг, analytics. Он публикует факт “отмена произошла”. Кто нужно, подпишется. Добавили новую подсистему “уведомление согласующего” – новый handler, старый код не трогаем. И в событие кладём только то, что нужно подписчикам: не весь агрегат, а сводные поля (id, сумма штрафа, время).
Что случится, когда BookingCancelled нужно обработать не в этом процессе, а в отдельном сервисе биллинга? Когда Booking и Payment живут в разных bounded contexts? Когда одна отмена должна запустить цепочку из пяти шагов с компенсациями? Разбор этих сценариев – в следующей статье про event-driven архитектуру.
Repository: не ORM-обёртка
Repository – это контракт домена с persistence. Домен определяет интерфейс, инфраструктура реализует. Загружает/сохраняет агрегат целиком – это его единица работы.
Определение звучит просто. На практике 80% того, что называют Repository, – это generic Repository<T> поверх ORM, и вот почему я считаю это анти-паттерном.
История без привязки к проекту – такое я видел на нескольких MongoDB-проектах.
Команда заводит generic-обёртку:
public class MongoRepository<T>
{
public IQueryable<T> Query() => _collection.AsQueryable();
public Task<T> GetById(Guid id) => /* ... */;
public Task Save(T entity) => /* ... */;
public Task<List<T>> FindBy(Expression<Func<T, bool>> predicate) => /* ... */;
}
Первые три-шесть месяцев – красота. В коде пишешь _repo.Query().Where(x => x.Status == "Active" && x.ClientId == id).ToList(). Разработчики счастливы, DBA молчит.
Потом коллекция вырастает до двадцати миллионов документов. И DBA приходит с алертами.
Проблема номер один – индексы под динамические запросы. COLLSCAN в проде на коллекции в 20 ГБ. Запросы сгенерированы LINQ-провайдером динамически: никто заранее не знает, какие комбинации полей используют пятьдесят мест в коде. Индексы под это поставить невозможно. Каждая новая фича – потенциальный full scan.
Первая попытка вылечить – добавить в generic методы CreateIndex(field). Но индексы под MongoDB – это компаунды с учётом порядка полей и направления сортировки. В generic-контракт это не упаковывается без утечки MongoDB наружу.
Проблема номер два – отчётность. Аналитике нужна: группировка по дням, $lookup на другую коллекцию, $unwind, проекции. Это aggregation pipeline. Generic Query() даёт только Find. Два варианта:
- Добавить
Aggregate(BsonDocument[] pipeline)в generic. Но тогдаBsonDocument– тип MongoDB – протекает в доменный слой. Вся идея абстракции рушится. - Завести второй репозиторий рядом, не-generic, только для аналитики.
Выбирают второй. Через полгода в коде два способа ходить в ту же коллекцию. Половина разработчиков не знает, где какой использовать. Добавили кеш в generic, в аналитическом не добавили. Данные расходятся. На ретроспективе: “давайте напишем документацию, какой репозиторий когда брать”. Через квартал документация устарела.
Я работал на проектах, где это приходилось разворачивать обратно. И это больно, потому что _repo.Query().Where(...) рассыпан по коду в сотне мест.
Как надо: доменный репозиторий с конкретными методами.
public interface IBookingRepository
{
Task<Booking> GetByIdAsync(BookingId id);
Task<IReadOnlyList<Booking>> GetActiveByTravelerAsync(TravelerId travelerId);
Task<IReadOnlyList<Booking>> GetForReconciliationAsync(DateRange period);
Task SaveAsync(Booking booking);
}
Что это даёт:
- Под каждый метод – свой индекс. DBA видит точный список запросов, понимает, что тюнить.
- Доменный язык.
GetActiveByTravelerговорит, что ищем.Query().Where(x => x.Status == ...)– не говорит ничего, кроме схемы базы. - Aggregation pipelines – внутри реализации. Снаружи – доменные методы. Протечки нет.
- Один способ ходить в коллекцию. Нет двух репозиториев, нет рассинхронизации.
Главный тезис, который я усвоил: Generic Repository<T> – это не абстракция, это отказ от проектирования. Если ты не можешь назвать запросы доменным языком – значит, ты не проектировал границы репозитория. Ты обернул CRUD в интерфейс и назвал это “чистой архитектурой”.
Когда generic-репо всё-таки норм? На простых CRUD-приложениях, где коллекции не растут, запросы тривиальные, и никакой аналитики нет. На Generic и Supporting – пожалуйста. Но на Core, где запросы – это часть доменной логики, – никогда.
Domain Service: логика, которая не помещается в агрегат
Последний паттерн с кодом – Domain Service. Важно одно: не путать с Application Service. Application Service оркестрирует use case – принимает команду от UI, открывает транзакцию, вызывает методы агрегата, сохраняет, публикует события. Он живёт в слое приложения.
Domain Service – другое. Это чистая доменная логика, которая объективно не принадлежит ни одному агрегату.
Правило:
- Работает с одним агрегатом → метод агрегата.
- Работает с несколькими → Domain Service.
История про то, как обходятся без Domain Service.
Корпоративный travel. У каждой компании-клиента – своя TripPolicy:
- Пороги автосогласования: до 40 000 ₽ – автоматом, выше – согласующий от руководителя.
- Классы обслуживания по грейду: junior – только эконом, senior – до бизнеса на перелётах дольше трёх часов, C-level – без ограничений.
- Географические ограничения – некоторые страны запрещены политикой.
- VIP-исключения – отдельные сотрудники имеют обход правил, прописанный явно.
Логика проверки политики нужна в четырёх местах:
- Поиск – показать доступные варианты (скрыть бизнес для junior).
- Оформление – блокировать запрещённое.
- Согласование – понять, нужен ли аппрув и от кого.
- Отчёты – пометить исключения для аудита.
Команда писала проверку последовательно по мере появления фич. В BookingService.CreateBooking() при оформлении. В SearchService.ApplyPolicyFilter() для скрытия на поиске. В ApprovalWorkflow.RequiresApproval() для согласования. В reporting-модуле четвёртая реализация. Каждый раз кто-то смотрел “как сделано у соседей” и перепечатывал. Никто не считал это проблемой.
И вот реальный случай.
HR-директор крупного клиента (один из топ-3 по выручке) бронировал командировку в Лондон. Бизнес-класс, перелёт четыре часа. По политике его компании, для его грейда, это разрешено без согласования. Плюс он в VIP-исключениях – обход любых правил, явно.
Поиск показал варианты. Он выбрал. На этапе согласования – отказ: “требуется аппрув”. Согласующего нет: он сам на вершине иерархии.
Написал в поддержку. Проштамповали вручную. Через неделю – та же история на другой командировке. Пошёл к своему CEO. CEO позвонил нашему.
Разбираемся. В SearchService.ApplyPolicyFilter() VIP-флаг учитывался – поэтому поиск показал варианты. В ApprovalWorkflow.RequiresApproval() VIP-флаг кто-то забыл добавить при очередной итерации правил. Смотрели на две реализации как на независимые – “это же две разные фичи”.
Живём год. Клиент звонит CEO.
Решение: выделили Domain Service.
public class TripPolicyEvaluator
{
public PolicyEvaluationResult Evaluate(
BookingDraft booking,
TripPolicy policy,
EmployeeProfile employee)
{
if (employee.HasVipException)
return PolicyEvaluationResult.Allowed();
if (policy.IsCountryForbidden(booking.Destination))
return PolicyEvaluationResult.Denied(
$"Country {booking.Destination} is forbidden");
if (!policy.IsFareClassAllowed(employee.Grade, booking.FareClass, booking.FlightDuration))
return PolicyEvaluationResult.Denied(
$"Fare class {booking.FareClass} not allowed for grade {employee.Grade}");
if (booking.TotalPrice > policy.AutoApprovalThreshold)
return PolicyEvaluationResult.RequiresApproval(ApproverRole.LineManager);
return PolicyEvaluationResult.Allowed();
}
}
public record PolicyEvaluationResult(
PolicyDecision Decision, // Allowed | RequiresApproval | Denied
string? ViolationReason,
ApproverRole? RequiredApprover)
{
public static PolicyEvaluationResult Allowed() => new(PolicyDecision.Allowed, null, null);
public static PolicyEvaluationResult Denied(string reason) => new(PolicyDecision.Denied, reason, null);
public static PolicyEvaluationResult RequiresApproval(ApproverRole role) => new(PolicyDecision.RequiresApproval, null, role);
}
Stateless. Принимает BookingDraft (то, что сотрудник собирается оформить), TripPolicy компании, EmployeeProfile сотрудника. Возвращает решение. Вызывается отовсюду – поиск, оформление, согласование, отчёты. Одна логика. Меняются правила – меняется в одном месте.
Почему это Domain Service, а не метод агрегата:
- Нет одного агрегата-владельца.
Bookingпринадлежит сотруднику,TripPolicy– компании,EmployeeProfile– HR-контексту. Логика работает с тремя источниками. - Stateless, без собственных данных. Это чистая функция от входов.
- Это доменная логика, не оркестрация. Не открывает транзакций, не публикует событий, не вызывает другие сервисы.
Важное отличие от CancellationPolicy, который мы видели в секции про агрегаты: CancellationPolicy передаётся в метод агрегата Booking.Cancel() и работает только с данными Booking. Это Policy Object – “политика внутри агрегата”. Это не Domain Service. TripPolicyEvaluator – Domain Service, потому что ему нужны три агрегата из разных контекстов.
Урок: Domain Service – это не “сервис ради сервиса”. Это логика, которая объективно живёт между агрегатами. Если ты можешь впихнуть её в метод агрегата – впихивай туда, не плоди классы. Если две сущности из разных агрегатов обязаны участвовать в решении – это Domain Service.
И если ты обнаружил, что одна и та же проверка политики продублирована в четырёх местах – это не только нарушение DRY. Это сигнал, что ты год назад должен был выделить Domain Service.
Когда тактика – overhead
Я обещал в начале: “если у паттерна нет боли, которую он решает – в коде ему делать нечего”. Теперь, когда все паттерны на столе, поговорим про обратную сторону – карго-культ.
Агрегат без инвариантов. У вас public class Customer : AggregateRoot с десятью свойствами и ни одного метода, кроме геттеров. Никаких бизнес-правил, никаких проверок – просто бандл из данных. Это не агрегат, это DTO в дорогой обёртке. Либо выносите инварианты, либо называйте вещь своим именем – Entity.
Domain Event, на который никто не подписан. Вы генерируете BookingViewed, потому что “вдруг пригодится”. Никто не слушает. Это не event, это лог. Хотите лог – пишите лог, не надо накручивать infrastructure для событий.
Repository только с GetById и Save. Если весь ваш репозиторий – это две CRUD-операции, и вы пишете вручную реализацию, которая тупо вызывает ORM, вы добавили слой без выгоды. Либо у агрегата есть доменные запросы, достойные имён, либо работайте с ORM напрямую и не обманывайте себя “чистой архитектурой”.
Value Object ради Value Object. public record Name(string Value) без валидации, без поведения, без инвариантов – просто обёртка над string. Зачем? record для VO оправдан, когда есть хотя бы одно из: валидация при создании, поведение (методы), комбинация полей. Иначе это карго-культ в чистом виде.
Правило для всех пяти паттернов одно: начинается от боли, не от паттерна. Появилось дублирование логики в четырёх местах – выделили Domain Service. Стали ловить concurrency-конфликты – пересмотрели границы агрегата. Получили COLLSCAN в проде на generic-репозитории – переписали на доменный. Обнаружили, что decimal Amount + string Currency даёт баги – ввели Money.
Если боли нет – не вводите паттерн заранее. Это тот самый overhead, про который я говорил в прошлой статье: “DDD родился из работы со сложными доменами. Применять его к проекту, где технических решений больше, чем бизнес-правил, – это как использовать промышленный станок для нарезки хлеба дома”.
В итоге
Три вещи, которые стоит унести.
Первое. Value Object – единственный тактический паттерн, который я советую применять вообще всем. В C# это record вместо class. Получаете равенство, иммутабельность и инварианты бесплатно. Отказываться – только если у вас специфические требования по памяти или перформансу.
Второе. Aggregate – граница транзакционной консистентности, не “класс с детками”. Вопрос перед каждым новым агрегатом: какие данные ОБЯЗАНЫ меняться атомарно? Всё остальное – отдельные агрегаты, связанные по Id и через события. Если сомневаетесь – делите. Жирный агрегат резать потом больнее, чем объединять мелкие.
Третье, главное. Тактические паттерны – это инструменты под конкретные боли, не чеклист для Core-домена. Нет дублирования логики – Domain Service не нужен. Нет сложных инвариантов – Aggregate не нужен. Нет доменных запросов – репозиторий не нужен. Generic Repository<T> поверх ORM это не абстракция, а отказ от проектирования. Если паттерн не решает конкретной проблемы, он её создаёт.
Мы всю статью оставались внутри одного процесса. Но что происходит, когда BookingCancelled нужно обработать в отдельном сервисе биллинга? Когда команды и запросы живут разной жизнью? Когда одна бизнес-транзакция проходит через три bounded context и должна откатываться компенсациями? Это event-driven architecture – разговор для следующей статьи.
Если есть свой кейс тактического DDD (удачный или провальный), расскажите в телеграм-канале. Скоро там будет отдельный пост про generic Repository<T> – почему я считаю его отказом от проектирования, и что отвечать на “но в книжке же так написано”.
Что почитать
- Влад Хононов – “Learning Domain-Driven Design”. Часть про тактический дизайн – современная и прагматичная.
- Эрик Эванс – “Предметно-ориентированное проектирование”. Глава про Building Blocks – первоисточник определений Entity, Value Object, Aggregate, Repository.
- Вон Вернон – “Реализация методов предметно-ориентированного проектирования”. Более прикладная версия с примерами на C#. Особенно полезна глава про агрегаты.
- Мартин Фаулер – “Anemic Domain Model”. Короткое эссе, с которого началось всё обсуждение анемичных моделей в индустрии.
- Vaughn Vernon – “Effective Aggregate Design”. Три PDF на 20 страниц каждый – самый сжатый и прикладной разбор границ агрегата.