handbookНаписать

architecture/ddd/README.md · тема · 4 языка · 34 теста

Тактический DDD (Domain-Driven Design) ​

Правила предметной области живут в самих объектах домена: агрегат не даёт привести себя в неверное состояние, значения проверяются при создании, а о случившемся сообщают события.

Проблема ​

Оформление заказа обычно начинается как сервис над массивами. Правила раскиданы по контроллерам и сервисам, и каждый новый вход в систему (админка, импорт, API) должен помнить их сам:

php
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. Это обычные классы, и тесты проверяют правила без моков.

Структура ​

records

addLine

Money

+kopecks: int

+add(Money) : Money

+times(Quantity) : Money

Quantity

+value: int

OrderLine

+sku

+quantity: Quantity

+unitPrice: Money

+total() : Money

Order

+id

+customerId

-status

+addLine(sku, Quantity, Money)

+place()

+pay(Money)

+total() : Money

+releaseEvents() : list

OrderPlaced

+orderId

+customerId

+total: Money

«interface»

Catalog

+priceOf(sku) : Money

+stockOf(sku) : int

OrderPricing

+addItem(Order, sku, Quantity)

Жизненный цикл заказа: draft -> placed -> paid. Пустой заказ нельзя оформить, оплатить можно только оформленный и только ровно на сумму total(). Неотрицательность суммы обеспечивает тип: Money не создаётся из отрицательного числа, а сложение и умножение на Quantity не могут дать минус.

Как это собирается ​

Сборка - работа сервиса приложения (в проекте это обработчик HTTP-запроса или команды). Он создаёт OrderPricing с каталогом, получает от клиента пары sku и количество, превращает числа в Quantity и для каждой пары вызывает addItem: сервис проверит остаток и добавит позицию по цене каталога. Затем place переводит заказ в placed и записывает событие, сервис приложения забирает события через releaseEvents и передаёт уведомлению. После оплаты попытка докинуть кружку в заказ получает отказ от самого агрегата, а не от проверки в контроллере.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Блоки одинаковые во всех языках: два value object, агрегат со статусом и списком событий, интерфейс каталога с реализацией в памяти, доменный сервис. Различается то, чем язык выражает неизменяемость и отказ.

ЯзыкОсобенность
PHPfinal readonly class для Money, Quantity, OrderLine, OrderPlaced; приватный конструктор Money и фабрика Money::kopecks; правила - DomainException, значения - InvalidArgumentException
GoMoney и 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 после сохранения
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS