Definition
- Command record – An immutable DTO that carries the intent to change state, validated at the controller edge.
- Rich aggregate – A JPA entity that owns its state, exposes behavior methods, and registers domain events via
AbstractAggregateRoot. - Command handler – A
@Servicethat loads the aggregate, invokes its method, and saves it inside a@Transactionalboundary. - Projection listener – A method annotated with
@TransactionalEventListener(AFTER_COMMIT)that updates the denormalized read model only after the write commits. - Read model – A separate JPA entity with exactly the columns a query screen needs, served through its own controller.
At National Bank of India you do not need an expensive custom machine to run two counters. You already have everything in the normal office: a form for requests (command record), a strict manager who checks rules and updates the ledger (aggregate), a clerk who hears that a transfer happened and updates the display board (event listener and read model), and two windows, one for doing things and one for just looking.
Understanding the Concepts
How do you model commands?
Commands are immutable records that carry the intent to change state. They are plain DTOs with no behavior.
package com.bank.cqrs.command;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public record PlaceOrderCommand(
@NotBlank String orderId,
@NotBlank String accountId,
@Positive BigDecimal amount
) {}
public record ShipOrderCommand(
@NotBlank String orderId
) {}
Command example:
PlaceOrderCommand(orderId=ORD-001, accountId=SAV2001, amount=25000.00)
How do you build a rich aggregate that emits events?
The aggregate is a JPA entity that owns its state and exposes behavior. It extends AbstractAggregateRoot from Spring Data to register events that are published automatically on save.
package com.bank.cqrs.command;
import jakarta.persistence.*;
import org.springframework.data.domain.AbstractAggregateRoot;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "orders")
public class Order extends AbstractAggregateRoot<Order> {
@Id
private String id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private String accountId;
private BigDecimal amount;
private Instant createdAt;
protected Order() {} // JPA
public Order(String id, String accountId, BigDecimal amount) {
this.id = id;
this.accountId = accountId;
this.amount = amount;
this.status = OrderStatus.CREATED;
this.createdAt = Instant.now();
}
public void place() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Cannot place order in status: " + status);
}
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlacedEvent(this.id, this.accountId, this.amount));
}
public void ship() {
if (this.status != OrderStatus.PLACED) {
throw new IllegalStateException("Cannot ship order in status: " + status);
}
this.status = OrderStatus.SHIPPED;
registerEvent(new OrderShippedEvent(this.id));
}
public String getId() { return id; }
public OrderStatus getStatus() { return status; }
public String getAccountId() { return accountId; }
public BigDecimal getAmount() { return amount; }
}
public enum OrderStatus { CREATED, PLACED, SHIPPED, DELIVERED, CANCELLED }
Order state machine:
CREATED -> place() -> PLACED -> ship() -> SHIPPED -> deliver() -> DELIVERED
|
cancel() -> CANCELLED
How do domain events look?
Domain events are simple records describing what happened.
package com.bank.cqrs.command;
import java.math.BigDecimal;
public record OrderPlacedEvent(String orderId, String accountId, BigDecimal amount) {}
public record OrderShippedEvent(String orderId) {}
How does the command handler work?
The command handler is a @Service that loads the aggregate, invokes its method, and saves. The @Transactional boundary ensures the change and event publication happen together.
package com.bank.cqrs.command;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderCommandHandler {
private final OrderRepository orderRepo;
public OrderCommandHandler(OrderRepository orderRepo) {
this.orderRepo = orderRepo;
}
@Transactional
public String handle(PlaceOrderCommand cmd) {
Order order = new Order(cmd.orderId(), cmd.accountId(), cmd.amount());
order.place();
orderRepo.save(order);
System.out.println("Order placed: " + cmd.orderId());
return cmd.orderId();
}
@Transactional
public void handle(ShipOrderCommand cmd) {
Order order = orderRepo.findById(cmd.orderId()).orElseThrow();
order.ship();
orderRepo.save(order);
System.out.println("Order shipped: " + cmd.orderId());
}
}
Expected output:
Order placed: ORD-001
Order shipped: ORD-001
How does the projection listener update the read model?
The key choice is @TransactionalEventListener(phase = AFTER_COMMIT), which means the projection runs only after the write transaction commits successfully.
package com.bank.cqrs.query;
import com.bank.cqrs.command.OrderPlacedEvent;
import com.bank.cqrs.command.OrderShippedEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
@Component
public class OrderSummaryProjection {
private final OrderSummaryRepository readRepo;
public OrderSummaryProjection(OrderSummaryRepository readRepo) {
this.readRepo = readRepo;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
OrderSummaryView view = new OrderSummaryView();
view.setOrderId(event.orderId());
view.setAccountId(event.accountId());
view.setAmount(event.amount());
view.setStatus("PLACED");
readRepo.save(view);
System.out.println("Read model created for order: " + event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderShipped(OrderShippedEvent event) {
OrderSummaryView view = readRepo.findById(event.orderId()).orElseThrow();
view.setStatus("SHIPPED");
readRepo.save(view);
System.out.println("Read model updated to SHIPPED: " + event.orderId());
}
}
Expected output:
Read model created for order: ORD-001
Read model updated to SHIPPED: ORD-001
What does the read model entity look like?
A separate denormalized JPA entity with exactly the columns a query screen needs.
package com.bank.cqrs.query;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_summary_view")
public class OrderSummaryView {
@Id
private String orderId;
private String accountId;
private BigDecimal amount;
private String status;
// No business logic here, just data for queries
// Getters and setters
public String getOrderId() { return orderId; }
public void setOrderId(String orderId) { this.orderId = orderId; }
public String getAccountId() { return accountId; }
public void setAccountId(String accountId) { this.accountId = accountId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
How do you separate controllers?
Two controllers: one for commands (POST/PUT) and one for queries (GET). This makes responsibilities obvious and allows independent scaling.
package com.bank.cqrs.api;
import com.bank.cqrs.command.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/orders")
public class OrderCommandController {
private final OrderCommandHandler handler;
public OrderCommandController(OrderCommandHandler handler) {
this.handler = handler;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public String placeOrder(@RequestBody PlaceOrderCommand cmd) {
return handler.handle(cmd);
}
@PostMapping("/{id}/ship")
@ResponseStatus(HttpStatus.ACCEPTED)
public void shipOrder(@PathVariable String id) {
handler.handle(new ShipOrderCommand(id));
}
}
package com.bank.cqrs.api;
import com.bank.cqrs.query.*;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/orders")
public class OrderQueryController {
private final OrderSummaryRepository readRepo;
public OrderQueryController(OrderSummaryRepository readRepo) {
this.readRepo = readRepo;
}
@GetMapping("/{id}")
public OrderSummaryView getOrder(@PathVariable String id) {
return readRepo.findById(id).orElseThrow();
}
@GetMapping(params = "accountId")
public List<OrderSummaryView> getByAccount(@RequestParam String accountId) {
return readRepo.findByAccountId(accountId);
}
}
GET /api/v1/orders/ORD-001 returns:
{"orderId":"ORD-001","accountId":"SAV2001","amount":25000.00,"status":"SHIPPED"}
Why is this maintainable without a heavyweight framework?
Every piece is a standard Spring concept: records, JPA entities, @Service, @Transactional, @TransactionalEventListener, and JpaRepository. There is no event store to operate, no snapshotting, no replay machinery.
package com.bank.cqrs;
public class WhyNoFramework {
public static void main(String[] args) {
System.out.println("Building blocks used (all standard Spring):");
System.out.println(" - Java records for commands");
System.out.println(" - JPA entity extending AbstractAggregateRoot");
System.out.println(" - @Service + @Transactional command handlers");
System.out.println(" - @TransactionalEventListener(AFTER_COMMIT) projections");
System.out.println(" - JpaRepository for write and read repos");
System.out.println(" - Separate REST controllers");
System.out.println();
System.out.println("No Axon, no event store, no replay engine.");
System.out.println("Graduate to heavier machinery only when genuinely needed.");
}
}
Expected output:
Building blocks used (all standard Spring):
- Java records for commands
- JPA entity extending AbstractAggregateRoot
- @Service + @Transactional command handlers
- @TransactionalEventListener(AFTER_COMMIT) projections
- JpaRepository for write and read repos
- Separate REST controllers
No Axon, no event store, no replay engine.
Graduate to heavier machinery only when genuinely needed.
What are the caveats?
The AFTER_COMMIT listener runs outside the original transaction, so the projection must be idempotent. If it must survive a crash, upgrade to the transactional outbox pattern.
Caveats:
1. AFTER_COMMIT runs in a NEW transaction -> projection must be idempotent
2. If the app crashes after commit but before projection -> read model is stale
-> Fix: use outbox pattern for crash-safe projection updates
3. Projection rebuild: if schema changes, replay from write model or events
4. This is NOT event sourcing: the write model is a normal JPA entity, not an event log
Visual Overview
Plain Spring Boot CQRS Flow:
POST /api/v1/orders
│
▼
┌──────────────────┐
│ Command │
│ Controller │
└────────┬─────────┘
│
┌────────▼─────────┐
│ Command Handler │ @Transactional
│ 1. Load aggregate│
│ 2. order.place() │ enforces rules
│ 3. repo.save() │ triggers registerEvent()
└────────┬─────────┘
│ COMMIT
│
┌────────▼──────────────┐
│ @TransactionalEvent │ AFTER_COMMIT
│ Listener │
│ -> update read model │
└────────┬──────────────┘
│
┌────────▼─────────┐ GET /api/v1/orders/ORD-001
│ Read Model │◀──────────────────────┐
│ (order_summary_ │ │
│ view table) │ ┌─────────────────┤
└──────────────────┘ │ Query Controller│
└─────────────────┘
Key Takeaway
You do not need Axon or any event-sourcing framework to implement CQRS in Spring Boot, because Spring already provides every building block. Commands are modeled as immutable records, aggregates are rich JPA entities that enforce state transitions and register domain events via AbstractAggregateRoot, and command handlers are transactional services that load, invoke, and save.
The projection listener uses @TransactionalEventListener(phase = AFTER_COMMIT), which is the critical detail: the read model is updated only after the write transaction has actually committed, so a rolled-back change never leaks into the read side. The read model is a separate denormalized entity with exactly the columns a screen needs, and two controllers keep command and query responsibilities physically separate.
At National Bank of India, the order-placement code had become risky because it kept a reporting table in sync, and occasionally the reporting table got updated for orders whose transaction later rolled back. Pankaj moved to this plain-Spring CQRS style: placing an order now only touches the Order aggregate, and the OrderSummaryView is maintained by an AFTER_COMMIT projection. The honest caveat is that AFTER_COMMIT runs outside the original transaction, so the projection must be idempotent, and for crash safety the team upgrades to a transactional outbox.
Remember one sentence: CQRS in plain Spring Boot is commands as records, a rich aggregate that registers domain events, a TransactionalEventListener projection that updates a denormalized read entity after commit, and distinct command and query controllers, delivering real separation with tools the whole team already knows.
---