CQRS Approaches, Rolled Out Across the Bank
Compare five real structural flavors of CQRS, understand exactly what problem each one solves over the one before it, and see the same event-sourced aggregate pattern applied consistently across a bank's account, card, and loan services.
Learning objectives
- List the five structural flavors of CQRS from simplest to most powerful
- Explain the out-of-order delivery risk in CQRS without event sourcing
- Justify why full CQRS with event sourcing on separate databases is worth its added complexity
- Apply the aggregate, event-sourcing-handler, and projection pattern to a new service
◆ Imagine this
You could run command handling and query handling out of one single office, just with a "commands" door and a separate "questions" door — same building, same staff, same address. Or you could put them in two entirely separate buildings, run by different teams, deployed and scaled completely independently, connected only by a courier delivering notices between them. Both are genuinely valid ways to organize the same underlying split between writing and reading.
CQRS — Command Query Responsibility Segregation — is the general idea that the part of a system handling writes and the part handling reads don't have to be the same model, or even the same database. But "implement CQRS" isn't one fixed recipe. It's a spectrum of real structural choices, and where a team lands on that spectrum depends entirely on how much of CQRS's actual power they need, weighed against how much operational complexity they're willing to take on to get it.
Reading this table top to bottom, each flavor exists to fix a real limitation of the one before it.
| Flavor | Shape | Trade-off |
|---|---|---|
| 1. Single model, single database | One shared model handles both commands and queries, on one database. | The only real benefit is separating the API surface into a write side and a read side. Simple and testable, but almost none of CQRS's actual advantages. Fine for small apps or learning, not recommended for anything serious. |
| 2. Separate models, single database | Command side and query side each get their own model classes, but both still point at the same database. | The write side can use a heavier, validation-focused model while the read side stays lightweight. Because it's still one database, every change commits in a single atomic transaction — full, immediate consistency, no eventual consistency at all. |
| 3. Separate models, separate databases, no event sourcing | A genuine write database and a genuine read database, kept in sync by an event bus — but without event sourcing underneath. | Real independent scaling and technology freedom, but a subtle danger: without event sourcing's strict, sequenced event log, events can arrive at the read side out of order, silently corrupting the read model. |
| 4. CQRS + event sourcing, separate databases | A real event store as the write side, a separate read database kept in sync through strictly ordered events. | Fixes flavor 3's out-of-order problem directly, since sequence numbers guarantee strict ordering. High performance and scalability, genuine eventual consistency, and real added complexity — which is exactly why a framework like Axon is worth using rather than hand-building it. |
| 5. CQRS + event sourcing, single database | Both the event store and the read model live in the same, usually NoSQL, database, as separate tables or schemas. | A middle ground: strong, immediate consistency, since it's one database, with event sourcing's benefits, like replay and snapshots, still available — at the cost of losing flavor 4's independent technology and scaling freedom. |
There's no single "correct" flavor. A small internal admin tool has no business paying for flavor 4's complexity. A high-volume, multi-service banking platform, on the other hand, has real, specific needs — independent scaling, reliable replay, an auditable history — that only flavor 4 fully satisfies.
◆ Under the hood
Imagine an account's write side quickly publishes two events: "$100 deposited" (event 1), then "$50 withdrawn" (event 2). Without event sourcing's strict per-aggregate sequencing, a distributed event bus can, in principle, deliver event 2 to the read side microseconds before event 1. For a brief window, the read model would show a withdrawal happening before its matching deposit ever "arrived" — and any logic on the read side that depends on order, such as a running balance computed incrementally, could compute a wrong intermediate value.
This is exactly the failure mode flavor 4 exists to close. An event-sourced write side assigns every event for a given aggregate a strict, monotonically increasing sequence number the moment it's stored, tied to that aggregate's identifier. A projection's event handler methods then always see events for that aggregate delivered in the exact order they truly happened, because the underlying processor honors that sequence rather than merely "whatever order the network happened to deliver messages in."
▲ Edge case — this guarantee is per-aggregate, not global
Strict ordering applies within one aggregate's own stream — account A's events are always processed in order relative to each other. It says nothing about the relative order between account A's events and account B's events, which can interleave freely. A read model that needs a global, cross-aggregate ordering guarantee — rare, but it happens — needs an additional mechanism on top of this, since per-aggregate sequencing alone doesn't provide it.
The three-part shape used for a customer aggregate — a command-handling aggregate that enforces rules and applies events, event-sourcing handler methods that rebuild state from those events, and a projection maintaining a fast read model — repeats, essentially unchanged in structure, for every other service in the bank. AccountAggregate follows the exact same pattern as a customer aggregate:
By the time you've built this shape a second or third time, the repetition should feel reassuring rather than tedious — it's proof the same reliable pattern generalizes cleanly across genuinely different business domains, not a coincidence specific to one aggregate.
◆ Under the hood — extracting shared conventions
Once a team has built this pattern two or three times across services, it's genuinely worth extracting shared conventions — a common base class for read-model repositories, a shared error-response shape, a shared way of registering interceptors — into a small internal library reused across every microservice, rather than copy-pasting the same boilerplate for every new aggregate. The goal isn't to remove the repetition of the pattern itself, which is valuable and predictable, but to remove the repetition of its incidental plumbing.
💻 Code example
@Aggregate public class AccountAggregate { @AggregateIdentifier private String accountId; private String customerId; private BigDecimal balance; public AccountAggregate() {} @CommandHandler public AccountAggregate(OpenAccountCommand command) { AggregateLifecycle.apply(new AccountOpenedEvent( command.getAccountId(), command.getCustomerId(), command.getInitialBalance())); } @EventSourcingHandler public void on(AccountOpenedEvent event) { this.accountId = event.getAccountId(); this.customerId = event.getCustomerId(); this.balance = event.getInitialBalance(); } @CommandHandler public void handle(DepositFundsCommand command) { AggregateLifecycle.apply(new FundsDepositedEvent(accountId, command.getAmount())); } @EventSourcingHandler public void on(FundsDepositedEvent event) { this.balance = this.balance.add(event.getAmount()); } }
▲ Common mistake — defaulting to flavor 4 without checking the need
Full event sourcing on separate databases is the most capable flavor, not automatically the correct one for every project. It's the right choice when a system genuinely needs reliable replay, an auditable history, or independently scaled write and read sides. A small internal tool or an early-stage product without those specific needs can reasonably start at flavor 1 or 2 and migrate later — paying for flavor 4's operational complexity before it's needed is a real, avoidable cost, not a sign of thoroughness.
▲ Edge case — flavor 3 looks like flavor 4 until it isn't
Flavor 3 and flavor 4 can look identical on an architecture diagram — separate write database, separate read database, an event bus in between. The difference only shows up under load, or during a network hiccup, when flavor 3's lack of strict ordering can silently corrupt a read model with no error thrown anywhere. Teams sometimes discover they built flavor 3 by accident, believing they had flavor 4's guarantees, only after an ordering bug surfaces in production.
▲ Common mistake — assuming the pattern needs reinventing per service
Because each service's aggregate looks structurally identical — command handler, event-sourcing handler, projection — it's a mistake to redesign this shape from scratch for every new service "to fit its specific needs." The shape itself rarely needs to change; only the fields and business rules inside it do.
A retail bank's account, card, and loan services are a natural fit for flavor 4: transaction volume is high, an auditable history of every balance change is often a regulatory requirement, and different services genuinely need to scale independently — card issuance and loan approval have very different, largely uncorrelated traffic patterns.
Smaller internal tools inside the same organization, like an employee directory or an internal ticketing dashboard, are just as reasonably built at flavor 1 or 2 — a single model, or separate models on one database — since they don't need independent scaling or a full audit trail, and the operational overhead of running a separate event store wouldn't be justified.
Flavor 5, event sourcing with everything in one database, shows up in mid-sized systems that want event sourcing's replay and audit benefits without yet operating two separate database technologies — a reasonable stepping stone for a team not ready to commit to flavor 4's full operational surface.
- Q: What are the five structural flavors of CQRS, from simplest to most powerful? A: Single model/single database; separate models/single database; separate models/separate databases without event sourcing; CQRS with event sourcing on separate databases; and CQRS with event sourcing on one shared database.
- Q: What real problem can occur in flavor 3 that flavor 4 fixes? A: Events can arrive at the read side out of order, since there's no strict per-aggregate sequencing — event sourcing's ordered log guarantees events are always processed in the order they truly happened.
- Q: Does event sourcing's ordering guarantee apply across different aggregates too? A: No — it's a per-aggregate guarantee. Events for one aggregate are strictly ordered relative to each other, but there's no ordering guarantee between events from different aggregates.
- Q: Should every project default to flavor 4? A: No — flavor 4 is the most capable option, not automatically the correct one. It earns its complexity when a system genuinely needs reliable replay, an audit trail, or independent scaling of writes and reads.
- Q: Why does applying the same aggregate/event-sourcing-handler/projection pattern to Accounts, Cards, and Loans feel repetitive, and is that a problem? A: It repeats because the same reliable structural pattern generalizes cleanly across business domains — it's a sign the pattern is sound, and the incidental boilerplate (not the pattern itself) is what's worth extracting into shared conventions.
Want a visual for this concept?
Generate a diagram tailored to “CQRS Approaches, Rolled Out Across the Bank” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →