← назад к разделу

Когда система вырастает, её делят на несколько независимых частей — Bounded Context. Каждая часть отвечает за свой кусок предметной области: заказы, доставка, оплата. Но части всё равно должны как-то взаимодействовать.

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

DDD описывает семь паттернов интеграции. Разберём каждый простыми словами.

Обязательно

Anti-Corruption Layer — защитный слой-переводчикспросят на собеседовании

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

Anti-Corruption Layer (ACL) — это слой-переводчик. Он принимает «чужой» формат и переводит его в понятия вашего домена. Снаружи — чужой мир, внутри — ваши типы.

# Порт — ваш домен описывает, что ему нужно
class PaymentGateway(Protocol):
    def get_payment_status(self, id: PaymentOrderId) -> PaymentOrder: ...


# ACL-адаптер — переводит чужой ответ в ваш тип
class SberPaymentAdapter:
    def __init__(self, client: SberClient) -> None:
        self._client = client

    def get_payment_status(self, id: PaymentOrderId) -> PaymentOrder:
        response = self._client.get_order_status(id.value)
        return PaymentOrder(
            id=PaymentOrderId(response.order_id),
            amount=Money.of_kopecks(response.amount),
            status=self._map_status(response.order_status),
        )

Смена платёжного провайдера затрагивает только адаптер. Домен заказов ничего не знает о Сбере — он работает через интерфейс PaymentGateway.

ACL применяется везде, где граница между «нашей» и «чужой» моделью существенна: внешние API, сторонние сервисы, соседние команды. В микросервисной архитектуре ACL — стандарт для каждой внешней интеграции.

Open Host Service — публичный API с устойчивым форматомспросят на собеседовании

Обратная ситуация: не вы потребляете чужое, а другие потребляют ваше. Если вы просто «открываете» внутренние классы наружу — любое изменение модели ломает клиентов.

Open Host Service (OHS) — это когда контекст публикует стабильный документированный API. Внутренняя модель может меняться, а API остаётся предсказуемым.

Published Language — конкретный формат этого API: стабильные DTO, OpenAPI-схема, версионированный контракт.

# Стабильный контракт — Published Language
class OrderJson(BaseModel):
    order_id: UUID
    status: str
    total_amount: Decimal
    currency: str
    created_at: datetime


# Роутер публикует API, не доменную модель
router = APIRouter(prefix="/api/v1/orders")


@router.get("/{id}", response_model=OrderJson)
def get_order(id: UUID) -> OrderJson:
    dto = dispatcher.dispatch(GetOrderQuery(id))
    return mapper.to_json(dto)  # маппер изолирует домен от контракта

Клиенты зависят от OrderJson, а не от внутреннего класса Order. Добавили поле в Order — OrderJson не меняется, клиенты не ломаются.

В микросервисах это оформляется как OpenAPI-спецификация с версией в URL (/api/v1/, /api/v2/). Контракт проверяется контракт-тестами в CI.

Customer–Supplier — поставщик и потребитель

Один контекст поставляет данные или функциональность, другой потребляет. Поставщик (Supplier) решает, как и когда менять контракт. Потребитель (Customer) влияет на приоритеты, но не диктует условия.

Классический пример: сервис каталога поставляет данные о продуктах, сервис заказов их потребляет.

# Supplier объявляет интерфейс
class ProductQueries(Protocol):
    def find_by_id(self, id: ProductId) -> Product | None: ...


# Customer получает его через внедрение зависимости
class CreateOrderHandler:
    def __init__(self, products: ProductQueries) -> None:
        self._products = products

В микросервисах поставщик публикует REST или Kafka API, поддерживает SLA, ведёт журнал изменений. Потребитель — формальный клиент. Breaking changes поставщик согласует заранее.

Ключевое правило: изменение контракта — ответственность поставщика. Потребитель следит за журналом изменений, контракт-тесты проверяют совместимость автоматически.

Conformist — принятие чужой модели как естьспросят на собеседовании

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

Когда это оправдано: внешний API стабилен, меняется редко, его модель вас полностью устраивает.

# Используем модель ЦБ РФ напрямую — меняется раз в годы
@dataclass(frozen=True)
class CbrCurrencyRate:
    code: str
    rate: Decimal
    date: date


class CurrencyService:
    def __init__(self, cbr_client: CbrClient) -> None:
        self._cbr_client = cbr_client

    def get_rate(self, code: str) -> CbrCurrencyRate:
        return self._cbr_client.get_rate(code)  # никакого маппинга

Conformist — это осознанный выбор, не лень. Если чужая модель начала меняться, в ваш домен стали проникать чужие термины, или вы хотите поддерживать несколько версий API — пора строить ACL.

Внутри системы (между своими модулями или сервисами) Conformist почти всегда плохая идея: ACL внутри одного процесса почти бесплатный, зато даёт изоляцию.

Shared Kernel — общая часть моделиспросят на собеседовании

Некоторые типы фундаментальны и нужны нескольким контекстам: идентификаторы, денежные суммы, базовые перечисления. Дублировать их в каждом модуле неудобно.

Shared Kernel — небольшая общая часть модели, которой совместно владеют контексты.

shared_kernel/
└── shared/
    ├── ids.py      ← UserId, OrderId
    └── value.py    ← Money

Правило: в Shared Kernel только фундаментальные типы без бизнес-логики. Никаких агрегатов, никаких правил домена. Изменение типа в Shared Kernel требует согласования всех команд, которые его используют.

В монолите это просто общий пакет. В микросервисах — осторожно: общая библиотека связывает деплои всех сервисов. Если поменяли Money — надо пересобрать и выкатить все сервисы одновременно. Это называют «распределённым монолитом» и считают проблемой. Альтернатива: дублировать тип в каждом сервисе и передавать примитивы (UUID, str) на границах.

Partnership — совместная разработка

Два контекста развиваются вместе: обе команды совместно согласовывают изменения контракта, релизы координируются.

Это уместно, когда два модуля разрабатывает одна команда или две команды очень тесно сотрудничают. Нет формального «поставщика» — обе стороны равноправны.

# Оба модуля согласовали добавление нового поля
class CreateOrderRequest(BaseModel):
    customer_id: UUID
    items: list[OrderItemRequest]
    warehouse_id: UUID  # ← добавлено совместно Orders + Inventory

Риск: если команды разойдутся или появятся новые менеджеры — совместная разработка превращается в неконтролируемую связность. Сигнал к смене паттерна: ввести версионирование и перейти к Customer–Supplier.

Separate Ways — независимые пути

Иногда два контекста просто не должны знать друг о друге. Они решают разные задачи, даже если используют похожие данные.

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

Notification Service — своя таблица пользователей (email, телефон, настройки)
Analytics Service    — своя таблица посетителей (сегмент, последний визит)

Дублирование данных? Да. Но зато полная независимость: релизы, инциденты, деплои — всё отдельно. Иногда это правильный выбор.

Domain Events — как контексты сообщают о том, что произошло

Domain Events — не отдельный паттерн выбора связности, а механизм коммуникации. Контекст публикует событие о том, что произошло («Заказ подтверждён»), другой контекст реагирует.

В монолите события передаются внутри процесса через event bus. В Python это обычно собственный небольшой EventBus или библиотека вроде blinker:

class ConfirmOrderHandler:
    def __init__(self, session: Session, orders: OrderRepository, events: EventBus) -> None:
        self._session = session
        self._orders = orders
        self._events = events

    def handle(self, cmd: ConfirmOrderCommand) -> None:
        with self._session.begin():
            order = self._orders.find_by_id(cmd.order_id)
            order.confirm()
            self._orders.save(order)
            self._events.publish(OrderConfirmed(order_id=order.id, total=order.total))


# EventBus буферизует события и рассылает их только после коммита
@event.listens_for(Session, "after_commit")
def flush_events(session: Session) -> None:
    event_bus.dispatch_buffered(session)


class ShippingListener:
    def on_order_confirmed(self, e: OrderConfirmed) -> None:
        # Выполняется только после успешного коммита заказа
        ...

Гарантия: если транзакция откатилась — слушатель не вызовется. Хук SQLAlchemy after_commit гарантирует, что реакция произойдёт только при успешном сохранении.

В микросервисах события передаются через брокер сообщений (Kafka, RabbitMQ). Здесь возникает проблема: что если сервис сохранил заказ в базу, но упал до публикации события в Kafka? Событие потеряно.

Решение — Transactional Outbox: событие записывается в ту же базу данных в той же транзакции. Отдельный процесс (relay) читает события из базы и публикует в Kafka.

def handle(self, cmd: ConfirmOrderCommand) -> None:
    with self._session.begin():
        order = self._orders.find_by_id(cmd.order_id)
        order.confirm()
        self._orders.save(order)

        # Outbox — в той же транзакции, что и сохранение заказа
        self._outbox.save(OutboxEvent.of(
            "order.confirmed.v1",
            str(order.id),
            OrderConfirmedV1(order_id=order.id, total=order.total),
        ))


# Отдельный процесс читает outbox и публикует в Kafka
async def publish_pending() -> None:
    while True:
        for e in outbox.find_pending():
            await producer.send_and_wait("order-events", e.payload)
            outbox.mark_published(e.id)
        await asyncio.sleep(1)

Важно: Kafka доставляет события минимум один раз (at-least-once). Дубликаты возможны. Потребитель должен быть идемпотентным — проверять, не обработано ли событие уже.

Как выбрать паттернспросят на собеседовании

Простое дерево решений:

связь с другим контекстом? не нужна Separate Ways чужая модель устраивает Conformist чужая, нужна защита Anti-Corruption Layer свой поставщик, много потребителей Open Host Service общие базовые типы Shared Kernel

Выбор идёт по двум вопросам: нужна ли связь и чей контекст по ту сторону; ответы ведут к паттернам от Separate Ways до Shared Kernel.

  • Нет интеграции → Separate Ways
  • Внешняя система, их модель устраивает → Conformist
  • Внешняя система, нужна защита домена → Anti-Corruption Layer
  • Один поставляет, много потребляют → Open Host Service + Published Language
  • Один поставляет, один потребляет → Customer–Supplier
  • Одна команда, два модуля → Partnership
  • Общие базовые типы → Shared Kernel (осторожно в микросервисах)

Частые ошибки

ACL «протекает». ACL принимает чужой тип и возвращает его наружу — защита не работает. Всегда маппируйте до границы ACL, наружу — только свои типы.

Shared Kernel разросся. В «общий» модуль попала бизнес-логика, агрегаты, правила. Теперь все зависят от всего. Shared Kernel — только фундаментальные примитивы.

Temporal coupling. Синхронная цепочка: сервис A вызывает B, B вызывает C. Упал C — сломалось всё. Domain Events + асинхронная обработка разрывают цепочку.

God Context. Один контекст знает про всех: хранит клиентов, заказы, доставку, оплату. Нет смысла в Bounded Context — всё в одном месте. Делите по ответственности.

Дополнительно: при первом чтении можно пропустить

Глубже: сага и process manager: довести процесс через три агрегатарасширенное

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

Ответ называется сагой, а её координатор process manager. Это обычный агрегат со своим состоянием: OrderFulfillment(order_id, step, reserved, paid, deadline), который живёт в своей таблице и реагирует на события. Пришло StockReserved: записать и отправить команду ChargePayment. Пришло PaymentFailed: отправить компенсацию ReleaseStock и перевести заказ в отказ. Каждая реакция это обработчик события в потребителе, который загружает process manager по order_id, меняет состояние и кладёт следующую команду в outbox той же транзакцией.

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

В Python для этого не нужен фреймворк: таблица состояния, потребитель на aiokafka и outbox из статьи про распределённые паттерны. Оркестратор вроде Temporal берут, когда процессов десятки и у каждого свои ветки.

Глубже: версии событий: v1 и v2 в одном топикерасширенное

В коде выше событие называется order.confirmed.v1 и класс OrderConfirmedV1, и на этом статья останавливается. Через полгода в событии нужно новое поле, а через год другое значение старого, и вот как с этим живут.

Совместимые изменения без новой версии. Добавить необязательное поле, добавить новое значение в перечисление, которое старые читатели игнорируют. Потребители на Pydantic по умолчанию отбрасывают неизвестные поля, и это свойство здесь нужно: extra="forbid" ставят на моделях входящих команд, а на моделях событий не ставят, иначе любое расширение ломает читателя.

Несовместимые изменения через новую версию. Переименовали поле, сменили его смысл или тип, убрали значение: это order.confirmed.v2 с классом OrderConfirmedV2. Какое-то время производитель публикует оба события на каждое подтверждение, потребители переезжают по одному, и у каждого в коде маршрутизация по полю type сообщения. Когда метрика потребления v1 неделю на нуле, v1 перестают публиковать.

Где лежит схема. Классы событий это и есть контракт, и они живут в репозитории производителя в пакете, который потребители ставят как зависимость, либо в реестре схем. Что нельзя: публиковать ORM-модель или model_dump() агрегата: тогда каждая миграция таблицы становится сменой версии события для всех соседей.

Глубже: идемпотентный потребитель как механизмрасширенное

«Потребитель обязан быть идемпотентным» выше это требование; механизм короткий, и он один на весь курс.

Таблица обработанных событий processed_event(event_id, consumer) с первичным ключом по обоим полям, в той же базе, что и бизнес-данные. Обработчик открывает транзакцию, первым делом вставляет пару, затем делает работу, затем фиксирует. Повтор события упирается в первичный ключ: IntegrityError на вставке означает «уже было», транзакцию откатывают и подтверждают смещение без обработки. Имя потребителя в ключе обязательно: одно событие читают несколько потребителей, и у каждого свой журнал.

Что здесь важно. Вставка идёт первой, а не последней: если она последняя, параллельный повтор того же события успеет выполнить работу дважды до того, как оба упрутся в ключ. Работа и вставка в одной транзакции: иначе падение между ними оставит событие «обработанным» без эффекта или с эффектом без отметки. Смещение в Kafka подтверждают после фиксации транзакции, не до.

Побочный эффект за пределами базы (письмо, HTTP к партнёру) под эту транзакцию не попадает, и его защищают отдельно: пишут намерение в outbox той же транзакцией, а исполняющая его задача несёт ключ идемпотентности к получателю. Так одна таблица и один порядок действий закрывают повторы и при ребалансе, и при перезапуске, и при ручной переотправке из DLQ.

Коротко

  • ACL — слой-переводчик между чужой моделью и вашим доменом. Защищает домен от внешних изменений.
  • Open Host Service + Published Language — стабильный публичный API, отделённый от внутренней модели.
  • Customer–Supplier — один поставляет, другой потребляет. Поставщик управляет контрактом.
  • Conformist — принятие чужой модели как есть. Оправдан для стабильных внешних API.
  • Shared Kernel — общие базовые типы. В монолите удобен, в микросервисах опасен.
  • Partnership — совместная разработка двух контекстов одной командой.
  • Separate Ways — без интеграции. Полная независимость ценой дублирования.
  • Domain Events — механизм оповещения: в монолите через event bus, в микросервисах через брокер + Transactional Outbox.
  • Сага это агрегат-координатор со своей таблицей и реакциями на события, таймауты и компенсации в нём; события версионируют именем (v1, v2), не ORM-слепком; идемпотентность даёт processed_event(event_id, consumer) с вставкой первой в той же транзакции.

Что почитать дальше