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.
JOptimize Team
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.
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.
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.
// 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(); } }
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(); } }
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); }
Use CQRS when:
Don't use CQRS when:
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 feedbackCQRS 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.
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.
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.