Definition
- Event Storming – a collaborative workshop mapping domain events (orange), commands (blue), and aggregates (yellow) on a wall with domain experts.
- Rule 1 – keep aggregates small; a god aggregate causes optimistic-locking contention that serializes writes.
- Rule 2 – the aggregate is the consistency and transaction boundary; one transaction modifies one aggregate.
- Rule 3 – reference other aggregates by identity only, never by direct object reference, keeping boundaries clean.
- Throughput – aggregate size is a direct throughput lever: smaller means less contention and more parallelism.
Event Storming is like gathering the bank's experts around a huge wall and having them stick orange notes for every important thing that happens ('Money Transferred', 'Loan Approved'), blue notes for the action that caused it ('Transfer Money'), and yellow notes for the thing responsible for deciding (the Account). The high-traffic rule is: do not make one giant master ledger that every clerk must line up to sign. Give each account its own small passbook so thousands of transfers can happen at once.
Understanding the Concepts
How does Event Storming work?
Event Storming works backwards from events. You gather domain experts and developers and cover a wall with sticky notes.
Event Storming wall at National Bank of India: COMMAND (blue) AGGREGATE (yellow) EVENT (orange) ───────────── ──────────────── ────────────────── TransferMoney -> Account -> MoneyTransferred PayEmi -> LoanAccount -> EmiPaid BlockCard -> CreditCard -> CardBlocked CheckFraud -> FraudScore -> FraudDetected Clusters of related events reveal bounded contexts: Account + MoneyTransferred + BalanceUpdated -> Payment Context LoanAccount + EmiPaid + LoanSanctioned -> Lending Context
How do you identify aggregates from Event Storming?
The thing that owns an invariant reveals an aggregate. An aggregate is a cluster of objects treated as one unit for data changes, accessed only through its root.
package com.bank.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Account {
@Id
private String accountId;
private BigDecimal balance;
@Version
private Long version; // Optimistic locking
private final transient List<DomainEvent> uncommittedEvents = new ArrayList<>();
public void debit(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException(
"Insufficient funds in " + accountId);
}
balance = balance.subtract(amount);
uncommittedEvents.add(new AccountDebited(accountId, amount));
}
public void credit(BigDecimal amount) {
balance = balance.add(amount);
uncommittedEvents.add(new AccountCredited(accountId, amount));
}
// All changes go through the root. Root enforces invariants.
// Never go negative: this is the aggregate's invariant.
}
Aggregate root = Account
Invariant = balance >= 0
All changes go through account.debit() and account.credit().
Why must aggregates be small? (Rule 1)
A god aggregate becomes a single contended row that serializes writes under optimistic locking.
package com.bank.antipattern;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.util.Map;
// WRONG: God aggregate. Every account is a field in one object.
@Entity
public class GodLedger {
@Id
private String ledgerId; // only one row in the whole table
@Version
private Long version; // every write contends on this version
private Map<String, BigDecimal> accounts;
// Two transfers on two different accounts both load version 7.
// Both try to save version 8.
// One wins, one gets OptimisticLockException and retries.
// At high TPS, almost every write collides.
}
// CORRECT: Small per-account aggregate.
@Entity
public class SmallAccount {
@Id
private String accountId; // each account is its own row
@Version
private Long version; // contention only on same-account writes
private BigDecimal balance;
// Two transfers on different accounts NEVER contend.
// Throughput scales with number of accounts.
}
God aggregate: 1 row, every write contends -> throughput collapses
Small aggregate: N rows, contention only on same account -> scales linearly
Why is the aggregate the transaction boundary? (Rule 2)
One transaction modifies exactly one aggregate and keeps its invariant true. Cross-aggregate operations use Sagas.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountService {
private final AccountRepository repository;
public AccountService(AccountRepository repository) {
this.repository = repository;
}
@Transactional
public void debit(String accountId, BigDecimal amount) {
Account account = repository.findById(accountId).orElseThrow();
account.debit(amount); // enforces invariant: balance >= 0
repository.save(account);
// One transaction, one aggregate, one invariant check.
}
// Debiting Rajesh AND crediting Priya = two aggregates = Saga, not one tx.
}
Rule: one transaction modifies exactly one aggregate.
Strongly consistent INSIDE one aggregate.
Eventually consistent ACROSS aggregates (Saga + events).
Why reference by ID only? (Rule 3)
Inside the Transfer aggregate, store fromAccountId and toAccountId as strings, never a direct Account object reference.
package com.bank.payment.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class Transfer {
@Id
private String transferId;
private String fromAccountId; // ID only, NOT @ManyToOne Account
private String toAccountId; // ID only, NOT @ManyToOne Account
private BigDecimal amount;
private String status;
// By referencing by ID only:
// 1. Object graph stays small (no lazy loading surprises)
// 2. No accidental locking of the counterparty aggregate
// 3. Account aggregate can be extracted to its own service later
// with NO code change to this reference.
}
WRONG: @ManyToOne Account fromAccount;
-> Loads and potentially locks Account aggregate.
-> Cannot extract Account to another service without code change.
CORRECT: String fromAccountId;
-> Small, clean reference.
-> Extractable to separate service with zero code change.
How does optimistic locking work?
Optimistic locking with @Version detects concurrent modification.
Optimistic locking scenario:
Thread A: reads Account SAV2001, version=7
Thread B: reads Account SAV2001, version=7
Thread A: saves with version=8 -> SUCCESS (version 7 -> 8)
Thread B: saves with version=8 -> FAIL (expected 7, found 8)
Thread B: catches OptimisticLockException -> retries
With small aggregate: only same-account writes contend.
With god aggregate: ALL writes contend on the same version.
How do the three rules relate to service extraction?
Small aggregates with ID-only references are clean enough to extract into separate services with minimal code changes.
Before extraction:
One monolith with Account and Transfer aggregates.
Transfer.fromAccountId = "SAV2001" (string reference)
After extraction:
Account Service owns Account aggregate.
Payment Service owns Transfer aggregate.
Transfer.fromAccountId = "SAV2001" (unchanged!)
Payment Service calls Account Service by ID.
What should a senior engineer know?
Aggregate size is a direct throughput lever. Smaller aggregates mean less contention, more parallelism, and cleaner service extraction. At high TPS, design the smallest aggregate that still protects its invariant.
Visual Overview
Event Storming Wall:
COMMAND (blue) AGGREGATE (yellow) EVENT (orange)
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│TransferMoney│───►│ Account │────►│MoneyTransferred │
└─────────────┘ └──────────────┘ └──────────────────┘
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ PayEmi │───►│ LoanAccount │────►│ EmiPaid │
└─────────────┘ └──────────────┘ └──────────────────┘
Three Rules for High TPS:
Rule 1: SMALL aggregates (no god aggregate)
God: 1 row, all writes contend -> collapse
Small: N rows, parallel writes -> scales
Rule 2: Aggregate = transaction boundary
One tx modifies ONE aggregate
Cross-aggregate -> Saga
Rule 3: Reference by ID only
Transfer.fromAccountId = "SAV2001" (string)
NOT @ManyToOne Account fromAccount
Key Takeaway
Event Storming is a collaborative workshop where domain experts and developers map the business as a story on a wall using sticky notes: orange for events, blue for commands, yellow for aggregates. Clusters of related events reveal bounded contexts, and the thing that owns an invariant reveals an aggregate.
For high TPS at National Bank of India, three rules govern aggregate design. First, keep aggregates small: a god aggregate holding every account becomes a single contended row where every write collides on the same optimistic-locking version, collapsing throughput. A small per-account aggregate means two transfers on different accounts never contend. Second, the aggregate is the transaction boundary: one transaction modifies one aggregate, and cross-aggregate operations use Sagas. Third, reference other aggregates by ID only, never by direct object reference, keeping the object graph small and the boundary clean enough to extract into a separate service.
The thread tying it together is that aggregate size is a direct throughput lever. Smaller aggregates mean less lock contention, more parallelism, and cleaner service extraction.
Remember one sentence: keep aggregates small, treat each as the transaction boundary, reference others by ID only, and aggregate size directly determines throughput at high TPS.
---