Тактический DDD (Domain-Driven Design)
Правила предметной области живут в самих объектах домена: агрегат не даёт привести себя в неверное состояние, значения проверяются при создании, а о случившемся сообщают события.
Проблема
Оформление заказа обычно начинается как сервис над массивами. Правила раскиданы по контроллерам и сервисам, и каждый новый вход в систему (админка, импорт, API) должен помнить их сам:
final class OrderService
{
public function addItem(int $orderId, string $sku, int $qty): void
{
$order = $this->db->find('orders', $orderId);
// the admin panel forgot this check, so paid orders change silently
if ($order['status'] === 'paid') {
throw new RuntimeException('paid');
}
$price = $this->db->find('products', $sku)['price']; // float rubles
$order['lines'][] = ['sku' => $sku, 'qty' => $qty, 'price' => $price];
$order['total'] += $qty * $price; // qty may be 0 or -3
$this->db->save('orders', $order);
$this->mailer->send($order['customer'], 'updated');
}
}Сумма во float, количество не проверено, итог хранится отдельно от позиций и может с ними разойтись, а запрет на изменение оплаченного заказа держится на дисциплине. Письмо отправляется прямо из сервиса, и тест добавления позиции тянет за собой почту.
Решение
Тактический DDD даёт набор строительных блоков, чтобы правила жили в одном месте:
- Value object - значение без идентичности, сравнивается по содержимому и проверяется при создании.
Money- целые копейки и никогда не отрицательные,Quantity- от 1 до 100. Невалидное значение нельзя создать, поэтому дальше по коду его не проверяют. - Агрегат - группа объектов с одной точкой входа (корнем), которая отвечает за инварианты.
Orderвладеет позициямиOrderLine: снаружи нельзя поменять позицию в обход заказа. Итог не хранится, а считается из позиций, поэтому не может с ними разойтись. Позиции меняются только в черновике: оплаченный заказ отказывает вaddLine. - Доменный сервис - правило, которое не принадлежит ни одному агрегату. Цена и остаток живут в каталоге, позиции - в заказе, поэтому проверку наличия и выбор цены делает
OrderPricing. Клиент присылает только sku и количество, цену берёт домен. - Доменное событие - факт в прошедшем времени.
placeзаписываетOrderPlacedс итогом на тот момент, а уведомление клиента реагирует на событие и не живёт в агрегате.
Агрегат не знает о базе, почте и HTTP. Это обычные классы, и тесты проверяют правила без моков.
Структура
Жизненный цикл заказа: draft -> placed -> paid. Пустой заказ нельзя оформить, оплатить можно только оформленный и только ровно на сумму total(). Неотрицательность суммы обеспечивает тип: Money не создаётся из отрицательного числа, а сложение и умножение на Quantity не могут дать минус.
Как это собирается
Сборка - работа сервиса приложения (в проекте это обработчик HTTP-запроса или команды). Он создаёт OrderPricing с каталогом, получает от клиента пары sku и количество, превращает числа в Quantity и для каждой пары вызывает addItem: сервис проверит остаток и добавит позицию по цене каталога. Затем place переводит заказ в placed и записывает событие, сервис приложения забирает события через releaseEvents и передаёт уведомлению. После оплаты попытка докинуть кружку в заказ получает отказ от самого агрегата, а не от проверки в контроллере.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Блоки одинаковые во всех языках: два value object, агрегат со статусом и списком событий, интерфейс каталога с реализацией в памяти, доменный сервис. Различается то, чем язык выражает неизменяемость и отказ.
| Язык | Особенность |
|---|---|
| PHP | final readonly class для Money, Quantity, OrderLine, OrderPlaced; приватный конструктор Money и фабрика Money::kopecks; правила - DomainException, значения - InvalidArgumentException |
| Go | Money и Quantity - структуры с неэкспортируемым полем и конструкторами NewMoney, NewQuantity, которые возвращают error; сравнение amount != o.Total() работает для структур из коробки; отказы - сентинелы ErrLinesFrozen, ErrOutOfStock |
| Python | @dataclass(frozen=True) с проверкой в __post_init__, Money.__add__ позволяет sum(..., Money(0)); доменный сервис - функция add_item, каталог описан Protocol |
| TypeScript | Классы с readonly полями, приватный конструктор Money и Money.of; состояние агрегата в #private полях; событие - объект с полем type и Object.freeze |
Запуск: make test T=architecture/ddd
Плюсы и минусы
Тесты одинаковы во всех языках: value object не создаётся из отрицательной суммы и нулевого количества; та же позиция дважды сливается в одну строку, итог считается из позиций; доменный сервис отказывает при нехватке остатка и не трогает заказ; place записывает OrderPlaced один раз; пустой заказ не оформляется, черновик не оплачивается, сумма оплаты должна совпасть с итогом, оплаченный заказ не принимает новые позиции.
- ✅ Правило записано один раз, в агрегате, и его нельзя обойти из нового контроллера или импорта.
- ✅ Value object убирает проверки из остального кода: если есть
Money, это неотрицательные целые копейки. - ✅ Домен тестируется без базы и моков, тесты читаются как правила бизнеса.
- ✅ События отвязывают реакции (письмо, склад, аналитику) от модели.
- ❌ Больше типов и кода, чем у сервиса над массивами, и нужна привычка команды мыслить агрегатами.
- ❌ Границы агрегата выбрать трудно: слишком большой агрегат блокирует параллельные изменения и долго грузится, слишком маленький не может удержать инвариант.
- ❌ ORM и сериализация сопротивляются: приватное состояние и value object приходится маппить вручную или настраивать (см. Repository и Data Mapper).
Когда не применять
- CRUD без правил: справочник товаров, настройки, админка. Агрегат с одним сеттером на поле - церемония.
- Правила простые и живут в одном месте, а предметная область не меняется. Сервис с проверками будет короче и понятнее.
- Нет доступа к людям, которые знают предметную область. Тактические блоки без общего языка с бизнесом превращаются в классы с умными названиями и той же логикой в сервисах.
Отличия между языками
- PHP -
readonlyкласс и приватный конструктор дают настоящий value object: создатьMoneyв обход проверки нельзя. ПеречислениеOrderStatusсо строковыми значениями сразу годится и для сообщений, и для базы. - Go - неизменяемость держится на неэкспортируемом поле: снаружи пакета
Money{-5}не написать, толькоNewMoney. Нулевое значениеMoney{}- валидный ноль, поэтомуvar sum Moneyне требует конструктора. Структуры сравниваются через==,equalsне нужен. - Python -
frozen=Trueзапрещает присваивание,__eq__dataclass сравнивает по значению, и тест сравнивает события одним==. Доменный сервис без состояния естественно выражается функцией, класс ради одного метода был бы лишним. - TypeScript - типы проверяются только при компиляции, поэтому
Money.ofпроверяет ещё иNumber.isInteger:0.5копейки иначе прошли бы. Приватный конструктор закрываетnew Money(-1), а#linesскрыт и в рантайме, а не только для компилятора.
Связанные темы
- CQRS - агрегат остаётся моделью записи, а чтение уходит в отдельную модель
- Event Sourcing - состояние агрегата хранится как последовательность его событий
- Доменное событие (Domain Event) - подробно о записи и выдаче событий агрегатом
- Репозиторий (Repository) - как агрегат сохраняется и загружается целиком
- Outbox - как надёжно доставить
OrderPlacedпосле сохранения