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

В обычном приложении один и тот же сервис и сохраняет заказ, и отдаёт его на экран. Это удобно, пока объём небольшой. Когда нагрузка растёт, возникает конфликт: операции записи требуют блокировок и тяжёлых агрегатов, операции чтения — простых плоских данных без лишних JOIN'ов. Оба требования выполнить одновременно сложно.

Эта статья — про 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()));
    }
}

Два ключевых момента, и оба про то, что случится, если их пропустить: без readOnly запись из «запроса» пройдёт незамеченной, а чтение через основной репозиторий утащит в ответ всю доменную модель ради трёх полей.

@Transactional(readOnly = true) — не просто декларация намерений, но и не ускорение запроса. Блокировок PostgreSQL здесь не снимает: транзакция берёт на таблицы те же ACCESS SHARE, что и обычная. Что флаг действительно делает: открывает транзакцию только на чтение, и любая попытка записи в ней падает с ошибкой; Hibernate при нём не сверяет объекты на изменения (нет dirty-checking); такую транзакцию можно обслужить на горячей реплике.

Последнее не включается само. Чтобы запросы с readOnly = true уезжали на реплику, нужен источник данных, который смотрит на этот флаг и выбирает по нему подключение — обычно наследник AbstractRoutingDataSource. Рядом с ним ставят LazyConnectionDataSourceProxy: он откладывает выдачу соединения до первого запроса, чтобы к этому моменту флаг readOnly был уже известен. Сам по себе, без маршрутизирующего источника, LazyConnectionDataSourceProxy никуда ничего не направляет.

А что будет, если внутри readOnly всё-таки случится запись? Вопрос практический, и ответ зависит от того, как вы пишете, — поэтому в первый же день можно встретить оба поведения.

Через JPA изменение проходит тихо и не сохраняется. Hibernate в режиме только для чтения переводит сброс изменений в ручной режим: вы меняете поле сущности, исключения нет, а UPDATE в базу не уходит вовсе. Самое опасное поведение из возможных: код выглядит работающим, тест на «метод не упал» зелёный, а данные не изменились. Обнаруживается это обычно в проде через жалобу «я нажал, а ничего не поменялось».

Через прямой SQL база отвечает ошибкой. Транзакция открыта как «только чтение» на уровне базы, и UPDATE падает с сообщением вида «cannot execute UPDATE in a read-only transaction». Это честное поведение: ошибка видна сразу, транзакция откатывается.

Отсюда два вывода. Первый: не полагайтесь на readOnly как на защиту — она не срабатывает предсказуемо через слой сущностей. Настоящая защита от записи в запросе — то, что обработчик запроса работает через репозиторий только для чтения, у которого физически нет методов записи. Второй: если пишете напрямую и хотите поймать такие ошибки раньше, ошибка базы — ваш друг, и убирать readOnly, чтобы «не мешал», — плохая идея: он ловит настоящие ошибки проектирования.

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> с десятками полей ради одного числа — лишняя работа.

Такой подход называют денормализованной проекцией: данные из нескольких таблиц собраны в одну плоскую структуру, оптимальную для конкретного экрана.

запись command-handler OrderRepository агрегат Order чтение query-handler OrderViewRepository read-DTO

Две дорожки не пересекаются нигде, кроме базы: в нижней нет ни агрегата, ни блокировки, ни событий, поэтому чтение и не платит за них.

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)).asField("item_count"),
                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) {
        return new OrderSummary(
            r.get(ORDER.ID),
            r.get(ORDER.STATUS),
            r.get("customer_name", String.class),
            Money.of(r.get(ORDER.TOTAL_AMOUNT)),
            r.get("item_count", Integer.class),
            r.get(ORDER.CREATED_AT),
            r.get(ORDER.UPDATED_AT));
    }
}

Вот зачем псевдонимы: в Record поля лежат под теми именами, что заданы в select. Без as("customer_name") два разных NAME из разных таблиц в одном запросе было бы не различить, а у подсчёта позиций своего имени нет вовсе — его пришлось бы доставать по номеру колонки, и любая перестановка полей ломала бы маппинг.

Если данные уже хранятся в отдельной денормализованной таблице 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);
}

Постраничная навигация

В интерфейсе выше стоит Page<OrderSummary> и Pageable, и это самое недообъяснённое место стороны чтения. Разбираем три вопроса: чем листать, откуда общее число и что ломается на больших выборках.

Смещение против ключа. Два способа, и разница между ними принципиальная.

Смещение (LIMIT 20 OFFSET 4000) — то, что даёт Pageable по умолчанию. Просто, позволяет перейти на произвольную страницу, и имеет два неприятных свойства: база всё равно читает и отбрасывает первые 4000 строк (то есть чем дальше страница, тем дороже запрос), и строки съезжают — если между запросами первой и второй страницы добавилась запись, пользователь увидит один элемент дважды, а другой пропустит.

Листание по ключу (курсор) — условие на последнее увиденное значение:

SELECT ... FROM order_summary
 WHERE customer_id = ?
   AND (created_at, order_id) < (?, ?)      -- последняя строка предыдущей страницы
 ORDER BY created_at DESC, order_id DESC
 LIMIT 20;

Стоимость не зависит от номера страницы (индекс сразу попадает в нужное место), и строки не съезжают. Цена: нельзя прыгнуть на страницу 200, и нужен уникальный составной порядок — отметка времени плюс идентификатор, иначе при одинаковых значениях строки будут пропадать или дублироваться.

Практическое правило: бесконечная прокрутка и API для приложений — по ключу; таблица с номерами страниц для человека — по смещению, но с ограничением глубины. Ограничение честное: страницы дальше пятидесятой всё равно никто не листает, и вместо них нужен фильтр или поиск.

Откуда берётся общее число. Page требует totalElements, а это отдельный запрос COUNT(*) с тем же условием — и он дороже самой страницы: страница читает 20 строк по индексу, подсчёт проходит по всем подходящим. На выборке в миллион строк это секунды, причём на каждый запрос страницы.

Что с этим делают:

  • Не возвращать общее число вовсе. Для бесконечной прокрутки достаточно признака «есть ли ещё» — а он получается бесплатно: запросить LIMIT 21 и посмотреть, пришла ли двадцать первая строка.
  • Приблизительное число. «Примерно 12 000 результатов» — оценка из статистики базы; для интерфейса этого обычно достаточно, а стоит она почти ничего.
  • Точное число с ограничением. Считать «не больше тысячи»: COUNT по подзапросу с LIMIT 1000, а в интерфейсе показывать «более 1000». Дёшево и честно.
  • Хранить счётчик в витрине. Если число нужно точным и часто, его держат посчитанным — это одна из причин, по которым витрину и заводят.

Ещё три вещи, которые ломаются на постраничной навигации и о которых стоит знать заранее: сортировка обязана быть однозначной (при неуникальном порядке база вправе отдать строки в любом порядке, и страницы будут «дрожать»); размер страницы ограничивают сверху в самом обработчике (min(requested, 100)), иначе клиент попросит миллион строк одним запросом; и параметры сортировки не берут из запроса как есть — только из белого списка полей, иначе это и утечка по неиндексированным колонкам, и подстановка в запрос.

Сколько структур ответа заводить

Интерфейс показывает OrderSummary и OrderListItem, и правило выбора между ними не названо — а это главный вопрос проектирования стороны чтения: один вид на сущность или один на экран?

Правильный ответ — один на экран (точнее, на запрос). Не «одна структура заказа для всего приложения», а своя для списка, своя для карточки, своя для отчёта. Причина не в аккуратности, а в том, что ради одной общей структуры приходится читать лишнее: в списке нужны шесть полей, в карточке — тридцать, и общая структура заставит выбирать тридцать всегда.

Признаки, что вы сделали неправильно (общая структура на всё):

  • В ответе списка половина полей null, потому что «в списке они не нужны».
  • В структуре есть поля, которые не использует ни один экран, — их добавили «на будущее».
  • Изменение одного экрана требует правки структуры, которую читают пять других.
  • Запрос для списка тянет объединения, нужные только карточке.

Чего бояться не надо: похожих структур с пересекающимися полями. Три записи по десять полей с семью общими — это нормально и дешевле, чем одна на двадцать пять. Дублирование структур ответа — не долг, а свойство стороны чтения: они производные от экранов, и живут ровно столько, сколько живёт экран. Общая структура, наоборот, связывает экраны между собой: изменение для одного ломает другой.

Где границу всё-таки проводят: если два экрана требуют буквально одинаковый набор полей — одна структура на оба; если появилось третье место, использующее её, — это повод посмотреть, не стало ли это «общим на всё». И вложенные структуры (CustomerBrief внутри нескольких ответов) переиспользуют спокойно: это части, а не ответы.

Практический ориентир по числу: на сущность с развитым интерфейсом обычно приходится три-пять структур ответа (список, карточка, короткая для выпадающих списков, отчётная, выгрузка). Одна — признак, что вы читаете лишнее; двадцать — признак, что ответы делают на каждый запрос без разбора.

Кеш ответа — не витрина чтения

Два похожих механизма, которые постоянно путают, и путаница дорого стоит.

Кеш ответа — это ускоритель: тот же запрос, тот же результат, только не считается второй раз. У него есть срок жизни, промах означает обычный путь в базу, и потеря кеша не теряет ничего, кроме скорости. Источник правды — база.

Витрина чтения — это источник ответа: данных в таком виде больше нигде нет, их специально построили. Потеря витрины означает, что отвечать нечем, пока её не пересоберут; отставание витрины — свойство, с которым живут.

Отсюда практические различия, из-за которых их нельзя использовать вместо друг друга:

Кеш ответаВитрина чтения
Что при потерезамедлениенет ответа, нужна пересборка
Свежестьдо срока жизнидо отставания потребителя
Сброспо сроку или по событиюне сбрасывают, обновляют
Форма данныхровно ответ запросаструктура под целый класс запросов
Можно ли отвечать при недоступности источниканетда, если витрина жива

Когда достаточно кеша, а витрина не нужна. Запрос тяжёлый, но данные меняются редко и одинаковы для многих (справочник, список категорий, настройки). Кеш решает это за час работы и не требует ни потребителей, ни синхронизации. Это первое, что стоит попробовать перед разговором о витрине.

Когда кеш не помогает. Запрос уникален для каждого пользователя (его лента, его заказы) — попаданий почти не будет; данные должны быть свежими; или проблема не в скорости, а в форме данных (нужны фильтры и сортировки, которых в нормализованной схеме нет). Во всех трёх случаях нужна витрина.

Чего не делают. Не кешируют ответы с данными, зависящими от прав: ключ кеша обязан включать того, от чьего имени запрос, иначе один пользователь получит ответ другого — и это самая дорогая ошибка кеширования на стороне чтения. И не кешируют то, что и так читается по индексу за две миллисекунды: кеш добавляет свою задержку и своё расхождение, не давая выигрыша.

Частые ошибки

Изменение данных внутри 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, не поднимая позиции в память.

Оговорка про подзапрос: в примере выше DSL.selectCount() стоит в списке выбираемых полей, то есть выполняется для каждой строки результата. Для findSummaryById, где строка одна, это ровно один подсчёт. А вот в search() из того же интерфейса строк будет страница, и подсчёт пойдёт по каждой — там лучше посчитать позиции один раз через LEFT JOIN с группировкой или взять готовое item_count из денормализованной таблицы.

Вызов доменных методов в query

Query-handler не вправе вызывать бизнес-методы агрегата — order.confirm(), order.archive() и т.п. Если при чтении нужно что-то изменить, это должен делать отдельный scheduled command-handler, не обработчик UI-запроса.

Формулировка про задачу по расписанию верна не для всех случаев, и стоит уточнить: изменение делает отдельная команда, а вызывает её тот, кому это нужно. Вариантов ровно три — в зависимости от того, кто инициатор:

  • Инициатор — пользователь, и запись нужна сейчас. Контроллер вызывает команду рядом с запросом: MarkOrderViewedCommand, потом GetOrderDetailsQuery. Это случай «отметить просмотр», и он самый частый.
  • Инициатор — пользователь, но запись не обязана произойти немедленно. Обработчик запроса публикует событие (или кладёт задачу), а запись делает отдельный обработчик. Так поступают, когда запись дорогая и не должна замедлять ответ: счётчики просмотров, статистика поиска.
  • Инициатора нет вообще. Запись нужна по расписанию: архивировать старое, пересчитать агрегаты, закрыть просроченное. Вот здесь и появляется задача по расписанию — она вызывает команду сама, и к обработчику запроса отношения не имеет.

Путаница возникает из-за того, что все три называются «отдельной командой»; различает их инициатор, а не механизм.

Права доступа: проверка на стороне чтения

Обработчик запроса отдаёт плоскую структуру мимо агрегата — значит, все проверки прав, которые жили в доменных методах, здесь не выполняются. Это самая частая утечка данных именно на стороне чтения: GET /orders/42 отдаёт чужой заказ, потому что никто не проверил, чей он.

Правило: у запроса всегда есть тот, от чьего имени он выполняется, и это часть самого запроса, а не контекст, который «где-то есть».

// Владелец — часть запроса, а не подразумевается
public record GetOrderSummaryQuery(Long orderId, CallerContext caller)
        implements UseCaseQuery<OrderSummary> {}

Где именно проверять — три способа, и они не равноценны:

1. Условием в самом запросе к базе — лучший вариант. Ограничение уходит в WHERE, и данные, которые нельзя показывать, просто не читаются:

public Optional<OrderSummary> findSummaryFor(long orderId, long customerId) {
    return dsl.select(...)
            .from(ORDER_SUMMARY)
            .where(ORDER_SUMMARY.ORDER_ID.eq(orderId))
            .and(ORDER_SUMMARY.CUSTOMER_ID.eq(customerId))   // ← доступ в условии
            .fetchOptional(this::toSummary);
}

Почему так лучше: невозможно забыть проверку после чтения, работает и для списков (в выборку попадает только своё), и по объёму данных выгоднее. Для списков это вообще единственный правильный способ: фильтровать страницу после выборки означает отдавать неполные страницы и врать в счётчике.

2. Проверкой в обработчике после чтения. Прочитали, сравнили владельца, отказали. Годится для одиночного объекта, когда условие в запрос не вписывается (сложные правила, несколько ролей). Важно: отсутствие прав на существующий объект отдают как 404, а не 403, если сам факт существования — тоже информация (чужой заказ, чужой документ): иначе по кодам ответа можно перебирать идентификаторы.

3. Правилами в базе (защита на уровне строк). База сама не отдаёт чужие строки. Надёжнее всего, но требует передавать пользователя в соединение и усложняет пул соединений; берут в системах, где цена утечки высока.

Что ещё проверяют на стороне чтения, кроме владельца:

  • Видимость полей. Один и тот же объект разным ролям показывают по-разному: покупатель не видит внутренние пометки, поддержка видит. Это разные структуры ответа, а не одна с обнулёнными полями — иначе поле однажды уедет наружу.
  • Массовые выборки. Отчёт «все заказы» для сотрудника должен быть ограничен его зоной (магазин, регион, команда), и это то же условие в запросе.
  • Поиск по идентификатору другого пользователя. Параметр customerId в запросе от покупателя брать из запроса нельзя — только из контекста вызывающего; иначе подмена параметра отдаёт чужие данные. Это классическая ошибка, и находят её обычно снаружи.

И проверяемое правило на будущее: в обработчике запроса не должно быть выборки без условия, привязанного к вызывающему — кроме явно публичных данных. Такое легко проверить на ревью и даже тестом, а стоит это одного поля в записи запроса.

Возврат агрегата наружу

Query должен возвращать read-DTO, не агрегат. Если контроллер получит Order, он технически сможет вызвать order.confirm() напрямую, в обход command-handler'а и всех бизнес-проверок. Это разрушает смысл инкапсуляции агрегата.

Куда кладётся read-DTO

core/
└── domain/
    ├── orders/
    │   └── 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 не меняет данные, не вызывает доменные методы, не возвращает агрегат.
  • Если при чтении нужно что-то записать — это отдельная команда.
  • Запись внутри readOnly через слой сущностей проходит тихо и не сохраняется, через прямой SQL падает ошибкой; настоящая защита — репозиторий только для чтения без методов записи.
  • Права на стороне чтения проверяют условием в самом запросе (для списков — единственный верный способ), владелец приходит из контекста вызывающего, а не из параметра, и отсутствие прав отдают как 404.
  • Листание по ключу не дорожает с номером страницы и не даёт съезжания строк; общее число — дороже самой страницы, поэтому его не отдают, оценивают, ограничивают или держат в витрине.
  • Структуру ответа заводят на экран, а не на сущность: похожие структуры с пересечением полей нормальны, общая на всё связывает экраны и заставляет читать лишнее.
  • Кеш ответа — ускоритель с источником правды в базе, витрина — сам источник ответа; ключ кеша обязан включать вызывающего, иначе один пользователь получит ответ другого.

Что почитать дальше

  • Command side — пишущая половина: handler через агрегат и outbox.
  • Read-model — где и в каком виде хранить read-данные.
  • Sync via events — как read-таблица обновляется из событий write-side.
  • jOOQ → View Repository — реализация <X>ViewRepository через DSL.