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 (internal/domain)
type PaymentGateway interface {
PaymentStatus(ctx context.Context, id PaymentOrderID) (PaymentOrder, error)
}
// ACL adapter — translates the foreign response into your type (internal/adapter/sber)
type SberPaymentAdapter struct {
client *SberClient
}
func (a *SberPaymentAdapter) PaymentStatus(ctx context.Context, id domain.PaymentOrderID) (domain.PaymentOrder, error) {
resp, err := a.client.OrderStatus(ctx, id.Value())
if err != nil {
return domain.PaymentOrder{}, err
}
status, err := mapStatus(resp.OrderStatus)
if err != nil {
return domain.PaymentOrder{}, err
}
return domain.NewPaymentOrder(
domain.NewPaymentOrderID(resp.OrderID),
domain.MoneyFromKopecks(resp.Amount),
status,
), nil
}
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 types 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
type OrderJSON struct {
OrderID uuid.UUID `json:"orderId"`
Status string `json:"status"`
TotalAmount decimal.Decimal `json:"totalAmount"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"createdAt"`
}
// The HTTP handler publishes the API, not the domain model
func (h *OrderAPIHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
order, err := h.queries.OrderByID(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(toOrderJSON(order)) // the mapper isolates the domain from the contract
}
Clients depend on OrderJSON, not on the internal Order type. 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
type ProductQueries interface {
FindByID(ctx context.Context, id ProductID) (*Product, error)
}
// The Customer receives it through dependency injection
type CreateOrderHandler struct {
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
type CbrCurrencyRate struct {
Code string
Rate decimal.Decimal
Date time.Time
}
type CurrencyService struct {
cbrClient *CbrClient
}
func (s *CurrencyService) Rate(ctx context.Context, code string) (CbrCurrencyRate, error) {
return s.cbrClient.Rate(ctx, 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 package is inconvenient.
Shared Kernel is a small shared part of the model that contexts jointly own.
sharedkernel/
├── ids/user_id.go
├── ids/order_id.go
└── money/money.go
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 (a Go module) 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.UUID, 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
type CreateOrderRequest struct {
CustomerID uuid.UUID `json:"customerId"`
Items []OrderItemRequest `json:"items"`
WarehouseID uuid.UUID `json:"warehouseId"` // ← 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 Go that's a small EventBus interface of your own that publishes after the transaction commits:
type ConfirmOrderHandler struct {
orders OrderRepository
events EventBus
tx TxManager
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
return h.tx.WithinTx(ctx, func(ctx context.Context) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Confirm(); err != nil {
return err
}
if err := h.orders.Save(ctx, order); err != nil {
return err
}
h.events.PublishAfterCommit(ctx, NewOrderConfirmed(order.ID(), order.Total()))
return nil
})
}
type ShippingListener struct{}
func (l *ShippingListener) OnOrderConfirmed(ctx context.Context, e OrderConfirmed) {
// Runs only after the order is successfully committed
}
The guarantee: if the transaction rolled back, the listener won't be called. PublishAfterCommit defers publication until a successful commit and 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.
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
return h.tx.WithinTx(ctx, func(ctx context.Context) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Confirm(); err != nil {
return err
}
if err := h.orders.Save(ctx, order); err != nil {
return err
}
// Outbox — in the same transaction as saving the order
return h.outbox.Save(ctx, NewOutboxEvent(
"order.confirmed.v1",
order.ID().String(),
OrderConfirmedV1{OrderID: order.ID(), Total: order.Total()},
))
})
}
// A separate goroutine reads the outbox and publishes to Kafka
func (r *OutboxRelay) Run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.publishPending(ctx)
}
}
}
func (r *OutboxRelay) publishPending(ctx context.Context) {
events, err := r.outbox.FindPending(ctx)
if err != nil {
return
}
for _, e := range events {
if err := r.kafka.Send(ctx, "order-events", e.Payload); err != nil {
return
}
r.outbox.MarkPublished(ctx, 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" package. 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.