Domain-Driven Design DDD: зачем нужен, когда не нужен и карта трека
0%

DDD: зачем нужен, когда не нужен и карта трека

DDD: зачем нужен, когда не нужен и карта трека

Domain-Driven Design — не фреймворк, не набор папок Domain/Application/Infrastructure и не обязанность называть классы «агрегатами». Это дисциплина, которая отвечает на один вопрос:

где в коде живёт знание о том, как работает бизнес, и почему его там легко найти и безопасно менять?

Всё остальное в DDD — следствие. Ниже мы разберём проблему с первых принципов, посмотрим, где подход окупается и где вредит, напишем сравнительный код «до/после», а в конце — карта трека: что и в каком порядке читать дальше.


1. Проблема: знание о домене испаряется

Возьмём реальную задачу. Интернет-магазин, правило:

«Заказ нельзя отменить после того, как он передан в доставку — кроме случая, когда клиент имеет статус Premium и с момента передачи прошло меньше 30 минут; при отмене замороженные бонусные баллы возвращаются, а зарезервированный товар освобождается».

Такое правило почти никогда не живёт в одном месте. Оно распадается на:

  • if в HTTP-контроллере (проверка статуса),
  • ветку в сервисном методе OrderService.cancel(),
  • триггер в базе, который списывает резерв,
  • фоновой job, который «догоняет» бонусы раз в час,
  • строчку в фронтенде, которая прячет кнопку «Отменить».

Через полгода никто не может ответить на вопрос «а можно ли отменить заказ?» — не читая пять модулей. Через год добавляют шестое место, и правило начинает противоречить само себе. Именно это Эрик Эванс называет анемичной моделью: объекты хранят данные, а поведение размазано по процедурам (Fowler, «AnemicDomainModel»).

Оценка «сложности» по-инженерному

Пусть в системе n бизнес-правил и m мест в коде, откуда меняется состояние заказа.

  • Без модели каждое место обязано знать про каждое правило: поверхность корректности — O(n · m). Добавление одного правила требует O(m) правок, и каждая — шанс на баг. Тестов, чтобы покрыть комбинации, нужно тоже порядка O(n · m).
  • С агрегатом, который единственный владеет переходами состояния, правила проверяются в одной точке: поверхность — O(n), добавление правила — O(1) правок, тесты — O(n) юнит-тестов без БД и HTTP.

Это и есть главный экономический аргумент DDD: он не делает код быстрее и не уменьшает объём кода, он уменьшает число мест, где можно ошибиться.


2. Что такое DDD строго: три уровня

DDD — это три слоя практик, и их часто путают.

  • Стратегический уровень отвечает на вопрос «какие вообще у нас модели и где границы между ними». Это про людей, команды и деньги: что мы пишем сами, что покупаем, где проходят швы системы. Ошибка здесь стоит месяцев. Разбираем в Стратегический DDD и Ограниченные контексты.
  • Тактический уровень — как выразить одну модель в коде: агрегаты, инварианты, репозитории. Ошибка здесь стоит недель. См. Тактические блоки и Репозитории и персистентность.
  • Процесс — как вообще добыть модель из голов экспертов: Event Storming.

Ключевое, что стоит запомнить сразу: стратегия важнее тактики. Идеальные агрегаты внутри неправильно проведённой границы контекста — это красиво оформленный тупик. Обратное неверно: правильные границы спасают даже посредственный код внутри.


3. Единый язык — не «глоссарий», а тест на понимание

Ubiquitous Language (единый язык) — это требование, чтобы одно слово означало одно и то же в разговоре с бизнесом, в тикете, в тесте и в имени класса. Не «бизнес говорит отгрузка, а в коде ShipmentEntityDtoV2», а буквально одно слово.

Практический критерий, работает ли язык: прочитайте вслух эксперту тело метода домена. Если он понимает и говорит «нет, тут не так» — язык есть. Если он слышит repository.save(dto) — языка нет.

# Языка НЕТ: эксперт не понимает ни строчки
def process(self, order_id: int, flag: bool) -> None:
    o = self.dao.load(order_id)
    o.status = 7 if flag else 3
    self.dao.update(o)

# Язык ЕСТЬ: читается вслух и проверяется экспертом
def cancel(self, order: Order, reason: CancellationReason, now: datetime) -> None:
    order.cancel(reason, now)          # «клиент отменяет заказ по причине X»
    self.bonuses.release(order.id)     # «замороженные баллы возвращаются»

Важный нюанс: единый язык не глобален. Слово «Клиент» в биллинге (плательщик, ИНН, лимит кредита) и в поддержке (человек с историей обращений) — разные понятия. Попытка сделать один общий Customer на всю компанию — самая частая и самая дорогая ошибка. Отсюда и вырастают ограниченные контексты.


4. Куда DDD кладёт код: направление зависимостей

DDD сам по себе не диктует архитектуру, но практически всегда сочетается с «луковой»/гексагональной компоновкой: доменная модель в центре и не зависит ни от чего внешнего.

Кольца зависимостей: домен в центре, инфраструктура снаружи

Правило одно и жёсткое: стрелки зависимостей смотрят внутрь. Домен не импортирует ORM, HTTP-клиент, брокер сообщений и «утилиты проекта». Интерфейс репозитория объявлен в домене, реализация — снаружи.

Это не эстетика. Это ровно то, что позволяет тестировать бизнес-правила за микросекунды без Docker, БД и моков половины мира. Подробности — в Репозитории и персистентность и в треке Архитектурные паттерны.


5. «До и после» на живом коде

5.1. Транзакционный скрипт

Так пишут по умолчанию, и до определённого размера это правильно.

class OrderService:
    def __init__(self, db, bonuses, warehouse):
        self.db, self.bonuses, self.warehouse = db, bonuses, warehouse

    def cancel_order(self, order_id: int, user_id: int) -> None:
        row = self.db.query("SELECT * FROM orders WHERE id = %s", order_id)
        if row["status"] == "CANCELLED":
            raise ValueError("уже отменён")
        if row["status"] == "SHIPPED":
            # правило про Premium — здесь и ещё в трёх местах кодовой базы
            user = self.db.query("SELECT tier FROM users WHERE id = %s", user_id)
            minutes = (datetime.now(UTC) - row["shipped_at"]).total_seconds() / 60
            if not (user["tier"] == "PREMIUM" and minutes < 30):
                raise ValueError("отмена невозможна")
        self.db.execute("UPDATE orders SET status='CANCELLED' WHERE id=%s", order_id)
        self.bonuses.refund(user_id, row["bonus_frozen"])   # если упадёт — статус уже изменён
        self.warehouse.release(order_id)                     # и здесь тоже

Что здесь плохо объективно, а не «некрасиво»:

  1. Правило отмены не имеет единственного места жительства — его нельзя ни найти, ни протестировать целиком.
  2. Порядок «сначала запись, потом побочные эффекты» негерметичен: падение на строке 3 оставит систему в противоречивом состоянии.
  3. Невозможно ответить на вопрос «какие переходы статуса вообще легальны» — нет ни одного места, где это записано.

5.2. Доменная модель

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum


class OrderStatus(Enum):
    NEW = "new"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"


class OrderCancellationForbidden(Exception):
    """Нарушение инварианта домена, а не техническая ошибка."""


@dataclass(frozen=True)
class Money:
    """Объект-значение: без идентичности, неизменяемый, сравнивается по значению."""
    amount: int          # в минорных единицах — никаких float в деньгах
    currency: str = "RUB"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("нельзя складывать разные валюты")
        return Money(self.amount + other.amount, self.currency)


@dataclass(frozen=True)
class OrderCancelled:
    """Доменное событие: факт, который уже произошёл. Прошедшее время в имени."""
    order_id: str
    refunded_bonuses: Money
    occurred_at: datetime


@dataclass
class Order:
    """Агрегат: единственный вход для изменения заказа и его строк."""
    id: str
    customer_is_premium: bool
    status: OrderStatus
    frozen_bonuses: Money
    shipped_at: datetime | None = None
    events: list[object] = field(default_factory=list)

    # окно отмены после отгрузки — часть языка домена, а не «магическое число»
    PREMIUM_CANCEL_WINDOW = timedelta(minutes=30)

    def can_cancel(self, now: datetime) -> bool:
        if self.status in (OrderStatus.CANCELLED, OrderStatus.DELIVERED):
            return False
        if self.status is not OrderStatus.SHIPPED:
            return True
        assert self.shipped_at is not None
        return (
            self.customer_is_premium
            and now - self.shipped_at < self.PREMIUM_CANCEL_WINDOW
        )

    def cancel(self, now: datetime) -> None:
        if not self.can_cancel(now):
            raise OrderCancellationForbidden(
                f"заказ {self.id} в статусе {self.status.value} отменить нельзя"
            )
        self.status = OrderStatus.CANCELLED
        self.events.append(
            OrderCancelled(self.id, self.frozen_bonuses, occurred_at=now)
        )
        self.frozen_bonuses = Money(0)

    def pull_events(self) -> list[object]:
        """Забрать накопленные события и очистить буфер — публикует их прикладной слой."""
        collected, self.events = self.events, []
        return collected

Прикладной слой становится тонким и говорит только про транзакцию:

class CancelOrder:
    """Сценарий использования: загрузить — вызвать домен — сохранить — опубликовать."""

    def __init__(self, orders, uow, bus):
        self.orders, self.uow, self.bus = orders, uow, bus

    def __call__(self, order_id: str, now: datetime) -> None:
        with self.uow:                       # одна транзакция = один агрегат
            order = self.orders.get(order_id)
            order.cancel(now)                # всё решение принято внутри домена
            self.orders.save(order)
            events = order.pull_events()
        self.bus.publish(events)             # эффекты — ПОСЛЕ фиксации транзакции

А тест бизнес-правила не требует ни базы, ни контейнеров:

def test_premium_can_cancel_within_30_minutes():
    shipped = datetime(2026, 7, 16, 12, 0)
    order = Order("o-1", customer_is_premium=True, status=OrderStatus.SHIPPED,
                  frozen_bonuses=Money(500), shipped_at=shipped)

    order.cancel(shipped + timedelta(minutes=29))

    assert order.status is OrderStatus.CANCELLED
    assert isinstance(order.events[-1], OrderCancelled)


def test_regular_customer_cannot_cancel_shipped_order():
    order = Order("o-2", customer_is_premium=False, status=OrderStatus.SHIPPED,
                  frozen_bonuses=Money(0), shipped_at=datetime(2026, 7, 16, 12, 0))
    with pytest.raises(OrderCancellationForbidden):
        order.cancel(datetime(2026, 7, 16, 12, 1))

Три таких теста заменяют десяток интеграционных, которые раньше поднимали Postgres ради проверки if.

5.3. Что мы получили и чем заплатили

Транзакционный скрипт Доменная модель
Время до первой фичи часы дни
Стоимость 20-го правила растёт нелинейно почти константа
Тест бизнес-правила интеграционный, секунды юнит, микросекунды
Порог входа для новичка низкий средний/высокий
Мэппинг на БД тривиальный требует работы (см. статью 04)
Оправдан при простом CRUD сложных, меняющихся правилах

6. Когда DDD не нужен

Это самая недооценённая часть темы. DDD платный: он берёт наценку авансом и возвращает её позже.

Кривая стоимости изменения: CRUD против доменной модели

Слева от точки окупаемости честный SELECT/UPDATE выигрывает по всем метрикам. Справа — проигрывает катастрофически. Задача архитектора — понять, где вы находитесь, а не занять позицию «всегда DDD».

Правый нижний квадрант особенно поучителен: бухучёт — чудовищно сложный домен, но он не даёт вам преимущества перед конкурентами. Его надо покупать, а не моделировать.

Чек-лист принятия решения

Отдельно про «нет эксперта». Это не мелочь, а стоп-фактор: DDD — это способ перенести чужое знание в код. Если знания нет, вы перенесёте собственные фантазии, и они будут гораздо хуже, чем честный CRUD, потому что окажутся зацементированы в «красивой архитектуре».

Сигналы, что DDD вам сейчас не нужен:

  • прототип, гипотеза, MVP на выброс со сроком жизни в недели;
  • ETL и аналитика — там правит трансформация данных, а не инварианты (см. Data Engineering);
  • CRUD-админки, справочники, конфигурационные экраны;
  • команда из 1–2 человек на всю систему без разделения контекстов;
  • домен, который вы обязаны реализовывать буква-в-букву по внешнему регламенту (тогда модель уже написана регулятором — переносите её, а не переизобретайте).

7. Как это выглядит в проде

Несколько наблюдений, которые редко попадают в книги.

Границы контекстов дороже классов. Перенести правило из сервиса в агрегат — работа на день. Разделить сросшиеся контексты, у которых общая база и общие таблицы, — работа на кварталы. Поэтому стратегическую часть делают рано и обсуждают публично, а тактическую — итеративно. Это же аргумент против «начнём с микросервисов»: сначала контексты в монолите («модульный монолит»), микросервисы — потом, когда границы доказали устойчивость.

Один агрегат — одна транзакция. Практическое правило Вернона: в рамках одной транзакции меняем ровно один экземпляр агрегата; согласованность между агрегатами — через доменные события и eventual consistency. Нарушение этого правила — источник дедлоков и «загадочных» блокировок в проде. Подробно — в Доменные события и интеграция.

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

DDD и legacy. В существующей системе не бывает «переписать всё». Работает паттерн Strangler Fig: новый контекст с чистой моделью растёт рядом, старому оставляют «антикоррупционный слой» — переводчик между чужой моделью и вашей (Fowler, «StranglerFigApplication»).

Событийная эволюция подхода. Полезно помнить хронологию: сообщество прошло путь от «слоёв и агрегатов» к «границам и событиям».


8. Типичные ошибки (обзорно)

Полный разбор — в Типичные ошибки внедрения DDD, здесь — список, чтобы вы узнавали их с первой статьи.

  1. DDD как структура папок. Создали Domain/, положили туда те же анемичные классы — ничего не изменилось, кроме длины импортов.
  2. Одна модель на всю компанию. «Единый Customer для всех» — прямой путь к классу на 3000 строк, который никому не подходит.
  3. Агрегат = таблица. Границы агрегата определяются инвариантами, а не схемой БД. Если два объекта не обязаны быть согласованы мгновенно — это два агрегата.
  4. Гигантский агрегат. Customer, включающий все заказы, — гарантированные конфликты блокировок. Ссылайтесь между агрегатами по идентификатору, а не по объекту.
  5. Тактика без стратегии. Value objects и репозитории при полном игноре границ контекстов — самая частая форма карго-культа.
  6. DDD там, где CRUD. Три слоя, маппинг и события ради формы «создать/удалить справочник».
  7. Модель без эксперта. Разработчики «сами придумали, как работает бизнес».
  8. Утечка персистентности в домен. Аннотации ORM, ленивая загрузка и session внутри агрегата превращают доменную модель в надстройку над таблицами.

9. Карта трека

Статья О чём Кому особенно важно
01. Стратегический DDD Домен, ядро и поддомены, единый язык, «строить или купить» Архитекторы, тимлиды, продакты
02. Ограниченные контексты Границы моделей, паттерны интеграции, context map Все, кто режет систему на части
03. Тактические блоки Сущности, объекты-значения, агрегаты, доменные сервисы Разработчики
04. Репозитории и персистентность Unit of Work, мэппинг, как не протечь ORM в домен Бэкенд-разработчики
05. Доменные события События, outbox, согласованность между контекстами Проектирующие распределённые системы
06. Event Storming Как добыть модель из голов экспертов за один день Фасилитаторы, аналитики, лиды
07. DDD в коде Полный пример: домен → приложение → API → тесты Практики
08. Типичные ошибки Антипаттерны и как из них выбираться Все

Как читать. Если вы принимаете архитектурные решения — 01 → 02 → 06, остальное по мере надобности. Если вы пишете код в уже нарезанном контексте — 03 → 04 → 05 → 07. Полный проход подряд даёт цельную картину и занимает несколько вечеров.

Смежные треки портала. DDD стыкуется с архитектурными паттернами (гексагональная архитектура, CQRS, saga), паттернами проектирования (фабрики, стратегии внутри домена) и принципами разработки (SOLID, YAGNI — последний особенно важен, чтобы не переусердствовать).


10. Мини-итог

  • DDD решает не техническую, а когнитивную проблему: где живёт знание о бизнесе и как его безопасно менять.
  • Экономика подхода: убрать дублирование правил, снизив поверхность корректности с O(n · m) до O(n).
  • Стратегия (границы, язык) важнее тактики (агрегаты, репозитории). Обратный порядок — карго-культ.
  • DDD платный. В простом CRUD, в MVP, в аналитике и при отсутствии эксперта домена он вреден.
  • Направление зависимостей внутрь и правило «одна транзакция — один агрегат» — два практических правила, которые дают львиную долю пользы.

Источники


Что дальше

Дальше — самое важное и самое дешёвое в исправлении: границы и язык. Разберём, как отличить ядро домена от вспомогательного, где проходит граница «строить или купить» и как вырастить единый язык, который переживёт три состава команды.

Читайте: Стратегический DDD: домен, поддомены, единый язык.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов