CQRS Pattern in Java: Complete Guide with Spring Boot

CQRS — Command Query Responsibility Segregation — is an architectural pattern that separates the read (query) and write (command) sides of your application into distinct models. Instead of a single model handling both reads and writes, you have two: one optimized for writing data, one optimized for reading it.

This guide covers CQRS theory, practical implementation with Spring Boot, and real-world trade-offs so you can decide when it is the right choice for your system.

What is CQRS?

The term was coined by Greg Young, building on Bertrand Meyer's Command-Query Separation (CQS) principle. CQS states that a method should either change state (a command) or return data (a query) — never both. CQRS applies this at the architectural level.

In a traditional CRUD application, a single OrderService handles creating orders, updating them, cancelling them, and also fetching order lists and order details. As the system grows, the read requirements (paginated lists, aggregated reports, dashboards) and write requirements (validation, business rules, consistency) start to conflict. CQRS solves this by splitting them apart.

Key insight: In most applications, reads vastly outnumber writes — often 80–95% of all operations are reads. CQRS lets you optimize each side independently.

CQRS Architecture Overview

A CQRS system has two sides:

  • Command side: Receives commands (CreateOrder, CancelOrder, UpdateAddress). Applies business rules, validates input, writes to the write store. Returns only a success acknowledgement — never data.
  • Query side: Receives queries (GetOrderById, ListOrdersByCustomer). Reads from a read-optimized store. Returns DTOs shaped exactly for the UI. No business logic.

In simple CQRS, both sides share the same database but use separate models. In full CQRS with Event Sourcing, the read store is a separate database (e.g., Elasticsearch) kept in sync via domain events.

Implementing the Command Side

Define a command as a simple immutable object:

public record CreateOrderCommand(
    @NotBlank String customerId,
    @NotEmpty List<OrderLineItem> items,
    String shippingAddress
) {}

Create a command handler that contains all business logic:

@Service
@Transactional
public class OrderCommandHandler {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OrderCommandHandler(OrderRepository repo, ApplicationEventPublisher pub) {
        this.orderRepository = repo;
        this.eventPublisher = pub;
    }

    public UUID handle(CreateOrderCommand command) {
        if (command.items().isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        Order order = new Order();
        order.setCustomerId(command.customerId());
        order.setShippingAddress(command.shippingAddress());
        order.setStatus(OrderStatus.PENDING);
        order.setItems(command.items());

        Order saved = orderRepository.save(order);
        // Publish event so the read side can sync
        eventPublisher.publishEvent(new OrderCreatedEvent(saved.getId()));
        return saved.getId();
    }
}

The handler returns only the new entity ID — never the full object. That is the query side's job.

Implementing the Query Side

Create dedicated read models (DTOs) shaped for what the UI actually needs:

public record OrderSummaryDto(
    UUID id,
    String customerName,
    int itemCount,
    BigDecimal totalAmount,
    String status,
    LocalDateTime createdAt
) {}

Use a dedicated query service that bypasses the domain model entirely:

@Service
@Transactional(readOnly = true)
public class OrderQueryService {

    private final EntityManager em;

    public OrderQueryService(EntityManager em) { this.em = em; }

    public List<OrderSummaryDto> findByCustomer(String customerId) {
        return em.createQuery("""
            SELECT new com.example.dto.OrderSummaryDto(
                o.id, c.name, SIZE(o.items), o.totalAmount, o.status, o.createdAt
            )
            FROM Order o JOIN Customer c ON o.customerId = c.id
            WHERE o.customerId = :cid ORDER BY o.createdAt DESC
            """, OrderSummaryDto.class)
            .setParameter("cid", customerId)
            .getResultList();
    }
}

REST Controller Wiring

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderCommandHandler commands;
    private final OrderQueryService queries;

    @PostMapping
    public ResponseEntity<Map<String, UUID>> create(@RequestBody @Valid CreateOrderCommand cmd) {
        UUID id = commands.handle(cmd);
        return ResponseEntity.status(201).body(Map.of("id", id));
    }

    @GetMapping("/customer/{customerId}")
    public List<OrderSummaryDto> listByCustomer(@PathVariable String customerId) {
        return queries.findByCustomer(customerId);
    }
}

When to Use CQRS

CQRS adds complexity. Use it when you have a genuine need:

  • Read and write workloads have very different scalability requirements
  • Complex domain logic on the write side conflicts with flexible query requirements
  • You are implementing Event Sourcing and need a full audit log
  • Different teams own read and write paths and need to evolve them independently
When NOT to use CQRS: Simple CRUD applications, small teams, or when the complexity of maintaining two models outweighs the benefits. It is a solution to a specific problem, not a default pattern.

CQRS vs Traditional CRUD

AspectCRUDCQRS
ModelsSingle model for read + writeSeparate command and query models
ComplexityLowHigher — two models to maintain
ScalabilityScale whole service togetherScale read and write independently
Query flexibilityLimited by domain model shapeQuery model shaped for UI needs
ConsistencyImmediate/strongOften eventual (if separate stores)
Best forSimple apps, small teamsComplex domains, high read:write ratio

Conclusion

CQRS is a powerful pattern for systems where read and write concerns have genuinely different requirements. By separating commands from queries, you can optimize each side independently and simplify complex domain logic. Start with simple CQRS (same database, separate models) before reaching for full event-sourced CQRS — most applications do not need it.

Related: Java Design Patterns Guide | Java Microservices Architecture