Часть I: Фундамент — как мы здесь оказались
Глава 4

Домены, контексты и язык — DDD для работающих людей

Проблема

Проблема: Вы приходите на новый проект. Там есть слова «заказ», «клиент», «продукт». Бизнес говорит одно, код — другое, документация — третье. Через три месяца выясняется, что «заказ» в коде одного сервиса и «заказ» в коде другого — это разные вещи. И никто не знает, что такое «статус заказа» на самом деле.


Я ненавидел DDD. Серьёзно.

Когда я впервые услышал про Domain-Driven Design, это звучало как секта. Какие-то «агрегаты», «события домена», «вездесущий язык». Народ на конференциях говорил про DDD с таким видом, будто открыл истину мироздания. А я сидел и думал: «Чувак, у меня CRUD-приложение на 10 таблиц, зачем мне вся эта сложность?»

И знаете что? Я был прав. Для CRUD-приложения на 10 таблиц DDD — это overkill. Как скальпель для открытия консервной банки. Инструмент правильный, но не для этой задачи.

Но потом я работал над проектом, где бизнес-логика была настолько запутанной, что никто не мог сказать, что вообще происходит. Бухгалтерия говорила одно, продакт-менеджер — другое, код делал третье. Баги плодились, как кролики. Каждое изменение ломало что-то в соседнем модуле. И тогда я вспомнил про DDD.

Не как про религию. Как про набор инструментов для наведения порядка в хаосе.

Эта глава — про то, как эти инструменты работают. Без фанатизма. Без «DDD — единственный путь». Просто: вот проблема, вот инструмент, вот когда он нужен, а когда — нет.


#Зачем DDD обычному веб-разработчику

Давайте сразу расставим точки над i.

DDD — это НЕ про микросервисы. Вы не можете «внедрить DDD, потому что переходим на микросервисы». Это как сказать «я купил дрель, потому что строю дом». Нет, вы покупаете дрель, чтобы сверлить дыры. Микросервисы — это инструмент организации кода и команд. DDD — это инструмент понимания бизнеса.

DDD отвечает на вопрос: как перевести бизнес-требования на язык кода, не потеряв по пути смысл?

Вот типичная картина в проектах без DDD:

  1. Бизнес говорит: «У нас есть концепция "активный клиент" — это тот, кто сделал хотя бы одну покупку за последние 90 дней»
  2. Аналитик записывает: «Для поля isActive: true если есть заказы за 90 дней»
  3. Разработчик пишет: user.is_active = orders.any? { |o| o.created_at > 90.days.ago }
  4. Через месяц приходит новый бизнес-требование: «Активный клиент может получить скидку 5%»
  5. Разработчик проверяет: ага, user.is_active == true → скидка
  6. Через два месяца: «Активный клиент — это не только покупки, но и входы в систему»
  7. Разработчик добавляет ещё одно условие в is_active
  8. Через год никто не знает, что такое «активный клиент» на самом деле

Проблема не в коде. Код работает. Проблема в том, что бизнес-понятие нигде не зафиксировано как единая, разделяемая всеми модель. Оно размазано по условиям, запросам и комментариям.

DDD предлагает простое решение: давайте сядем и договоримся, что мы имеем в виду, а потом запишем это в коде так, чтобы любой разработчик мог открыть файл и понять бизнес-правило за 30 секунд.

Не нужно быть архитектором, чтобы это делать. Не нужно внедрять микросервисы. Нужно просто перестать писать код как «табличку в БД с методами» и начать думать о домене — той предметной области, которую вы автоматизируете.

Cross-reference

Cross-reference: В Главе 3 мы говорили о связности (cohesion) и SRP. DDD — это следуюший шаг: как определить, где проходят границы модуля и что должно быть внутри.


#Ubiquitous Language — первый и главный инструмент

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

Например, «заказ».

Почти всегда результаты разные. Продакт говорит: «Заказ — это когда клиент выбрал товары и нажал "купить"». Разработчик: «Запись в таблице orders со статусом confirmed». Аналитик: «Событие, после которого мы начинаем отслеживать воронку». Бухгалтер: «Основание для списания денег».

Все правы. И все говорят о разном. Потому что «заказ» — это не один концепт. Это несколько концептов, которые живут в разных контекстах, но называются одинаково.

#Что такое Ubiquitous Language

Ubiquitous Language (вездесущий язык) — это единый словарь терминов, который используют и бизнес-эксперты, и разработчики. Без перевода. Без «в бизнесе это называется "клиент", а в коде — "user"». Одно понятие — один термин — одно определение.

Звучит банально. На практике — это самый сложный и самый ценный артефакт DDD.

Вот как это выглядит в хорошем проекте:

Бизнес говорит:             Код говорит:
«Клиент оформляет заказ»    OrderClient.placeOrder()
«Заказ подтверждён»         Order.confirm()
«Товар добавлен в корзину»  Cart.addItem()
«Скидка применена к заказу» Discount.applyTo(order)

Заметили? В коде нет «User», «Transaction», «Entry». Есть те же слова, что и в разговоре с бизнесом. Разработчик может прийти к продакту и сказать: «У нас баг: при подтверждении заказа скидка не применяется к доставке». Продакт поймёт без перевода. Потому что «подтверждение заказа», «скидка» и «доставка» — это термины из их общего языка.

#Как построить Ubiquitous Language

Это не про «написать словарь в wiki». Это про живую практику.

  1. Слушайте бизнес. Когда продакт говорит «мы отменяем заказ», не переводите в голове в order.status = 'cancelled'. Спросите: «А что значит "отменить"? Какие есть варианты отмены? Что происходит с деньгами, с товаром, с доставкой?»

  2. Записывайте термины. Не в вакууме, а в контексте. «Отмена заказа» — это одно, «отмена доставки» — другое. Создайте глоссарий, но не как мёртвый документ, а как живую таблицу, к которой все имеют доступ.

  3. Используйте термины в коде. Названия классов, методов, переменных, модулей — всё должно отражать язык бизнеса. Если бизнес говорит «клиент», в коде должно быть Client, а не User. Если бизнес говорит «отмена заказа», метод должен называться cancelOrder(), а не setStatusCancelled().

  4. Спорьте о терминах. Если разработчик и продакт называют одно и то же по-разному — это красный флаг. Остановитесь и договоритесь. Не «ну, в коде это так, а в жизни — иначе». Иначе — непонимание, баги, переработки.

#Практический пример: интернет-магазин

Представьте, что мы проектируем интернет-магазин. Без DDD это выглядит так:

# Типичный CRUD-подход
class Order:
    id: int
    user_id: int
    items: List[OrderItem]
    status: str  # 'pending', 'confirmed', 'shipped', 'cancelled'
    total: Decimal
    created_at: datetime
    paid_at: datetime | None

С бизнесом мы выясняем, что «заказ» в их понимании — это не просто запись в БД. Это процесс, у которого есть этапы, правила и исключения.

После сессии с продактом и аналитиком мы составляем Ubiquitous Language:

Термин             | Определение
-------------------|--------------------------------------------------
Корзина (Cart)     | Временный набор товаров до подтверждения
Заказ (Order)      | Подтверждённый набор товаров с обязательством оплаты
Платёж (Payment)   | Факт списания средств по заказу
Отгрузка (Shipment)| Факт передачи товара в доставку
Возврат (Return)   | Возврат товара с компенсацией
Отмена (Cancellation)| Отмена заказа до отгрузки
Скидка (Discount)  | Снижение цены по правилу (промо, лояльность)

И код начинает выглядеть так:

# DDD-подход: код говорит на языке бизнеса
class Order:
    def confirm(self) -> None:
        # Бизнес-правило: заказ можно подтвердить только если
        # все товары в наличии
        ...

    def cancel(self, reason: CancellationReason) -> None:
        # Бизнес-правило: при отмене возвращаем деньги,
        # если они уже списаны
        ...

    def apply_discount(self, discount: Discount) -> None:
        # Бизнес-правило: скидка не суммируется с другими скидками
        ...

Разница? В первом случае статус заказа — просто поле, которое кто-то где-то меняет. Во втором — заказ ведёт себя как заказ. У него есть поведение (behavior), а не просто данные.

Cross-reference

Cross-reference: Это напрямую связано с тем, что мы обсуждали в Главе 3 — SRP и CQS. Order.confirm() — это команда, которая меняет состояние. Order.status — это запрос, который возвращает текущее состояние.


#Bounded Context — границы, которые стоит защищать

В большой системе один и тот же термин может значить разное в разных частях системы. И это нормально. DDD называет эти части bounded context (ограниченный контекст).

#Проблема одного слова на всех

Вернёмся к интернет-магазину. Что такое «Клиент»?

  • Для отдела продаж: Клиент — это тот, кто может совершить покупку (лид, потенциальный клиент, постоянный клиент)
  • Для службы поддержки: Клиент — это тот, у кого есть открытые обращения
  • Для бухгалтерии: Клиент — это юридическое лицо с договором
  • Для доставки: Клиент — это адрес и контактное лицо

Если вы попытаетесь создать одну модель Client на все случаи жизни, у вас получится God Object (см. анти-паттерны в Главе 3). Модель будет содержать всё: isLead, isVip, contractNumber, defaultAddress, supportTickets. Разные части системы будут менять разные поля. Никто не будет знать, какие поля консистентны, а какие — нет.

Решение: разделите на контексты.

Bounded Context      | Что такое «Клиент» в этом контексте
---------------------|-------------------------------------------
Sales (Продажи)      | Потенциальный покупатель: лид, контакт, воронка
Support (Поддержка)  | Пользователь с обращением: тикеты, история обращений
Billing (Биллинг)    | Плательщик: договор, счета, задолженность
Shipping (Доставка)  | Получатель: адрес, контактное лицо, зона доставки

В каждом контексте своя модель Клиента. Со своими полями, своим поведением, своими правилами. Они могут быть связаны (например, по ID), но не обязаны иметь одинаковую структуру.

#Как определить границы контекста

Универсальный признак: разные эксперты называют одно и то же по-разному.

Если вы слышите от разных людей разные определения одного термина — у вас как минимум два bounded context. Зафиксируйте их. Разделите код.

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

Третий признак: разные команды. Если над частью системы работают разные команды (или даже разные люди в одной команде), у них должен быть свой bounded context. Иначе — бесконечные конфликты в мерж-реквестах.

#Context Map

Когда вы определили bounded context, следующий шаг — нарисовать карту (context map), которая показывает, как контексты связаны.

Основные типы связей:

Тип связи Описание Когда использовать
Partnership Два контекста координируются: изменение в одном требует синхронизации с другим Strongly coupled процессы
Shared Kernel Общая часть модели (обычно небольшой набор классов), оба контекста её используют Общие фундаментальные понятия
Customer-Supplier Один контекст (Supplier) поставляет данные для другого (Customer) API между командами
Conformist Customer принимает модель Supplier как есть, без адаптации Маленькая команда от большой
Anticorruption Layer Customer создаёт прослойку, которая переводит модель Supplier в свою Защита от «чужой» модели
Open-Host Service Один контекст предоставляет публичный API для других Официальная интеграция
Separate Ways Контексты вообще не связаны Полная независимость

Для интернет-магазина context map может выглядеть так:

[Sales] -- (Customer/Supplier) --> [Fulfillment]
   |                                      |
   |                                      v
   +-- (Shared Kernel: Клиент) --> [Support]
   |
   +-- (Anticorruption Layer) --> [Legacy CRM]

Sales говорит Fulfillment'у: «У нас новый заказ, обработай». Fulfillment не спрашивает Sales, что такое «заказ». У него своя модель. Anticorruption Layer переводит данные из Legacy CRM (которая вообще на COBOL и не менялась 15 лет) в нормальный формат.

Cross-reference

Cross-reference: Context map — это стратегический паттерн DDD. О том, как он переходит в архитектуру сервисов — в Главе 15 «Микросервисы — правильный способ». Об Anticorruption Layer — в той же главе, раздел «Интеграция с легаси».


#Entities vs Value Objects

Теперь к тактическим паттернам. Первое, с чем сталкивается новичок в DDD — два типа объектов: Entity (сущность) и Value Object (объект-значение).

Разница на самом деле простая, хотя вокруг неё накручено много пафоса.

#Entity — «кто ты?»

Entity — это объект, у которого есть идентичность (identity). Он уникален. Его нельзя заменить другим объектом, даже если все поля совпадают.

Классический пример: Человек. Вы — это вы, даже если сменили имя, адрес и работу. У вас есть идентичность, которая сохраняется во времени.

В коде:

class Client:
    def __init__(self, client_id: ClientId, name: str, email: Email):
        self.id = client_id  # идентичность — это главное
        self.name = name
        self.email = email

    def change_email(self, new_email: Email) -> None:
        self.email = new_email
        # Client остаётся тем же Client, просто у него новый email

Два Client с одинаковыми name и email, но разными ID — это разные клиенты. Потому что у них разная идентичность.

Entity обычно живёт долго, проходит через разные состояния, участвует в бизнес-процессах. В заказе: Order — это Entity. OrderItem — тоже (у каждой позиции своя идентичность в рамках заказа).

#Value Object — «что ты такое?»

Value Object — это объект, который не имеет идентичности. Он описывается своими атрибутами.

Классический пример: Деньги. 100 рублей — это 100 рублей. Вы не отличаете свою купюру от моей. Если вы заплатили 100 рублей, а я тоже заплатил 100 рублей — эти суммы эквивалентны.

В коде:

class Money:
    def __init__(self, amount: Decimal, currency: str):
        self.amount = amount
        self.currency = currency

    def __eq__(self, other):
        return (self.amount, self.currency) == (other.amount, other.currency)

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

Особенности Value Object:

  • Immutable (неизменяемый). Вы не меняете объект Money — вы создаёте новый. money.add(other) возвращает новый Money, а не меняет старый.
  • Сравнивается по значению. Два Money с одинаковой суммой и валютой — это одно и то же.
  • Не имеет ID. В БД может не быть отдельной таблицы — часто VO хранятся как JSON или колонки внутри Entity.

#Как выбирать

Простые правила:

Если у объекта:

  • Есть ID, который не меняется → Entity
  • Нет ID, все поля описывают его суть → Value Object
  • Может быть заменён другим с теми же полями → Value Object
  • Живёт своей жизнью, меняет состояние → Entity
  • Просто данные → Value Object

Примеры из интернет-магазина:

Объект Тип Почему
Order Entity Есть orderId, статус меняется, уникален
OrderItem Entity Есть itemId, ссылается на продукт
Product Entity Есть sku/ID, характеристики меняются
Money Value Object 100 рублей = 100 рублей
Address Value Object Адрес — это просто данные; если переехали — новый адрес
Email Value Object Строка с валидацией; не уникальна (один email может быть у разных юзеров, хотя по жизни редко)
Discount Value Object Процент/сумма с правилами

#Почему это важно

Разделение на Entity и Value Object — не академическое упражнение. Оно напрямую влияет на то, как вы пишете код.

Entity имеют жизненный цикл. У них есть состояние, которое меняется через методы. Их нужно сохранять в репозитории. Для них важна консистентность.

Value Objects — это кирпичики. Они иммутабельны, их можно копировать, сравнивать, использовать как ключи. Они упрощают код, потому что «не живут своей жизнью» — они просто есть.

Многие проблемы в проектах возникают именно потому, что Entity и Value Object смешиваются. В объект пихают идентичность, хотя она не нужна. Или наоборот — два разных Entity сравнивают по полям, не учитывая ID.


#Domain Events — как прошлое влияет на будущее

Domain Event (событие домена) — это запись того, что произошло в системе. Не то, что «надо сделать», а то, что «уже случилось».

Событие: OrderPlaced
Время: 2026-07-27T10:30:00Z
Данные: orderId=123, clientId=456, total=5800₽
Смысл: «Заказ размещён. Что-то должно произойти дальше.»

#Зачем нужны Domain Events

В реальной системе одно действие часто запускает цепочку других. Клиент оформил заказ → нужно проверить платёж → зарезервировать товар → отправить уведомление → обновить аналитику.

Без Domain Events эта цепочка выглядит как спагетти:

class OrderService:
    def place_order(self, order_data):
        order = Order.create(order_data)
        self.order_repo.save(order)
        self.payment_service.charge(order)   # прямой вызов
        self.inventory_service.reserve(order)  # прямой вызов
        self.notification_service.send(order)  # прямой вызов
        self.analytics.track(order)           # прямой вызов

Проблема: OrderService знает про payment, inventory, notification, analytics. Добавление нового шага (например, fraud_check) требует изменения OrderService.

С Domain Events:

class OrderService:
    def place_order(self, order_data):
        order = Order.create(order_data)
        self.order_repo.save(order)
        self.event_publisher.publish(OrderPlaced(order.id, order.client_id, order.total))
        # OrderService не знает, что будет дальше
        # Он просто говорит: «Заказ размещён»

Теперь FraudCheckService, InventoryService, NotificationService подписываются на событие OrderPlaced и делают своё дело. OrderService ничего о них не знает.

#Когда Domain Events — не просто «события»

Важно: Domain Event — это не техническое событие. Это бизнес-событие на языке домена.

  • UserClickedButton — не domain event. Это UI-событие.
  • UserRegistered — domain event. Произошла регистрация.
  • CacheEvicted — не domain event. Это техническая деталь.
  • OrderConfirmed — domain event. Бизнес-событие.

Domain Events обычно записываются в прошедшем времени: OrderPlaced, PaymentReceived, ShipmentDelayed, DiscountApplied. Потому что они описывают факт, который уже произошёл.

#Как это используется на практике

Domain Events становятся особенно мощными в сочетании с Event Sourcing (Глава 8), но и без него они полезны:

  1. Аудит. Вы знаете, какие бизнес-события произошли и когда.
  2. Реактивность. Разные части системы реагируют на события независимо.
  3. Интеграция. Событие публикуется в очередь/стрим, и внешние системы подписываются.
  4. Аналитика. События — источник данных о том, как система используется.

Простейшая реализация без библиотек:

from dataclasses import dataclass
from datetime import datetime
from typing import List, Callable

@dataclass
class DomainEvent:
    occurred_at: datetime

class OrderPlaced(DomainEvent):
    order_id: str
    client_id: str
    total: Money

# Простой in-process event bus
class EventBus:
    def __init__(self):
        self.handlers: dict[type, List[Callable]] = {}

    def register(self, event_type, handler):
        self.handlers.setdefault(event_type, []).append(handler)

    def publish(self, event):
        for handler in self.handlers.get(type(event), []):
            handler(event)

# Использование
bus = EventBus()
bus.register(OrderPlaced, lambda e: inventory_service.reserve(e.order_id))
bus.register(OrderPlaced, lambda e: notification_service.send_order_confirmation(e.client_id))

В продакшене вы, скорее всего, будете использовать что-то более серьёзное — Kafka, RabbitMQ, или встроенный event bus из фреймворка (Symfony Messenger, MediatR для .NET, Haren для Ruby, etc.). Но принцип тот же.


#Aggregate — единица консистентности

Если Entity — это «кто?», Value Object — «что?», то Aggregate — это «что с чем всегда должно быть консистентно».

Aggregate (агрегат) — это группа объектов (Entity + Value Objects), которые обрабатываются как единое целое. У агрегата есть корень (Aggregate Root) — единственная точка входа.

Классический пример:

class Order:                        # Aggregate Root
    def __init__(self):
        self.items: List[OrderItem] = []   # OrderItem внутри Order
        self.delivery: Delivery = ...      # Delivery — Value Object
        self.total: Money = Money(0, "RUB")

    def add_item(self, product, quantity):
        # Бизнес-правило: нельзя добавить больше 10 единиц одного товара
        if quantity > 10:
            raise DomainError("Максимум 10 единиц одного товара")
        self.items.append(OrderItem(product, quantity))
        # Всё изменение происходит через корень агрегата
        # Никто не может изменить items напрямую

Правила работы с агрегатами:

  1. Нет прямой ссылки на внутренние объекты. Внешний код не может взять order.items и удалить что-то. Только через order.remove_item(item_id).
  2. Агрегат гарантирует консистентность. Если внутри ограничение «общая сумма заказа не больше 100000₽», агрегат его проверяет.
  3. Одна транзакция — один агрегат. Не пытайтесь сохранить два агрегата в одной транзакции. Разные агрегаты — разные транзакции. Это сложно, но это основа масштабирования.

Агрегат — это спорная концепция. На практике я видел два подхода:

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

Выбирайте по контексту (отсылка к Главе 1).


#Repositories и Factories — как доставать и создавать

Два тактических паттерна, которые проще всего объяснить.

#Repository (Репозиторий)

Репозиторий — это коллекция агрегатов, к которой вы обращаетесь так, будто они в памяти. Репозиторий скрывает детали хранения.

class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, order_id: OrderId) -> Order | None:
        pass

    @abstractmethod
    def save(self, order: Order) -> None:
        pass

    @abstractmethod
    def find_recent_by_client(self, client_id: ClientId, days: int) -> List[Order]:
        pass

Реализация может быть SQL, MongoDB, Redis, файл — неважно. Код домена не знает (см. Dependency Inversion в Главе 3).

Важно: Repository работает на уровне агрегатов, не таблиц. Вы не делаете order_repo.save_items(). Вы сохраняете весь Order, реализация сама разберётся, какие строки вставить/обновить.

#Factory (Фабрика)

Фабрика инкапсулирует сложную логику создания агрегата.

class OrderFactory:
    @staticmethod
    def create_from_cart(cart: Cart, client: Client, delivery_address: Address) -> Order:
        if cart.is_empty():
            raise DomainError("Нельзя оформить пустую корзину")
        if not client.can_place_order():
            raise DomainError("Клиент не может оформлять заказы")

        order = Order(client.id, delivery_address)
        for item in cart.items:
            order.add_item(item.product_id, item.quantity)

        order.apply_discount(client.get_applicable_discount())
        return order

#Стратегические vs Тактические паттерны

DDD принято делить на две большие части.

Стратегические паттерны (про границы):

  • Ubiquitous Language — общий язык
  • Bounded Context — границы модели
  • Context Map — карта связей между контекстами

Тактические паттерны (про реализацию):

  • Entity — объект с идентичностью
  • Value Object — объект без идентичности
  • Aggregate — группа с консистентностью
  • Domain Event — запись бизнес-события
  • Repository — абстракция хранения
  • Factory — абстракция создания
  • Service (Domain Service) — бизнес-логика, которая не влезает в Entity/Value Object

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

Тактические паттерны — не всем. Если у вас CRUD-приложение с простой бизнес-логикой (форма ввода → таблица в БД → отчёт), Entity и Value Object вам не нужны. Data Class и Service сделают ту же работу без ритуала.

Если же у вас сложная бизнес-логика с правилами, статусами, ограничениями — тактические паттерны помогут не утонуть.


#Практика DDD

#DDD в монолите (Package by Domain)

Самое распространённое заблуждение: «DDD — это для микросервисов». Нет. DDD отлично работает в монолите. Более того, DDD в монолите может быть эффективнее, чем в микросервисах, потому что нет on-the-wire latency и распределённых транзакций.

Вот как выглядит «package by domain» (пакеты по доменам) в монолите:

src/
├── sales/                    # Bounded Context: Продажи
│   ├── domain/
│   │   ├── Order.py          # Entity
│   │   ├── OrderItem.py      # Entity
│   │   ├── Discount.py       # Value Object
│   │   └── OrderRepository.py  # Интерфейс репозитория
│   ├── application/
│   │   └── OrderService.py   # Use case / Application Service
│   └── infrastructure/
│       ├── OrderRepositoryImpl.py  # SQL реализация
│       └── controllers/
│           └── OrderController.py   # API endpoint
├── billing/                  # Bounded Context: Биллинг
│   ├── domain/
│   │   ├── Invoice.py
│   │   ├── Payment.py
│   │   └── BillingRepository.py
│   ├── application/
│   │   └── BillingService.py
│   └── infrastructure/
│       └── controllers/
└── shipping/                 # Bounded Context: Доставка
    ├── domain/
    │   ├── Shipment.py
    │   ├── Parcel.py
    │   └── ShippingRepository.py
    ├── application/
    │   └── ShippingService.py
    └── infrastructure/
        └── ...

Обратите внимание:

  • Каждый bounded context — отдельный пакет (директория)
  • Внутри — чистая архитектура: domain → application → infrastructure
  • Контексты не импортируют друг друга напрямую. Только через интерфейсы или события
  • Если контекст A нужно что-то из контекста B — он идёт через Application Service B (событие или API)

#DDD в микросервисах (каждый сервис = bounded context)

В идеальном мире каждый bounded context становится микросервисом. Или группой микросервисов. Или модулем в модульном монолите.

Правило простое: границы bounded context = границы сервиса.

sales-service/          # Один микросервис = один bounded context
├── src/
│   ├── domain/         # Доменная модель
│   ├── application/    # Use cases (CQRS commands/queries)
│   └── infrastructure/ # Хранилища, внешние интеграции
└── api/                # Protobuf / OpenAPI для внешнего мира

Проблема микросервисов: консистентность между контекстами. Вы не можете сделать JOIN между заказом (sales-service) и инвойсом (billing-service). Приходится решать через события и eventual consistency (Глава 16).

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

#Domain Service — когда не влезает в Entity

Иногда бизнес-логика не принадлежит ни одному Entity. Например, расчёт стоимости доставки зависит от корзины, адреса, тарифов и промо-акций.

class ShippingCostCalculator:
    def calculate(self, cart: Cart, address: Address, promotions: List[Promotion]) -> Money:
        base_cost = self._base_rate(address.zone)
        weight_surcharge = self._weight_surcharge(cart.total_weight())
        discounts = sum(p.apply(base_cost) for p in promotions)
        return base_cost + weight_surcharge - discounts

Это Domain Service — объект без состояния, который содержит бизнес-логику, не привязанную к конкретному Entity. Называйте его так, чтобы было понятно, что он делает. Не OrderHelper, не OrderManager — ShippingCostCalculator.


#Компромиссы и альтернативы

Подход Когда работает Цена
CRUD / Active Record Простые данные, минимум логики При росте логики — хаос
DDD (full) Сложная бизнес-логика, много правил Overhead на моделирование, больше кода
DDD (strategic only) Команда растёт, терминология путается Меньше, чем full, но не решает тактических проблем
Anemic Model CRUD с тонкой логикой Не даёт защитить инварианты

Самая частая ошибка: взять DDD как «религию» и попытаться внедрить все паттерны сразу, включая Event Sourcing и CQRS, в проекте, где нужно просто вывести форму с данными.


#Когда это применимо

Ситуация DDD нужен? Что делать
Стартап, MVP, 3 разработчика Нет, но Используйте Ubiquitous Language хотя бы в коде и разговорах. Об остальном забудьте.
Средний проект, 5-10 разработчиков, средняя сложность Стратегический DDD Bounded Context + Ubiquitous Language обязательны. Тактические паттерны — только для сложных доменов.
Enterprise, 20+ разработчиков, высокая сложность Да, полный DDD Все стратегические и тактические паттерны. Context Map. Domain Events.
Микросервисы Обязательно Каждый сервис — bounded context. Без DDD = distributed monolith.
Простой CRUD / CMS Нет Anemic model + Active Record. Серьёзно, не надо.

Если ваша бизнес-логика умещается в 5 таблиц с простыми валидациями — DDD вам не нужен. Поздравляю, вы сэкономили кучу времени.

Если же бизнес-логика напоминает лабиринт из правил, статусов, исключений и кросс-доменных взаимодействий — DDD ваш инструмент. Второго такого нет.


#Практический кейс: DDD в SoundCloud

SoundCloud (стриминг музыки) прошёл через типичную эволюцию. Они начинали как Rails-монолит с быстрым прототипированием и Active Record. Работало. Росло.

К 2014 году монолит весил тысячи файлов. Добавление новой фичи требовало изменения в 15-20 разных частях кода. Терминология в коде разошлась с терминологией бизнеса. Продуктовые команды говорили друг с другом по-разному об одних и тех же вещах.

SoundCloud пошёл по пути DDD не потому что «это модно», а потому что других вариантов не было. Без чётких границ и общего языка они не могли масштабировать ни код, ни команды.

Что они сделали:

  1. Определили bounded context: Tracks (треки), Users (пользователи), Playlists (плейлисты), Social (социальные взаимодействия), Ads (реклама)
  2. Построили context map: как они связаны, кто кому что отдаёт
  3. Каждый bounded context выделили в свой сервис (или группу сервисов)

Ключевое решение: они не пытались сделать «идеальный DDD» с первого дня. Они начали с bounded context в монолите — разделили код на доменные пакеты. Ubiquitous Language внедрили в командах. Domain Events добавили, когда поняли, что синхронные вызовы между контекстами создают проблемы.

Через два года, когда контексты были стабильны, они начали выносить их в отдельные сервисы. Но процесс занял годы — SoundCloud осознанно не спешил с микросервисами, потому что знал: плохие границы сервисов дороже, чем монолит.

[Source: "How SoundCloud divided its monolith into microservices" — SoundCloud Engineering Blog; "The Death and Rebirth of a Monolith" — SoundCloud Engineering Blog]

Урок: DDD — это не про «переписать всё на микросервисы». Это про «понять границы». Микросервисы — потом, если нужно.

Cross-reference

Cross-reference: SoundCloud — далеко не единственная компания, которая прошла этот путь. Как Amazon вводил API Mandate (Глава 3) и как Netflix строил свои границы сервисов (Глава 15) — всё это про bounded context на уровне бизнеса, который затем перешёл в архитектуру.


#Чек-лист

  • Я знаю основные термины моего бизнес-домена и их определения
  • Терминология в коде совпадает с терминологией в разговорах с продактом и аналитиком
  • У нас есть bounded context, и я могу объяснить границы каждого
  • Разные bounded context не используют одну модель для разных целей
  • Entity в моей системе имеют идентичность и меняют состояние через методы, а не через прямое присваивание полей
  • Value Object иммутабельны, сравниваются по значению, и я не смешиваю их с Entity
  • Агрегаты (если есть) защищают свои инварианты — бизнес-правила внутри них не обойти
  • Репозитории абстрагированы от конкретной БД (Dependency Inversion)
  • Domain Events (если есть) — это бизнес-события в прошедшем времени, а не технические уведомления
  • В коде нет классов с названиями Helper, Manager, Utils, которые содержат бизнес-логику
  • DDD внедрён не как религия, а как инструмент — мы используем только то, что нужно прямо сейчас

#«Не делай так» — Анти-паттерны DDD

#1. Анальный DDD (DDD by the book)

Самый популярный анти-паттерн. Команда прочитала Эванса, решила «внедрить DDD правильно» и потратила три месяца на моделирование домена. Написали десятки страниц UML-диаграмм, определили 47 агрегатов, нарисовали context map на стену. А потом выяснили, что бизнес-процесс, который они моделировали, изменился месяц назад.

Симптомы: больше времени на моделирование, чем на код; UML-диаграммы на стене, которые никто не обновляет; «DDD-полиция» в код-ревью, требующая строгого следования всем паттернам.

Лечение: DDD — это инструмент, а не цель. Если Ubiquitous Language работает, а агрегаты — нет, не внедряйте агрегаты. Используйте DDD итеративно. Начните с малого.

#2. Перепроектирование (Big Design Up Front — BDUF)

Строительство идеальной модели до написания кода. Святая вера в то, что можно предусмотреть все бизнес-правила на старте.

Проблема: бизнес меняется быстрее, чем вы моделируете. Модель, которая была идеальной в январе, в марте уже устарела.

Лечение: моделируйте с запасом, но без фанатизма. Итеративное уточнение модели (refinement) — это нормально. Ожидать, что первая версия модели будет идеальной — иллюзия. Подробнее — в Главе 5 (YAGNI).

#3. Anemic Domain Model (Анемичная модель)

У модели есть Entity, Value Objects, агрегаты — но вся бизнес-логика в сервисах. Entity — это просто структуры данных с геттерами и сеттерами.

# Анемично: просто контейнер данных
class Order:
    def __init__(self):
        self.status = "pending"
    def set_status(self, s):
        self.status = s  # Кто угодно может вызвать

# Не анемично: поведение внутри
class Order:
    def confirm(self) -> None:
        if self.payment is None:
            raise DomainError("Нельзя подтвердить без оплаты")
        self._status = OrderStatus.CONFIRMED
        self._events.append(OrderConfirmed(self.id))

Парадокс: при анемичной модели формально «DDD есть», но выгоды от него — ноль. Вы платите за сложность тактических паттернов, но не получаете их защиту бизнес-правил.

Лечение: либо кладите бизнес-логику в Entity (там, где это естественно), либо не используйте DDD вообще — обычный CRUD будет проще и прозрачнее.

#4. DDD как оправдание микросервисов

«Давай перейдём на микросервисы, чтобы внедрить DDD». Это обратная логика. DDD определяет границы. Микросервисы — один из способов их реализовать.

Симптомы: «Выделили 20 bounded context» (хотя на деле — 3). «Каждый контекст — отдельный сервис» (хотя контексты слишком малы). «Теперь у нас 20 баз данных для 5 разработчиков».

Лечение: сначала контексты, потом сервисы. И то — только если реально нужны. Модульный монолит (Глава 12) часто даёт те же границы без распределённой сложности.

#5. Over-engineering Value Objects

«Давайте сделаем PhoneNumber, Email, FirstName, LastName, StreetAddress, CityName, ZipCode — всё как Value Objects с валидацией». Десять маленьких классов вместо одной строки.

Когда это оправдано? Когда типы имеют сложное поведение (например, Email с проверкой MX-записи, PhoneNumber с форматированием для разных стран). Когда нет — это преждевременная абстракция.

Золотое правило: делайте Value Object для типа, если он повторяется в коде и/или у него есть бизнес-правила. Email с валидацией — ок. FirstName как str — тоже ок, если имя не участвует нигде в логике.

#6. Репозиторий на каждую сущность

«OrderRepository, ClientRepository, PaymentRepository, InvoiceRepository...» А потом ещё OrderItemRepository, AddressRepository, DiscountRepository.

Репозиторий нужен только для агрегатов. Сущность, которая входит в другой агрегат, не должна иметь свой репозиторий. OrderItem не сохраняется отдельно — он живёт внутри Order.

Как понять, нужен ли репозиторий: задайте вопрос «Мне когда-нибудь нужно получить этот объект сам по себе, без его родителя?» Если нет — репозиторий не нужен.


#Ключевые выводы

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

  2. Ubiquitous Language — самый ценный и самый недооценённый паттерн. Если бизнес говорит «активный клиент», в коде должно быть ActiveClient, а не переменная is_active = orders.any?. Вы удивитесь, сколько багов исчезает, когда код говорит на языке бизнеса.

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

  4. Тактические паттерны — опциональны. Entity, Value Object, Aggregate, Domain Event — берите только то, что реально нужно. CRUD-приложению не нужны ни Event Sourcing, ни сложные иерархии агрегатов. Сложному домену — нужны. Контекст решает (отсылка к Главе 1).

  5. DDD не терпит фанатизма. «Идеальный DDD» — это оксюморон. Модель должна быть достаточно хороша, чтобы команда понимала друг друга. Всё остальное — шум. О том, как найти золотую середину — в Главе 5 «YAGNI, KISS и когда их нарушать».


В следующей главе: как не построить космический корабль для перевозки одного человека — YAGNI, KISS и искусство балансирования сложности.