In a typical application, the same service both saves an order and serves it to the screen. This is convenient while the volume is small. As load grows, a conflict appears: write operations need locks and heavy aggregates, read operations need simple flat data without extra JOINs. Meeting both requirements at once is hard.
CQRS (Command Query Responsibility Segregation) splits these two flows. The command side handles writes: it changes state through the aggregate and business rules. The query side handles reads: it returns data fast and without side effects. This article is about the query side.
What a Query is
In CQRS, every read request is described by a separate object — a Query. It is not a method and not a string — it is a Java record holding the request parameters.
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
The UseCaseQuery<R> interface is a marker: the parameter R states exactly what this query returns. Here it is OrderSummary.
Another example — a paginated search:
public record SearchOrdersQuery(
Long customerId,
OrderStatus status,
int page,
int size
) implements UseCaseQuery<Page<OrderSummary>> {}
Names follow the pattern Get…Query / Search…Query / List…Query. A Query is only parameters, with no logic.
Query handler: readOnly and no aggregate
Since a Query describes the request, a query handler executes it. It is simpler than a command handler — no aggregate, no domain methods, no events.
@Component
@RequiredArgsConstructor
class GetOrderSummaryHandler implements UseCaseHandler<GetOrderSummaryQuery, OrderSummary> {
private final OrderViewRepository orderViewRepository;
@Override
@Transactional(readOnly = true)
public OrderSummary handle(GetOrderSummaryQuery query) {
return orderViewRepository.findSummaryById(query.orderId())
.orElseThrow(() -> new OrderNotFoundException(query.orderId()));
}
}
Two key points:
@Transactional(readOnly = true) is not just a declaration of intent. With readOnly = true, PostgreSQL drops a number of internal locks. Spring does not run dirty checking (when JPA is used). If a read replica is available, the query can be routed there automatically via LazyConnectionDataSourceProxy.
OrderViewRepository is not the main repository but a separate read-only interface. Its implementation is covered in detail in the article jOOQ → View Repository.
Read-DTO: a flat record shaped for the UI
The result of a query handler is not an aggregate but a read-DTO. It is a Java record whose structure is driven by what needs to be shown to the user, not by how the data is stored inside the aggregate.
public record OrderSummary(
Long orderId,
OrderStatus status,
String customerName,
Money totalAmount,
int itemCount,
OffsetDateTime createdAt,
OffsetDateTime lastUpdatedAt
) {}
Here customerName is a denormalized field: the customer's name is included right in OrderSummary, even though it is stored in the customers table. There is no need to load the customer separately — a single SQL query with a JOIN provides everything.
itemCount is a precomputed number of order items. For an order-list screen it matters to know "3 items", not the items themselves. Returning a List<OrderItem> with dozens of fields just for a single number is wasted work.
This approach is called a denormalized projection: data from several tables is gathered into a single flat structure optimized for a specific screen.
ViewRepository: a read-only interface
OrderViewRepository is a separate interface, unrelated to the main OrderRepository. The main repository works with the aggregate and is needed for writes. The view repository works with read-DTOs and is needed only for reads.
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable pageable);
List<OrderListItem> findRecentByCustomer(Long customerId, int limit);
}
The jOOQ implementation is a single SQL query with the needed fields:
@Repository
@RequiredArgsConstructor
class JooqOrderViewRepository implements OrderViewRepository {
private final DSLContext dsl;
@Override
public Optional<OrderSummary> findSummaryById(Long orderId) {
return dsl.select(
ORDER.ID,
ORDER.STATUS,
CUSTOMER.NAME.as("customer_name"),
ORDER.TOTAL_AMOUNT,
DSL.selectCount().from(ORDER_ITEM).where(ORDER_ITEM.ORDER_ID.eq(ORDER.ID)),
ORDER.CREATED_AT,
ORDER.UPDATED_AT)
.from(ORDER)
.join(CUSTOMER).on(CUSTOMER.ID.eq(ORDER.CUSTOMER_ID))
.where(ORDER.ID.eq(orderId))
.fetchOptional(this::toSummary);
}
private OrderSummary toSummary(Record r) { ... }
}
If the data is already stored in a separate denormalized order_summary table (how to keep it up to date is covered in the article Sync via events), the query becomes even simpler:
public Optional<OrderSummary> findSummaryById(Long orderId) {
return dsl.selectFrom(ORDER_SUMMARY)
.where(ORDER_SUMMARY.ORDER_ID.eq(orderId))
.fetchOptional(this::toSummary);
}
Common mistakes
Modifying data inside a query handler
A Query is read-only. If, while requesting order details, you also want to save "when the user last viewed it", that is a separate command (MarkOrderViewedCommand) which the controller invokes separately. Mixing reads and writes in one handler violates the meaning of CQRS.
Loading the full aggregate just to build a read-DTO
A common mistake is to use the main OrderRepository in a query handler:
// Don't do this
public OrderSummary handle(GetOrderSummaryQuery query) {
Order order = orderRepository.findById(new OrderId(query.orderId()), NO_LOCK)
.orElseThrow(...);
return new OrderSummary(
order.id().value(),
order.status(),
order.customerName(),
order.total(),
order.items().size(), // ← loaded every item just for a single number
order.createdAt(),
order.lastUpdatedAt()
);
}
Here order.items() loads the full list of order items — only to call .size(). This is wasted work: dozens of rows from the database for a single integer. The view repository solves it with one COUNT in SQL.
Calling domain methods in a query
A query handler is not allowed to call the aggregate's business methods — order.confirm(), order.archive(), and so on. If something needs to change during a read, that must be done by a separate scheduled command handler, not by a UI-request handler.
Returning the aggregate outward
A Query must return a read-DTO, not an aggregate. If the controller receives an Order, it will technically be able to call order.confirm() directly, bypassing the command handler and all business checks. This destroys the point of the aggregate's encapsulation.
Where the read-DTO lives
core/
└── order/
├── domain/
│ ├── Order.java # aggregate
│ └── port/out/
│ ├── OrderRepository.java # write-side
│ └── OrderViewRepository.java # read-side
└── dto/view/
├── OrderSummary.java # read-DTO for the detail view
└── OrderListItem.java # read-DTO for the list view
Read-DTOs live next to the domain but in a separate dto/view/ folder — they are part of the public contract of the query side, not part of the domain model.
In short
- A Query is a record with the request parameters; it implements
UseCaseQuery<R>, whereRis the read-DTO type. - A query handler processes the query, uses
@Transactional(readOnly = true), and returns a read-DTO. - It takes data from a
<X>ViewRepository— a separate read-only interface, not from the aggregate's main repository. - A read-DTO is a flat record, denormalized for the UI:
customerNameinstead ofCustomer,itemCountinstead ofList<OrderItem>. - A Query does not modify data, does not call domain methods, and does not return the aggregate.
- If something needs to be written during a read — that is a separate command.
What to read next
- Command side — the writing half: a handler working through the aggregate and the outbox.
- Read-model — where and in what form to store read data.
- Sync via events — how the read table is updated from write-side events.
- jOOQ → View Repository — implementing the
<X>ViewRepositorywith the DSL.