Domain Events and Aggregates#
In Domain-Driven Design, an aggregate is a cluster of related entities treated as a single unit for consistency. A domain event is a fact about something that happened, emitted by the aggregate.
flowchart LR
subgraph Aggregate[Order Aggregate]
Root[Order root] --- I1[OrderLine]
Root --- I2[ShippingAddress]
end
Cmd([Command: PlaceOrder]) --> Root
Root -->|emits| Ev[OrderPlaced]
Ev --> Bus[(Event bus)]
Bus --> H1[Inventory handler]
Bus --> H2[Email handler]
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
class Root,I1,I2,H1,H2 service;
class Bus queue;
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
The aggregate guarantees its own invariants; events propagate facts to anyone interested. Consumers react asynchronously, decoupled from the write path.
Two of the most important tactical patterns from DDD. Used together, they give you a clean write path and a flexible read/integration path.
Aggregate rules#
An aggregate is a graph of objects that change together. It has one aggregate root through which all access happens.
classDiagram
class Order {
-id: OrderId
-customerId: CustomerId
-lines: List~OrderLine~
-status: Status
+addLine(productId, qty)
+submit()
+cancel()
}
class OrderLine {
-productId
-qty
-priceAtAdd
}
Order "1" *-- "many" OrderLine
The five aggregate rules#
- One root: external code references only the root.
- Invariants enforced at root: e.g. total can't exceed limit; only the root checks.
- Reference other aggregates by id, not by pointer.
- One aggregate per transaction: don't update Order and Customer in the same DB tx. Use events for the second.
- Small aggregates: design around invariants, not "everything related". Smaller aggregates have fewer lock conflicts.
Domain events#
A domain event is a past-tense, immutable fact:
data class OrderPlaced(
val orderId: OrderId,
val customerId: CustomerId,
val total: Money,
val placedAt: Instant,
)
Events vs commands#
| Command | Event | |
|---|---|---|
| Tense | Imperative ("PlaceOrder") | Past ("OrderPlaced") |
| Can be rejected? | Yes | No - it already happened |
| Audience | Single handler | Zero-to-many |
| Generated by | Caller | Aggregate |
The transactional outbox#
Events emitted by the aggregate must be durably delivered even if a process crashes between DB commit and broker publish. The outbox pattern:
flowchart TB
App[App txn] -->|same DB tx| DB[(DB:<br/>orders table<br/>outbox table)]
CDC[CDC / poller] --> DB
CDC --> Kafka[(Kafka)]
Kafka --> H1[Handler 1]
Kafka --> H2[Handler 2]
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
class App,H1,H2,CDC service;
class DB datastore;
class Kafka queue;
- Aggregate change + event row written in the same DB transaction.
- A poller (or CDC) reads the outbox table and publishes to Kafka.
- On publish success, mark the outbox row as delivered.
Application service example#
class PlaceOrderService:
def __init__(self, orders: OrderRepo, outbox: Outbox):
self.orders = orders
self.outbox = outbox
def execute(self, cmd: PlaceOrderCommand):
order = Order.create(cmd.items, cmd.customer) # invariants checked here
with self.unit_of_work:
self.orders.save(order)
for e in order.pull_events():
self.outbox.append(e)
# commit triggers the poller; nothing async inside the txn
Relationship to event sourcing and CQRS#
| Pattern | Relationship |
|---|---|
| Domain events | The events here |
| Event sourcing | Store the events instead of state; reconstruct aggregates by replay |
| CQRS | Separate write model (aggregates) from read model (projections from events) |
You can use domain events without event sourcing - just store state and emit events as facts.
Common anti-patterns#
- Anaemic aggregates: pure data; logic in services. Drift back to procedural code.
- God aggregates: an Order with 50 lines and 10 collections. Lock contention, slow loads.
- Events as RPC: "DoX" instead of "XHappened". Becomes a brittle async API.
- Cross-aggregate transactions: tempts you back into one giant aggregate. Use eventual consistency between aggregates.
Where domain events fit#
flowchart TB
DE((Domain events<br/>+ aggregates))
DDD[DDD tactical<br/>parent]
ES[Event Sourcing + CQRS<br/>follow-on]
RP[Repository pattern<br/>persistence boundary]
CDC[Change Data Capture<br/>outbox shipper]
DDD --> DE
DE --> ES
RP --> DE
DE --> CDC
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class DE service;
class DDD,ES,RP,CDC datastore;
Glossary & fundamentals#
| Tag | Concept | What it is | Page |
|---|---|---|---|
LLD |
DDD tactical | parent concept | ddd-tactical |
HLD |
Event Sourcing CQRS | natural follow-on | event-sourcing-cqrs |
LLD |
Repository pattern | aggregate persistence boundary | repository-pattern |
HLD |
Change Data Capture | the outbox shipper | change-data-capture |
Quick reference#
Five aggregate rules#
- One root; external code references only the root.
- Invariants enforced at the root.
- Reference other aggregates by id, not pointer.
- One aggregate per DB transaction.
- Small aggregates around invariants, not relationships.
Event vs command#
| Command | Event | |
|---|---|---|
| Tense | Imperative | Past |
| Reject? | Yes | No |
| Audience | 1 | 0..N |
Outbox pattern#
- Save aggregate + event row in same DB txn
- Poller / CDC reads outbox
- Publish to broker, mark delivered
Anti-patterns#
- Anaemic aggregate (data only)
- God aggregate (50 children)
- Events as RPC ("DoX")
- Cross-aggregate single txn
Related patterns#
- Event sourcing: store events as state
- CQRS: separate write/read models
- DDD repositories: persistence per aggregate
Refs#
- Evans - Domain-Driven Design
- Vernon - Implementing DDD; Effective Aggregate Design
- Fowler - DDD Aggregate
FAQ#
What is a domain event?#
A domain event is a past-tense fact that something important happened inside the domain, like OrderPlaced or InventoryReserved. Other parts of the system react to it.
What is the difference between a command and a domain event?#
A command is a request to do something and can be rejected. A domain event is a record that something already happened and cannot be denied by listeners.
How do I publish domain events reliably with a database write?#
Use the outbox pattern: write the event to an outbox table in the same transaction as the state change, then a relay forwards it to the broker. This prevents lost events.
Are domain events the same as Kafka events?#
Not quite. Domain events live inside one bounded context. Integration events on Kafka or another broker are usually a translated subset published outside the service.
Should aggregates publish events directly to a message bus?#
No. Aggregates collect events in memory and the application layer dispatches them after the transaction commits. This keeps the domain free of infrastructure.
Related Topics#
- DDD Tactical: the broader pattern family aggregates belong to
- Event Sourcing CQRS: durable-event approach that builds on domain events
- Repository Pattern: the persistence boundary for an aggregate