Skip to content
ioob.dev
Go back

DDD Part 3 — Context Mapping

· 9 min read
DDD Series (3/7)
  1. DDD Part 1 — Why Domain-Centric Design Matters
  2. DDD Part 2 — Ubiquitous Language and Bounded Context
  3. DDD Part 3 — Context Mapping
  4. DDD Part 4 — Entity and Value Object
  5. DDD Part 5 — Aggregate and Repository
  6. DDD Part 6 — Domain Service and Application Service
  7. DDD Part 7 — Domain Event and Anti-Corruption Layer
Table of contents

Table of contents

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:

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:

  1. Translate internal model to external model — Convert the internal PaymentRequest to a PgPaymentRequest that the PG understands
  2. Call the external system — Invoke the PG’s API
  3. 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.

SituationRecommended Pattern
Both teams are close and the shared model is smallShared Kernel
The upstream is willing to accommodate downstream needsCustomer-Supplier
The upstream is an external system with no leverageConformist or ACL
The external model risks corrupting the internal domainACL
Need to provide a general-purpose API to multiple downstream contextsOHS / PL
Two teams develop together as equalsPartnership
Integration cost exceeds the benefitSeparate 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.


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.

-> Part 4: Entity and Value Object — Identity and Equality


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 2 — Ubiquitous Language and Bounded Context
Next Post
DDD Part 4 — Entity and Value Object