Sharing Event Classes Across Domains
The Problem
Two domains -- Order and Fulfillment -- communicate through events. The Order
domain raises OrderPlaced. The Fulfillment domain consumes it. A developer
extracts the OrderPlaced class into a shared library so both domains use
the same class definition:
shared-events/
events/
order_events.py # OrderPlaced, OrderCancelled, OrderShipped
order-domain/
requirements.txt # depends on shared-events
fulfillment-domain/
requirements.txt # depends on shared-events
This feels clean -- no duplication, one source of truth. But it creates a form of coupling that undermines the autonomy that bounded contexts are supposed to provide:
-
Release coupling. When the Order domain needs to add a field to
OrderPlaced, it must update the shared library, release a new version, and wait for the Fulfillment domain to upgrade. If the Fulfillment domain is maintained by a different team or on a different release cadence, this creates coordination overhead. -
Diamond dependency. Both domains depend on the shared library. If the Order domain needs version 2.0 and the Fulfillment domain is still on 1.5, you have a version conflict. Resolving it means either forcing the Fulfillment team to upgrade or maintaining backward compatibility in the shared library -- both add friction.
-
Conceptual coupling. The
OrderPlacedclass now serves two masters. It must satisfy the Order domain's need to express what happened and the Fulfillment domain's need to consume it. If these needs diverge (Order wants to add internal fields, Fulfillment only needs a subset), the shared class becomes a compromise. -
Deployment coupling. If the shared library has a bug or a breaking change, both domains are affected simultaneously. Independent deployment -- a key benefit of bounded contexts -- is compromised.
The root cause: sharing code across domain boundaries creates coupling that sharing messages avoids.
The Pattern
Share schemas (the message contract), not code (the class definition). Each domain defines its own classes that conform to the agreed-upon schema.
Shared (contract, not code):
- Event name: "OrderPlaced"
- Fields: order_id (string), customer_id (string), items (list), total (float)
- Published on channel: "orders"
Order domain (publisher):
@domain.event(part_of=Order)
class OrderPlaced(BaseEvent): # Order domain's own class
order_id = Identifier(...)
customer_id = Identifier(...)
items = List(...)
total = Float(...)
Fulfillment domain (consumer):
@domain.event(part_of=Shipment)
class ExternalOrderPlaced(BaseEvent): # Fulfillment domain's own class
order_id = Identifier(...)
customer_id = Identifier(...)
items = List(...)
total = Float(...)
Both classes serialize to and deserialize from the same JSON structure. They don't need to be the same Python class.
Why Schemas, Not Classes
Independent Evolution
When the Order domain adds discount_code to OrderPlaced:
- With shared classes: Both domains must update the shared library, both must redeploy.
- With shared schemas: The Order domain adds the field to its class. The Fulfillment domain's class ignores the extra field until it chooses to add it.
Independent Deployment
Each domain owns its own class definition. Changes to one domain's class don't affect the other domain's codebase. Deployment is independent.
Subset Consumption
The Fulfillment domain doesn't need every field the Order domain publishes. With its own class, it defines only the fields it cares about:
# Order domain publishes this
@domain.event(part_of=Order)
class OrderPlaced(BaseEvent):
order_id = Identifier(required=True)
customer_id = Identifier(required=True)
customer_email = String(required=True)
items = List(required=True)
subtotal = Float(required=True)
tax = Float(required=True)
total = Float(required=True)
currency = String(required=True)
discount_code = String()
placed_at = DateTime(required=True)
ip_address = String()
user_agent = String()
# Fulfillment domain only needs this
@domain.event(part_of=Shipment)
class ExternalOrderPlaced(BaseEvent):
order_id = Identifier(required=True)
customer_id = Identifier(required=True)
items = List(required=True)
The Fulfillment domain ignores customer_email, tax, discount_code,
ip_address, and user_agent. It doesn't need them and shouldn't depend on
them.
How to Define the Contract
Option 1: Documentation
The simplest approach. The publishing domain documents its event schemas in human-readable form:
OrderPlaced Event Schema
Published on channel: orders
| Field | Type | Required | Description |
|---|---|---|---|
| order_id | string (UUID) | yes | The order's unique identifier |
| customer_id | string (UUID) | yes | The customer who placed the order |
| items | list of objects | yes | Line items in the order |
| items[].product_id | string | yes | Product identifier |
| items[].quantity | integer | yes | Quantity ordered |
| total | float | yes | Order total including tax |
| placed_at | ISO 8601 datetime | yes | When the order was placed |
Consuming domains read the documentation and define their own classes accordingly.
Best for: Small teams, few domains, simple event schemas.
Option 2: JSON Schema
A machine-readable contract that can be validated automatically:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OrderPlaced",
"type": "object",
"required": ["order_id", "customer_id", "items", "total"],
"properties": {
"order_id": { "type": "string", "format": "uuid" },
"customer_id": { "type": "string", "format": "uuid" },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["product_id", "quantity"],
"properties": {
"product_id": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
}
}
},
"total": { "type": "number", "minimum": 0 }
}
}
Each domain validates its events against the schema in tests, ensuring compatibility without sharing code.
Best for: Multiple teams, formal integration contracts, CI/CD validation.
Option 3: Schema Registry
For large-scale systems, a centralized schema registry stores event schemas and enforces compatibility rules:
- New event versions must be backward-compatible with the previous version
- Consumers can discover available events and their schemas
- Schema evolution is tracked and versioned
Best for: Many domains, multiple teams, strict compatibility requirements.
Contract Testing
Instead of sharing code, use contract tests to verify that the publisher's events match what consumers expect:
Publisher-Side Contract Test
class TestOrderPlacedContract:
"""Verify that OrderPlaced events conform to the published schema."""
def test_order_placed_has_required_fields(self, test_domain):
order = Order(
customer_id="cust-123",
total=100.0,
)
order.items.add(OrderItem(product_id="prod-1", quantity=2))
order.place()
event = order._events[0]
event_dict = event.to_dict()
# Verify contract fields exist and have correct types
assert "order_id" in event_dict
assert isinstance(event_dict["order_id"], str)
assert "customer_id" in event_dict
assert isinstance(event_dict["customer_id"], str)
assert "items" in event_dict
assert isinstance(event_dict["items"], list)
assert "total" in event_dict
assert isinstance(event_dict["total"], (int, float))
Consumer-Side Contract Test
class TestExternalOrderPlacedConsumption:
"""Verify that we can consume OrderPlaced events from the Order domain."""
def test_can_deserialize_order_placed(self, test_domain):
# Simulate an event payload from the external domain
external_payload = {
"order_id": "ord-123",
"customer_id": "cust-456",
"items": [
{"product_id": "prod-1", "quantity": 2},
{"product_id": "prod-2", "quantity": 1},
],
"total": 75.0,
"placed_at": "2024-06-15T10:30:00Z",
# Fields we don't use but should tolerate
"customer_email": "user@example.com",
"discount_code": "SAVE10",
}
event = ExternalOrderPlaced(**external_payload)
assert event.order_id == "ord-123"
assert event.customer_id == "cust-456"
assert len(event.items) == 2
These tests run independently in each domain. If the publisher changes the schema in a way that breaks the contract, the consumer's contract test fails without needing shared code.
When Sharing Code Is Acceptable
Intentional Shared Kernel
DDD recognizes the Shared Kernel pattern: two bounded contexts explicitly agree to share a subset of their domain model. This is appropriate when:
- Both contexts are owned by the same team
- They share the same deployment pipeline
- The shared concepts are stable and rarely change
- The coupling is intentional and documented
# shared_kernel/events.py -- intentionally shared
class OrderPlacedEvent(BaseEvent):
order_id = Identifier(required=True)
customer_id = Identifier(required=True)
items = List(required=True)
total = Float(required=True)
A shared kernel is a conscious architectural decision, not an accidental dependency. It should be small, stable, and explicitly agreed upon by both teams.
Same Deployment Unit
If two domains are always deployed together (e.g., a monolith with logical bounded contexts), sharing event classes adds no deployment coupling because they're already coupled. The overhead of maintaining separate classes may not be worth it.
Protobuf / Avro Definitions
When using schema-first serialization formats (Protocol Buffers, Avro), the schema itself generates code for each domain. Both domains use generated classes derived from the same schema definition, which is a form of schema-sharing, not code-sharing.
Anti-Patterns
Accidental Shared Kernel
# Anti-pattern: sharing evolved into a large shared library
shared-events/
events/
order_events.py # 15 event classes
customer_events.py # 12 event classes
inventory_events.py # 8 event classes
payment_events.py # 10 event classes
utils.py # Helper functions
validators.py # Shared validation
What started as one shared event class grew into a large shared library that every domain depends on. Changes to any file affect all domains. This is an accidental shared kernel -- coupling by convenience, not by design.
Exposing Internal Events
# Anti-pattern: sharing events that include internal details
class OrderPlaced(BaseEvent):
order_id = Identifier(required=True)
customer_id = Identifier(required=True)
items = List(required=True)
total = Float(required=True)
# Internal implementation details shared with consumers
_processing_flags = Dict()
_audit_trail = List()
_internal_status = String()
Only publish events with fields that consumers should see. Internal details belong to the publishing domain and should not be part of the public contract.
Summary
| Approach | Coupling | Independence | Best For |
|---|---|---|---|
| Shared classes (library) | High | Low | Same team, same deploy |
| Shared schemas (documented) | Low | High | Multiple teams, simple events |
| JSON Schema / contract tests | Low | High | Formal integration, CI/CD |
| Schema registry | Low | Very High | Large-scale, many domains |
| Intentional shared kernel | Medium | Medium | Stable, agreed-upon concepts |
The principle: domains communicate through messages, not through shared code. Each domain defines its own classes that conform to the agreed-upon schema. Share the contract (schema), not the implementation (classes). Use contract tests to verify compatibility without code dependencies.