← Back to the section

When a system grows, it gets split into several independent parts — Bounded Contexts. Each part is responsible for its own slice of the domain: orders, delivery, payments. But the parts still have to interact somehow.

This raises the key question: how does one part of the system talk to another? The answer determines how independent they are, who dictates the rules of change, and how expensive maintenance becomes.

DDD describes seven integration patterns. Let's go through each one in plain language.

Anti-Corruption Layer — the protective translator layer

Imagine this: your order code needs to fetch data from a legacy payment system. It has its own terminology, its own fields, its own format. If you pull its concepts straight into your code, your order domain will eventually start speaking the payment system's language. Change the payment system, and you'll have to rewrite the domain.

Anti-Corruption Layer (ACL) is a translator layer. It takes a "foreign" format and translates it into your domain's concepts. Outside — a foreign world; inside — your own types.

// Port — your domain describes what it needs
export interface PaymentGateway {
  getPaymentStatus(id: PaymentOrderId): Promise<PaymentOrder>;
}

// ACL adapter — translates the foreign response into your type
@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),
    );
  }
}

Switching the payment provider only affects the adapter. The order domain knows nothing about Sber — it works through the PaymentGateway interface.

An ACL applies anywhere the boundary between "our" and "their" model matters: external APIs, third-party services, neighbouring teams. In a microservice architecture, an ACL is the standard for every external integration.

Open Host Service — a public API with a stable format

The reverse situation: you're not consuming someone else's model, others are consuming yours. If you simply "expose" internal classes to the outside, any change to the model breaks clients.

Open Host Service (OHS) is when a context publishes a stable, documented API. The internal model can change, while the API stays predictable.

Published Language is the concrete format of that API: stable DTOs, an OpenAPI schema, a versioned contract.

// Stable contract — Published Language
export interface OrderJson {
  readonly orderId: string;
  readonly status: string;
  readonly totalAmount: string;
  readonly currency: string;
  readonly createdAt: string;
}

// The controller publishes the API, not the domain model
@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); // the mapper isolates the domain from the contract
  }
}

Clients depend on OrderJson, not on the internal Order class. Add a field to OrderOrderJson doesn't change, and clients don't break.

In microservices this takes the shape of an OpenAPI specification with a version in the URL (/api/v1/, /api/v2/). The contract is verified by contract tests in CI.

Customer–Supplier — supplier and consumer

One context supplies data or functionality, another consumes it. The Supplier decides how and when to change the contract. The Customer influences priorities but doesn't dictate terms.

A classic example: the catalog service supplies product data, the order service consumes it.

// The Supplier declares the interface
export interface ProductQueries {
  findById(id: ProductId): Promise<Product | null>;
}

// The Customer receives it through dependency injection
export class CreateOrderHandler {
  constructor(private readonly products: ProductQueries) {}
  // ...
}

In microservices, the supplier publishes a REST or Kafka API, maintains an SLA, and keeps a changelog. The consumer is a formal client. The supplier agrees breaking changes in advance.

The key rule: changing the contract is the supplier's responsibility. The consumer watches the changelog, and contract tests verify compatibility automatically.

Conformist — accepting a foreign model as-is

Sometimes the simplest way out is to use another system's model directly, without translation. This is the Conformist pattern: we accept a foreign data format and don't build our own model on top of it.

When it's justified: the external API is stable, rarely changes, and its model suits you completely.

// We use the Central Bank of Russia model directly — it changes once in years
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); // no mapping at all
  }
}

Conformist is a deliberate choice, not laziness. If the foreign model starts changing, foreign terms start leaking into your domain, or you want to support several API versions — it's time to build an ACL.

Inside your own system (between your own modules or services) Conformist is almost always a bad idea: an ACL within a single process is almost free, yet it gives you isolation.

Shared Kernel — a shared part of the model

Some types are fundamental and needed by several contexts: identifiers, money amounts, basic enums. Duplicating them in every module is inconvenient.

Shared Kernel is a small shared part of the model that contexts jointly own.

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

The rule: put only fundamental types with no business logic in the Shared Kernel. No aggregates, no domain rules. Changing a type in the Shared Kernel requires agreement from every team that uses it.

In a monolith it's simply a shared module. In microservices — be careful: a shared library couples the deployments of all services. If you change Money, you have to rebuild and roll out all services at once. This is called a "distributed monolith" and is considered a problem. The alternative: duplicate the type in each service and pass primitives (string) at the boundaries.

Partnership — joint development

Two contexts evolve together: both teams jointly agree on contract changes, and releases are coordinated.

This fits when one team develops both modules, or two teams cooperate very closely. There's no formal "supplier" — both sides are equals.

// Both modules agreed on adding a new field
export interface CreateOrderRequest {
  readonly customerId: string;
  readonly items: OrderItemRequest[];
  readonly warehouseId: string; // ← added jointly by Orders + Inventory
}

The risk: if the teams drift apart or new managers appear, joint development turns into uncontrolled coupling. The signal to change the pattern: introduce versioning and move to Customer–Supplier.

Separate Ways — independent paths

Sometimes two contexts simply shouldn't know about each other. They solve different problems, even if they use similar data.

Example: the notification service and the analytics service both deal with "users", but for completely different purposes. There's no integration between them.

Notification Service — its own users table (email, phone, settings)
Analytics Service    — its own visitors table (segment, last visit)

Data duplication? Yes. But in return you get full independence: releases, incidents, deployments — all separate. Sometimes this is the right choice.

Domain Events — how contexts announce what happened

Domain Events aren't a separate coupling-choice pattern, but a communication mechanism. A context publishes an event about something that happened ("Order confirmed"), and another context reacts.

In a monolith, events are delivered within the process via an event bus. NestJS provides 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 {
    // Runs only after the order is successfully committed
  }
}

The guarantee: if the transaction rolled back, the listener won't be called. Emitting the event after the transaction commit guarantees the reaction happens only on a successful save.

In microservices, events are delivered through a message broker (Kafka, RabbitMQ). Here a problem arises: what if the service saved the order to the database but crashed before publishing the event to Kafka? The event is lost.

The solution is the Transactional Outbox: the event is written to the same database in the same transaction. A separate process (relay) reads the events from the database and publishes them to 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 — in the same transaction as saving the order
    await this.outbox.save(OutboxEvent.of(
      'order.confirmed.v1',
      order.id.toString(),
      new OrderConfirmedV1(order.id, order.total),
    ));
  });
}

// A separate process reads the outbox and publishes to 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);
  }
}

Important: Kafka delivers events at least once. Duplicates are possible. The consumer must be idempotent — it should check whether the event has already been processed.

How to choose a pattern

A simple decision tree:

  • No integration → Separate Ways
  • External system, their model suits you → Conformist
  • External system, you need to protect the domain → Anti-Corruption Layer
  • One supplies, many consume → Open Host Service + Published Language
  • One supplies, one consumes → Customer–Supplier
  • One team, two modules → Partnership
  • Shared basic types → Shared Kernel (careful in microservices)

Common mistakes

A "leaky" ACL. The ACL accepts a foreign type and returns it to the outside — the protection doesn't work. Always map before the ACL boundary; expose only your own types.

A bloated Shared Kernel. Business logic, aggregates and rules ended up in the "shared" module. Now everything depends on everything. The Shared Kernel is for fundamental primitives only.

Temporal coupling. A synchronous chain: service A calls B, B calls C. C goes down — everything breaks. Domain Events + asynchronous processing break the chain.

God Context. One context knows about everyone: it stores customers, orders, delivery, payments. There's no point in Bounded Contexts — everything is in one place. Split by responsibility.

In short

  • ACL — a translator layer between a foreign model and your domain. Protects the domain from external changes.
  • Open Host Service + Published Language — a stable public API, separated from the internal model.
  • Customer–Supplier — one supplies, another consumes. The supplier controls the contract.
  • Conformist — accepting a foreign model as-is. Justified for stable external APIs.
  • Shared Kernel — shared basic types. Convenient in a monolith, dangerous in microservices.
  • Partnership — joint development of two contexts by one team.
  • Separate Ways — no integration. Full independence at the cost of duplication.
  • Domain Events — a notification mechanism: in a monolith via an event bus, in microservices via a broker + Transactional Outbox.