iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🗺️

Drafting Context Maps Before Coding: A Guide to Strategic DDD

に公開2

Introduction

Aggregates, Value Objects, Repositories, and Use Cases... Introducing tactical DDD patterns has certainly improved code quality. However, as systems span across multiple teams and services, other issues arise:

  • The "User" defined by one team is slightly different from the "User" of another, yet the same type is being shared.
  • Changes in an external API directly impact your own domain model.
  • No one knows which context depends on which.

These are problems that tactical DDD alone cannot solve. This is because they stem from the relationships between teams and contexts, not the structure of the code. In this article, I will introduce the Context Map, a tool for visualizing and resolving these issues.


Problems That Tactical DDD Cannot Solve

Tacitcal DDD focuses on "how to design the inside of a single Bounded Context."

A Bounded Context is the boundary within which a specific domain model has a consistent meaning. For example, a "Product" in an "Order" context and a "Product" in a "Catalog" context may contain different information or behaviors despite sharing the same name. By clarifying these boundaries, each team can evolve the models within their own context freely.

However, in real-world systems, multiple contexts work in collaboration.

If relationships between contexts remain implicit, the following problems occur:

Issue Example
Model Pollution The Order context directly uses the Transaction type from the Payment service, causing the domain model to break when the Payment API specs change.
Hidden Dependencies The Inventory context directly references internal types of the Shipping context, making the impact of changes invisible.
Team Friction Lack of consensus on changes to shared libraries leads to development stalls.

Eric Evans proposed the Context Map as a way to deal with these issues. A Context Map is a tool for defining the relationships between Bounded Contexts. It is not just a diagram; it is intended to record organizational and technical integration patterns (Evans, Domain-Driven Design, 2003, Chapter 14).


How to Draw a Context Map

A Context Map is not just an architectural diagram; it also expresses the power dynamics and collaboration policies between teams.

Step 1: Identify Contexts

First, list the Bounded Contexts that exist in your system.

1. Order Context (Order acceptance and management)
2. Inventory Context (Inventory level management and allocation)
3. Catalog Context (Product information management)
4. Shipping Context (Shipping arrangement and tracking)
5. Payment Context (Integration with external payment services)
6. Notification Context (Sending emails and push notifications)

Step 2: Identify Relationship Patterns

Classify the relationships between each context into the relationship patterns defined in DDD. The main patterns are as follows:

Pattern English Name Overview
Shared Kernel Shared Kernel Two contexts share a portion of the model
Customer-Supplier Customer-Supplier The upstream (supplier) provides an API according to the downstream (customer) needs
Conformist Conformist The downstream follows the upstream's model exactly
Anti-Corruption Layer Anti-Corruption Layer The downstream creates a translation layer to protect itself from the upstream model
Open Host Service Open Host Service The upstream provides a public protocol (API)
Published Language Published Language A standard data format shared between contexts
Separate Ways Separate Ways Contexts do not interact and are developed independently

Step 3: Draw the Map

Legend: Arrows indicate the flow of data or API calls. Labels with 'U' (Upstream) / 'D' (Downstream) indicate which side has control over the model. '---' (no direction) indicates an equal relationship.

From this map, we can understand the following:

  • An ACL is required between Order and the Payment service (to protect ourselves from the external service's model).
  • Order and Inventory have a Customer-Supplier relationship (Inventory is upstream=supplier, Order is downstream=customer; the Inventory team adjusts the API according to the needs of the Order team).
  • Catalog and Order have a Shared Kernel (sharing product IDs and basic product information).
  • In the integration from Shipping to Notification, the Notification side conforms to the Shipping model.

Relationship Patterns Between Teams (Detailed)

Shared Kernel

This pattern involves two contexts sharing a portion of the domain model. Any changes to the shared part require consensus from both teams.

shared/
├── go.mod
├── money.go        # Value object for money
├── product_id.go   # Type definition for product ID
└── address.go      # Value object for address
// shared/money.go
package shared

// Money is a value object for money shared across multiple contexts.
// Changes require agreement between the Order team and the Catalog team.
type Money struct {
    Amount   int64
    Currency string
}

func NewMoney(amount int64, currency string) Money {
    return Money{Amount: amount, Currency: currency}
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, ErrCurrencyMismatch // Error definition omitted
    }
    return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

Note: While the Shared Kernel is convenient, widening the scope of sharing increases coupling between contexts. Limit sharing to items with low change frequency, such as value objects or ID types.

Evans states that the Shared Kernel is a relationship where two teams share a subset of a small model they have agreed upon. The kernel should be kept small (Evans, Domain-Driven Design, 2003, Chapter 14).

Customer-Supplier

In this pattern, the upstream (supplier) team designs APIs according to the needs of the downstream (customer) team.

// inventory/service.go (Upstream: Supplier)
package inventory

import "context"

// StockService is a service provided by the Inventory context.
// The API is designed according to the needs of the Order context (Customer).
type StockService struct {
    repo stockReader
}

// CheckAvailability is an inventory check API required by the Order context.
// It receives a product ID and quantity and returns availability based on customer needs.
func (s *StockService) CheckAvailability(ctx context.Context, productID string, quantity int) (*Availability, error) {
    stock, err := s.repo.FindByProductID(ctx, productID)
    if err != nil {
        return nil, fmt.Errorf("failed to get stock: %w", err)
    }
    return &Availability{
        ProductID: productID,
        Available: stock.Quantity >= quantity,
        Remaining: stock.Quantity,
    }, nil
}
// order/usecase/place_order.go (Downstream: Customer)
package usecase

// StockStatus is a type for inventory check results defined by the Order context.
// It does not directly use types from the inventory package but defines its own.
type StockStatus struct {
    ProductID string
    Available bool
    Remaining int
}

type stockChecker interface {
    CheckAvailability(ctx context.Context, productID string, quantity int) (*StockStatus, error)
}

type PlaceOrderUseCase struct {
    stock     stockChecker
    orderRepo orderWriter
}

The downstream context does not directly reference upstream types (inventory.Availability) but defines its own (StockStatus). By converting from upstream types to downstream types in the stockChecker implementation (infrastructure layer adapter), the boundary between contexts is maintained.

In a Customer-Supplier relationship, the downstream team can request APIs that they need from the upstream team, and the upstream team designs the API taking those requests into account.

Conformist

In this pattern, the downstream follows the upstream's model exactly. This is adopted when the upstream team lacks the willingness or capacity to respond to downstream requests, and the cost of building an ACL (translation layer) is higher than the risk of dependency on the upstream model.

// notification/handler.go (Downstream: Conformist)
package notification

import "myapp/shipping"

// HandleShipmentEvent receives the event from the Shipping context as is,
// conforming to the Shipping context's model to send notifications.
type HandleShipmentEvent struct {
    sender emailSender
}

func (h *HandleShipmentEvent) Handle(ctx context.Context, event *shipping.ShipmentStatusChanged) error {
    // Use the Shipping context's model as is
    subject := fmt.Sprintf("Shipping Update: %s", event.TrackingID)
    body := fmt.Sprintf("Shipping status changed to '%s'.", event.NewStatus)
    return h.sender.Send(ctx, event.CustomerEmail, subject, body)
}

In the Conformist pattern, you weigh the cost of establishing an ACL against the risk of dependency on the upstream model. For auxiliary contexts like notifications, conforming to the upstream model can sometimes be practical.


Steps for Application in Real Projects

Here are concrete steps for introducing a Context Map into a real project.

Step 1: Visualize Current Context Boundaries

Identify implicit context boundaries from the existing codebase.

# Visualize dependency between packages in a Go project
go list -json ./... | jq '.ImportPath'

# Check import relationships between packages
go list -f '{{.ImportPath}}: {{join .Imports ", "}}' ./internal/...

Visualize these dependencies and check for circular or unnatural dependencies.

Step 2: Discuss Boundaries with Domain Experts

Consider not only technical dependencies but also business boundaries:

  • "Who is responsible for this concept?"
  • "Are these two teams using the same terms with the same meanings?"
  • "Can this service be changed independently by another team?"

Step 3: Determine Relationship Patterns

Select the relationship for each context from the patterns described above based on the following criteria:

Situation Recommended Pattern
Both teams can collaborate closely Shared Kernel
Upstream team can respond to downstream requests Customer-Supplier
Upstream cannot respond and translation cost > dependency risk Conformist
Collaboration with external services or legacy systems Anti-Corruption Layer
No need for collaboration Separate Ways

Step 4: Reflect in Code

Reflect the relationship patterns determined in the Context Map in your directory structure and implementation.

myapp/
├── internal/
│   ├── order/           # Order Context
│   │   ├── domain/
│   │   ├── usecase/
│   │   └── infra/
│   │       └── acl/     # ACL for Payment Service
│   │           ├── payment_adapter.go
│   │           └── payment_translator.go
│   ├── inventory/       # Inventory Context (Supplier to Order)
│   ├── catalog/         # Catalog Context
│   ├── shipping/        # Shipping Context
│   └── notification/    # Notification Context (Conformist to Shipping)
├── shared/              # Shared Kernel
│   ├── money.go
│   └── product_id.go
└── docs/
    └── context-map.md   # Context Map documentation

Step 5: Maintain the Map

Creating a Context Map is not a one-time task. Review it on the following occasions:

  • When adding a new context (service).
  • When team structures change.
  • When starting a new integration with an external service.
  • When issues arise with dependencies between contexts.

It is recommended to manage the map as documentation within the repository and update it alongside your ADR (Architecture Decision Record).


Summary

Aspect Content
Limits of Tactical DDD Solves design within a single context but does not address relationships between them.
Role of Context Map Visualizes relationships between contexts and clarifies collaboration patterns.
Key Relationship Patterns Shared Kernel, Customer-Supplier, Conformist, Anti-Corruption Layer, etc.
Points for Application Visualize current state first, discuss with domain experts, then reflect in code.

Writing "good code" with tactical DDD and drawing "correct boundaries" with strategic DDD are like two wheels of a car. By drawing a Context Map, it becomes clear where an ACL is needed, which models should be shared, and which team should be the supplier. I recommend drawing a map before writing code.


References

Content Source
Original source of Context Map Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
Explanation of Strategic DDD patterns Vaughn Vernon, Implementing Domain-Driven Design (2013)
Practical use of Context Map Martin Fowler, BoundedContext
Organizing inter-team patterns Vladik Khononov, Learning Domain-Driven Design (2021)
Go Module structure Go Modules Reference

Terminology Notes

Context: Throughout this article, the term "context" refers to the range of premises and scope where a concept holds a specific meaning. DDD explicitly defines these boundaries as "Bounded Contexts" to serve as units of design. For details, refer to the "Problems That Tactical DDD Cannot Solve" section.

GitHubで編集を提案

Discussion

ideodoraideodora

ステップ2:ドメインエキスパートと境界を議論する

コレを機会として持つのが難しいんよな…