Back to Event-Driven Architecture & CQRS
Q2Question 2 of 8

How do you implement CQRS in Spring Boot without a framework like Axon

Key Concepts
  • Commands are immutable records carrying intent; aggregates are rich JPA entities that enforce state transitions.
  • The aggregate uses Spring Data's AbstractAggregateRoot to register events published automatically on save.
  • Command handlers are transactional services that load the aggregate, invoke its methods, and save it.
  • The projection listener uses @TransactionalEventListener(AFTER_COMMIT) so the read model is never updated for a rolled-back write.
  • The read model is a separate denormalized JPA entity with exactly the columns a query screen needs.
  • Two separate controllers: one for commands (POST/PUT returning 201/202) and one for queries (GET from the read repo).
  • Every piece is a standard Spring concept the whole team knows: records, JPA entities, @Service, @Transactional, JpaRepository.

Definition

CQRS in plain Spring Boot is commands as records, a rule-enforcing JPA aggregate that publishes events, a transactional event listener that updates a read table, and separate command and query controllers.
  • 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 @Service that loads the aggregate, invokes its method, and saves it inside a @Transactional boundary.
  • 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.

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

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

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

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

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

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

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

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

TEXT
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

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

---

Share
Use ← → arrow keys to navigate