Event Sourcing & CQRS · Event Sourcing Concepts
Aggregates and Consistency Boundaries
Where invariants are enforced and how commands become events.
An aggregate (DDD) is a consistency boundary: a cluster of objects that enforces invariants (e.g., an account can't be overdrawn) and is changed by one command at a time. Command handling: load the aggregate's stream, validate the command against current state, emit new events, and append them with optimistic concurrency (expected stream version). Keep aggregates small; consistency across aggregates is eventual (via policies/sagas).
Aggregate sizing
| Design choice | Small aggregates | Large aggregates |
|---|---|---|
| Contention | Low | High (many writers to one stream) |
| Invariants | Local only | Broader but slower |
| Stream length | Short | Long (snapshots needed) |
| Cross-entity rules | Eventual (policies/sagas) | Immediate |
async def withdraw(store, account_id: str, amount: int) -> None:
events, version = await store.read_stream(f"account-{account_id}")
account = rehydrate(events)
if account.closed:
raise DomainError("Account closed")
if account.balance < amount:
raise DomainError("Insufficient funds") # invariant enforced in the aggregate
await store.append(f"account-{account_id}",
[{"type": "MoneyWithdrawn", "amount": amount}],
expected_version=version) # fails if someone appended meanwhile
public final class Account {
private long balance; private boolean closed; private final List<Object> changes = new ArrayList<>();
public static Account from(List<Object> history) { var a = new Account(); history.forEach(a::apply); return a; }
public void withdraw(long amount) {
if (closed) throw new DomainException("Account closed");
if (balance < amount) throw new DomainException("Insufficient funds");
raise(new MoneyWithdrawn(amount));
}
private void raise(Object e) { apply(e); changes.add(e); }
private void apply(Object e) {
switch (e) {
case MoneyDeposited d -> balance += d.amount();
case MoneyWithdrawn w -> balance -= w.amount();
case AccountClosed c -> closed = true;
default -> {}
}
}
public List<Object> pendingChanges() { return changes; }
}
Common pitfalls
- Huge aggregates with heavy write contention.
- Invariants spanning aggregates enforced synchronously.
- Appending without expected-version checks (lost updates).
Key points
- Aggregates = consistency boundaries
- Command → validate → events
- Optimistic concurrency on append
Further reading: Martin Fowler: DDD Aggregate
Practice questions
How does optimistic concurrency work when appending events?
Answer
The writer states the stream version it based its decision on; the store rejects the append if other events were appended since, and the command is retried with fresh state.
How are rules spanning multiple aggregates handled?
Answer
Through eventual consistency: policies or sagas react to events from one aggregate and issue commands to others, with compensation if needed.
Open in the app to mark lessons complete, take notes and track your progress.