Опирается на правила:
R-CQRS-QRY-1…R-CQRS-QRY-4иR-CQRS-QRY-X1…R-CQRS-QRY-X3из CQRS Style Guide → раздел 3. Query side.
Важно знать
- Query — это намерение прочитать. Record, реализует
UseCaseQuery<R>. Без побочных эффектов.- Query-handler:
@Transactional(readOnly = true), грузит через<X>ViewRepository(отдельный от write-<X>Repository), возвращает read-DTO.- Read-DTO — это record, структура которого продиктована UI/API needs, не структурой агрегата. Денормализованный, с pre-computed полями.
- Query-handler не вызывает доменные методы (
order.confirm()нельзя). Только read.<X>ViewRepository— отдельный интерфейс с read-методами, возвращает read-DTO. Не основной<X>Repository, который возвращает агрегат.- Запрещено: query делает write, грузит агрегат целиком ради read-DTO, возвращает агрегат или внутренние Entity наружу.
Query-side — это половина CQRS, занятая чтением. Она оптимизирована под чтение: денормализованные read-DTO, отдельный репозиторий, read-only транзакции. Главная мысль: query никогда не пользуется write-инструментами (агрегат, FOR UPDATE, domain methods) — у неё свой стек, и он легче. Раскрытие раздела 3 гайда.
Query — record с маркером UseCaseQuery
R-CQRS-QRY-1: query — это record, реализует UseCaseQuery<R>, где R — тип read-DTO.
public record GetOrderSummaryQuery(Long orderId)
implements UseCaseQuery<OrderSummary> {}
public record SearchOrdersQuery(
Long customerId,
OrderStatus status,
int page,
int size
) implements UseCaseQuery<Page<OrderSummary>> {}
Что важно:
- Record, без вычислений. Query — параметры запроса, никакой логики.
- Параметр
<R>— тип read-DTO или коллекции/Page read-DTO. Не агрегат. - Имя в форме
Get…Query/Search…Query/List…Query— глаголGet/Search/List+ предметная область + суффиксQuery. Соответствует REST-вербу GET. - Маркер
UseCaseQueryразличим отUseCaseCommandна уровне типов — dispatcher выбирает разный handler-pipeline, метрики разделимы.
Структура query-handler-а
R-CQRS-QRY-2: классический query-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()));
}
}
Что важно:
readOnly = true. PostgreSQL и Spring оптимизируют: hint в драйвере, отсутствие dirty checking (если бы был JPA), потенциальная маршрутизация на read-replica (черезLazyConnectionDataSourceProxy).OrderViewRepository— отдельный интерфейс. Подробно — в jOOQ → View Repository.- Возвращает read-DTO, не агрегат. Read-DTO — record, неизменяемый, с денормализованными полями.
Для пагинированного query:
@Component
@RequiredArgsConstructor
class SearchOrdersHandler implements UseCaseHandler<SearchOrdersQuery, Page<OrderSummary>> {
private final OrderViewRepository orderViewRepository;
@Override
@Transactional(readOnly = true)
public Page<OrderSummary> handle(SearchOrdersQuery query) {
return orderViewRepository.search(
query.customerId(),
query.status(),
PageRequest.of(query.page(), query.size())
);
}
}
Read-DTO — денормализованный record
R-CQRS-QRY-3: read-DTO — это record, расположенный в core/<bc>/dto/view/ или core/<bc>/domain/repository/view/. Структура продиктована UI/API needs, не агрегатом.
public record OrderSummary(
Long orderId,
OrderStatus status,
String customerName, // денормализовано — не нужно отдельно грузить Customer
Money totalAmount,
int itemCount, // pre-computed, не List<OrderItem>
OffsetDateTime createdAt,
OffsetDateTime lastUpdatedAt
) {}
Что хорошего:
customerNameденормализован. Если бы это был агрегат, пришлось бы грузитьCustomerотдельно (или через JOIN). Здесь — одно поле.itemCount, а неList<OrderItem>. Для UI-списка нужно показать «3 позиции», не сами позиции. Pre-computed целое — на порядок дешевле.OrderStatusenum — это OK, потому что enum безопасно сериализуется и не содержит поведения.MoneyVO — это OK, потому что Value Object без identity, тоже сериализуется тривиально.
Расположение:
core/
└── order/
├── domain/
│ ├── Order.java # агрегат
│ ├── OrderItem.java # внутренняя Entity
│ └── port/out/
│ ├── OrderRepository.java # write-side (агрегат)
│ └── OrderViewRepository.java # read-side
└── dto/view/
├── OrderSummary.java # read-DTO
└── OrderListItem.java # read-DTO для списка
ViewRepository — отдельный интерфейс
Из R-CQRS-QRY-2 следует: query идёт через <X>ViewRepository (см. jOOQ → View Repository для деталей реализации).
public interface OrderViewRepository {
Optional<OrderSummary> findSummaryById(Long orderId);
Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable pageable);
List<OrderListItem> findRecentByCustomer(Long customerId, int limit);
}
Реализация — в persistence/ модуле:
@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) { ... }
}
Если есть отдельная read-таблица order_summary (см. Read-model), запрос становится ещё проще:
public Optional<OrderSummary> findSummaryById(Long orderId) {
return dsl.selectFrom(ORDER_SUMMARY)
.where(ORDER_SUMMARY.ORDER_ID.eq(orderId))
.fetchOptional(this::toSummary);
}
Query не вызывает доменные методы
R-CQRS-QRY-4: внутри query-handler-а нет вызова бизнес-методов агрегата. Никаких order.confirm(), customer.suspend(), никаких событий.
// ПЛОХО — query вызывает доменный метод
@Transactional(readOnly = true)
public OrderSummary handle(GetOrderSummaryQuery query) {
Order order = orderRepository.findById(new OrderId(query.orderId()), NO_LOCK)
.orElseThrow(...);
if (order.shouldBeArchived()) { // ← доменный метод в read
order.archive(); // ← мутация в readOnly
}
return new OrderSummary(order.id().value(), ...);
}
Что не так:
readOnly = trueнарушено: мутация внутри read-only транзакции либо приведёт к ошибке драйвера на коммите, либо silently не сохранится.- Принцип CQRS нарушен: query побочный эффект.
- Логика «когда архивировать» должна быть в отдельном scheduled command-handler-е, не приклеена к UI-чтению.
Что запрещено
Query делает write
R-CQRS-QRY-X1: внутри query — никаких INSERT / UPDATE / DELETE. Если возникает потребность, это уже command, перенеси.
Часто соблазн: «сохраню last_viewed_at пользователя при чтении детали». Это отдельный command (MarkOrderViewedCommand), который контроллер дёрнет асинхронно или явным фоновым call'ом.
Query грузит агрегат целиком и маппит в read-DTO
R-CQRS-QRY-X2: query не должен грузить полный агрегат через основной <X>Repository (с multiset на коллекции, FOR UPDATE) только чтобы потом смаппить в DTO.
// ПЛОХО — грузим Order со всеми OrderItem через основной репо
@Transactional(readOnly = true)
public OrderSummary handle(GetOrderSummaryQuery query) {
Order order = orderRepository.findById(new OrderId(query.orderId()), NO_LOCK)
.orElseThrow(...); // ← multiset, грузим все items
return new OrderSummary(
order.id().value(),
order.status(),
order.customerName(), // потребовалось дёрнуть Customer тоже
order.total(),
order.items().size(), // ← items были загружены ради .size()
order.createdAt(),
order.lastUpdatedAt()
);
}
Что не так:
- Лишняя работа. Грузим
List<OrderItem>со всеми полями, чтобы вернуть только.size(). Это десятки строк из БД ради одного integer-а. - N+1 / тяжёлые JOIN'ы. Multiset для items и payments может выливаться в большие subselect-ы. Для read-проекции это перебор.
- Несоответствие границ. Read-DTO — отдельная модель, её нельзя зависеть от структуры write-агрегата.
Корректно: либо <X>ViewRepository собирает только нужные поля одним запросом, либо данные уже лежат в денормализованной order_summary-таблице.
Query возвращает агрегат наружу
R-CQRS-QRY-X3: query никогда не возвращает агрегат (Order) или его внутренние Entity (OrderItem) клиенту/контроллеру.
// ПЛОХО — возвращаем Order
public record GetOrderQuery(Long orderId) implements UseCaseQuery<Order> {}
@Transactional(readOnly = true)
public Order handle(GetOrderQuery query) {
return orderRepository.findById(new OrderId(query.orderId()), NO_LOCK)
.orElseThrow(...);
}
@GetMapping("/orders/{id}")
public OrderJson get(@PathVariable Long id) {
Order order = dispatcher.dispatch(new GetOrderQuery(id));
order.confirm(); // ← клиент может вызвать business-method!
return orderMapper.toJson(order);
}
Что не так:
- Утечка business-API наружу. Контроллер (или внешний слой) может вызвать
confirm()без проходения через command-handler. Инварианты рассыпаются. - Серилизация. Аггрегаты не предназначены для JSON: ленивые коллекции, registered events, internal mutable state — всё это плохо ложится в Jackson.
Корректно: всегда read-DTO на выходе query. Маппинг в JSON — тривиальный, потому что DTO уже плоский record.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Query-handler делает INSERT / UPDATE | R-CQRS-QRY-X1 | Перенести в отдельный command |
| Query грузит весь агрегат через основной repository | R-CQRS-QRY-X2 | <X>ViewRepository с минимально нужным набором полей |
| Query возвращает агрегат / Entity наружу | R-CQRS-QRY-X3 | Read-DTO как record |
Query вызывает доменный метод (order.archive()) | R-CQRS-QRY-4 | Отдельный scheduled command-handler |
Query-handler без readOnly = true | R-CQRS-QRY-2 | @Transactional(readOnly = true) |
| Read-DTO с полями из write-агрегата 1-в-1 | R-CQRS-QRY-3 | Денормализация, pre-computed поля |
Куда дальше
- CQRS → раздел 3. Query side — нормативные формулировки
R-CQRS-QRY-*. - Command side — пишущая половина: handler через агрегат и outbox.
- Read-model — где и в каком виде хранить read-данные.
- Sync via events — как read-таблица заполняется из событий write-side.
- jOOQ → View Repository — реализация
<X>ViewRepositoryчерез DSL. - REST API Style Guide — формат GET-эндпоинтов и пагинации.
- Use Case Pattern — маркер
UseCaseQueryи dispatcher.