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.
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.
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
) {}
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);
}
}
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
) {}
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.
// 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.
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.
@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.
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.