Back to Blog
spring-bootcqrsarchitecturejavaaxonddd

CQRS with Spring Boot: A Practical Implementation Guide (2026)

CQRS separates reads from writes to scale them independently. Learn how to implement Command Query Responsibility Segregation in Spring Boot with Axon Framework and without it.

J

JOptimize Team

May 23, 2026· 10 min read

CQRS (Command Query Responsibility Segregation) is one of those patterns that sounds complex but solves a very concrete problem: your read requirements and write requirements are fundamentally different, and trying to serve both with the same model creates friction that grows with your codebase.


What Problem Does CQRS Solve?

In a traditional Spring Boot application, you use the same @Entity and @Repository for both reads and writes:

// One model for everything - reads AND writes @Service public class OrderService { public Order createOrder(CreateOrderRequest req) { ... } // Write public Order getOrder(Long id) { ... } // Read public List<Order> getOrdersByCustomer(Long customerId) { ... } // Read public OrderSummaryDto getDashboardSummary() { ... } // Complex read }

This works fine at small scale. The problems emerge when:

  • Complex reads require joins across many tables, but writes need to be fast and normalized
  • Read load is 10x write load - you need to scale them differently
  • Your read model needs denormalized data that doesn't fit your write model
  • You need audit trails or event history

CQRS Level 1: Simple Separation (No Framework)

You don't need Axon or event sourcing to get value from CQRS. Start with a clean separation of command and query services:

// Command side - handles state mutations @Service @Transactional public class OrderCommandService { public Long createOrder(CreateOrderCommand cmd) { Order order = Order.create(cmd.customerId(), cmd.items()); return orderRepository.save(order).getId(); } public void cancelOrder(CancelOrderCommand cmd) { Order order = orderRepository.findById(cmd.orderId()).orElseThrow(); order.cancel(cmd.reason()); } } // Query side - handles reads, can use different data sources @Service @Transactional(readOnly = true) public class OrderQueryService { public OrderDetailDto getOrder(Long id) { return orderRepository.findOrderDetail(id) // Optimized read query .orElseThrow(() -> new OrderNotFoundException(id)); } public Page<OrderSummaryDto> getOrdersByCustomer(Long customerId, Pageable pageable) { return orderRepository.findSummariesByCustomer(customerId, pageable); } }

Separate DTOs for reads vs. commands:

// Commands - represent intent to change state public record CreateOrderCommand(Long customerId, List<OrderItemRequest> items) {} public record CancelOrderCommand(Long orderId, String reason) {} // Query DTOs - shaped for the UI, not the domain model public record OrderDetailDto(Long id, String status, CustomerDto customer, List<OrderItemDto> items, BigDecimal total) {} public record OrderSummaryDto(Long id, String status, BigDecimal total, LocalDate date) {}

CQRS Level 2: Separate Read Model with Optimized Queries

Once reads are separated, you can optimize the read side independently:

// Use projections for efficient reads - no entity loading public interface OrderSummaryProjection { Long getId(); String getStatus(); BigDecimal getTotal(); LocalDate getCreatedAt(); } @Repository public interface OrderRepository extends JpaRepository<Order, Long> { // Write-side: full entity Optional<Order> findById(Long id); // Read-side: lightweight projections @Query("SELECT o.id as id, o.status as status, o.total as total, " + "o.createdAt as createdAt FROM Order o WHERE o.customerId = :cid") Page<OrderSummaryProjection> findSummariesByCustomerId( @Param("cid") Long customerId, Pageable pageable); }

For high-read scenarios, add a dedicated read database (read replica or Redis):

@Service public class OrderQueryService { private final OrderReadRepository readRepository; // Points to read replica private final RedisTemplate<String, OrderDetailDto> cache; public OrderDetailDto getOrder(Long id) { String cacheKey = "order:" + id; OrderDetailDto cached = cache.opsForValue().get(cacheKey); if (cached != null) return cached; OrderDetailDto order = readRepository.findDetailById(id).orElseThrow(); cache.opsForValue().set(cacheKey, order, Duration.ofMinutes(5)); return order; } }

CQRS Level 3: Event-Driven with Axon Framework

For full CQRS with event sourcing, Axon Framework provides the infrastructure:

<dependency> <groupId>org.axonframework</groupId> <artifactId>axon-spring-boot-starter</artifactId> <version>4.9.0</version> </dependency>
// Aggregate - handles commands, emits events @Aggregate public class OrderAggregate { @AggregateIdentifier private Long orderId; private OrderStatus status; @CommandHandler public OrderAggregate(CreateOrderCommand cmd) { AggregateLifecycle.apply(new OrderCreatedEvent( cmd.orderId(), cmd.customerId(), cmd.items() )); } @EventSourcingHandler public void on(OrderCreatedEvent event) { this.orderId = event.orderId(); this.status = OrderStatus.PENDING; } @CommandHandler public void handle(CancelOrderCommand cmd) { if (status == OrderStatus.SHIPPED) throw new IllegalStateException("Cannot cancel shipped order"); AggregateLifecycle.apply(new OrderCancelledEvent(orderId, cmd.reason())); } @EventSourcingHandler public void on(OrderCancelledEvent event) { this.status = OrderStatus.CANCELLED; } } // Projection - builds read model from events @Component @ProcessingGroup("order-projections") public class OrderProjection { @EventHandler public void on(OrderCreatedEvent event) { orderViewRepository.save(new OrderView( event.orderId(), "PENDING", event.customerId() )); } @EventHandler public void on(OrderCancelledEvent event) { orderViewRepository.updateStatus(event.orderId(), "CANCELLED"); } @QueryHandler public OrderView handle(FindOrderQuery query) { return orderViewRepository.findById(query.orderId()).orElseThrow(); } }

When to Use Each Level

LevelWhen to use
Level 1 (separate services)Most Spring Boot apps - immediate wins, zero overhead
Level 2 (separate read model)Read-heavy apps, need caching or read replicas
Level 3 (Axon + event sourcing)Audit requirements, event replay needed, complex domain

Don't jump to Axon for a CRUD app. Start with Level 1 - the separation of concern alone reduces complexity significantly.


Common Mistakes to Avoid

  • Putting queries in command handlers - violates the separation; command handlers should only validate and emit events/mutations
  • Returning domain entities from query services - always return DTOs from the query side; entities carry write-side behavior that reads don't need
  • Synchronous read model updates - in event-driven CQRS, read models update asynchronously; callers must accept eventual consistency
  • Starting with Axon before understanding the domain - the framework adds significant complexity; validate the domain model first with Level 1

Summary

CQRS separates read and write responsibilities to allow independent optimization and scaling. Start with simple service separation (Level 1) - it delivers most of the value with minimal complexity. Add a dedicated read model (Level 2) when read performance demands it. Move to Axon + event sourcing (Level 3) only when audit trails or event replay are genuine requirements.


Analyze Your Architecture with JOptimize

JOptimize's web dashboard analyzes your Spring Boot project architecture - circular dependencies, coupling scores, and layer violations that indicate where CQRS patterns would reduce complexity.

Understand your architecture before refactoring - free scan, no configuration required.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.