Domain Events: Language of Change
What This Concept Is
A domain event is an immutable, past-tense fact about something meaningful that happened in a bounded context. It is the primary mechanism by which DDD models change.
Three grades of "event" sometimes get confused:
- Domain event -- a fact in the ubiquitous language of one bounded context. Rich, often internal.
ShipmentDelivered(shipment_id, at, by_driver_id, signature_type). Consumed inside the context (by other aggregates, read-model builders, local handlers). - Integration event -- a fact published across bounded contexts for consumers outside. Leaner, contract-shaped, versioned.
shipping.shipment.delivered.v2. This is what an OHS + Published Language actually exposes. - Event-carried state transfer -- an integration event fat enough to carry all data the consumer needs, to avoid a callback.
ShipmentDelivered(shipment_id, at, customer_id, weight, service_class, …).
The discipline:
- domain events stay inside the aggregate's context -- they may mention internal concepts
- when a domain event needs to leave the context, it is transformed into an integration event by the application layer (the transformation is a small, explicit mapping, not "publish whatever the aggregate emitted")
- no domain event is ever modified after being emitted -- events are immutable facts
Shape of a good domain event
| Field | Purpose |
|---|---|
| event name | past-tense verb of what happened (LabelPrinted) |
| aggregate id | which aggregate emitted it |
| occurred_at | when the fact became true in the domain |
| event_id | unique id (uuid) for dedupe, tracing, idempotency |
| schema/version | machine-readable contract version |
| payload | the minimum data needed to understand the fact |
| optional: causation_id / correlation_id | which command or upstream event caused this one |
Delivery mechanics
Events are emitted by an aggregate and leave the context via a transactional outbox:
┌─────────────┐ command ┌───────────┐ emits ┌────────────┐
│ Application │──────────▶│ Aggregate │───────────▶│ Events list│
│ layer │ └───────────┘ └─────┬──────┘
└─────┬───────┘ │ same DB tx
│ save(aggregate) │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ Aggregate table│ │ Outbox table │
└────────────────┘ └────────┬─────────┘
│ async relay
▼
┌──────────────────┐
│ Message broker │ (Kafka, SNS, etc.)
└──────────────────┘
The outbox guarantees atomicity: either both the aggregate change and the event are persisted, or neither. The relay then publishes them at least once. Consumers must be idempotent (use event_id for dedupe).
Why It Matters Here
Domain events are the thread that ties every other concept in this module together:
- EventStorming (concept 7) produces events.
- Context mapping (concept 6) routes events across edges.
- Aggregates (concept 10) emit events as a result of commands and invariant checks.
- CQRS (concept 13) and event sourcing (concept 14) consume event streams.
- Context evolution (concept 9) uses event contracts as the stable interface that lets boundaries shift safely.
Without events, aggregates become dumb state containers and the bounded-context border becomes an RPC surface.
Concrete Example
Case: Parcel Shipping -- ShipmentDelivered crossing from Tracking to Billing
The domain event (inside Tracking)
# Domain event -- lives inside Tracking's bounded context
@dataclass(frozen=True)
class ShipmentDelivered:
event_id: str # uuid
shipment_id: str # AWB
occurred_at: datetime
delivered_at: datetime # may differ from occurred_at if backdated
driver_id: Optional[str] # internal Tracking concept
signature_kind: Literal["signed", "contactless", "door_photo"]
scan_ids: list[str] # internal scan identifiers
driver_id and scan_ids are meaningful inside Tracking but nobody outside should depend on them.
Emission from the aggregate
class ShipmentJourney: # aggregate root in Tracking
def record_delivery(self, scan: CarrierScan, by_driver: Optional[str], signature_kind):
if self.status == "delivered":
return # idempotent, already delivered
self.status = "delivered"
self._pending_events.append(ShipmentDelivered(
event_id=str(uuid.uuid4()),
shipment_id=self.shipment_id,
occurred_at=datetime.utcnow(),
delivered_at=scan.event_time,
driver_id=by_driver,
signature_kind=signature_kind,
scan_ids=[s.id for s in self.scans if s.kind == "DELIVERY"],
))
Transform for integration (at the bounded-context edge)
class TrackingIntegrationPublisher:
"""Map Tracking's domain events to Published Language contracts."""
def to_integration_event(self, e: ShipmentDelivered) -> dict:
# Lean payload. No scan_ids. No driver_id.
return {
"schema": "shipping.shipment.delivered.v1",
"event_id": e.event_id,
"shipment_id": e.shipment_id,
"occurred_at": e.occurred_at.isoformat() + "Z",
"delivered_at": e.delivered_at.isoformat() + "Z",
"signature_kind": e.signature_kind,
}
Billing and any other downstream never sees scan_ids or driver_id. If Tracking later renames driver_id internally, nothing downstream breaks.
Outbox, bus, and idempotent consumer
# Billing's consumer
def on_shipment_delivered(integration_event: dict):
key = integration_event["event_id"]
if processed_events.exists(key):
return # dedupe
with uow:
invoice = invoices.find_by_shipment(integration_event["shipment_id"])
invoice.mark_deliverable(datetime.fromisoformat(integration_event["delivered_at"].rstrip("Z")))
invoices.save(invoice)
processed_events.mark(key)
bus.publish({...}) # optional cascade
The flow end-to-end
Types of events, by when and who consumes
| Kind | Example | Who uses it | Shape |
|---|---|---|---|
| Internal domain event | ScanDeduplicated | Tracking only, to update read models inside Tracking | rich, volatile |
| Internal policy trigger | DeliveryExceptionRaised | Tracking policy reacts | rich |
| Integration event | shipping.shipment.delivered.v1 | Billing, Notifications | lean, contract |
| Event-carried state transfer | shipping.shipment.snapshot.v1 | Analytics warehouse | fat, all fields |
Pick the grade deliberately. Confusing them is the most common source of coupling between contexts.
Common Confusion / Misconception
"Publishing a change to the database is the same as emitting an event." A row change is not a business fact. CustomerLocationUpdated should only be emitted if updating the location is meaningful to the domain -- otherwise it is just gossip about your storage.
"Events should carry everything consumers might want, to avoid callbacks." Consider the readership. If one consumer needs it and nine do not, design for the nine and provide a callback API for the one.
"I can modify an event later to fix a bug." You cannot. Events are immutable facts. To correct, emit a compensating event -- ShipmentDeliveryCorrected with the correct data -- and have consumers learn the correction rule.
"Versioning an event contract is the same as versioning a REST API." Same principles, sharper edges: with events you cannot control who has read what yet. Prefer additive changes (v1 -> v1 additive) for years before a v2.
"Domain events and integration events are the same thing." A common cause of pain. Keep them separate and translate at the edge.
"At-least-once + idempotent consumer = exactly-once." It does not; it just behaves like exactly-once for the application. Call it by its honest name.
How To Use It
For every aggregate command:
- Decide the event's name in past tense. Run it past a domain expert. If they say "we don't call it that," rename.
- Include the minimum payload to make the fact understandable without a callback.
- Add
event_id,occurred_at, and a schema/version tag. - Emit from inside the aggregate.
- Persist to an outbox in the same transaction as the aggregate save.
- At the context boundary, translate into the integration event shape; do not publish domain events raw.
- Consumers dedupe by
event_id, handle idempotently, and logcorrelation_idthrough. - Version as a contract. Evolve additively.
Check Yourself
- Why are events past-tense?
- What problem does a transactional outbox solve that "publish after commit" does not?
- Name one danger of publishing your domain events directly to downstream consumers with no translation.
Mini Drill or Application
For the e-commerce checkout aggregate you designed in concept 10:
- List every domain event it emits.
- For one of them (e.g.,
OrderPlaced), write both the internal domain-event Python dataclass and the corresponding integration event schema (JSON) with version tag. - Sketch an outbox + relay setup.
- Write an idempotent consumer in another context (e.g.,
Fulfillment) that processesorders.order.placed.v1. - Describe a plausible bug (say, double-publishing) and show how your design absorbs it.