Back to Event-Driven Architecture & CQRS
Q1Question 1 of 8

Explain CQRS and when to use it

Key Concepts
  • CQRS separates the write path that enforces business rules from the read path that serves fast, purpose-built views.
  • The command side receives commands, loads aggregates, enforces invariants, and emits domain events.
  • The query side maintains denormalized projections shaped exactly for the screens that need them.
  • Each side can use different storage, different models, and scale independently.
  • In banking, reads vastly outnumber writes, so you can run many read replicas while keeping a smaller write side.
  • CQRS does not require two databases or Event Sourcing; the lightest form uses two models over one database.
  • Use CQRS when read and write workloads differ sharply in shape or volume; skip it for simple, low-traffic CRUD.

Definition

CQRS separates the write path that enforces rules from the read path that serves fast, purpose-built views, so each side is modeled and scaled for its own job.
  • 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.

JAVA
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");
    }
}
TEXT
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.

JAVA
package com.bank.cqrs.command;

import java.math.BigDecimal;

public record TransferCommand(
        String fromAccountId,
        String toAccountId,
        BigDecimal amount
) {}
JAVA
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());
    }
}
TEXT
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.

JAVA
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
}
JAVA
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");
    }
}
TEXT
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 |

JAVA
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();
    }
}
TEXT
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.

JAVA
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");
    }
}
TEXT
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.

JAVA
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");
    }
}
TEXT
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.

JAVA
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");
    }
}
TEXT
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.

TEXT
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate