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.
JOptimize Team
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.
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:
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) {}
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; } }
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(); } }
| Level | When 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.
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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.