CQRS is not a binary «apply it or not». It is a scale: you start with a minimal split and add infrastructure only when the pain becomes real. Excessive CQRS infrastructure on a small service costs more than gradual growth.
This article covers four maturity levels, the typical evolution path, and common mistakes.
Level 1 — No CQRS
A classic Spring service: a single @Service, a shared @Transactional, read and write methods in one class. No special markers, no separate repositories.
@Service
public class OrderService {
@Transactional
public OrderDto createOrder(CreateOrderRequest req) { ... }
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) { ... }
}
This is fine for utility microservices, CRUD proxies, and small supporting services. A single data model, a simple structure — Spring Data covers everything.
Adding CQRS markers here without a real read/write split is formalism with no benefit.
Level 2 — Lightweight Markers
This appears when the service gains a real business domain: Use Case Pattern, separate commands and queries, handlers. Reads and writes still go through a single repository, but the split is now formal and explicitly enforced.
Each use case implements a marker interface: UseCaseCommand for a command, UseCaseQuery for a query. This is more than a convention — the markers provide enforcement:
@Transactional(readOnly = true)on a query handler — a hint to the database that there will be no write. Without it, theUseCaseQuerymarker has no effect.SelectMode.NO_LOCKon read operations — we do not lock rows we are not modifying.- Separate metrics for commands and queries.
public record CreateOrderCommand(...) implements UseCaseCommand<OrderId> {}
public record GetOrderQuery(Long id) implements UseCaseQuery<OrderJson> {}
@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, OrderId> {
private final OrderRepository orderRepository;
@Override
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items());
orderRepository.save(order);
return order.id();
}
}
@Component
@RequiredArgsConstructor
class GetOrderHandler implements UseCaseHandler<GetOrderQuery, OrderJson> {
private final OrderRepository orderRepository;
private final OrderMapper orderMapper;
@Override
@Transactional(readOnly = true)
public OrderJson handle(GetOrderQuery query) {
Order order = orderRepository.findById(query.id(), SelectMode.NO_LOCK)
.orElseThrow(...);
return orderMapper.toJson(order);
}
}
Both handlers use the same OrderRepository. Splitting the interfaces is premature — there is no benefit from that step at this level.
Level 3 split — A Separate ViewRepository
Over time, the read side starts to differ from the write side. The UI asks for projections with fields from several tables, analysts want summaries — but the aggregate is designed for writing, not for display. Fetching an OrderSummary through OrderRepository becomes awkward.
The solution is two separate interfaces in the domain:
// core/order/domain/port/out/OrderRepository.java
public interface OrderRepository {
Optional<Order> findById(OrderId id, SelectMode mode);
void save(Order order);
}
// core/order/domain/port/out/OrderViewRepository.java
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable p);
}
OrderRepository returns the aggregate and is used in command handlers. OrderViewRepository returns a read DTO and is used in query handlers. No overlap.
The read DTOs (OrderSummary) are standalone records. Their structure is driven by what the API and UI need, not by the aggregate's internal layout.
Physically, the data still lives in a single PostgreSQL — for now the split is only at the level of types and interfaces, not infrastructure.
A common mistake: adding methods like findSummary() directly to OrderRepository. That way OrderRepository grows with every new piece of UI functionality, mixing the write API with the read API in a single interface.
Level 3 event-driven — A Separate Store
The next step is needed when the read load starts to interfere with the write side, or when the read pattern is fundamentally different — for example, you need full-text search or analytical summaries over millions of records.
The read model moves to a separate table, Redis, or Elasticsearch. Synchronization goes through outbox and Kafka:
write-side: read-side:
PostgreSQL order_summary (denormalized table)
├── order (aggregate) └── indexes for the query side
└── outbox (atomic with the write)
↓ outbox-relay
Kafka (order.events)
↓ read-side consumer
UPSERT order_summary
What is added on top of the previous level:
- Outbox table in the write database. The write to the outbox happens in the same transaction as the aggregate write.
- Outbox relay — a background process that publishes events to Kafka.
- Read-side consumer — updates the read model from events. Idempotency is mandatory: a single event may arrive twice.
- Bootstrap procedure for the initial population and recovery of the read model after a failure.
The cost of this level:
- Eventual consistency: data appears in the read model with a delay (100 ms — 1 s under normal conditions, longer under load or during failures).
- New points of failure: a stuck consumer, a backlog piling up in the outbox, desynchronization.
- Additional monitoring: consumer lag, outbox relay health.
If the read load is not yet critical, it is often cheaper to get by with a PostgreSQL read replica and a cache.
Evolution Always Goes Bottom-Up
The path through the levels is strictly one-directional: 1 → 2 → 3-split → 3-event-driven. Every transition must be justified by metrics or new requirements, not by a desire to use the «right architecture».
A typical service lifecycle:
- Started as a CRUD proxy — Level 1.
- A real domain appeared, Use Case Pattern was introduced — Level 2 with markers.
- The UI started asking for projections that do not match the aggregate — Level 3 split with
OrderViewRepository. - Read p95 latency breached the SLA or full-text search became necessary — Level 3 event-driven.
Moving back down happens rarely and usually means you started too high. If you merged two services into one and the event-driven read model lost its meaning — you simplify down to split or to Level 2.
Common Mistakes
Markers without enforcement. Adding implements UseCaseCommand to a command at Level 1 while still calling it directly through a @Service, without readOnly = true on queries — that is empty decoration. A marker must change something in behavior, otherwise it is redundant.
Read methods in the write repository at Level 3. If OrderViewRepository is carved out but queries like findSummary() remain in OrderRepository — the point of the split is lost. OrderRepository will grow along with the UI, mixing responsibilities.
Jumping from Level 1 straight to event-driven. Outbox, Kafka, a separate read table — this is infrastructure with a non-trivial cost. Without real load it creates complexity but solves no problem.
In Short
- CQRS is a maturity scale, not a binary decision. You take exactly as much as you need right now.
- Level 1: a classic Spring service without a split. Fine for simple services.
- Level 2:
UseCaseCommand/UseCaseQuerymarkers, a single repository,readOnly = trueon query handlers. - Level 3 split: two interfaces —
OrderRepository(aggregate, write) andOrderViewRepository(read DTO). One database. - Level 3 event-driven: a separate store for the read model, synchronization via outbox + Kafka. Eventual consistency.
- Every transition is driven by metrics and real pain, not by fashion.
- Markers without enforcement are formalism. Do not add them if there is nothing to enforce.
Further Reading
- Command side — the write handler at Levels 2 and 3.
- Query side — the read handler with a
ViewRepository. - Read model — where to store a separate projection at Level 3 event-driven.
- Sync via events — outbox and Kafka for synchronization.
- When CQRS is justified — thresholds for transitioning between levels.