Domain events skill

A domain event represents something that happened in the domain that domain experts care about.

by wondelai·MIT license·★ 2,235 Stars on the repo·GitHub ↗

Use now

Files of Domain events

wondelai/main1 file
domain-events.md
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 placed
  • PaymentReceived -- a payment was received
  • ShipmentDispatched -- a shipment was dispatched
  • AccountSuspended -- an account was suspended
  • PolicyRenewed -- a policy was renewed

Incorrect naming:

  • PlaceOrder -- this is a command, not an event
  • OrderPlacing -- this implies the action is in progress
  • OrderEvent -- 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, OrderFulfilled
  • InvoiceSent, InvoicePaid, InvoiceOverdue
  • MemberRegistered, 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:

  1. Within the same database transaction that persists the aggregate, insert the event into an outbox table
  2. A separate process (poller or CDC -- Change Data Capture) reads from the outbox and publishes to the message broker
  3. 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:

  1. Order context raises OrderPlaced (domain event, internal language)
  2. Anti-corruption layer translates to PurchaseCompleted (integration event, published language)
  3. Shipping context receives PurchaseCompleted and translates to ShipmentRequested (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:

  1. OrderPlaced triggers the saga
  2. Saga sends ReserveInventory command
  3. InventoryReserved event continues the saga
  4. Saga sends AuthorizePayment command
  5. PaymentAuthorized event continues the saga
  6. 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 
3A 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 
7A 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 
20Ask 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 
26Domain 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 
43Be 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 
53Adopt a consistent naming pattern across the codebase:
54 
55```
56{AggregateType}{DomainAction}
57```
58 
59Examples:
60- `OrderPlaced`, `OrderCancelled`, `OrderFulfilled`
61- `InvoiceSent`, `InvoicePaid`, `InvoiceOverdue`
62- `MemberRegistered`, `MemberSuspended`, `MemberReinstated`
63 
64## Event Structure
65 
66A 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 
80Include enough data for consumers to react without calling back to the producer:
81 
82**Too little:**
83```json
84{ "orderId": "ORD-12345" }
85```
86Every consumer must call back to the Order service to get details. This creates coupling and latency.
87 
88**Too much:**
89```json
90{ "order": { /* entire order aggregate serialized */ } }
91```
92This bloats messages, exposes internal model details, and creates tight coupling to the aggregate structure.
93 
94**Just right:**
95```json
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```
107Enough 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 
113Domain events are raised within the aggregate, as part of the domain operation that caused them:
114 
115```
116class 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 
128The 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 
132The most reliable way to publish domain events is the transactional outbox:
133 
1341. Within the same database transaction that persists the aggregate, insert the event into an `outbox` table
1352. A separate process (poller or CDC -- Change Data Capture) reads from the outbox and publishes to the message broker
1363. 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 
164When a domain event crosses a bounded context boundary, it should be translated into an integration event that uses the published language:
165 
1661. **Order context** raises `OrderPlaced` (domain event, internal language)
1672. **Anti-corruption layer** translates to `PurchaseCompleted` (integration event, published language)
1683. **Shipping context** receives `PurchaseCompleted` and translates to `ShipmentRequested` (domain event, shipping language)
169 
170This translation prevents internal model changes from breaking external consumers.
171 
172### Event-Driven Architecture Patterns
173 
174#### Event Notification
175 
176The 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 
180The 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 
184Events 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 
188Event 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```
204Stream: 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```
214currentState = OrderCreated.apply(emptyOrder)
215currentState = PaymentAuthorized.apply(currentState)
216currentState = OrderConfirmed.apply(currentState)
217currentState = ItemShipped.apply(currentState)
218currentState = 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 
236Every 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 
244Events 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 
251Events 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 
255Long-running business processes that span multiple aggregates or bounded contexts can be coordinated using sagas:
256 
2571. `OrderPlaced` triggers the saga
2582. Saga sends `ReserveInventory` command
2593. `InventoryReserved` event continues the saga
2604. Saga sends `AuthorizePayment` command
2615. `PaymentAuthorized` event continues the saga
2626. If any step fails, the saga sends compensating commands (`ReleaseInventory`, `RefundPayment`)
263 
264Sagas maintain their own state and react to events. They do not hold locks -- they coordinate through events and compensating actions.
265 

Discussion