Available Hire Me
← All Writing Architecture

CQRS — Separating Commands from Queries in a Spring Boot Service

How to implement CQRS in a Spring Boot service — the command and query separation, write and read model design, why the split pays off under load, and when CQRS is the wrong choice.

CQRS — Command Query Responsibility Segregation — separates operations that change state (commands) from operations that read state (queries). In a standard CRUD service, the same model does both. In a CQRS service, commands go through a write model that enforces invariants, and queries go through a read model optimised for how data is consumed. The split sounds simple; the benefits emerge under load and as requirements diverge.

The core principle

Client → Command → Write Model → Domain Logic → Persistence
Client → Query  → Read Model  → Projection    → Response

The write model is your aggregate — the thing that enforces business rules. The read model is whatever is most convenient to query — a denormalised view, a dedicated table, a cache — optimised for the caller’s needs.

Commands

A command represents intent to change state. It is a value object with no behaviour:

public record PlaceOrderCommand(
    String marketId,
    String selectionId,
    double price,
    double size,
    String side
) {}

public record CancelOrderCommand(
    String orderId,
    String reason
) {}

Command handlers

The command handler validates, loads the aggregate, applies the command, and saves:

@Service
@RequiredArgsConstructor
public class OrderCommandHandler {

    private final OrderRepository orderRepository;

    @Transactional
    public String handle(PlaceOrderCommand cmd) {
        Order order = Order.place(cmd);
        orderRepository.save(order);
        return order.id();
    }

    @Transactional
    public void handle(CancelOrderCommand cmd) {
        Order order = orderRepository.findById(cmd.orderId())
            .orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));
        order.cancel(cmd.reason());
        orderRepository.save(order);
    }
}

Queries

A query returns data shaped for the caller. It has no side effects and does not go through the aggregate:

public record OrderSummaryQuery(String marketId) {}

public record OrderSummary(
    String orderId,
    String selectionName,
    double price,
    double size,
    String side,
    String status,
    double profitLoss
) {}

Query handlers

The query handler goes directly to the read store — often a denormalised view or a projection table:

@Service
@RequiredArgsConstructor
public class OrderQueryHandler {

    private final OrderReadRepository readRepository;

    @Transactional(readOnly = true)
    public List<OrderSummary> handle(OrderSummaryQuery query) {
        return readRepository.findSummariesByMarket(query.marketId());
    }
}

@Transactional(readOnly = true) enables read-only optimisations in Hibernate and allows routing to a read replica in some configurations.

Separate read and write repositories

// Write side — rich domain model
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
}

// Read side — projection/DTO focused
public interface OrderReadRepository {
    List<OrderSummary> findSummariesByMarket(String marketId);
    Optional<OrderDetailView> findDetailById(String orderId);
    Map<String, Long> countByStatus();
}

The read repository uses Spring Data projections or native queries to produce exactly the shape the UI or API consumer needs — no mapping overhead, no loading fields that aren’t needed.

Keeping the read model updated

With a single database, the simplest approach: the read model is a view or query on the write tables. The read repository uses an @Query that joins and projects:

@Query("""
    SELECT new com.example.OrderSummary(
        o.id, r.name, o.price, o.size, o.side, o.status,
        CASE WHEN o.status = 'FILLED' THEN (o.price - 1) * o.size ELSE 0 END
    )
    FROM Order o JOIN Runner r ON r.selectionId = o.selectionId
    WHERE o.marketId = :marketId
    ORDER BY o.placedAt DESC
    """)
List<OrderSummary> findSummariesByMarket(@Param("marketId") String marketId);

For higher read/write independence — separate databases, eventual consistency — use domain events to update a dedicated read store. The command handler publishes OrderPlacedEvent; a listener updates the read table.

REST controller wiring

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

    private final OrderCommandHandler commandHandler;
    private final OrderQueryHandler   queryHandler;

    @PostMapping
    public ResponseEntity<Map<String, String>> placeOrder(
        @Valid @RequestBody PlaceOrderRequest req) {
        String id = commandHandler.handle(PlaceOrderRequest.toCommand(req));
        return ResponseEntity.created(URI.create("/orders/" + id))
            .body(Map.of("orderId", id));
    }

    @GetMapping
    public List<OrderSummary> listOrders(@RequestParam String marketId) {
        return queryHandler.handle(new OrderSummaryQuery(marketId));
    }
}

The controller is thin — it translates HTTP to command/query objects and delegates. No business logic.

When CQRS is the right choice

  • Read and write workloads differ significantly: Query models can be tuned independently of write invariants
  • Complex read requirements: Multiple views with different shapes from the same data
  • High read/write ratio: Read replicas can serve queries without touching the write database
  • Event sourcing: CQRS pairs naturally with event sourcing — commands produce events, events build read projections

When CQRS adds accidental complexity

  • Simple CRUD services: If reads and writes use the same data in the same shape, CQRS adds indirection for no benefit
  • Small team, single service: The ceremony of command/query objects, handlers, and separate repositories is overhead when one developer owns the whole service
  • Tight consistency requirements: Eventual read model updates mean a command’s effect isn’t immediately visible on the read side

CQRS is an architectural pattern that pays off at scale. Applied prematurely, it is over-engineering. Applied to the right boundary, it makes read and write scaling independent and keeps the domain model focused on invariants rather than display requirements.

If you’re designing a Java service architecture and want help deciding where CQRS adds value, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.