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

When CQRS adds accidental complexity

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. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.