Back to Blog
spring-bootcqrsarchitecturejavaddddesign-patterns

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

CQRS separates read and write models to scale each independently. Learn how to implement Command Query Responsibility Segregation in Spring Boot with real code - without overcomplicating it.

J

JOptimize Team

May 22, 2026· 10 min read

CQRS (Command Query Responsibility Segregation) sounds complex but solves a concrete problem: your read model and write model have different needs. Writes need consistency and validation; reads need speed and flexibility. Forcing both through the same model means compromises that hurt performance or correctness. CQRS separates them cleanly.


The Problem CQRS Solves

A typical Spring Boot service looks like this:

@Service public class OrderService { public Order createOrder(CreateOrderRequest request) { ... } public Order updateStatus(Long id, OrderStatus status) { ... } public Order findById(Long id) { ... } public Page<Order> findByCustomer(Long customerId, Pageable p) { ... } public OrderSummaryDto getDashboardSummary(Long customerId) { ... } }

The problem: getDashboardSummary() needs a complex JOIN across 5 tables, but it's going through the same JPA entity model as createOrder() - which needs strict validation and transaction control. The Order entity becomes a compromise between write-optimized (validation, relationships) and read-optimized (flat projections, aggregations).

CQRS says: use a different model for reads and writes.


Core Concept: Commands vs Queries

Command = intent to change state (write)
  ? CreateOrderCommand, UpdateOrderStatusCommand, CancelOrderCommand

Query  = request for data (read, no side effects)
  ? GetOrderQuery, ListOrdersByCustomerQuery, GetDashboardSummaryQuery

Each has its own handler, its own model, and can be optimized independently.


Implementation: Command Side

// 1. Define the command (immutable data carrier) public record CreateOrderCommand( Long customerId, List<OrderItemDto> items, String shippingAddress ) {} // 2. Command handler - owns validation and business logic @Service @RequiredArgsConstructor @Transactional public class CreateOrderCommandHandler { private final OrderRepository orderRepository; private final CustomerRepository customerRepository; private final InventoryService inventoryService; private final ApplicationEventPublisher eventPublisher; public Long handle(CreateOrderCommand command) { Customer customer = customerRepository.findById(command.customerId()) .orElseThrow(() -> new CustomerNotFoundException(command.customerId())); inventoryService.validateStock(command.items()); Order order = Order.create(customer, command.items(), command.shippingAddress()); Order saved = orderRepository.save(order); eventPublisher.publishEvent(new OrderCreatedEvent(saved.getId())); return saved.getId(); } }

Implementation: Query Side

The query side uses separate, read-optimized models - often plain DTOs fetched with custom SQL or JPA projections:

// 1. Define the query public record GetOrderQuery(Long orderId) {} // 2. Read-optimized DTO (flat, no lazy loading) public record OrderDetailView( Long id, String customerName, String customerEmail, OrderStatus status, BigDecimal total, List<OrderItemView> items, String shippingAddress, LocalDateTime createdAt ) {} // 3. Query handler - uses native SQL or projections for speed @Service @RequiredArgsConstructor public class GetOrderQueryHandler { private final EntityManager em; public OrderDetailView handle(GetOrderQuery query) { // Native query - bypasses JPA entity model entirely return em.createNativeQuery(""" SELECT o.id, c.name AS customer_name, c.email AS customer_email, o.status, o.total, o.shipping_address, o.created_at FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.id = :orderId """, "OrderDetailViewMapping") .setParameter("orderId", query.orderId()) .getSingleResult(); } }

Wiring It Together: The Command/Query Bus

For small apps, inject handlers directly. For larger systems, use a simple dispatcher:

@Component @RequiredArgsConstructor public class CommandBus { private final ApplicationContext context; @SuppressWarnings("unchecked") public <R> R dispatch(Object command) { // Resolve handler by command type String handlerName = command.getClass().getSimpleName() .replace("Command", "CommandHandler"); handlerName = Character.toLowerCase(handlerName.charAt(0)) + handlerName.substring(1); var handler = context.getBean(handlerName); try { return (R) handler.getClass() .getMethod("handle", command.getClass()) .invoke(handler, command); } catch (Exception e) { throw new RuntimeException("Command dispatch failed", e); } } } // In your controller: @PostMapping("/orders") public ResponseEntity<Long> createOrder(@RequestBody CreateOrderRequest request) { Long orderId = commandBus.dispatch(new CreateOrderCommand( request.customerId(), request.items(), request.shippingAddress() )); return ResponseEntity.created(URI.create("/orders/" + orderId)).body(orderId); }

When to Use CQRS (and When Not To)

Use CQRS when:

  • Read and write models have significantly different shapes
  • Read queries need to join many tables or use aggregations
  • You need to scale reads and writes independently
  • The domain has complex business logic that benefits from separation

Don't use CQRS when:

  • CRUD operations with simple reads and writes (overkill)
  • Small teams - the overhead of separate models slows development
  • The read model closely mirrors the write model
  • You're building an MVP - start simple, introduce CQRS when pain is felt

Common Mistakes to Avoid

  • Synchronous event consistency - if you publish an OrderCreatedEvent and immediately query the read model, the event may not have been processed yet; design for eventual consistency or use synchronous queries for immediate feedback
  • Putting business logic in query handlers - queries should be pure data retrieval; any logic belongs in command handlers
  • Sharing the JPA entity model for reads - use projections or DTOs; sharing entities between read and write leads back to the original compromise
  • Over-engineering with Event Sourcing from day one - CQRS and Event Sourcing are independent patterns; start with CQRS alone and add Event Sourcing only if you need full audit history

Summary

CQRS separates your application into commands (state changes with validation) and queries (data retrieval with no side effects). In Spring Boot, this means separate handler classes, separate DTOs, and often separate data access strategies - JPA entities for writes, native queries or projections for reads. Start simple with direct handler injection; add a command bus as the app grows. Don't add CQRS to a CRUD app - it's a tool for domains with complex reads and writes.


Analyze Your Architecture with JOptimize

JOptimize's web dashboard includes an architecture analysis module that detects coupling between read and write paths, circular dependencies, and service boundary violations - the patterns that CQRS is designed to solve.

Understand your architecture before it becomes a maintenance burden - 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.