Files of Domain events
wondelai/
Show the full text265 lines
Domain Events
A domain event represents something that happened in the domain that domain experts care about. Events are named in past tense, are immutable facts, and serve as the primary mechanism for decoupling bounded contexts and achieving eventual consistency across aggregate boundaries.
What Domain Events Are
A domain event captures a meaningful occurrence in the business domain. "Meaningful" means that a domain expert would recognize it as significant -- not just a technical state change.
Domain Events vs. Technical Events
| Domain Event | Technical Event | Why It Matters |
|---|---|---|
OrderPlaced |
RowInserted |
The domain expert cares about orders being placed; they do not care about database rows |
PaymentReceived |
WebhookProcessed |
The business reacts to payments; the webhook is an implementation detail |
ClaimDenied |
StatusUpdated |
Denial triggers business processes (appeals, notifications); a status update triggers nothing meaningful |
InventoryDepleted |
CountReachedZero |
The business has specific procedures for depleted inventory; zero is just a number |
The Litmus Test
Ask a domain expert: "Would you care if this happened?" If yes, it is a domain event. If they shrug, it is a technical event that belongs in infrastructure, not in the domain model.
Naming Domain Events
The Past-Tense Rule
Domain events are always named in past tense because they represent facts that have already occurred. By the time anyone processes the event, the thing has already happened.
Correct naming:
OrderPlaced-- an order was placedPaymentReceived-- a payment was receivedShipmentDispatched-- a shipment was dispatchedAccountSuspended-- an account was suspendedPolicyRenewed-- a policy was renewed
Incorrect naming:
PlaceOrder-- this is a command, not an eventOrderPlacing-- this implies the action is in progressOrderEvent-- too generic; what happened?OrderUpdate-- "update" is not a domain concept; what specifically changed?
Naming Specificity
Be specific about what happened. Vague event names create the same problems as vague method names -- consumers cannot understand what occurred without reading the payload.
| Vague | Specific | Why Specific Is Better |
|---|---|---|
OrderChanged |
OrderItemAdded, OrderItemRemoved, OrderAddressChanged |
Different changes trigger different business reactions |
UserUpdated |
UserEmailVerified, UserPasswordChanged, UserProfileCompleted |
A password change requires a security audit; a profile completion triggers onboarding flow |
PaymentProcessed |
PaymentAuthorized, PaymentCaptured, PaymentRefunded |
Authorization and capture are distinct business steps with different downstream effects |
Event Naming Conventions
Adopt a consistent naming pattern across the codebase:
{AggregateType}{DomainAction}
Examples:
OrderPlaced,OrderCancelled,OrderFulfilledInvoiceSent,InvoicePaid,InvoiceOverdueMemberRegistered,MemberSuspended,MemberReinstated
Event Structure
A well-designed domain event contains:
| Field | Purpose | Example |
|---|---|---|
eventId |
Unique identifier for this specific event occurrence | uuid("a1b2c3d4...") |
eventType |
The name of the event | "OrderPlaced" |
occurredAt |
When the event happened | "2024-03-15T14:30:00Z" |
aggregateId |
The ID of the aggregate that produced the event | orderId: "ORD-12345" |
aggregateType |
The type of aggregate | "Order" |
payload |
The domain-relevant data | { customerId, items, total, shippingAddress } |
metadata |
Technical metadata (correlation ID, causation ID, user ID) | { correlationId, userId } |
What Goes in the Payload
Include enough data for consumers to react without calling back to the producer:
Too little:
{ "orderId": "ORD-12345" }
Every consumer must call back to the Order service to get details. This creates coupling and latency.
Too much:
{ "order": { /* entire order aggregate serialized */ } }
This bloats messages, exposes internal model details, and creates tight coupling to the aggregate structure.
Just right:
{
"orderId": "ORD-12345",
"customerId": "CUST-789",
"items": [
{ "productId": "PROD-1", "quantity": 2, "unitPrice": 29.99 }
],
"totalAmount": 59.98,
"currency": "USD",
"shippingAddress": { "city": "Springfield", "state": "IL" }
}
Enough for most consumers to react; detailed enough to avoid callbacks for common cases.
Publishing Domain Events
Where Events Are Raised
Domain events are raised within the aggregate, as part of the domain operation that caused them:
class Order:
def place(self):
self._validate_can_be_placed()
self.status = OrderStatus.PLACED
self._raise_event(OrderPlaced(
order_id=self.id,
customer_id=self.customer_id,
items=self.items,
total=self.total
))
The aggregate records the event internally. An infrastructure mechanism (event dispatcher, outbox pattern) publishes it after the aggregate is persisted.
The Outbox Pattern
The most reliable way to publish domain events is the transactional outbox:
- Within the same database transaction that persists the aggregate, insert the event into an
outboxtable - A separate process (poller or CDC -- Change Data Capture) reads from the outbox and publishes to the message broker
- After successful publication, mark the outbox entry as published
Why this matters: If you publish the event and then save the aggregate, the save might fail -- you published a lie. If you save the aggregate and then publish, the publish might fail -- the event is lost. The outbox pattern ties both operations to the same database transaction.
Delivery Guarantees
| Guarantee | Meaning | Implementation |
|---|---|---|
| At-most-once | Events may be lost but never duplicated | Fire-and-forget; no outbox; acceptable for non-critical events |
| At-least-once | Events are never lost but may be duplicated | Outbox pattern with retry; consumers must be idempotent |
| Exactly-once | Events are delivered exactly once | Practically impossible in distributed systems; achieve via at-least-once + idempotent consumers |
At-least-once with idempotent consumers is the standard approach. Design every event handler to be safe to run multiple times with the same event.
Domain Events for Cross-Context Integration
Internal vs. Integration Events
| Aspect | Domain Event (Internal) | Integration Event (External) |
|---|---|---|
| Scope | Within a bounded context | Across bounded contexts |
| Audience | Event handlers in the same context | Other teams' services |
| Schema | Can change freely with the model | Must be versioned and backward-compatible |
| Naming | Uses internal ubiquitous language | Uses published language (shared schema) |
| Transport | In-process event bus or same database | Message broker (Kafka, RabbitMQ, SNS) |
Translation at the Boundary
When a domain event crosses a bounded context boundary, it should be translated into an integration event that uses the published language:
- Order context raises
OrderPlaced(domain event, internal language) - Anti-corruption layer translates to
PurchaseCompleted(integration event, published language) - Shipping context receives
PurchaseCompletedand translates toShipmentRequested(domain event, shipping language)
This translation prevents internal model changes from breaking external consumers.
Event-Driven Architecture Patterns
Event Notification
The event carries minimal data ("something happened") and consumers call back for details. Simplest pattern but creates temporal coupling.
Event-Carried State Transfer
The event carries all the data consumers need. Consumers maintain their own local copy of relevant data, reducing coupling but increasing event size and requiring consumers to maintain projections.
Event Sourcing
Events are the source of truth. Current state is derived by replaying events. This is the most powerful and most complex pattern.
Event Sourcing
Event sourcing stores the complete history of state changes as an ordered sequence of events. Instead of storing only the current state ("account balance is $1,000"), the system stores every event that led to that state ("deposited $500, deposited $800, withdrew $300").
When to Use Event Sourcing
| Good Fit | Poor Fit |
|---|---|
| Audit requirements (financial, medical, legal) | Simple CRUD with no audit needs |
| Complex domain with many state transitions | Domains with few state changes |
| Need to answer "how did we get here?" | Only need current state |
| Need to rebuild state at any point in time | No temporal query requirements |
| High-value domain events that are worth preserving | High-volume, low-value data (telemetry) |
Event Sourcing Mechanics
Storing events:
Stream: Order-12345
1: OrderCreated { customerId, items }
2: PaymentAuthorized { paymentId, amount }
3: OrderConfirmed { confirmedAt }
4: ItemShipped { trackingNumber, items }
5: OrderDelivered { deliveredAt, signedBy }
Rebuilding state:
currentState = OrderCreated.apply(emptyOrder)
currentState = PaymentAuthorized.apply(currentState)
currentState = OrderConfirmed.apply(currentState)
currentState = ItemShipped.apply(currentState)
currentState = OrderDelivered.apply(currentState)
Snapshots optimize performance: periodically save the current state so you do not need to replay from the beginning every time.
Event Sourcing Challenges
| Challenge | Solution |
|---|---|
| Event schema evolution | Use upcasters to transform old events into the current schema; never delete old events |
| Performance with long event streams | Snapshots at regular intervals; read models for queries |
| Complexity | Only use event sourcing for aggregates where it provides clear value; not everything needs to be event-sourced |
| Debugging | Event logs provide excellent debugging and auditing; invest in tooling to browse and replay events |
Patterns for Event Handling
Idempotent Handlers
Every event handler must be safe to execute multiple times with the same event. Strategies:
- Idempotency key: Store processed event IDs; skip duplicates
- Idempotent operations: Design the operation itself to be naturally idempotent (e.g., "set balance to X" instead of "add Y to balance")
- Conditional writes: Use optimistic concurrency (version checks) to prevent double-application
Ordering Guarantees
Events from the same aggregate should be processed in order. Events from different aggregates have no ordering guarantees. Design consumers accordingly:
- Partition by aggregate ID in the message broker (Kafka partition key = aggregate ID)
- Handle out-of-order events gracefully (check event version, buffer and reorder if needed)
Dead Letter Handling
Events that repeatedly fail to process should be routed to a dead letter queue for investigation. Never silently drop failed events. Monitor dead letter queues actively.
Sagas and Process Managers
Long-running business processes that span multiple aggregates or bounded contexts can be coordinated using sagas:
OrderPlacedtriggers the saga- Saga sends
ReserveInventorycommand InventoryReservedevent continues the saga- Saga sends
AuthorizePaymentcommand PaymentAuthorizedevent continues the saga- If any step fails, the saga sends compensating commands (
ReleaseInventory,RefundPayment)
Sagas maintain their own state and react to events. They do not hold locks -- they coordinate through events and compensating actions.
| 1 | # Domain Events |
| 2 | |
| 3 | A domain event represents something that happened in the domain that domain experts care about. Events are named in past tense, are immutable facts, and serve as the primary mechanism for decoupling bounded contexts and achieving eventual consistency across aggregate boundaries. |
| 4 | |
| 5 | ## What Domain Events Are |
| 6 | |
| 7 | A domain event captures a meaningful occurrence in the business domain. "Meaningful" means that a domain expert would recognize it as significant -- not just a technical state change. |
| 8 | |
| 9 | ### Domain Events vs. Technical Events |
| 10 | |
| 11 | | Domain Event | Technical Event | Why It Matters | |
| 12 | |-------------|----------------|----------------| |
| 13 | | `OrderPlaced` | `RowInserted` | The domain expert cares about orders being placed; they do not care about database rows | |
| 14 | | `PaymentReceived` | `WebhookProcessed` | The business reacts to payments; the webhook is an implementation detail | |
| 15 | | `ClaimDenied` | `StatusUpdated` | Denial triggers business processes (appeals, notifications); a status update triggers nothing meaningful | |
| 16 | | `InventoryDepleted` | `CountReachedZero` | The business has specific procedures for depleted inventory; zero is just a number | |
| 17 | |
| 18 | ### The Litmus Test |
| 19 | |
| 20 | Ask a domain expert: "Would you care if this happened?" If yes, it is a domain event. If they shrug, it is a technical event that belongs in infrastructure, not in the domain model. |
| 21 | |
| 22 | ## Naming Domain Events |
| 23 | |
| 24 | ### The Past-Tense Rule |
| 25 | |
| 26 | Domain events are always named in past tense because they represent facts that have already occurred. By the time anyone processes the event, the thing has already happened. |
| 27 | |
| 28 | **Correct naming:** |
| 29 | `OrderPlaced` -- an order was placed |
| 30 | `PaymentReceived` -- a payment was received |
| 31 | `ShipmentDispatched` -- a shipment was dispatched |
| 32 | `AccountSuspended` -- an account was suspended |
| 33 | `PolicyRenewed` -- a policy was renewed |
| 34 | |
| 35 | **Incorrect naming:** |
| 36 | `PlaceOrder` -- this is a command, not an event |
| 37 | `OrderPlacing` -- this implies the action is in progress |
| 38 | `OrderEvent` -- too generic; what happened? |
| 39 | `OrderUpdate` -- "update" is not a domain concept; what specifically changed? |
| 40 | |
| 41 | ### Naming Specificity |
| 42 | |
| 43 | Be specific about what happened. Vague event names create the same problems as vague method names -- consumers cannot understand what occurred without reading the payload. |
| 44 | |
| 45 | | Vague | Specific | Why Specific Is Better | |
| 46 | |-------|----------|----------------------| |
| 47 | | `OrderChanged` | `OrderItemAdded`, `OrderItemRemoved`, `OrderAddressChanged` | Different changes trigger different business reactions | |
| 48 | | `UserUpdated` | `UserEmailVerified`, `UserPasswordChanged`, `UserProfileCompleted` | A password change requires a security audit; a profile completion triggers onboarding flow | |
| 49 | | `PaymentProcessed` | `PaymentAuthorized`, `PaymentCaptured`, `PaymentRefunded` | Authorization and capture are distinct business steps with different downstream effects | |
| 50 | |
| 51 | ### Event Naming Conventions |
| 52 | |
| 53 | Adopt a consistent naming pattern across the codebase: |
| 54 | |
| 55 | |
| 56 | {AggregateType}{DomainAction} |
| 57 | |
| 58 | |
| 59 | Examples: |
| 60 | `OrderPlaced`, `OrderCancelled`, `OrderFulfilled` |
| 61 | `InvoiceSent`, `InvoicePaid`, `InvoiceOverdue` |
| 62 | `MemberRegistered`, `MemberSuspended`, `MemberReinstated` |
| 63 | |
| 64 | ## Event Structure |
| 65 | |
| 66 | A well-designed domain event contains: |
| 67 | |
| 68 | | Field | Purpose | Example | |
| 69 | |-------|---------|---------| |
| 70 | | `eventId` | Unique identifier for this specific event occurrence | `uuid("a1b2c3d4...")` | |
| 71 | | `eventType` | The name of the event | `"OrderPlaced"` | |
| 72 | | `occurredAt` | When the event happened | `"2024-03-15T14:30:00Z"` | |
| 73 | | `aggregateId` | The ID of the aggregate that produced the event | `orderId: "ORD-12345"` | |
| 74 | | `aggregateType` | The type of aggregate | `"Order"` | |
| 75 | | `payload` | The domain-relevant data | `{ customerId, items, total, shippingAddress }` | |
| 76 | | `metadata` | Technical metadata (correlation ID, causation ID, user ID) | `{ correlationId, userId }` | |
| 77 | |
| 78 | ### What Goes in the Payload |
| 79 | |
| 80 | Include enough data for consumers to react without calling back to the producer: |
| 81 | |
| 82 | **Too little:** |
| 83 | |
| 84 | { "orderId": "ORD-12345" } |
| 85 | |
| 86 | Every consumer must call back to the Order service to get details. This creates coupling and latency. |
| 87 | |
| 88 | **Too much:** |
| 89 | |
| 90 | { "order": { /* entire order aggregate serialized */ } } |
| 91 | |
| 92 | This bloats messages, exposes internal model details, and creates tight coupling to the aggregate structure. |
| 93 | |
| 94 | **Just right:** |
| 95 | |
| 96 | { |
| 97 | "orderId": "ORD-12345", |
| 98 | "customerId": "CUST-789", |
| 99 | "items": [ |
| 100 | { "productId": "PROD-1", "quantity": 2, "unitPrice": 29.99 } |
| 101 | ], |
| 102 | "totalAmount": 59.98, |
| 103 | "currency": "USD", |
| 104 | "shippingAddress": { "city": "Springfield", "state": "IL" } |
| 105 | } |
| 106 | |
| 107 | Enough for most consumers to react; detailed enough to avoid callbacks for common cases. |
| 108 | |
| 109 | ## Publishing Domain Events |
| 110 | |
| 111 | ### Where Events Are Raised |
| 112 | |
| 113 | Domain events are raised within the aggregate, as part of the domain operation that caused them: |
| 114 | |
| 115 | |
| 116 | class Order: |
| 117 | def place(self): |
| 118 | self._validate_can_be_placed() |
| 119 | self.status = OrderStatus.PLACED |
| 120 | self._raise_event(OrderPlaced( |
| 121 | order_id=self.id, |
| 122 | customer_id=self.customer_id, |
| 123 | items=self.items, |
| 124 | total=self.total |
| 125 | )) |
| 126 | |
| 127 | |
| 128 | The aggregate records the event internally. An infrastructure mechanism (event dispatcher, outbox pattern) publishes it after the aggregate is persisted. |
| 129 | |
| 130 | ### The Outbox Pattern |
| 131 | |
| 132 | The most reliable way to publish domain events is the transactional outbox: |
| 133 | |
| 134 | Within the same database transaction that persists the aggregate, insert the event into an `outbox` table |
| 135 | A separate process (poller or CDC -- Change Data Capture) reads from the outbox and publishes to the message broker |
| 136 | After successful publication, mark the outbox entry as published |
| 137 | |
| 138 | **Why this matters:** If you publish the event and then save the aggregate, the save might fail -- you published a lie. If you save the aggregate and then publish, the publish might fail -- the event is lost. The outbox pattern ties both operations to the same database transaction. |
| 139 | |
| 140 | ### Delivery Guarantees |
| 141 | |
| 142 | | Guarantee | Meaning | Implementation | |
| 143 | |-----------|---------|----------------| |
| 144 | | At-most-once | Events may be lost but never duplicated | Fire-and-forget; no outbox; acceptable for non-critical events | |
| 145 | | At-least-once | Events are never lost but may be duplicated | Outbox pattern with retry; consumers must be idempotent | |
| 146 | | Exactly-once | Events are delivered exactly once | Practically impossible in distributed systems; achieve via at-least-once + idempotent consumers | |
| 147 | |
| 148 | **At-least-once with idempotent consumers** is the standard approach. Design every event handler to be safe to run multiple times with the same event. |
| 149 | |
| 150 | ## Domain Events for Cross-Context Integration |
| 151 | |
| 152 | ### Internal vs. Integration Events |
| 153 | |
| 154 | | Aspect | Domain Event (Internal) | Integration Event (External) | |
| 155 | |--------|------------------------|------------------------------| |
| 156 | | Scope | Within a bounded context | Across bounded contexts | |
| 157 | | Audience | Event handlers in the same context | Other teams' services | |
| 158 | | Schema | Can change freely with the model | Must be versioned and backward-compatible | |
| 159 | | Naming | Uses internal ubiquitous language | Uses published language (shared schema) | |
| 160 | | Transport | In-process event bus or same database | Message broker (Kafka, RabbitMQ, SNS) | |
| 161 | |
| 162 | ### Translation at the Boundary |
| 163 | |
| 164 | When a domain event crosses a bounded context boundary, it should be translated into an integration event that uses the published language: |
| 165 | |
| 166 | **Order context** raises `OrderPlaced` (domain event, internal language) |
| 167 | **Anti-corruption layer** translates to `PurchaseCompleted` (integration event, published language) |
| 168 | **Shipping context** receives `PurchaseCompleted` and translates to `ShipmentRequested` (domain event, shipping language) |
| 169 | |
| 170 | This translation prevents internal model changes from breaking external consumers. |
| 171 | |
| 172 | ### Event-Driven Architecture Patterns |
| 173 | |
| 174 | #### Event Notification |
| 175 | |
| 176 | The event carries minimal data ("something happened") and consumers call back for details. Simplest pattern but creates temporal coupling. |
| 177 | |
| 178 | #### Event-Carried State Transfer |
| 179 | |
| 180 | The event carries all the data consumers need. Consumers maintain their own local copy of relevant data, reducing coupling but increasing event size and requiring consumers to maintain projections. |
| 181 | |
| 182 | #### Event Sourcing |
| 183 | |
| 184 | Events are the source of truth. Current state is derived by replaying events. This is the most powerful and most complex pattern. |
| 185 | |
| 186 | ## Event Sourcing |
| 187 | |
| 188 | Event sourcing stores the complete history of state changes as an ordered sequence of events. Instead of storing only the current state ("account balance is $1,000"), the system stores every event that led to that state ("deposited $500, deposited $800, withdrew $300"). |
| 189 | |
| 190 | ### When to Use Event Sourcing |
| 191 | |
| 192 | | Good Fit | Poor Fit | |
| 193 | |----------|----------| |
| 194 | | Audit requirements (financial, medical, legal) | Simple CRUD with no audit needs | |
| 195 | | Complex domain with many state transitions | Domains with few state changes | |
| 196 | | Need to answer "how did we get here?" | Only need current state | |
| 197 | | Need to rebuild state at any point in time | No temporal query requirements | |
| 198 | | High-value domain events that are worth preserving | High-volume, low-value data (telemetry) | |
| 199 | |
| 200 | ### Event Sourcing Mechanics |
| 201 | |
| 202 | **Storing events:** |
| 203 | |
| 204 | Stream: Order-12345 |
| 205 | 1: OrderCreated { customerId, items } |
| 206 | 2: PaymentAuthorized { paymentId, amount } |
| 207 | 3: OrderConfirmed { confirmedAt } |
| 208 | 4: ItemShipped { trackingNumber, items } |
| 209 | 5: OrderDelivered { deliveredAt, signedBy } |
| 210 | |
| 211 | |
| 212 | **Rebuilding state:** |
| 213 | |
| 214 | currentState = OrderCreated.apply(emptyOrder) |
| 215 | currentState = PaymentAuthorized.apply(currentState) |
| 216 | currentState = OrderConfirmed.apply(currentState) |
| 217 | currentState = ItemShipped.apply(currentState) |
| 218 | currentState = OrderDelivered.apply(currentState) |
| 219 | |
| 220 | |
| 221 | **Snapshots** optimize performance: periodically save the current state so you do not need to replay from the beginning every time. |
| 222 | |
| 223 | ### Event Sourcing Challenges |
| 224 | |
| 225 | | Challenge | Solution | |
| 226 | |-----------|----------| |
| 227 | | Event schema evolution | Use upcasters to transform old events into the current schema; never delete old events | |
| 228 | | Performance with long event streams | Snapshots at regular intervals; read models for queries | |
| 229 | | Complexity | Only use event sourcing for aggregates where it provides clear value; not everything needs to be event-sourced | |
| 230 | | Debugging | Event logs provide excellent debugging and auditing; invest in tooling to browse and replay events | |
| 231 | |
| 232 | ## Patterns for Event Handling |
| 233 | |
| 234 | ### Idempotent Handlers |
| 235 | |
| 236 | Every event handler must be safe to execute multiple times with the same event. Strategies: |
| 237 | |
| 238 | **Idempotency key:** Store processed event IDs; skip duplicates |
| 239 | **Idempotent operations:** Design the operation itself to be naturally idempotent (e.g., "set balance to X" instead of "add Y to balance") |
| 240 | **Conditional writes:** Use optimistic concurrency (version checks) to prevent double-application |
| 241 | |
| 242 | ### Ordering Guarantees |
| 243 | |
| 244 | Events from the same aggregate should be processed in order. Events from different aggregates have no ordering guarantees. Design consumers accordingly: |
| 245 | |
| 246 | Partition by aggregate ID in the message broker (Kafka partition key = aggregate ID) |
| 247 | Handle out-of-order events gracefully (check event version, buffer and reorder if needed) |
| 248 | |
| 249 | ### Dead Letter Handling |
| 250 | |
| 251 | Events that repeatedly fail to process should be routed to a dead letter queue for investigation. Never silently drop failed events. Monitor dead letter queues actively. |
| 252 | |
| 253 | ### Sagas and Process Managers |
| 254 | |
| 255 | Long-running business processes that span multiple aggregates or bounded contexts can be coordinated using sagas: |
| 256 | |
| 257 | `OrderPlaced` triggers the saga |
| 258 | Saga sends `ReserveInventory` command |
| 259 | `InventoryReserved` event continues the saga |
| 260 | Saga sends `AuthorizePayment` command |
| 261 | `PaymentAuthorized` event continues the saga |
| 262 | If any step fails, the saga sends compensating commands (`ReleaseInventory`, `RefundPayment`) |
| 263 | |
| 264 | Sagas maintain their own state and react to events. They do not hold locks -- they coordinate through events and compensating actions. |
| 265 |
Discussion
Browse more free Claude skills.