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