Опирается на правила: R-CQRS-QRY-1R-CQRS-QRY-4 и R-CQRS-QRY-X1R-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 целое — на порядок дешевле.
  • OrderStatus enum — это OK, потому что enum безопасно сериализуется и не содержит поведения.
  • Money VO — это 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 / UPDATER-CQRS-QRY-X1Перенести в отдельный command
Query грузит весь агрегат через основной repositoryR-CQRS-QRY-X2<X>ViewRepository с минимально нужным набором полей
Query возвращает агрегат / Entity наружуR-CQRS-QRY-X3Read-DTO как record
Query вызывает доменный метод (order.archive())R-CQRS-QRY-4Отдельный scheduled command-handler
Query-handler без readOnly = trueR-CQRS-QRY-2@Transactional(readOnly = true)
Read-DTO с полями из write-агрегата 1-в-1R-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.