Table of contents
- The Problem After Drawing Boundaries
- The Full Context Map
- Shared Kernel — The Shared Core
- Customer-Supplier — Upstream and Downstream
- Conformist — The Downstream Conforms to the Upstream
- Anti-Corruption Layer — The Translation Layer
- Open Host Service / Published Language — The Public API
- Partnership — A Peer Relationship
- Separate Ways — Independence
- Pattern Selection Criteria
- When to Draw a Context Map
- Key Takeaways
The Problem After Drawing Boundaries
In Part 2, we looked at how to divide a system’s boundaries using bounded contexts. Catalog, ordering, shipping, payment — each became an independent world with its own language and model.
But real systems aren’t islands. The ordering context needs product information from the catalog, the shipping context needs a signal that an order has been confirmed, and the payment context needs the order amount to process a payment. Once you’ve drawn the boundaries, you need to decide how to connect them.
This is Context Mapping. A Context Map visualizes the relationships between all bounded contexts in a system. It goes beyond simply saying A calls B — it makes explicit the power dynamics and collaboration patterns between two contexts.
Eric Evans defined several relationship patterns. Each pattern is chosen based on the situation — none is universally superior.
The Full Context Map
Let’s first draw the full Context Map for an e-commerce system. It provides an at-a-glance view of which patterns connect each context.
flowchart TB
subgraph map["Context Map — E-Commerce"]
CAT["Catalog"]
ORD["Ordering"]
PAY["Payment"]
SHIP["Shipping"]
NOTI["Notification"]
AUTH["Identity"]
EXT_PG["External PG"]
EXT_CARRIER["External Carrier"]
end
CAT -->|"OHS/PL"| ORD
ORD -->|"Customer-Supplier"| SHIP
ORD -->|"Customer-Supplier"| PAY
PAY -->|"ACL"| EXT_PG
SHIP -->|"ACL"| EXT_CARRIER
AUTH -->|"Shared Kernel"| ORD
AUTH -->|"Shared Kernel"| PAY
ORD -.->|"Event"| NOTI
SHIP -.->|"Event"| NOTI
In this diagram, solid lines represent synchronous connections and dotted lines represent asynchronous, event-based connections. The labels on each arrow indicate the relationship pattern. Now let’s examine each pattern in detail.
Shared Kernel — The Shared Core
Shared Kernel is a pattern where two or more contexts share a portion of their models. The shared portion is managed by mutual agreement between both teams.
The most typical example is user identity information. Consider a case where the UserId defined in the Identity context is used by both the ordering and payment contexts.
// shared-kernel module: shared code that both contexts depend on
package com.shop.shared.kernel
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class Money(val amount: BigDecimal) {
operator fun plus(other: Money): Money = Money(amount + other.amount)
operator fun times(quantity: Int): Money = Money(amount * quantity.toBigDecimal())
companion object {
val ZERO = Money(BigDecimal.ZERO)
}
}
UserId and Money are used with the same meaning across multiple contexts. If each context defined these fundamental value objects separately, you’d just end up with more conversion code for identical semantics.
The key rules of Shared Kernel are:
- Keep the shared scope minimal. The more you share, the higher the coupling, and the greater the risk that one side’s changes break the other
- Changes to shared code require agreement from both teams. One team must not unilaterally modify it
- What’s typically shared is limited to value objects, ID types, and basic interfaces. Sharing entities or services erodes context boundaries
Shared Kernel works well when there’s trust and close communication between teams. Both teams need to be able to quickly detect and respond to each other’s code changes. If the teams are organizationally distant, it’s better to consider other patterns.
Customer-Supplier — Upstream and Downstream
Customer-Supplier is a relationship where one side is upstream (supplier) and the other is downstream (customer). The upstream provides APIs or events, and the downstream consumes them.
The relationship between the ordering context (upstream) and the shipping context (downstream) is a classic example. When an order is confirmed, shipping needs to begin. The shipping context depends on the information that the ordering context provides.
// Ordering Context (upstream): publishes an event upon order confirmation
package com.shop.ordering.application
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: DomainEventPublisher,
) {
fun confirmOrder(orderId: OrderId) {
val order = orderRepository.findById(orderId)
order.confirm()
orderRepository.save(order)
// Downstream contexts subscribe to this event
eventPublisher.publish(
OrderConfirmedEvent(
orderId = order.id,
customerId = order.customerId,
lines = order.snapshotLines(),
totalAmount = order.totalAmount(),
)
)
}
}
The shipping context (downstream) receives this event and transforms it into its own model.
// Shipping Context (downstream): receives the order confirmed event and prepares shipment
package com.shop.shipping.application
class ShipmentEventHandler(
private val shipmentRepository: ShipmentRepository,
) {
fun onOrderConfirmed(event: OrderConfirmedEvent) {
val parcels = event.lines.map { line ->
// Transform order items into the shipping model
Parcel(
productId = line.productId,
quantity = line.quantity,
// Weight, volume, etc. are looked up from the shipping context's product master
)
}
val shipment = Shipment.create(
orderId = event.orderId,
parcels = parcels,
)
shipmentRepository.save(shipment)
}
}
The key in a Customer-Supplier relationship is whether the upstream respects the downstream’s needs. In a healthy relationship, the upstream team reflects the downstream team’s requirements in its backlog and communicates API changes in advance. If the upstream ignores the downstream, the downstream suffers — in which case the Conformist or ACL patterns discussed next become necessary.
Conformist — The Downstream Conforms to the Upstream
Conformist is a pattern where the downstream adopts the upstream’s model as-is. The downstream aligns its own model to the upstream’s API or data format, subordinating itself to it.
When do you choose this pattern? When the upstream is an external system or belongs to a different organization, and you have no leverage to influence it.
For example, say your internal payment context uses an external payment gateway’s API. Even if the gateway responds with field names like tran_cd, app_no, and amt — full of abbreviations — you can’t exactly ask them to “please rename these.”
Choosing Conformist produces code like this:
// Conformist: accept the external PG's response format as-is
public class PgPaymentResponse {
private String tran_cd; // PG's abbreviation as-is
private String app_no;
private String amt;
private String res_cd;
// Uses the PG's model directly. No translation.
}
This approach has the advantage of simplicity, but the downside is that the external model penetrates your internal code. If the PG changes field names, your entire internal codebase is affected. If this is unacceptable, the next pattern is the answer.
Anti-Corruption Layer — The Translation Layer
Anti-Corruption Layer (ACL) is a pattern that places a translation layer to prevent external models from corrupting the internal domain model. It’s one of the most practical and frequently used patterns in DDD.
While Conformist was about using the upstream's model as-is, ACL translates the upstream’s model into your own language.
// Anti-Corruption Layer: translates external PG responses to internal domain models
package com.shop.payment.infrastructure.pg
class PgAntiCorruptionLayer(
private val pgClient: PgClient,
) {
fun requestPayment(request: PaymentRequest): PaymentResult {
// 1. Internal model -> external model translation
val pgRequest = PgPaymentRequest(
mchtId = request.merchantId.value,
amt = request.amount.amount.toString(),
goodsNm = request.description,
ordNo = request.orderId.value.toString(),
)
// 2. External system call
val pgResponse = pgClient.pay(pgRequest)
// 3. External model -> internal model translation
return PaymentResult(
transactionId = TransactionId(pgResponse.tran_cd),
approvalNumber = ApprovalNumber(pgResponse.app_no),
amount = Money(BigDecimal(pgResponse.amt)),
status = mapStatus(pgResponse.res_cd),
)
}
private fun mapStatus(resCd: String): PaymentStatus =
when (resCd) {
"0000" -> PaymentStatus.APPROVED
"0001" -> PaymentStatus.PENDING
else -> PaymentStatus.REJECTED
}
}
Breaking down the ACL’s structure reveals three stages:
- Translate internal model to external model — Convert the internal
PaymentRequestto aPgPaymentRequestthat the PG understands - Call the external system — Invoke the PG’s API
- Translate external model to internal model — Translate the PG’s response to an internal
PaymentResult
Thanks to this layer, the internal domain model is unaware of the external system’s existence. If you switch PG providers, you only modify the ACL. Even if tran_cd gets renamed to transaction_code, the domain code is unaffected.
ACL is frequently used not just for external third parties, but also for integration with legacy systems. It creates a boundary that prevents the bizarre data formats of older systems from corrupting the new system’s domain model.
Open Host Service / Published Language — The Public API
Open Host Service (OHS) is a pattern where the upstream context publishes a well-defined API for downstream contexts. When combined with Published Language (PL), even the API’s data format is standardized.
Consider a situation where the catalog context provides product information to multiple downstream contexts (ordering, search, recommendations, etc.). Creating a separate API for each downstream context would be a maintenance nightmare. Instead, the catalog publishes a single well-designed API that all downstream contexts consume.
// Open Host Service: the catalog context's public API
package com.shop.catalog.api
@RestController
@RequestMapping("/api/catalog")
class CatalogOpenHostService(
private val productQueryService: ProductQueryService,
) {
// Published Language: standardized response format
@GetMapping("/products/{id}")
fun getProduct(@PathVariable id: Long): ProductDto {
val product = productQueryService.findById(ProductId(id))
return ProductDto(
id = product.id.value,
name = product.name,
description = product.description,
price = product.price.amount,
category = product.category.name,
status = product.displayStatus.name,
)
}
}
// Published Language: the data format published to the outside
data class ProductDto(
val id: Long,
val name: String,
val description: String,
val price: BigDecimal,
val category: String,
val status: String,
)
The key to OHS/PL is that it doesn’t cater to each downstream context’s individual needs. Instead, it provides a general-purpose, stable API, and downstream contexts transform it to fit their own needs. REST APIs, gRPC, GraphQL, and event schemas can all serve as implementation mechanisms for an Open Host Service.
Partnership — A Peer Relationship
Partnership is a pattern where the teams of two contexts collaborate closely as equals. Unlike Customer-Supplier, there’s no upstream/downstream distinction. The two teams coordinate schedules together, mutually adjust API changes, and share in both failures and successes.
An example would be when the ordering team and the payment team need to implement a new discount policy simultaneously. The ordering side builds the discount application logic while the payment side builds the discounted amount processing logic in tandem.
This pattern works when the two teams are in the same office, share the same sprint cycle, or frequently meet face-to-face. If there’s organizational distance, it’s hard to maintain.
Separate Ways — Independence
Separate Ways is a pattern where two contexts aren’t connected at all. It’s chosen when the cost of integration exceeds the benefits.
For example, the notification context and the catalog context in an e-commerce system may not need a direct connection. If catalog changes don’t affect notifications and the notification system never needs to reference the catalog, not connecting them is the simplest approach.
Separate Ways is a conscious decision not to integrate. It’s different from simply not having connected them. Explicitly stating “these two contexts are independent, and there’s no need to connect them” has value in itself.
Pattern Selection Criteria
When should you use which pattern? The following table provides situation-based guidance.
| Situation | Recommended Pattern |
|---|---|
| Both teams are close and the shared model is small | Shared Kernel |
| The upstream is willing to accommodate downstream needs | Customer-Supplier |
| The upstream is an external system with no leverage | Conformist or ACL |
| The external model risks corrupting the internal domain | ACL |
| Need to provide a general-purpose API to multiple downstream contexts | OHS / PL |
| Two teams develop together as equals | Partnership |
| Integration cost exceeds the benefit | Separate Ways |
In practice, multiple patterns coexist within a single system. It’s natural to apply Shared Kernel at one boundary, ACL at another, and Customer-Supplier at yet another. Forcing the same pattern on every relationship is what would be unnatural.
When to Draw a Context Map
At what point in a project do you draw a Context Map?
Ideally, early in the project. Identifying bounded contexts and defining the relationships between them upfront makes the interfaces between teams clear. But in practice, the context often only becomes apparent after the system has taken some concrete shape.
When applying to a system already in production, it’s best to start by drawing the Context Map as-is. The first step is understanding which modules depend on which, and whether those relationships are healthy. You might conclude that a current Conformist relationship should become an ACL, or that there are too many connections and some should be cut with Separate Ways.
A Context Map is not a document you draw once and pin to a wall. It must be updated as the system evolves. Whenever new contexts are added, team structures change, or external systems are replaced, the relationship patterns may shift.
Key Takeaways
Here’s a summary of what this article covered.
- A Context Map visualizes the relationships between bounded contexts
- Shared Kernel: both sides share a portion of their model by mutual agreement. The scope must be kept minimal
- Customer-Supplier: the upstream provides APIs and the downstream consumes them. The premise is that the upstream respects the downstream’s needs
- Conformist: the downstream adopts the upstream’s model as-is. Chosen when there’s no leverage over an external system
- Anti-Corruption Layer: a translation layer that converts external models to internal models. The most practical pattern
- Open Host Service / Published Language: a general-purpose API with a standardized data format is published
- Partnership: two teams collaborate as equals
- Separate Ways: a conscious decision not to integrate
The next article covers the first building block of tactical design: Entity and Value Object. We’ll examine the difference between objects distinguished by identity and objects compared solely by their values, and why this distinction matters.




Loading comments...