Definition
- CQRS – Command Query Responsibility Segregation: an architectural pattern that splits an application into a write model (command side) and a read model (query side).
- Command side – Receives commands, loads aggregates, enforces business invariants, persists changes, and emits domain events.
- Query side – Serves queries from denormalized projections shaped exactly for the screens that need them, with no business logic.
- Projection – A denormalized table or document kept in sync by consuming domain events from the write side.
- Independent scaling – Reads and writes scale separately; in banking, reads outnumber writes so you can run many query instances.
At National Bank of India, imagine two separate counters. One counter is where Rajesh goes to move money: the clerk there is strict, checks every rule, and updates the master ledger carefully. The other counter is where Rajesh goes just to see his statement: that clerk has a pre-printed summary ready and never touches the master ledger. Writing money and reading statements are two different jobs handled by two different counters so neither slows the other down.
Understanding the Concepts
What is CQRS and why does it exist?
In a traditional CRUD design the same model serves both writes and reads. That single model is pulled in two directions: writes want normalized tables and strict invariants, while reads want wide denormalized views. CQRS resolves that tension by giving each concern its own model.
package com.bank.cqrs;
public class CqrsMotivation {
public static void main(String[] args) {
System.out.println("Traditional CRUD: ONE model for both reads and writes");
System.out.println(" Writes want: normalized tables, strict invariants");
System.out.println(" Reads want: denormalized views, no joins");
System.out.println(" Conflict: one model cannot optimize for both");
System.out.println();
System.out.println("CQRS: TWO models, each optimized for its job");
System.out.println(" Command side: small, normalized, focuses on rules");
System.out.println(" Query side: wide, denormalized, focuses on speed");
}
}
Expected output:
Traditional CRUD: ONE model for both reads and writes
Writes want: normalized tables, strict invariants
Reads want: denormalized views, no joins
Conflict: one model cannot optimize for both
CQRS: TWO models, each optimized for its job
Command side: small, normalized, focuses on rules
Query side: wide, denormalized, focuses on speed
How does the command side work?
A command is an intent to change state. A command handler loads the aggregate, invokes a method that enforces invariants, persists the change, and emits a domain event.
package com.bank.cqrs.command;
import java.math.BigDecimal;
public record TransferCommand(
String fromAccountId,
String toAccountId,
BigDecimal amount
) {}
package com.bank.cqrs.command;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TransferCommandHandler {
private final AccountRepository accountRepo;
private final org.springframework.context.ApplicationEventPublisher eventPublisher;
public TransferCommandHandler(AccountRepository accountRepo,
org.springframework.context.ApplicationEventPublisher eventPublisher) {
this.accountRepo = accountRepo;
this.eventPublisher = eventPublisher;
}
@Transactional
public void handle(TransferCommand cmd) {
Account from = accountRepo.findById(cmd.fromAccountId()).orElseThrow();
Account to = accountRepo.findById(cmd.toAccountId()).orElseThrow();
from.debit(cmd.amount()); // Enforces invariant: balance >= 0
to.credit(cmd.amount());
accountRepo.save(from);
accountRepo.save(to);
eventPublisher.publishEvent(
new TransferCompletedEvent(cmd.fromAccountId(), cmd.toAccountId(), cmd.amount()));
System.out.println("Transfer command handled: " + cmd.amount() + " from "
+ cmd.fromAccountId() + " to " + cmd.toAccountId());
}
}
Expected output:
Transfer command handled: 50000.00 from SAV2001 to SAV2002
How does the query side work?
The query side maintains projections: denormalized tables shaped for specific query needs. A projection listener consumes domain events and updates the read model.
package com.bank.cqrs.query;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "account_summary_view")
public class AccountSummaryView {
@Id
private String accountId;
private String holderName;
private BigDecimal balance;
private String lastTransactionDescription;
private java.time.Instant lastTransactionTime;
// Everything a dashboard needs in ONE row, no joins
// Getters, setters omitted for brevity
}
package com.bank.cqrs.query;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
@Component
public class AccountSummaryProjection {
private final AccountSummaryRepository readRepo;
public AccountSummaryProjection(AccountSummaryRepository readRepo) {
this.readRepo = readRepo;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTransferCompleted(TransferCompletedEvent event) {
// Update the denormalized read model AFTER the write commits
AccountSummaryView from = readRepo.findById(event.fromAccountId()).orElseThrow();
from.setBalance(from.getBalance().subtract(event.amount()));
from.setLastTransactionDescription("Transfer to " + event.toAccountId());
readRepo.save(from);
AccountSummaryView to = readRepo.findById(event.toAccountId()).orElseThrow();
to.setBalance(to.getBalance().add(event.amount()));
to.setLastTransactionDescription("Transfer from " + event.fromAccountId());
readRepo.save(to);
System.out.println("Projection updated for transfer");
}
}
Expected output:
Projection updated for transfer
Why does this separation help?
Three concrete reasons: simpler write logic, optimized reads, and independent scaling.
| Benefit | Without CQRS | With CQRS |
|---|---|---|
| Write logic | Cluttered with reporting concerns | Focused purely on invariants |
| Read performance | Multi-table joins per query | One indexed read, no joins |
| Scaling | One tier handles everything | Read and write tiers scale independently |
| Adding a new query | May require schema changes | Build a new projection, no domain changes |
package com.bank.cqrs.query;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AccountQueryController {
private final AccountSummaryRepository readRepo;
public AccountQueryController(AccountSummaryRepository readRepo) {
this.readRepo = readRepo;
}
@GetMapping("/api/v1/accounts/{id}/summary")
public AccountSummaryView getAccountSummary(@PathVariable String id) {
// ONE read, NO joins, NO aggregate loading
return readRepo.findById(id).orElseThrow();
}
}
Query response:
{
"accountId": "SAV2001",
"holderName": "Rajesh Kumar",
"balance": 950000.00,
"lastTransactionDescription": "Transfer to SAV2002",
"lastTransactionTime": "2024-01-15T10:30:00Z"
}
When should you use CQRS?
Use CQRS when read and write workloads differ sharply in shape or volume, when reads are complex, or when you need to scale them independently. Skip it for simple, low-traffic CRUD.
package com.bank.cqrs;
public class WhenToUseCqrs {
public static void main(String[] args) {
System.out.println("USE CQRS when:");
System.out.println(" - Read/write workloads differ in shape or volume");
System.out.println(" - Complex read requirements painful from write model");
System.out.println(" - Need independent scaling of reads and writes");
System.out.println(" - Write side has rich domain logic to keep uncluttered");
System.out.println();
System.out.println("SKIP CQRS when:");
System.out.println(" - Simple CRUD with same shape for reads and writes");
System.out.println(" - Low traffic, scaling argument disappears");
System.out.println(" - Every request needs strong read-after-write consistency");
}
}
Expected output:
USE CQRS when:
- Read/write workloads differ in shape or volume
- Complex read requirements painful from write model
- Need independent scaling of reads and writes
- Write side has rich domain logic to keep uncluttered
SKIP CQRS when:
- Simple CRUD with same shape for reads and writes
- Low traffic, scaling argument disappears
- Every request needs strong read-after-write consistency
What is the lightest form of CQRS?
CQRS does not require two databases or Event Sourcing. The lightest form shares one database with two models and synchronous projection, removing the eventual-consistency cost.
package com.bank.cqrs;
public class CqrsSpectrum {
public static void main(String[] args) {
System.out.println("CQRS spectrum (lightest to heaviest):");
System.out.println(" 1. Same DB, two JPA models, sync projection");
System.out.println(" -> No eventual consistency, simplest");
System.out.println(" 2. Same DB, async projection via @TransactionalEventListener");
System.out.println(" -> Slight lag, still one DB");
System.out.println(" 3. Separate DBs, async projection via events");
System.out.println(" -> Full independent scaling, eventual consistency");
System.out.println(" 4. Event Sourced write + separate read store");
System.out.println(" -> Maximum power, maximum complexity");
}
}
Expected output:
CQRS spectrum (lightest to heaviest):
1. Same DB, two JPA models, sync projection
-> No eventual consistency, simplest
2. Same DB, async projection via @TransactionalEventListener
-> Slight lag, still one DB
3. Separate DBs, async projection via events
-> Full independent scaling, eventual consistency
4. Event Sourced write + separate read store
-> Maximum power, maximum complexity
What about eventual consistency between write and read models?
The read model is updated after the write commits, so there is a window where a client who just wrote and immediately reads may see stale data. Design the UI to tolerate this.
package com.bank.cqrs;
public class EventualConsistency {
public static void main(String[] args) {
System.out.println("Handling eventual consistency:");
System.out.println(" 1. Return the command result directly (balance after transfer)");
System.out.println(" 2. Show 'pending' state in the UI until projection catches up");
System.out.println(" 3. Use read-your-own-writes: route to write store briefly");
System.out.println(" 4. Accept it: most banking screens tolerate 100ms lag");
}
}
Expected output:
Handling eventual consistency:
1. Return the command result directly (balance after transfer)
2. Show 'pending' state in the UI until projection catches up
3. Use read-your-own-writes: route to write store briefly
4. Accept it: most banking screens tolerate 100ms lag
What should a senior engineer know?
CQRS is not all-or-nothing. Apply it to only the few aggregates that need it while the rest stays plain CRUD. The mistake juniors make is adopting full event-sourced CQRS everywhere.
Senior guidelines:
- Apply CQRS only to aggregates with high read/write skew
- Keep simple reference data as plain CRUD
- Start with the lightest form and add complexity only when needed
- @TransactionalEventListener(AFTER_COMMIT) is the key detail
- Projection must be idempotent (redelivery-safe)
- Monitor projection lag as a first-class metric
Visual Overview
CQRS Architecture:
┌───────────────┐
│ Client │
└───┬───────┬───┘
POST/PUT │ │ GET
┌───▼───┐ ┌─▼─────────┐
│Command│ │ Query │
│Ctrl │ │ Controller│
└───┬───┘ └─────┬─────┘
│ │
┌───▼───────┐ │
│ Command │ │
│ Handler │ │
│ (loads │ │
│ aggregate│ │
│ enforces │ │
│ rules) │ │
└───┬───────┘ │
│ │
┌───▼───────┐ │
│ Write DB │ │
│ (normalized│ │
│ accounts) │ │
└───┬───────┘ │
│ event │
┌───▼───────┐ │
│ Projection│ │
│ Listener │ │
└───┬───────┘ │
│ │
┌───▼──────────▼──┐
│ Read DB │
│ (denormalized │
│ account_view) │
└─────────────────┘
Key Takeaway
CQRS separates the application into two models: a write model that enforces business rules and a read model that serves fast, purpose-built views. The command side receives commands, loads aggregates, enforces invariants like non-negative balances, persists changes, and emits domain events. The query side maintains denormalized projections kept in sync by consuming those events, so a dashboard query is one indexed read instead of a multi-table join.
The separation helps for three concrete reasons. The write model stays simple and correct because it only protects invariants. The read model is fast because it is denormalized and pre-joined. And each side scales independently, which matters at National Bank of India where statement reads outnumber transfers by fifty to one. Pankaj introduced CQRS for the account statement feature: the statement screen read a pre-built view keyed by account, eliminating the three-table join that competed with live transfers.
CQRS is not all-or-nothing. The lightest form shares one database with two models and synchronous projection, removing eventual consistency. Apply it only to aggregates with high read-write skew, and keep simple reference data as plain CRUD. The key implementation detail is @TransactionalEventListener(phase = AFTER_COMMIT), which ensures the projection is only updated after the write transaction commits.
Remember one sentence: CQRS separates a rule-enforcing write model from a performance-tuned read model so each is designed and scaled for its own job, and you adopt it when reads and writes diverge enough that one shared model becomes a bottleneck.
---