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

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

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

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

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

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

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

// Порт — ваш домен описывает, что ему нужно
export interface PaymentGateway {
  getPaymentStatus(id: PaymentOrderId): Promise<PaymentOrder>;
}

// ACL-адаптер — переводит чужой ответ в ваш тип
@Injectable()
export class SberPaymentAdapter implements PaymentGateway {
  constructor(private readonly client: SberClient) {}

  async getPaymentStatus(id: PaymentOrderId): Promise<PaymentOrder> {
    const response = await this.client.getOrderStatus(id.value);
    return new PaymentOrder(
      new PaymentOrderId(response.orderId),
      Money.ofKopecks(response.amount),
      this.mapStatus(response.orderStatus),
    );
  }
}

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

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

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

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

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

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

// Стабильный контракт — Published Language
export interface OrderJson {
  readonly orderId: string;
  readonly status: string;
  readonly totalAmount: string;
  readonly currency: string;
  readonly createdAt: string;
}

// Контроллер публикует API, не доменную модель
@Controller('api/v1/orders')
export class OrderApiController {
  constructor(
    private readonly dispatcher: Dispatcher,
    private readonly mapper: OrderJsonMapper,
  ) {}

  @Get(':id')
  async getOrder(@Param('id') id: string): Promise<OrderJson> {
    const dto = await this.dispatcher.dispatch(new GetOrderQuery(id));
    return this.mapper.toJson(dto); // маппер изолирует домен от контракта
  }
}

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

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

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

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

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

// Supplier объявляет интерфейс
export interface ProductQueries {
  findById(id: ProductId): Promise<Product | null>;
}

// Customer получает его через внедрение зависимости
export class CreateOrderHandler {
  constructor(private readonly products: ProductQueries) {}
  // ...
}

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

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

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

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

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

// Используем модель ЦБ РФ напрямую — меняется раз в годы
export interface CbrCurrencyRate {
  readonly code: string;
  readonly rate: number;
  readonly date: string;
}

@Injectable()
class CurrencyService {
  constructor(private readonly cbrClient: CbrClient) {}

  getRate(code: string): Promise<CbrCurrencyRate> {
    return this.cbrClient.getRate(code); // никакого маппинга
  }
}

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

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

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

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

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

shared-kernel/
└── src/shared/
    ├── ids/UserId.ts
    ├── ids/OrderId.ts
    └── value/Money.ts

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

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

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

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

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

// Оба модуля согласовали добавление нового поля
export interface CreateOrderRequest {
  readonly customerId: string;
  readonly items: OrderItemRequest[];
  readonly warehouseId: string; // ← добавлено совместно Orders + Inventory
}

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

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

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

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

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

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

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

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

В монолите события передаются внутри процесса через event bus. NestJS предоставляет EventEmitter2:

@Injectable()
class ConfirmOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly events: EventEmitter2,
    private readonly dataSource: DataSource,
  ) {}

  async handle(cmd: ConfirmOrderCommand): Promise<void> {
    const event = await this.dataSource.transaction(async () => {
      const order = await this.orders.findById(cmd.orderId);
      order.confirm();
      await this.orders.save(order);
      return new OrderConfirmed(order.id, order.total);
    });
    this.events.emit('order.confirmed', event);
  }
}

@Injectable()
class ShippingListener {
  @OnEvent('order.confirmed')
  on(e: OrderConfirmed): void {
    // Выполняется только после успешного коммита заказа
  }
}

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

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

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

async handle(cmd: ConfirmOrderCommand): Promise<void> {
  await this.dataSource.transaction(async () => {
    const order = await this.orders.findById(cmd.orderId);
    order.confirm();
    await this.orders.save(order);

    // Outbox — в той же транзакции, что и сохранение заказа
    await this.outbox.save(OutboxEvent.of(
      'order.confirmed.v1',
      order.id.toString(),
      new OrderConfirmedV1(order.id, order.total),
    ));
  });
}

// Отдельный процесс читает outbox и публикует в Kafka
@Interval(1000)
async publish(): Promise<void> {
  for (const e of await this.outbox.findPending()) {
    await this.kafka.send('order-events', e.payload);
    await this.outbox.markPublished(e.id);
  }
}

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

Как выбрать паттерн

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

  • Нет интеграции → 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 — всё в одном месте. Делите по ответственности.

Коротко

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

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