В обычном приложении один и тот же сервис и сохраняет заказ, и отдаёт его на экран. Это удобно, пока объём небольшой. Когда нагрузка растёт, возникает конфликт: операции записи требуют блокировок и тяжёлых агрегатов, операции чтения — простых плоских данных без лишних JOIN'ов. Оба требования выполнить одновременно сложно.
CQRS (Command Query Responsibility Segregation) разделяет эти два потока. Command side — запись: меняет состояние через агрегат и бизнес-правила. Query side — чтение: возвращает данные быстро и без побочных эффектов. Эта статья — про query side.
Что такое Query
В CQRS каждый запрос на чтение описывается отдельным объектом — Query. Это не метод и не строка — это Java-record с параметрами запроса.
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
Интерфейс UseCaseQuery<R> — маркер: параметр R говорит, что именно вернёт этот запрос. Здесь — OrderSummary.
Другой пример — поиск с пагинацией:
public record SearchOrdersQuery(
Long customerId,
OrderStatus status,
int page,
int size
) implements UseCaseQuery<Page<OrderSummary>> {}
Имена строятся по схеме Get…Query / Search…Query / List…Query. Query — только параметры, никакой логики.
Query-handler: readOnly и без агрегата
Раз Query описывает запрос, то query-handler его исполняет. Он устроен проще, чем command-handler — нет агрегата, нет доменных методов, нет событий.
@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()));
}
}
Два ключевых момента:
@Transactional(readOnly = true) — не просто декларация намерений. PostgreSQL при readOnly = true снимает ряд внутренних блокировок. Spring не запускает dirty-checking (если используется JPA). При наличии read-реплики запрос можно автоматически направить туда через LazyConnectionDataSourceProxy.
OrderViewRepository — не основной репозиторий, а отдельный интерфейс только для чтения. Подробно про его реализацию — в статье jOOQ → View Repository.
Read-DTO: плоский record под нужды UI
Результат query-handler'а — не агрегат, а read-DTO. Это Java-record, структура которого продиктована тем, что нужно показать пользователю, а не тем, как данные хранятся внутри агрегата.
public record OrderSummary(
Long orderId,
OrderStatus status,
String customerName,
Money totalAmount,
int itemCount,
OffsetDateTime createdAt,
OffsetDateTime lastUpdatedAt
) {}
Здесь customerName — денормализованное поле: имя клиента включено прямо в OrderSummary, хотя хранится в таблице customers. Загружать клиента отдельно не нужно — один SQL-запрос с JOIN даёт всё.
itemCount — заранее посчитанное число позиций заказа. Для экрана со списком заказов важно знать «3 позиции», а не сами позиции. Возвращать List<OrderItem> с десятками полей ради одного числа — лишняя работа.
Такой подход называют денормализованной проекцией: данные из нескольких таблиц собраны в одну плоскую структуру, оптимальную для конкретного экрана.
ViewRepository: интерфейс только для чтения
OrderViewRepository — отдельный интерфейс, не связанный с основным OrderRepository. Основной репозиторий работает с агрегатом и нужен для записи. View-репозиторий работает с read-DTO и нужен только для чтения.
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable pageable);
List<OrderListItem> findRecentByCustomer(Long customerId, int limit);
}
Реализация через jOOQ — один SQL-запрос с нужными полями:
@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) { ... }
}
Если данные уже хранятся в отдельной денормализованной таблице order_summary (о том, как её поддерживать в актуальном состоянии — в статье Sync via events), запрос становится ещё проще:
public Optional<OrderSummary> findSummaryById(Long orderId) {
return dsl.selectFrom(ORDER_SUMMARY)
.where(ORDER_SUMMARY.ORDER_ID.eq(orderId))
.fetchOptional(this::toSummary);
}
Частые ошибки
Изменение данных внутри query-handler'а
Query — только чтение. Если при запросе детали заказа хочется заодно сохранить «когда пользователь последний раз смотрел», это отдельная команда (MarkOrderViewedCommand), которую контроллер вызывает отдельно. Смешивать чтение и запись в одном handler'е — нарушение смысла CQRS.
Загрузка полного агрегата ради read-DTO
Частая ошибка — использовать основной OrderRepository в query-handler'е:
// Так делать не нужно
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(), // ← загрузили все позиции ради одного числа
order.createdAt(),
order.lastUpdatedAt()
);
}
Здесь order.items() загружает полный список позиций заказа — только чтобы вызвать .size(). Это лишняя работа: десятки строк из базы ради одного integer'а. View-репозиторий решает это одним COUNT в SQL.
Вызов доменных методов в query
Query-handler не вправе вызывать бизнес-методы агрегата — order.confirm(), order.archive() и т.п. Если при чтении нужно что-то изменить, это должен делать отдельный scheduled command-handler, не обработчик UI-запроса.
Возврат агрегата наружу
Query должен возвращать read-DTO, не агрегат. Если контроллер получит Order, он технически сможет вызвать order.confirm() напрямую, в обход command-handler'а и всех бизнес-проверок. Это разрушает смысл инкапсуляции агрегата.
Куда кладётся read-DTO
core/
└── order/
├── domain/
│ ├── Order.java # агрегат
│ └── port/out/
│ ├── OrderRepository.java # write-side
│ └── OrderViewRepository.java # read-side
└── dto/view/
├── OrderSummary.java # read-DTO для детали
└── OrderListItem.java # read-DTO для списка
Read-DTO живут рядом с доменом, но в отдельной папке dto/view/ — они часть публичного контракта query side, не часть доменной модели.
Коротко
- Query — record с параметрами запроса, реализует
UseCaseQuery<R>, гдеR— тип read-DTO. - Query-handler — обрабатывает query, использует
@Transactional(readOnly = true), возвращает read-DTO. - Данные берёт из
<X>ViewRepository— отдельного интерфейса только для чтения, не из основного репозитория агрегата. - Read-DTO — плоский record, денормализованный под нужды UI:
customerNameвместоCustomer,itemCountвместоList<OrderItem>. - Query не меняет данные, не вызывает доменные методы, не возвращает агрегат.
- Если при чтении нужно что-то записать — это отдельная команда.
Что почитать дальше
- Command side — пишущая половина: handler через агрегат и outbox.
- Read-model — где и в каком виде хранить read-данные.
- Sync via events — как read-таблица обновляется из событий write-side.
- jOOQ → View Repository — реализация
<X>ViewRepositoryчерез DSL.