CloudEvents as a Boundary Contract
The Problem
Your order service raises an OrderPlaced event. An external fulfillment
system needs to consume it. The event carries Protean-specific metadata --
stream names, sequence IDs, causal chain identifiers, checksums -- structured
for DDD and event sourcing, not for generic consumers.
If you expose the internal metadata format directly:
-
Structural coupling. External consumers depend on Protean's
metadata.headers,metadata.domain, andmetadata.envelopenesting. A refactor of your internal metadata breaks every consumer. -
Vocabulary coupling. Consumers must understand Protean-specific concepts like
stream_category,fqn, andexpected_version-- none of which are meaningful outside the bounded context. -
No standard tooling. Generic event routers, schema registries, and observability tools don't recognize Protean's format. You lose the ecosystem of CloudEvents-compatible middleware.
-
Bidirectional friction. Consuming events from external systems requires ad-hoc parsing of whatever format they chose.
The root cause: internal event metadata is optimized for the domain, not for interoperability. Exposing it directly couples every external consumer to your framework's internals.
The Pattern
Use CloudEvents v1.0 as the serialization format at system boundaries only. Internal metadata stays DDD-native. CloudEvents is an anti-corruption layer applied during serialization, not a structural change to your domain model.
Your Domain Boundary External System
┌─────────────────┐ to_cloudevent() ┌──────────────────┐
│ Protean Message │ ──────────────────► │ CloudEvents JSON │ ──► Kafka / HTTP / ...
│ (DDD metadata) │ │ (standard format)│
└─────────────────┘ └──────────────────┘
External System Boundary Your Domain
┌──────────────────┐ from_cloudevent() ┌─────────────────┐
│ CloudEvents JSON │ ──────────────────► │ Protean Message │ ──► Subscriber / Handler
│ (standard format)│ │ (DDD metadata) │
└──────────────────┘ └─────────────────┘
This mirrors the subscriber / ACL pattern that Protean already uses for consuming external events -- except instead of translating external events inward, we also translate internal events outward.
How Protean Supports It
Producing CloudEvents
Every Message has a to_cloudevent() method that derives all CloudEvents
attributes from existing Protean metadata:
from protean.utils.eventing import Message
message = Message.from_domain_object(event)
cloud_event = message.to_cloudevent()
# Publish to external topic
kafka_producer.send("orders", json.dumps(cloud_event))
All required CloudEvents attributes (specversion, id, type, source)
are derived automatically. Protean-specific metadata (causal chains, checksums,
sequence tracking) rides alongside as protean-namespaced extension
attributes.
Consuming CloudEvents
Parse incoming CloudEvents into Protean messages:
message = Message.from_cloudevent(cloud_event_dict)
# For external events: access data directly
order_id = message.data["order_id"]
# For Protean-originated events: reconstruct the domain object
event = message.to_domain_object()
Configuring source
The CloudEvents source attribute identifies your bounded context. Configure
it in domain.toml:
source_uri = "https://orders.example.com"
If not configured, Protean derives it from the domain name:
urn:protean:<normalized_domain_name>.
Extension philosophy
CloudEvents deliberately has a small core (4 required attributes) with an extension model for domain-specific concerns. Protean uses this exactly as intended:
- Core attributes cover interoperability (who sent it, what type, when).
protean-prefixed extensions carry DDD-specific metadata that Protean consumers understand but generic consumers can ignore.- User extensions from message enrichers are merged directly into the CloudEvent.
Applying the Pattern
Publishing to an external Kafka topic
@domain.event_handler(part_of=Order)
class OrderEventPublisher:
@handle(OrderPlaced)
def on_order_placed(self, event: OrderPlaced) -> None:
message = Message.from_domain_object(event)
cloud_event = message.to_cloudevent()
kafka_producer.send(
topic="order-events",
value=json.dumps(cloud_event).encode(),
)
The external fulfillment system receives a standard CloudEvents JSON object. It doesn't need to know about Protean, stream categories, or event sourcing mechanics.
Receiving from an external webhook
@domain.subscriber(stream="payment-webhooks")
class PaymentWebhookSubscriber:
def __call__(self, payload: dict) -> None:
message = Message.from_cloudevent(payload)
if message.metadata.headers.type == "com.stripe.payment.succeeded":
current_domain.process(
ConfirmPayment(
payment_id=message.data["payment_intent_id"],
amount=message.data["amount"],
)
)
Multi-domain Protean system
Two Protean services communicate via CloudEvents over a message broker:
# Service A: publish
message = Message.from_domain_object(event)
broker.publish("shared-topic", message.to_cloudevent())
# Service B: consume and reconstruct
cloud_event = broker.receive("shared-topic")
message = Message.from_cloudevent(cloud_event)
event = message.to_domain_object() # Works if type is registered
The correlation ID, causation ID, and checksum survive the round-trip via
protean-prefixed extensions.
Anti-Patterns
Restructuring internal metadata for CloudEvents
"Let's rename
headers.typetotypeanddomain.stream_categorytosourceso our internal format matches CloudEvents."
Internal metadata is optimized for DDD and event sourcing. CloudEvents is
optimized for interoperability. These are different concerns. to_cloudevent()
bridges the gap without contaminating either model.
Adding CloudEvents-only fields to internal metadata
"Let's add
source,subject, anddatacontenttypefields toMessageHeaders."
These fields would always duplicate information already available elsewhere (domain name, stream name, "application/json"). Redundant storage creates maintenance burden and divergence risk. Derive them at serialization time.
Using CloudEvents format for internal event storage
"Let's store all events in CloudEvents format in the event store."
CloudEvents is a wire format, not a storage format. Protean's internal format carries richer metadata (expected version, stream category, event store positions, processing priority) that CloudEvents doesn't represent. Use CloudEvents at the boundary; use Protean's native format internally.
When Not to Use
-
Single-domain applications with no external integrations. If all consumers are within the same bounded context, Protean's native format is simpler and carries more information.
-
Internal event handlers that only consume events from the same domain. They receive typed domain objects directly -- no serialization needed.
-
Performance-critical internal paths where the serialization overhead of
to_cloudevent()is unnecessary. CloudEvents is for boundary crossing, not for hot internal loops.
Summary
| Aspect | Guidance |
|---|---|
| When | Events cross bounded context boundaries |
| How | to_cloudevent() to produce, from_cloudevent() to consume |
| Configure | source_uri in domain.toml for stable source identification |
| Extensions | protean-prefixed for DDD metadata; user extensions from enrichers |
| Internal format | Unchanged -- CloudEvents is a serialization concern |
| Round-trip | Data, type, correlation/causation, checksum all preserved |
Related
- CloudEvents Interoperability Guide -- Step-by-step instructions for producing and consuming CloudEvents.
- Consuming Events from Other Domains -- The subscriber / ACL pattern for external event consumption.
- Fact Events as Integration Contracts -- Using fact events for cross-context state snapshots.
- Message Tracing -- How correlation and causation IDs flow through causal chains.
- Message Enrichment -- Attaching custom metadata to events and commands.