Skip to main content

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:

  1. 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).
  2. 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.
  3. 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

FieldPurpose
event namepast-tense verb of what happened (LabelPrinted)
aggregate idwhich aggregate emitted it
occurred_atwhen the fact became true in the domain
event_idunique id (uuid) for dedupe, tracing, idempotency
schema/versionmachine-readable contract version
payloadthe minimum data needed to understand the fact
optional: causation_id / correlation_idwhich 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

KindExampleWho uses itShape
Internal domain eventScanDeduplicatedTracking only, to update read models inside Trackingrich, volatile
Internal policy triggerDeliveryExceptionRaisedTracking policy reactsrich
Integration eventshipping.shipment.delivered.v1Billing, Notificationslean, contract
Event-carried state transfershipping.shipment.snapshot.v1Analytics warehousefat, 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:

  1. Decide the event's name in past tense. Run it past a domain expert. If they say "we don't call it that," rename.
  2. Include the minimum payload to make the fact understandable without a callback.
  3. Add event_id, occurred_at, and a schema/version tag.
  4. Emit from inside the aggregate.
  5. Persist to an outbox in the same transaction as the aggregate save.
  6. At the context boundary, translate into the integration event shape; do not publish domain events raw.
  7. Consumers dedupe by event_id, handle idempotently, and log correlation_id through.
  8. Version as a contract. Evolve additively.

Check Yourself

  1. Why are events past-tense?
  2. What problem does a transactional outbox solve that "publish after commit" does not?
  3. 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:

  1. List every domain event it emits.
  2. 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.
  3. Sketch an outbox + relay setup.
  4. Write an idempotent consumer in another context (e.g., Fulfillment) that processes orders.order.placed.v1.
  5. Describe a plausible bug (say, double-publishing) and show how your design absorbs it.

Read This Only If Stuck