Domain-Driven Design Гибкий дизайн: как модель становится глубокой
0%

Гибкий дизайн: как модель становится глубокой

Гибкий дизайн: как модель становится глубокой

Предыдущая статья трека — каталог ошибок — про то, как не сделать плохо. Но «не сделать плохо» и «сделать хорошо» — разные задачи. Существует третье состояние проекта, которое в книгах почти не описывают, а в жизни оно встречается чаще всего:

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

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


1. Три уровня модели

Возьмём сквозной пример: грузоперевозки. Клиент отдаёт груз в точке А, мы везём его в точку Б через произвольное число плеч (морем, авто, ж/д), считаем цену и проверяем допустимость маршрута.

Уровень Как выглядит Стоимость нового правила
Наивный Shipment(from, to, date) + ShipmentService.calculate() на 300 строк Ещё один if в том же методе; регрессия непредсказуема
Правильный тактически Агрегат Shipment, VO Money, репозиторий, события. Правило маршрута — 200-строчный метод внутри агрегата if переехал внутрь домена. Тестировать проще, менять — так же страшно
Глубокий Появились понятия Leg, Itinerary, RouteSpecification, HazmatSegregation. Правило звучит как spec.is_satisfied_by(itinerary) Новое правило = новый маленький объект или новая композиция существующих

Практический критерий глубины, который можно применить на ревью:

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

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


2. Почему модель застревает на «правильной»

Три причины, и все три не технические.

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

Тактические блоки создают иллюзию завершённости. Когда есть Money, OrderId, агрегат и репозиторий, кажется, что моделирование закончено. Между тем ни одно из этих понятий не пришло от эксперта.

Прорыв требует выбросить работающий код. Углубление почти всегда выглядит как «мы неделю переписывали то, что уже работало». Это главная причина, по которой команды остаются на втором уровне навсегда.

Эванс описывает процесс как чередование мелких рефакторингов и редких прорывов (breakthrough):

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


3. Вытащить неявное понятие наружу

Главный источник глубины — понятия, которые уже существуют в голове эксперта, но отсутствуют в коде. Их не изобретают, их обнаруживают. Вот работающий алгоритм поиска.

Разберём самый частый и самый ценный случай — ограничение, ставшее объектом.

3.1. Было: ограничение спрятано в методе

class Shipment:
    """Агрегат перевозки. Формально всё по DDD — а правило совместимости
    опасных грузов размазано по телу метода и не имеет имени."""

    def add_cargo(self, cargo: "Cargo") -> None:
        if self.total_weight_kg + cargo.weight_kg > self.vehicle.capacity_kg:
            raise ValueError("превышена грузоподъёмность")

        # Правило совместимости опасных грузов — 40 строк условий,
        # которые никто, кроме автора, прочитать не может.
        for existing in self._cargos:
            if {cargo.hazard_class, existing.hazard_class} == {"3", "5.1"}:
                raise ValueError("несовместимо")
            if cargo.hazard_class == "8" and existing.hazard_class in ("4.3", "6.1"):
                if not (cargo.packaging == "sealed" and existing.packaging == "sealed"):
                    raise ValueError("несовместимо без герметичной упаковки")
            # ... ещё 30 строк, и они же скопированы в планировщик рейсов
        self._cargos.append(cargo)

Симптомы налицо: правило безымянно, продублировано в планировщике, не тестируется отдельно от агрегата, а изменение таблицы совместимости (её обновляет регулятор раз в год) требует правки кода в двух местах.

3.2. Стало: ограничение — самостоятельное понятие домена

class HazardClass(str, Enum):
    """Классы опасности по ДОПОГ — терминология регулятора, а не наша выдумка."""
    FLAMMABLE_LIQUID, OXIDIZER, WATER_REACTIVE, TOXIC, CORROSIVE = "3", "5.1", "4.3", "6.1", "8"


class Packaging(str, Enum):
    STANDARD, SEALED = "standard", "sealed"


@dataclass(frozen=True)
class SegregationVerdict:
    """Результат — тоже понятие: он объясняет причину, а не только факт запрета."""
    ok: bool
    reason: str = ""


@dataclass(frozen=True)
class SegregationRule:
    """Одно правило разделения: пара классов и условие, при котором совмещение разрешено."""
    left: HazardClass
    right: HazardClass
    allowed_if_both_sealed: bool = False

    def applies_to(self, a: HazardClass, b: HazardClass) -> bool:
        return {a, b} == {self.left, self.right}

    def permits(self, a: "CargoUnit", b: "CargoUnit") -> bool:
        return (self.allowed_if_both_sealed
                and a.packaging is Packaging.SEALED
                and b.packaging is Packaging.SEALED)


@dataclass(frozen=True)
class SegregationTable:
    """Таблица разделения целиком — понятие, которое эксперт называет своим именем.
    Обновление регламента = замена данных, а не правка алгоритма."""
    rules: tuple[SegregationRule, ...]

    def check(self, new: "CargoUnit", loaded: "CargoUnit") -> SegregationVerdict:
        for rule in self.rules:
            if not rule.applies_to(new.hazard_class, loaded.hazard_class):
                continue
            if rule.permits(new, loaded):
                return SegregationVerdict(True)
            return SegregationVerdict(False, (
                f"класс {new.hazard_class.value} нельзя везти "
                f"с классом {loaded.hazard_class.value}"))
        return SegregationVerdict(True)

Метод агрегата худеет до размера, при котором его читает эксперт:

class Shipment:
    def __init__(self, capacity_kg: int, segregation: SegregationTable) -> None:
        self._capacity_kg = capacity_kg
        self._segregation = segregation
        self._cargos: list[CargoUnit] = []

    def load(self, unit: "CargoUnit") -> None:
        """«Погрузить единицу груза» — операция на языке домена, не add_cargo()."""
        self._ensure_capacity_for(unit)
        for loaded in self._cargos:
            verdict = self._segregation.check(unit, loaded)
            if not verdict.ok:
                raise SegregationViolation(verdict.reason)
        self._cargos.append(unit)

Что изменилось по существу:

  • Правило получило имя, которое эксперт узнаёт: «таблица разделения» — официальный термин ДОПОГ.
  • Таблица тестируется 30 юнит-тестами без агрегата: O(1) на проверку пары вместо прогона всего сценария.
  • Планировщик рейсов использует тот же объект — дубликат исчез, а не «был вынесен в утилиту».
  • Обновление регламента — правка данных (tuple правил, а в проде — таблица в БД или YAML), не кода.
  • Отказ объясняет причину, и эта причина сразу пригодна для показа пользователю.

Три типовых превращения, которые стоит держать в голове:

Что было Во что превращается Признак, что пора
Условие в теле метода Ограничение как объект (SegregationTable) Условие повторяется, у него длинное объяснение, оно меняется отдельно от метода
Последовательность шагов внутри сервиса Процесс как объект (RouteSelection, Settlement) У процесса есть варианты, промежуточные состояния или альтернативные стратегии
Фильтр в запросе плюс if в валидации Спецификация (см. статью 03) Один и тот же критерий нужен и в памяти, и в БД, и в отчёте

4. Семь паттернов гибкого дизайна

4.1. Интенционально-раскрывающие интерфейсы

Имя описывает намерение и результат, а не реализацию и не механику.

# Реализация наружу: читателю приходится знать про Дейкстру и про фильтры
def find_shortest_path_with_filters(graph, src, dst, filters, max_hops): ...

# Намерение наружу: читателю достаточно знать домен
def cheapest_itinerary_for(spec: "RouteSpecification") -> "Itinerary": ...

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

4.2. Функции без побочных эффектов

Разделяйте команды (меняют состояние, ничего не возвращают) и запросы (вычисляют, ничего не меняют) — принцип CQS Бертрана Мейера. Вычислительно сложную логику выносите в чистые функции над объектами-значениями.

@dataclass(frozen=True)
class Itinerary:
    """Маршрут — объект-значение. Любое «изменение» порождает новый маршрут."""
    legs: tuple["Leg", ...]

    def extended_with(self, leg: "Leg") -> "Itinerary":
        """Чистая функция: старый маршрут остаётся валидным и переиспользуемым."""
        if self.legs and self.legs[-1].to_port != leg.from_port:
            raise ValueError("плечо не стыкуется с предыдущим")
        return Itinerary(self.legs + (leg,))

    def duration(self) -> timedelta:
        return sum((leg.duration for leg in self.legs), timedelta())

Что это даёт практически: комбинаторный перебор маршрутов можно вести без страха испортить исходные данные; результаты кэшируются по значению; тест — это assert над возвращённым объектом без setUp и без моков. Побочный эффект остаётся ровно один и в одном месте — сохранение агрегата, см. репозитории и Unit of Work.

4.3. Утверждения

Постусловия и инварианты должны быть выражены, а не подразумеваться. В языках без контрактов (Python, Go, TypeScript) их роль играют три вещи: валидация в конструкторе неизменяемого объекта, явные предикаты-предусловия и тесты, названные как утверждения.

def test_расширение_маршрута_не_меняет_исходный():
    """Постусловие extended_with, записанное как утверждение."""
    base = Itinerary((leg_spb_helsinki,))
    extended = base.extended_with(leg_helsinki_hamburg)

    assert len(base.legs) == 1            # исходный объект не тронут
    assert extended.duration() == base.duration() + leg_helsinki_hamburg.duration

Диагностический приём: попробуйте сформулировать постусловие метода одним предложением. Если не получается без союза «и» три раза — метод делает слишком много и просится на разделение. Отдельно: никогда не полагайтесь на инструкцию assert для бизнес-инвариантов в проде — Python выбрасывает её при запуске с -O, а Java — без -ea. Бизнес-правило бросает доменное исключение, а не AssertionError.

4.4. Концептуальные контуры

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

Разрез модели поперёк швов домена и по швам

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

4.5. Самодостаточные классы

Понятие тем полезнее, чем меньше других понятий нужно держать в голове, чтобы его понять. Считать можно грубо: концептуальный вес = число различных доменных типов в сигнатурах публичных методов + число зависимостей в конструкторе.

Класс Вес Комментарий
Money 1 Зависит только от себя. Понимается без контекста, тестируется в вакууме
SegregationTable 3 CargoUnit, SegregationRule, SegregationVerdict — приемлемо
Shipment 8 Агрегату можно: он по определению собирает понятия вместе
ShipmentService 17 Красный флаг: класс, который знает про всё, не знает ничего

Целься не в «ноль зависимостей везде» (это невозможно и не нужно), а в то, чтобы низкоуровневые понятия домена были самодостаточны, а связность концентрировалась в немногих агрегатах и сценариях.

4.6. Замкнутость операций

Операция замкнута, если её аргумент и результат — один и тот же тип. Замкнутые операции дают домену алгебру: из них можно строить выражения, не заводя новых понятий на каждый шаг.

Операция Тип Что даёт
Money + Money -> Money замкнута Суммирование строк заказа без вспомогательных классов
Specification & Specification -> Specification замкнута Композиция правил отбора любой сложности
Itinerary.extended_with(Leg) -> Itinerary почти замкнута Пошаговое построение маршрута в переборе
TimeRange & TimeRange -> TimeRange | None замкнута Пересечение периодов действия тарифов
Money * Percent -> Money не замкнута, и это нормально Умножение на скаляр — законное исключение

Не превращайте это в самоцель: замыкать операции над типами, которые в домене разные по смыслу (Money * Money), — верный способ получить бессмысленную арифметику, которую компилятор пропустит.

4.7. Декларативный дизайн

Высшая форма гибкости: правило становится данными или композицией, а не кодом. Классический путь — комбинаторы спецификаций.

class Spec:
    """Базовый комбинатор: любые правила складываются в выражение."""

    def is_satisfied_by(self, candidate) -> bool:
        raise NotImplementedError

    def __and__(self, other: "Spec") -> "Spec": return AndSpec(self, other)
    def __or__(self, other: "Spec") -> "Spec": return OrSpec(self, other)
    def __invert__(self) -> "Spec": return NotSpec(self)


class MaxTransitTime(Spec):
    def __init__(self, limit: timedelta) -> None:
        self._limit = limit

    def is_satisfied_by(self, itinerary: Itinerary) -> bool:
        return itinerary.duration() <= self._limit


class AvoidsPort(Spec):
    def __init__(self, port: str) -> None:
        self._port = port

    def is_satisfied_by(self, itinerary: Itinerary) -> bool:
        return all(self._port not in (leg.from_port, leg.to_port) for leg in itinerary.legs)


# Требование клиента записывается одной строкой на языке домена:
requirement = MaxTransitTime(timedelta(days=12)) & ~AvoidsPort("SUEZ") & RefrigeratedOnly()

Сложность композиции: проверка — O(k) по числу листьев выражения, память — O(k); для перебора n маршрутов получаем O(n · k) вместо O(n) захардкоженных проверок с непредсказуемым числом ветвлений. Выигрыш не в скорости, а в том, что новое требование клиента добавляется без единой правки существующего кода.

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


5. Как измерить, что модель стала глубже

Углубление легко имитировать, поэтому нужны признаки, которые нельзя нарисовать в презентации.

Измеримые индикаторы, которые стоит смотреть раз в квартал:

  1. Ширина диффа на изменение требования. Медиана числа файлов домена в коммитах, где менялось бизнес-правило. Цель — один-два.
  2. Число ветвлений на правило. Цикломатическая сложность доменного слоя (radon и аналоги); растёт быстрее числа понятий — модель мельчает.
  3. Разрыв словарей. Имена классов и методов домена минус глоссарий, и наоборот. Обе разности должны стремиться к нулю; растущая — язык расходится.
  4. Тест вслух. Доля бизнес-правил, чей тест эксперт домена понимает без переводчика: раз в квартал показываете десять тестов и считаете.

6. Цена и когда углубляться не надо

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

Не углубляйте, если поддомен supporting или generic (см. стратегический DDD); если правило зафиксировано внешним регламентом и не менялось десять лет; если вы не можете назвать конкретное изменение, которое станет дешевле; если в команде нет никого, кто удержит абстракцию через полгода.

Углубляйте, если правило меняется чаще раза в квартал, его формулирует не программист и вокруг него уже накопились дубли — тот самый правый верхний квадрант на диаграмме выше.


7. Типичные ошибки

  1. Абстракция без домена. IRuleStrategyFactoryProvider — имя не из речи эксперта, значит, понятие выдумано. Признак: эксперт не понимает ни одного слова в названии.
  2. Декларативный движок без пользователя. Написали интерпретатор правил, а правила по-прежнему пишет тот же разработчик — вы построили худший язык программирования вместо кода на хорошем.
  3. Замкнутость ради формы. Money * Money и Address + Address компилируются и не значат ничего.
  4. assert как бизнес-правило. Отключается флагом интерпретатора и молча пропускает нарушение.
  5. Неизменяемость любой ценой. Промежуточные объекты стоят аллокаций; в горячем цикле перебора маршрутов это заметно — измеряйте, прежде чем делать immutable всё подряд.
  6. Углубление на дедлайне. Прорыв посреди релиза — способ сорвать релиз и дискредитировать подход.
  7. Один автор. Глубокая модель, которую понимает один человек, — не модель, а личный диалект; критерий приёмки — второй разработчик объясняет её третьему.

8. Мини-итог

  • Формально правильная модель (агрегаты, VO, границы) и глубокая модель — разные вещи. Разница видна не в структуре кода, а в стоимости изменения правила.
  • Глубина берётся из неявных понятий: слово эксперта, флаг в сигнатуре, повторяющийся if, противоречие в терминах, отраслевой стандарт — пять надёжных источников.
  • Три типовых превращения: ограничение → объект, процесс → объект, критерий → спецификация.
  • Семь паттернов гибкого дизайна: интенционально-раскрывающие интерфейсы, функции без побочных эффектов, утверждения, концептуальные контуры, самодостаточные классы, замкнутость операций, декларативный дизайн.
  • Углубление стоит дорого и окупается только в ядре домена, где правила меняются часто. В supporting — это потраченные деньги.

Источники

  • Eric Evans. Domain-Driven Design (2003), часть III — главы 8 «Breakthrough», 9 «Making Implicit Concepts Explicit», 10 «Supple Design», 13 «Refactoring Toward Deeper Insight». Бесплатная выжимка: DDD Reference.
  • Bertrand Meyer. Object-Oriented Software Construction — Design by Contract; краткое изложение разделения команд и запросов у Фаулера: CommandQuerySeparation.
  • Martin Fowler, Eric Evans. Specifications — исходная статья про спецификации и их композицию; Refactoring, 2nd ed. — механика безопасных мелких шагов, без которой прорыв невозможен.
  • Vaughn Vernon. Implementing Domain-Driven Design (2013), гл. 10 — примеры вытаскивания понятий на JVM.
  • Scott Wlaschin. Domain Modeling Made Functional — тот же набор идей на языке типов; подробно — в статье про моделирование типами.
  • ДОПОГ / ADR — отраслевой источник таблицы разделения опасных грузов: unece.org.

Что дальше

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

Дистилляция ядра и эволюция модели

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

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

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

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