Уровень 3 · DDD Глава 08 13 мин

Ubiquitous Language и Bounded Context

Единый язык команды и кода внутри контекста. Разные контексты — разный смысл терминов. Anti-Corruption Layer как защита границ.

TL;DR

  • Ubiquitous Language — общий словарь бизнеса и разработки. Если в коде user_id, а бизнес говорит "клиент" — уже разрыв.
  • Слово имеет смысл только в контексте. Order в billing — счёт; Order в shipping — отправление. Разные модели.
  • Bounded Context — не «модуль» и не «микросервис». Это семантическая граница, где термины консистентны.
  • Anti-Corruption Layer защищает вашу модель от навязанной чужой. Дороже интеграции, дешевле хаоса.

Зачем эта глава

DDD часто ассоциируют с tactical patterns — Entity, Value Object, Aggregate. Это упрощение. Evans начинал с другого: язык. Без Ubiquitous Language tactical patterns — просто способ раскладки классов, а не модель домена. Стратегический DDD (Strategic Design) отвечает на самые дорогие вопросы: где вообще проходят границы системы.

Ошибка на этом уровне — сотни человеко-часов рефакторинга. Ошибка на уровне tactical — переписать один класс.

Ubiquitous Language

Определение Ubiquitous Language (повсеместный язык)

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

— Evans, DDD, 2003

Принципы:

    Термины бизнеса появляются в коде без перевода. Бизнес говорит «активная позиция тендера» — в коде ActiveTenderPosition, а не PositionInProgress или CurrentItem.

    Термины кода появляются в разговорах. Если инженер на планёрке говорит «мы обновляем normalized_data», а бизнес не понимает — это разрыв.

    Язык уточняется совместно. Разработчик может спросить «а точно ли „заявка” и „предложение” — разные вещи?» — и это часть работы над моделью.

    Изменение языка = изменение модели. Появилось новое понятие в разговоре — оно должно попасть в код. Забыть — значит накопить долг.

A domain expert’s use of it should be crisp; a developer’s use of it should be accurate. When flaws in the language emerge, refactor the model — and the code.

Eric Evans Domain-Driven Design, 2003

Признаки нарушения языка

Признак 1 — «переводчик» между бизнесом и кодом. Аналитик пишет ТЗ: «клиент подтверждает заказ». Разработчик пишет user_service.confirm_order(user_id, order_id). user_id вместо customer_id, order вместо сделка (если бизнес говорит именно так). Каждый раз, читая ТЗ, разработчик переводит.

Признак 2 — синонимы. В базе users, в коде Account, в UI «профиль», в поддержке «клиент». Четыре имени для одной сущности. Через год никто не помнит, где какое имя.

Признак 3 — общие термины. process(), handle(), manage(), execute(). Не языковые термины, а маскировка неточности. Order.process() — что делает? Confirm? Cancel? Ship?

Признак 4 — технические термины в бизнес-логике. Order.sync_to_db(), User.serialize_for_api(). Технические детали протекают в domain.

Bounded Context

Определение Bounded Context (ограниченный контекст)

Явная граница, внутри которой модель домена консистентна, а Ubiquitous Language имеет однозначный смысл.

— Evans, DDD, 2003

Ключевая мысль: один термин может означать разное в разных контекстах, и это нормально.

Пример: слово Order в реальной e-commerce системе:

КонтекстЧто такое OrderКлючевые поля
SalesОформленная заказ покупателяitems, customer, total_amount, status
FulfillmentЗаказ к упаковке и отправкеitems, warehouse, packed_at, shipping_address
BillingФинансовая транзакцияline_items, tax, invoice_number, paid_at
AnalyticsСобытие покупкиorder_id, customer_id, revenue, category_tags

Одно слово — четыре модели. В каждом контексте Order имеет собственный набор полей, инвариантов, поведения. Пытаться слить их в один «универсальный Order» — самая частая ошибка.

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

Три сигнала:

1. Один термин — разный смысл. Если разработчики из команд-соседей понимают Product по-разному — вы уже пересекли границу контекста.

2. Разные стейкхолдеры. Sales работает с одним департаментом, Billing — с бухгалтерией, Shipping — с логистами. Разные заказчики требований — обычно разные контексты.

3. Разная скорость изменений. Sales-модель может меняться каждую неделю (эксперименты с промо), Billing — раз в квартал (нормативы). Разная частота = разные жизненные циклы.

Если хотя бы два сигнала совпали — рисуйте границу.

Границы контекста ≠ границы микросервиса

Возможные комбинации:

  • Один контекст = один микросервис. Наиболее чистый случай. Легко масштабировать и деплоить.
  • Один контекст = несколько микросервисов. Например, split по нагрузке (Order-Command и Order-Query для CQRS). Один контекст, один язык — но разные сервисы.
  • Модульный монолит: один процесс, несколько контекстов. Каждый контекст — отдельный модуль с чёткими границами. Хороший стартовый вариант.
  • Плохо: один микросервис = несколько контекстов. Внутренний код становится distributed monolith. Термины ломаются на границе.

Как правильно: язык в коде

Возьмём анонимизированный микросервис. Плохо и хорошо:

# Плохо — общие термины, разрыв языка
class OrderProcessor:                            
    async def process(self, data: dict) -> None: 
        result = await self._api.call(data)
        await self._db.save(data['id'], result)
        await self._mq.send(result)

# Хорошо — язык домена, конкретика
class OrderStandardizeUseCase:
    async def standardize(self, item: OrderItemMessage) -> None:
        properties = self._collect_standardizable(item)
        normalized = await self._normalizer.normalize(properties)
        await self._items.mark_as_standardized(item.id, normalized)
        await self._events.publish(StandardizationCompleted(item.id))

Второй вариант читается почти как английский: «стандартизирует item, собрав нормализуемые свойства, отправляет нормализатору, отмечает как стандартизированный, публикует событие StandardizationCompleted». Это Ubiquitous Language в действии.

Anti-Corruption Layer

Определение Anti-Corruption Layer (ACL) (антикорруптивный слой)

Изоляционный слой, который транслирует чужую модель в собственный язык. Защищает от «заражения» вашей модели чужими терминами и структурами.

— Evans, DDD, 2003

Когда нужен ACL:

  • Интеграция с legacy-системой, чей язык не соответствует вашему домену.
  • Внешний API диктует термины, которые вам не подходят.
  • Партнёр использует другой язык, вы не можете заставить его меняться.
  • Вы переходите с одной модели на другую — старая ещё работает.

Пример: интеграция с внешним marketplace

Внешний API — marketplace-api. Его язык: sku, unit_price, stock_quantity. Ваш язык: product, catalog_price, available_units. Термины не совпадают.

Без ACL:

# use case тонет в чужих терминах
async def execute(self, cmd):
    sku_response = await self._marketplace.get_sku(cmd.product_code)   
    stock_qty = sku_response['stock_quantity']                         
    unit_price = sku_response['unit_price']
    # ... 30 строк маппинга и переводов туда-сюда

Через полгода marketplace-api меняет unit_price на price_ex_tax — вы меняете код в 12 местах.

С ACL:

# app/infrastructure/marketplace/acl.py
class MarketplaceAdapter:                                              
    """Anti-Corruption Layer: транслирует marketplace-язык в наш."""

    async def get_product(self, product_code: ProductCode) -> Product:
        raw = await self._client.get_sku(product_code.value)
        return Product(
            code=product_code,
            catalog_price=Money(Decimal(raw['unit_price']), 'RUB'),
            available_units=raw['stock_quantity'],
        )

# app/application/use_cases/check_availability.py
async def execute(self, cmd):
    product = await self._marketplace.get_product(cmd.product_code)   
    if product.available_units < cmd.required:
        raise NotEnoughStock(product.code)

Use case говорит на вашем языке. Если marketplace-api завтра переименует unit_price — вы правите одну строчку в ACL.

▸ ACL стоит дорого — когда он оправдан

ACL — не бесплатный слой. Каждый вызов через ACL требует маппинга. Дублирование моделей, дополнительные тесты.

Когда стоит:

  • Внешняя система — legacy или партнёр, вы не можете влиять на её язык.
  • Термины серьёзно расходятся — не просто разные имена полей, а разная семантика.
  • Планируется миграция — ACL позволит переключиться на другой источник данных незаметно для core.

Когда не стоит:

  • Внешний API — ваш же микросервис, язык согласуется. Достаточно тонкого клиента.
  • Простой CRUD с 3 полями и одинаковыми терминами. ACL здесь — оверхэд.

Как не надо

1. Универсальная модель через все контексты

class User(Base):                                        
    """Универсальный пользователь для всех контекстов."""
    id: Mapped[int]
    email: Mapped[str]
    password_hash: Mapped[str]
    profile_photo_url: Mapped[str | None]
    billing_address: Mapped[str | None]
    credit_limit: Mapped[Decimal | None]
    marketing_opt_in: Mapped[bool]
    orders_count: Mapped[int]
    last_login: Mapped[datetime | None]
    # ... 30 полей

Один класс — четыре контекста. Изменения в auth ломают billing, изменения в marketing — auth. Каждая команда боится трогать.

Правильно: AuthUser в контексте auth, Customer в sales, Billee в billing, Subscriber в marketing. Каждый — со своим набором полей. Общий только UserId.

2. Общие термины в именах

class OrderManager:                          
    def process_order(self, data): ...
    def handle_order(self, order): ...
    def do_stuff(self, order_id): ...

Manager, process, handle — маскировка неопределённости. Что именно делает handle_order? Confirm? Cancel? Reship?

Правильно: конкретные бизнес-глаголы. Order.confirm(), Order.cancel(), Order.request_refund(). Каждый метод отражает событие в домене.

3. Технический язык вместо доменного

class Order:
    def sync_to_db(self): ...
    def serialize_for_api(self): ...
    def to_dict(self): ...
    def from_row(cls, row): ...

Domain-класс знает про БД, API, dict. Половина методов — про технические детали. Ubiquitous Language погиб.

Правильно: Order не знает про БД. Persistence — задача Repository. Serialization — задача mapper’а в infrastructure. Domain — про бизнес-инварианты и события.

4. Отсутствие ACL при интеграции с чужой системой

async def execute(self, cmd):
    resp = await self._legacy_api.query({'q': 'ORDR', 'params': {'id': cmd.order_id}})
    if resp['status_code'] == 200:                                            
        data = resp['payload'][0]                                             
        our_order = OurOrder(id=data['ORDER_ID'], amt=Decimal(data['AMT']))  

Use case знает про формат legacy-API, коды статусов, странные имена полей. Каждое изменение в legacy ломает вашу бизнес-логику.

Правильно: LegacyOrderAdapter инкапсулирует legacy-протокол, возвращает Order в вашем языке. Use case не знает, что за системой прячется — legacy или новый API.

Пример: карта контекстов e-commerce

Рассмотрим типичную e-commerce систему:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│    Sales     │     │  Fulfillment │     │   Billing    │
│              │────▶│              │────▶│              │
│  Order       │     │  Shipment    │     │  Invoice     │
│  Customer    │     │  Warehouse   │     │  Payment     │
│  Product     │     │  Carrier     │     │  Refund      │
└──────────────┘     └──────────────┘     └──────────────┘
       │                                          │
       ▼                                          ▼
┌──────────────┐                          ┌──────────────┐
│  Marketing   │                          │  Accounting  │
│              │                          │              │
│  Campaign    │                          │  Ledger      │
│  Segment     │                          │  Statement   │
│  Coupon      │                          │  Tax         │
└──────────────┘                          └──────────────┘
  • Sales говорит: «оформлен заказ на 3 позиции».
  • Fulfillment переводит: «на склад пришёл shipment на комплектацию».
  • Billing переводит: «сгенерирован invoice на 5000 ₽».

Всё это — про один «заказ», но каждый контекст видит своё. Термины не пересекаются.

Trade-offs

СитуацияBounded Context оправданОдин общий контекст
Разные стейкхолдеры для разных частей системы
Одно слово в разных ролях значит разное
Планируется microservices decomposition
Внутренний CRUD-инструмент на 2 недели
Домен из 5 сущностей без явной специализации
Один эксперт, одна команда, один язык

В твоём же коде

Анонимизированный микросервис — простой случай: один сервис = один контекст = стандартизация характеристик товарных позиций.

Что здесь хорошо:

  • Термины бизнеса (item, characteristic, normalization) появляются в коде.
  • Нет универсальных Process, Manager, Data.
  • Внешний marketplace-api инкапсулирован в MarketplaceAdapter — примитивная форма ACL.

Что можно улучшить:

  • matcher-service (потребитель наших событий) диктует формат события (values[0].normalized, length_normalized). Мы транслируем это внутри use case — язык consumer’а протек в наш core. Правильно: ACL на исходящей стороне — OutgoingMessageMapper в infrastructure/rabbitmq/.

Это — тот самый разбор из Case study → mapper-in-use-case.

Дальнейшее чтение

  • Eric Evans. Domain-Driven Design. Part IV (Strategic Design) — оригинальные главы про Ubiquitous Language и Bounded Context.
  • Vaughn Vernon. Implementing Domain-Driven Design. Chapter 2 — Domains, Subdomains, and Bounded Contexts.
  • Vaughn Vernon. Domain-Driven Design Distilled. Chapters 2–3 — быстрое введение с примерами.
  • Herberto Graça. Ubiquitous Language. Практика применения.
  • Alberto Brandolini. EventStorming. Метод коллективного построения Ubiquitous Language.

Проверьте себя

Мини-quiz · закрепить

Проверьте себя

  1. Q1. Что означает Ubiquitous Language?

  2. Q2. Bounded Context — это...

  3. Q3. Что делает Anti-Corruption Layer?

  4. Q4. Признак нарушения Ubiquitous Language:

  5. Q5. Универсальный класс User с полями auth, profile, billing, marketing — это...