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
class PaymentGateway(Protocol):
def get_payment_status(self, id: PaymentOrderId) -> PaymentOrder: ...
# ACL adapter — translates the foreign response into your type
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),
)
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
class OrderJson(BaseModel):
order_id: UUID
status: str
total_amount: Decimal
currency: str
created_at: datetime
# The router publishes the API, not the domain model
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) # the mapper isolates the domain from the contract
Clients depend on OrderJson, not on the internal Order class. Add a field to Order — OrderJson 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
class ProductQueries(Protocol):
def find_by_id(self, id: ProductId) -> Product | None: ...
# The Customer receives it through dependency injection
class CreateOrderHandler:
def __init__(self, products: ProductQueries) -> None:
self._products = products
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
@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) # 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/
└── shared/
├── ids.py ← UserId, OrderId
└── value.py ← Money
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 package. 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 (UUID, str) 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
class CreateOrderRequest(BaseModel):
customer_id: UUID
items: list[OrderItemRequest]
warehouse_id: UUID # ← 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. In Python this is usually a small EventBus of your own or a library like 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))
# The EventBus buffers events and dispatches them only after the commit
@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:
# Runs only after the order is successfully committed
...
The guarantee: if the transaction rolled back, the listener won't be called. SQLAlchemy's after_commit hook 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.
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 — in the same transaction as saving the order
self._outbox.save(OutboxEvent.of(
"order.confirmed.v1",
str(order.id),
OrderConfirmedV1(order_id=order.id, total=order.total),
))
# A separate process reads the outbox and publishes to Kafka
async def publish_pending() -> None:
while True:
for e in outbox.find_pending():
kafka.send("order-events", e.payload)
outbox.mark_published(e.id)
await asyncio.sleep(1)
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.
What to read next
- DDD Strategic Patterns — Bounded Context, Ubiquitous Language, Context Map.
- DDD Tactical Patterns — Entity, Value Object, Aggregate.
- Apache Kafka — the main transport for Domain Events in microservices.