← назад к разделу

В обычном приложении один и тот же сервис и сохраняет заказ, и отдаёт его на экран. Это удобно, пока объём небольшой. Когда нагрузка растёт, возникает конфликт: операции записи требуют блокировок и тяжёлых агрегатов, операции чтения — простых плоских данных без лишних 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.