Connecting Concepts Across Bounded Contexts
The Problem
In a non-trivial system, the same real-world concept inevitably appears in multiple bounded contexts. A "Customer" exists in Sales, Billing, Shipping, and Support, but each context cares about different attributes, enforces different rules, and evolves at its own pace.
This creates a tension:
- The contexts need to know about each other's state: When Sales registers a new customer, Billing needs to create an account. When Billing suspends an account, Support needs to know. When Shipping updates an address, Sales may want to reflect it.
- The contexts must not be coupled: If Billing directly queries the Sales database for customer information, it becomes dependent on Sales' schema, availability, and deployment schedule. Changes in Sales break Billing. The bounded contexts lose their independence.
The naive solutions all have problems:
- Shared database: All contexts read from the same customer table. Schema changes require coordinated deployments. One context's performance issues affect all others. The "bounded" in bounded context becomes meaningless.
- Direct API calls: Billing calls Sales' API to get customer data. Billing is now coupled to Sales' availability. If Sales is down, Billing cannot function. Latency compounds across call chains.
- Shared domain model: All contexts import the same
Customerclass. Requirements diverge, and the shared class accumulates attributes from every context until it becomes an unmanageable God Object.
Domain-Driven Design solves this through a combination of separate models per context, identity correlation, and event-driven state propagation.
Two Variations
Two scenarios look similar but need different approaches:
Different Concepts, Same Name
"Account" in Banking means a financial account with a balance and transaction history. "Account" in Identity Management means a user account with credentials and permissions. These share a name but are fundamentally different concepts.
The solution here is simple: rename to remove ambiguity. Use the Ubiquitous
Language of each context. FinancialAccount in Banking, UserAccount in
Identity Management. No connection is needed because these are not the same thing.
Same Concept, Multiple Contexts
A "Customer" who places orders in Sales is the same person who receives invoices in Billing and packages in Shipping. The real-world entity is one, but each bounded context models a different facet of it:
- Sales cares about preferences, order history, and segmentation.
- Billing cares about payment methods, invoices, and account standing.
- Shipping cares about addresses, delivery preferences, and logistics.
These are different models of the same real-world entity. They need to stay connected. This pattern addresses this second variation.
The Pattern: Identity Correlation and Event Propagation
The solution has three parts:
- Shared identity: All contexts refer to the same real-world entity using a common identifier (the correlation ID).
- Event propagation: The context that "owns" the concept publishes lifecycle events. Other contexts subscribe to these events and build their own local representations.
- Local models: Each context maintains its own model of the concept, shaped by its specific needs.
┌──────────────┐ CustomerRegistered ┌──────────────┐
│ │ ─ ─ ─{ customer_id: "c-123" }─ ─ ─> │
│ Sales │ name: "Jane Doe" │ Billing │
│ Context │ email: "jane@..." │ Context │
│ │ │ │
│ Customer │ CustomerAddressUpdated │ Account │
│ aggregate │ ─ ─ ─{ customer_id: "c-123" }─ ─ ─> │
│ │ │ customer_id │
└──────────────┘ │ (correlated)│
│ └──────────────┘
│ CustomerAddressUpdated
│ ─ ─ ─{ customer_id: "c-123" }─ ─ ─ ┐
│ street: "1 Main St" │
▼ city: "Springfield" ▼
┌──────────────┐ ┌──────────────┐
│ │ │ │
│ Shipping │ │ Support │
│ Context │ │ Context │
│ │ │ │
│ Recipient │ │ Contact │
│ aggregate │ │ aggregate │
│ │ │ │
│ customer_id │ │ customer_id │
│ (correlated)│ │ (correlated)│
└──────────────┘ └──────────────┘
The key insight: the customer_id is the thread that connects all representations. Each context stores this identifier and uses it to correlate incoming events with its local model.
Step 1: Establish the Source of Truth
One bounded context is the authority for a concept's lifecycle. This is the context where the concept is created, where its core identity is established, and where fundamental lifecycle changes (registration, suspension, deletion) originate.
For "Customer", this is typically the Sales or Identity context:
# Sales context: the authority for Customer
from enum import Enum
from protean import Domain
from protean.fields import Auto, Identifier, String
sales = Domain(__file__, "Sales")
class CustomerSegment(Enum):
STANDARD = "STANDARD"
PREMIUM = "PREMIUM"
@sales.aggregate
class Customer:
customer_id: Auto(identifier=True)
name: String(required=True, max_length=100)
email: String(required=True)
segment: String(choices=CustomerSegment, default="STANDARD")
This aggregate owns the customer_id. When a customer is registered, this
context generates the identity (following the
Creating Identities Early pattern) and publishes
the fact through events.
Step 2: Publish Lifecycle Events
The authoritative context raises events for every significant lifecycle change. These events carry the shared identity and the data that other contexts need:
@sales.event(part_of=Customer)
class CustomerRegistered:
customer_id: Identifier(required=True)
name: String(required=True)
email: String(required=True)
@sales.event(part_of=Customer)
class CustomerEmailUpdated:
customer_id: Identifier(required=True)
new_email: String(required=True)
@sales.event(part_of=Customer)
class CustomerAddressUpdated:
customer_id: Identifier(required=True)
street: String(required=True)
city: String(required=True)
zip_code: String(required=True)
@sales.event(part_of=Customer)
class CustomerDeactivated:
customer_id: Identifier(required=True)
reason: String()
Delta Events vs. Fact Events
You have two choices for how events carry state across context boundaries:
Delta events describe what changed. They are lightweight and precise, but consuming contexts must process every event type and build up state incrementally:
# fragment
# Consumer must handle each event type to build local state
@handle(CustomerRegistered)
def on_registered(self, event): ...
@handle(CustomerEmailUpdated)
def on_email_updated(self, event): ...
@handle(CustomerAddressUpdated)
def on_address_updated(self, event): ...
Fact events carry the complete aggregate state at a point in time. Consumers get a full snapshot with every event, simplifying their logic:
# Sales context: enable fact events
@sales.aggregate(fact_events=True)
class Customer:
customer_id: Auto(identifier=True)
name: String(required=True, max_length=100)
email: String(required=True)
segment: String(choices=CustomerSegment, default="STANDARD")
With fact_events=True, Protean automatically generates a fact event containing
the full aggregate state whenever the aggregate changes. Consuming contexts
overwrite their local representation with the latest snapshot.
When to use which:
| Approach | Best for |
|---|---|
| Delta events | Internal consumption within a bounded context, event sourcing, fine-grained reactions to specific changes |
| Fact events | Cross-context state transfer, building read models in other contexts, reducing consumer complexity |
For connecting concepts across bounded contexts, fact events are the recommended default. They implement the Event-Carried State Transfer pattern, keeping consumers simple and resilient to schema evolution in the source context.
Step 3: Consume Events and Build Local Models
Each consuming context listens to the source context's events and maintains its own representation. The mechanism depends on the deployment relationship between the contexts.
Choosing the right integration mechanism
Same domain: Use an event handler on the aggregate that raises the event, and have it issue a command to the other aggregate for simple state sync.
Co-located domains: Use event handlers with stream_category for
simple state sync, or register_external_event() with process managers
for multi-step cross-domain workflows.
Distributed domains: Use subscribers as anti-corruption layers.
See Multi-Domain Applications, Cross-domain communication for the full decision framework.
Same Domain: An Event Handler That Issues Commands
When multiple aggregates exist within the same Protean domain, the event handler stays with the aggregate that raises the events. It issues a command for each change the other aggregate must make, and that aggregate's command handler does the write:
@domain.event(part_of="Customer")
class CustomerRegistered:
customer_id: Identifier(required=True)
name: String(required=True)
email: String(required=True)
@domain.event(part_of="Customer")
class CustomerDeactivated:
customer_id: Identifier(required=True)
reason: String()
@domain.aggregate
class Customer:
"""Authority for customer lifecycle."""
customer_id: Auto(identifier=True)
name: String(required=True)
email: String(required=True)
@domain.aggregate
class BillingAccount:
"""Billing context's local model of a customer."""
account_id: Auto(identifier=True)
customer_id: Identifier(required=True) # Correlation ID
email: String(required=True)
account_status: String(default="ACTIVE")
def suspend(self):
self.account_status = "SUSPENDED"
@domain.command(part_of=BillingAccount)
class OpenBillingAccount:
customer_id: Identifier(required=True)
email: String(required=True)
@domain.command(part_of=BillingAccount)
class SuspendBillingAccount:
customer_id: Identifier(required=True)
@domain.event_handler(part_of=Customer)
class CustomerLifecycleHandler:
"""Turns Customer lifecycle events into BillingAccount commands."""
@handle(CustomerRegistered)
def on_customer_registered(self, event: CustomerRegistered):
current_domain.process(
OpenBillingAccount(customer_id=event.customer_id, email=event.email)
)
@handle(CustomerDeactivated)
def on_customer_deactivated(self, event: CustomerDeactivated):
current_domain.process(SuspendBillingAccount(customer_id=event.customer_id))
@domain.command_handler(part_of=BillingAccount)
class BillingAccountCommandHandler:
@handle(OpenBillingAccount)
def open_account(self, command: OpenBillingAccount):
repo = current_domain.repository_for(BillingAccount)
if repo.query.filter(customer_id=command.customer_id).all().total:
return # An account for this customer already exists
repo.add(
BillingAccount(
customer_id=command.customer_id, # Correlation
email=command.email,
account_status="ACTIVE",
)
)
@handle(SuspendBillingAccount)
def suspend_account(self, command: SuspendBillingAccount):
repo = current_domain.repository_for(BillingAccount)
account = repo.find_by(customer_id=command.customer_id)
if account.account_status == "SUSPENDED":
return # Already suspended
account.suspend()
repo.add(account)
The event handler is part_of the Customer aggregate and reads the
customer stream, where the events are raised. It translates Customer events
into BillingAccount commands, using customer_id as the correlation
identifier. BillingAccount changes only through its own command handler.
Events are delivered at least once, so the same event can arrive twice. The
command handler skips a redelivered command: it opens no second account for a
customer, and it leaves an account that is already suspended as it is. The
check reads before it writes, so it does not stop two copies of
OpenBillingAccount that are handled at the same moment by parallel workers.
The two commands are separate messages. If OpenBillingAccount fails and is
retried, SuspendBillingAccount can be processed before the account is stored.
Then find_by raises ObjectNotFoundError and the command fails. The server
retries a failed message, and by then the account usually exists. See
Error Handling for the retry settings.
Separate Domains: Subscribers as Anti-Corruption Layers
When bounded contexts are deployed as separate services communicating through a message broker, use subscribers. Subscribers receive raw payloads from external systems and translate them into the local domain's language:
# Shipping context: a separate domain
from protean import Domain
from protean.fields import Auto, Identifier, String, ValueObject
shipping = Domain(__file__, "Shipping")
@shipping.value_object
class Address:
street: String(max_length=200)
city: String(max_length=100)
zip_code: String(max_length=20)
@shipping.aggregate
class Recipient:
"""Shipping context's model of a customer."""
recipient_id: Auto(identifier=True)
customer_id: Identifier(required=True) # Correlation ID
name: String(required=True)
delivery_address = ValueObject(Address)
def update_address(self, address: Address) -> None:
self.delivery_address = address
@shipping.subscriber(stream="sales_customer_events")
class CustomerEventSubscriber:
"""Consumes customer events from the Sales context's broker stream.
Acts as an anti-corruption layer: translates Sales' customer schema
into Shipping's Recipient model.
"""
def __call__(self, payload: dict) -> None:
event_type = payload.get("type", "")
if "CustomerRegistered" in event_type:
self._handle_registration(payload)
elif "CustomerAddressUpdated" in event_type:
self._handle_address_update(payload)
def _handle_registration(self, payload: dict) -> None:
repo = shipping.repository_for(Recipient)
# A redelivered registration must not create a second recipient
if repo.query.filter(customer_id=payload["customer_id"]).all().items:
return
# The address arrives later, with CustomerAddressUpdated
repo.add(Recipient(customer_id=payload["customer_id"], name=payload["name"]))
def _handle_address_update(self, payload: dict) -> None:
repo = shipping.repository_for(Recipient)
recipient = repo.find_by(customer_id=payload["customer_id"])
recipient.update_address(
Address(
street=payload["street"],
city=payload["city"],
zip_code=payload["zip_code"],
)
)
repo.add(recipient)
The subscriber is the anti-corruption layer. It prevents the Sales
context's schema from leaking into the Shipping domain. If Sales changes how it
structures customer events, only the subscriber needs to change. The
Recipient aggregate and the rest of the Shipping domain remain untouched.
A registration creates a recipient with a name and no address. The address
comes with CustomerAddressUpdated. A subscriber can also receive the same
message twice, so it skips a registration for a customer it already knows.
Cross-Context Projections
For read-only views that combine data from multiple aggregates or contexts, use projectors. Projectors can subscribe to multiple stream categories simultaneously:
from protean.core.projector import on
from protean.utils.globals import current_domain
@domain.event(part_of=BillingAccount)
class InvoiceIssued:
invoice_id: Identifier(required=True)
customer_id: Identifier(required=True)
amount: Float(required=True)
@domain.projection
class CustomerDashboard:
"""Read model combining data from Customer and BillingAccount."""
customer_id: Identifier(identifier=True)
name: String()
email: String()
account_status: String()
total_invoiced: Float(default=0.0)
invoice_ids: List(content_type=String)
@domain.projector(
projector_for=CustomerDashboard,
stream_categories=[
Customer.meta_.stream_category, # "<domain>::customer"
BillingAccount.meta_.stream_category, # "<domain>::billing_account"
],
)
class CustomerDashboardProjector:
# Events can arrive more than once, so each handler skips a repeat
@on(CustomerRegistered)
def on_customer_registered(self, event: CustomerRegistered):
repo = current_domain.repository_for(CustomerDashboard)
if repo.query.filter(customer_id=event.customer_id).all().items:
return
dashboard = CustomerDashboard(
customer_id=event.customer_id,
name=event.name,
email=event.email,
)
repo.add(dashboard)
@on(InvoiceIssued)
def on_invoice_issued(self, event: InvoiceIssued):
repo = current_domain.repository_for(CustomerDashboard)
dashboard = repo.get(event.customer_id)
if event.invoice_id in dashboard.invoice_ids:
return
dashboard.total_invoiced += event.amount
dashboard.invoice_ids = [*dashboard.invoice_ids, event.invoice_id]
repo.add(dashboard)
Projections are ideal for query scenarios that span multiple aggregates. They are read-optimized, denormalized, and eventually consistent, exactly what you need for cross-context views.
The projector keeps the ids of the invoices it has counted, so an
InvoiceIssued that arrives twice adds to the total once.
The Correlation ID
The shared identifier, the correlation ID, is what holds this pattern together. Every context stores it on its local model and uses it to connect incoming events to the right local entity.
Design Guidelines
Use the source context's identity. The context that owns the concept generates its identity. All other contexts store this identity as a reference:
# fragment
# Sales (authority): customer_id is the primary identity
class Customer:
customer_id: Auto(identifier=True)
# Billing (consumer): customer_id is a stored reference
class BillingAccount:
account_id: Auto(identifier=True) # Own identity
customer_id: Identifier(required=True) # Correlation to Sales
Embed it in every event. Every event that crosses context boundaries must carry the correlation ID. Without it, the consuming context cannot route the event to the right local entity:
# fragment
class CustomerEmailUpdated:
customer_id: Identifier(required=True) # Always present
new_email: String(required=True)
Index it for efficient lookup. Consuming contexts will frequently query by the correlation ID. Make sure the field is indexed in whatever persistence layer backs the local model.
Multiple Correlation IDs
Complex workflows may involve concepts from several source contexts. A local model can carry multiple correlation IDs:
@domain.aggregate
class Shipment:
shipment_id: Auto(identifier=True)
order_id: Identifier(required=True) # Correlates to Order context
customer_id: Identifier(required=True) # Correlates to Sales context
warehouse_id: Identifier(required=True) # Correlates to Inventory context
Each identifier traces back to a different authoritative context. Events from any
of these contexts can be routed to the correct Shipment instance using the
appropriate correlation ID.
Event Propagation Patterns
Initialize on Creation
When a concept is created in the source context, consuming contexts create their own local representation. The creation event must carry enough data for the consumer to initialize its model:
# fragment
# Source raises:
CustomerRegistered(
customer_id="c-123",
name="Jane Doe",
email="jane@example.com",
)
# Billing consumer creates:
BillingAccount(
customer_id="c-123", # From event
email="jane@example.com", # From event
account_status="ACTIVE", # Billing's own default
)
Propagate Lifecycle Changes
After initialization, the source context publishes events for changes that other contexts care about. Each consumer decides which events are relevant and how to react:
# fragment
# Source raises: CustomerDeactivated
# Billing reacts: Suspend the account
# Shipping reacts: Cancel pending deliveries
# Support reacts: Close open tickets
Not every consumer reacts to every event. Billing may not care about
CustomerAddressUpdated, while Shipping certainly does. Each consumer subscribes
to what it needs and ignores the rest.
Use Read Models Where Appropriate
Sometimes a consuming context doesn't need a full aggregate. It only needs a read-only reference for display or validation. Use projections for these scenarios:
@domain.projection
class CustomerReference:
"""Read-only reference to a customer, built from Sales events."""
customer_id: Identifier(identifier=True)
name: String()
email: String()
is_active: Boolean(default=True)
Projections are simpler than aggregates. They have no business logic, no invariants, and no lifecycle. They are just data, kept up-to-date by a projector that listens to the source context's stream.
What to Avoid
Querying Across Context Boundaries
A consuming context should never call the source context's API to get current state. The source's state may have already changed between the query and the consumer's use of the data:
# fragment
# Anti-pattern: querying Sales from Billing
def create_billing_account(self, customer_id):
# DON'T DO THIS
customer = sales_api.get_customer(customer_id)
account = BillingAccount(
customer_id=customer.id,
email=customer.email, # May already be stale
)
Instead, consume events and maintain local state. The local copy may be eventually consistent, but it is self-contained and does not create runtime coupling.
Sharing Domain Classes Across Contexts
Two contexts should never import the same aggregate or entity class. Each context has its own model, shaped by its own Ubiquitous Language:
# fragment
# Anti-pattern: shared class
from sales.domain import Customer # DON'T import across contexts
# Correct: each context defines its own model
# Sales has Customer, Billing has BillingAccount, Shipping has Recipient
Even if the models look similar today, they will diverge as each context evolves. Sharing a class couples their evolution paths.
Treating Events as Remote Procedure Calls
Events are facts, not requests. A publishing context should not expect or
depend on a consuming context's reaction. The publisher raises
CustomerRegistered and moves on. It doesn't know whether Billing creates
an account, Support creates a contact, or no one reacts at all, and it
doesn't care.
If you need a guaranteed response from another context, use a command-based integration (e.g., an API call or a command sent through a broker), not events.
Summary
| Aspect | Approach |
|---|---|
| Shared identity | Use the source context's identity as a correlation ID in all consuming contexts |
| State propagation | Publish lifecycle events from the authoritative context; consume and build local models |
| Within same domain | An event handler on the aggregate that raises the event issues a command to the other aggregate |
| Across separate domains | Subscribers on broker streams, acting as anti-corruption layers |
| Read-only cross-context views | Projections built by projectors subscribing to multiple stream categories |
| Event format for cross-context transfer | Fact events (fact_events=True) for complete state snapshots |
| Schema isolation | Each context maintains its own model; subscribers translate between schemas |
| Querying across boundaries | Avoid; build local read models from events instead |
The pattern is rooted in a core DDD principle: bounded contexts are autonomous. They own their models, their data, and their rules. The only thing they share is identity and events. Sharing only identity and events keeps the contexts loosely coupled, and the shared events keep them synchronized on the real-world concepts they all model.
Related reading
Concepts:
- Events: Events as the communication mechanism between domains.
- Subscribers: Consuming messages from external bounded contexts.
Guides:
- Subscribers: Defining subscribers for external message consumption.
- Events: Event definition and structure.