1. Модели чтения mini-shop
Теперь соберём projections в две «рабочие» модели чтения для нашего mini-shop, чтобы они перестали быть теоретическими примерами на абстрактном User и стали частью проекта shop-data-jpa. Здесь мы сознательно берём record-проекции: ProductCatalogRow и OrderSummaryRow становятся явными именованными read-моделями проекта, которые сервису удобно передавать дальше по коду. Interface-based projections при этом никуда не пропадают — они по-прежнему хороши для более лёгких чтений, где отдельный именованный тип не так важен.
В нашем домене очень естественно выделяются два сценария чтения. Первый — страница каталога: список товаров, где обычно нужны sku, имя, цена, статус, и (в идеале) ничего лишнего. Второй — краткая сводка заказа: когда нужно быстро показать номер заказа, email и сумму, не поднимая весь граф CustomerOrder -> items -> product -> ... (даже если он у вас уже красиво замаплен).
Чтобы не держать всё в голове, зафиксируем это в маленькой таблице — она будет нашим «контрактным обещанием»:
| Use case | Что возвращаем | Почему это удобно |
|---|---|---|
| Каталог товаров (страница) | Page<ProductCatalogRow> | Сразу получаем модель под список + поддерживаем пагинацию |
| Короткая карточка заказа | Optional<OrderSummaryRow> | Сразу получаем сводку под «детальку», без загрузки всей сущности |
2. Каталог товаров
ProductCatalogRow как record
Начнём с каталога. Почти в любом проекте список товаров — одна из самых частых операций чтения. И именно в списках чаще всего и начинается «тихий» перерасход ресурсов, потому что разработчик возвращает List<Product>, а потом в сервисе или контроллере достаёт из него три поля… и делает вид, что ничего не произошло. ORM при этом, конечно, не плачет — плачет ваша база данных (и немного прод).
ProductCatalogRow — это простая модель: минимальный набор полей, которые нужны для отображения товара в каталоге. Мы сделаем её record, потому что это компактно, читабельно и снимает с нас обязанность писать гору шаблонного кода. Spring Data прямо поддерживает class-based projections, и Java records отлично подходят для DTO-типов.
Пример (кладём в пакет com.example.shopdatajpa.catalog.query):
import java.math.BigDecimal;
public record ProductCatalogRow(
Long id, // Идентификатор, чтобы строить ссылки / отправлять команды (например, "добавить в заказ")
String sku, // Артикул: обычно главный "человеческий" идентификатор товара в UI и интеграциях
String name, // Название, которое показываем в списке
BigDecimal price // Цена для отображения в каталоге (без вытаскивания всей сущности)
) {
}
Обрати внимание на поле id. В каталоге оно часто нужно не «чтобы любоваться», а чтобы затем открыть карточку товара, сформировать ссылку, отправить команду «добавить в заказ» и так далее. То есть id — это не «лишнее поле», а практичная точка привязки.
Если тебе хочется добавить status — это тоже нормально для каталога. Но давай начнём с минимального набора, чтобы прочувствовать главный принцип: в проекции мы держим только то, что реально нужно use case.
ProductRepository: select new и Page
Теперь самый вкусный кусок: сделаем репозиторный метод, который возвращает страницу каталога сразу в виде ProductCatalogRow. И вот тут projection превращается из «красивой идеи» в реальный контракт data-layer: метод репозитория прямо говорит вызывающему коду, что он получит.
Для class-based projections с JPQL используется constructor expression — то самое select new. Важно помнить, что в JPQL обычно требуется указывать полное имя класса (FQDN) в select new, иначе провайдер просто не поймёт, какой тип создавать.
Добавим метод в ProductRepository (показываю только фрагмент — подразумевается, что это внутри интерфейса репозитория):
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
@Query("""
select new com.example.shopdatajpa.catalog.query.ProductCatalogRow(
p.id, p.sku, p.name, p.price
-- Важно: порядок и типы аргументов должны совпадать с конструктором record'а
)
from Product p
-- Здесь нет join'ов и "лишних" полей: это именно чтение под каталог
""")
Page<ProductCatalogRow> findCatalogPage(Pageable pageable); // Pageable приходит снаружи и управляет paging/sorting
Тут есть несколько важных «почему так» (без паники, это не магия):
Во‑первых, мы возвращаем Page<ProductCatalogRow>, потому что каталог обычно живёт в пагинации. Здесь нужен именно Page: вместе со строками каталога он несёт общее количество элементов и страниц.
Во‑вторых, мы не возвращаем Page<Product>. Да, так можно. Но тогда почти всегда возникнет соблазн «а давайте ещё одно поле возьмём», потом «а давайте category подгрузим», потом «а давайте items»… и внезапно у вас каталог начинает вести себя как мини-энциклопедия всего домена.
В‑третьих, мы используем select new, а значит обязаны уважать правило constructor matching: выбранные поля должны совпадать по порядку и типам с конструктором record’а. Если перепутаешь местами sku и name, Java не обидится (она даже не узнает), а вот на выполнении запроса будет «сюрприз».
И ещё важный нюанс: в constructor expression нельзя пихать алиасы внутри списка аргументов. Это нормальная техника для interface-based projections (там действительно важны имена), но для DTO/record-конструктора она не нужна и может быть невалидной. Spring Data отдельно предупреждает, что JPQL constructor expressions не должны содержать алиасы.
Чтобы увидеть, что это работает вживую, можно вызвать метод из сервиса. Пусть в CatalogService (или отдельном query-сервисе) появится метод чтения:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
public Page<ProductCatalogRow> getCatalogPage(int page, int size) {
// PageRequest — конкретная реализация Pageable: задаём номер страницы и размер
PageRequest pageable = PageRequest.of(page, size);
// Важно: сервис получает данные сразу в проекции, не материализуя entity Product
return productRepository.findCatalogPage(pageable);
}
И да, это абсолютно нормальная архитектурная ситуация: сервис читает данные сразу в projection-тип, не трогая entity. Мы не «обязаны» материализовать Product, чтобы потом выбросить 80% полей.
3. Заказы
OrderSummaryRow как сводка
Теперь перейдём к заказам. Тут часто случается такая ловушка: вы хотите показать «короткую карточку заказа», но репозиторий возвращает CustomerOrder, и дальше начинается цепочка «ну раз уж заказ у нас есть, давай покажем ещё позиции», «раз позиции, давай продукты», «раз продукты, давай категории»… И в итоге «короткая карточка» превращается в «праздник жизни» с непредсказуемым количеством данных.
OrderSummaryRow — это read-модель, которая отвечает на конкретный вопрос: «дай мне минимум, чтобы показать пользователю (или сервису) сводку заказа». Она не пытается заменить CustomerOrder. Она просто честно делает своё дело.
Сделаем record и положим его рядом с query-логикой заказов: com.example.shopdatajpa.ordering.query.
import java.math.BigDecimal;
public record OrderSummaryRow(
String orderNumber, // Номер заказа: то, что обычно видит пользователь/оператор
String customerEmail, // Email клиента для отображения в сводке
BigDecimal totalAmount // Итоговая сумма (без подтягивания items и продуктов)
) {
}
Обрати внимание: здесь нет items, нет адреса доставки, нет статуса. Не потому что «мы не умеем», а потому что это другая форма чтения. Если завтра понадобится «сводка + статус» — это может стать отдельной проекцией, а не поводом раздувать эту.
CustomerOrderRepository: select new и Optional
Теперь добавим метод в CustomerOrderRepository, который вернёт сводку заказа по id. Здесь очень хорошо работает Optional: мы читаем один объект, и отсутствие результата — нормальная ситуация (например, заказ удалён или id неверный).
И снова используем select new с полным именем record’а, потому что это JPQL constructor expression.
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@Query("""
select new com.example.shopdatajpa.ordering.query.OrderSummaryRow(
o.orderNumber, o.customerEmail, o.totalAmount
-- Тут тоже важно совпадение порядка полей с record-конструктором
)
from CustomerOrder o
where o.id = :id
-- :id — именованный параметр запроса; метод читает ровно одну "сводку"
""")
Optional<OrderSummaryRow> findOrderSummaryById(@Param("id") Long id); // Optional подчёркивает, что "может не найтись" — это ок
Этот метод хорош тем, что он:
Во‑первых, сразу возвращает ровно то, что нужно.
Во‑вторых, не создаёт соблазна «а давайте прямо тут же менять заказ». Потому что менять OrderSummaryRow бессмысленно — это read-only контракт.
В‑третьих, удерживает нас в дисциплине. Репозиторий не пытается быть «и чтением, и записью, и конструктором домена, и сериализатором JSON». Он просто достаёт данные в нужной форме.
4. Сервис и организация кода
Сервисный слой: читаем проекции напрямую
На этом месте у начинающих часто появляется странное ощущение, что «сервис должен работать только с entity, а projection — это что-то несерьёзное». На практике всё наоборот: сервис должен работать с тем типом данных, который соответствует use case. Если use case — чтение для каталога, то самый честный и чистый контракт — Page<ProductCatalogRow>.
Можно представить это как простую схему потока данных. Никаких «тайных порталов», всё предельно прозрачно:
flowchart LR
S[CatalogService] --> R[ProductRepository]
R --> DB[(PostgreSQL)]
DB --> R
R --> S
S --> OUT["Page<ProductCatalogRow>"]
Если хочется сделать быстрый smoke-проверочный сценарий (без полноценного web-layer), можно завести небольшой CommandLineRunner, который печатает пару строк каталога. Это не «продовый» код, но отличный учебный тест на то, что ваша проекция реально строится.
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class CatalogDemoRunner implements CommandLineRunner {
private final CatalogService catalogService;
public CatalogDemoRunner(CatalogService catalogService) {
this.catalogService = catalogService;
}
@Override
public void run(String... args) {
// Демо: читаем первую страницу каталога и печатаем несколько строк в консоль
var page = catalogService.getCatalogPage(0, 3);
// record генерирует toString(), поэтому вывод будет читабельным без доп. кода
page.getContent().forEach(System.out::println);
// Пример ожидаемого формата:
// ProductCatalogRow[id=1, sku=SKU-1, name=Coffee, price=9.99]
}
}
Да, record сам генерирует toString(), поэтому вывод будет аккуратным. И да, это тот редкий случай, когда авто‑генерация делает вашу жизнь лучше, а не превращает проект в «Lombok-стайл сюрпризы».
Где хранить проекции и как называть
Когда проекции начинают «приживаться» в проекте, появляется другая опасность: их становится много, и они превращаются в «мусорный ящик» рядом с репозиторием. Очень хочется завести пакет dto и складывать туда всё подряд. Но наш проект устроен package-by-feature, и это сильная сторона: если ProductCatalogRow — это чтение каталога, то она должна жить рядом с catalog-кодом, а не в абстрактном «общем месте, где ничего не понятно».
Договоримся о простой дисциплине именования. Суффиксы вроде Row, View, Summary полезны, потому что сразу показывают смысл. ProductCatalogRow — это «строка» каталога. OrderSummaryRow — это «сводка» заказа. Они не претендуют быть «моделью домена», и по названию это видно.
Ещё одно правило, которое стоит держать в голове: если ты начинаешь добавлять в ProductCatalogRow поля «на всякий случай», значит ты снова тащишь в чтение привычку работать entity-образом. Лучше сделать вторую проекцию под другой use case, чем превратить одну в полу-Product.
И аккуратная ремарка про вложенные поля. Projections обычно хорошо оптимизируют выборку top-level полей сущности, но как только ты начинаешь тянуть вложенные свойства (например, p.category.name), ты неизбежно приходишь к join. Spring Data отмечает, что проекции ограничивают выборку top-level свойствами, а вложенные свойства приводят к join и могут материализовать связанные данные. Это не «плохо», просто это должно быть осознанным решением.
5. Типичные ошибки при работе с проекциями
В этом финальном разделе соберём грабли, на которые наступают почти все — и это нормально. Проблема не в том, что вы ошиблись, а в том, что ошибка повторяется снова и снова, пока вы не начнёте видеть её заранее.
Ошибка №1: “Верну Product, а потом где-нибудь сверху отрежу лишнее”.
Это выглядит безобидно: ну подумаешь, сущность же уже есть. Но архитектурно вы теряете главный плюс проекций — контракт чтения становится мутным. Вместо “страница каталога” у вас получается “страница сущностей, из которых кто-то потом выберет что надо”. Так легко незаметно начать тянуть лишние поля и связи в списки, особенно когда команда растёт.
Ошибка №2: попытка “обновить” ProductCatalogRow и сохранить через save(...).
record выглядит как объект с полями, и мозг новичка иногда говорит: “ну это же почти как entity, давайте поменяем price и сохраним”. Не получится. Projection — не persistence-модель. Она не managed, у неё нет жизненного цикла JPA, и Spring Data не обязан (и не будет) превращать её обратно в сущность ради вашей идеи “сэкономить пару строк”.
Ошибка №3: перепутан порядок полей в select new ...
Это одна из самых неприятных ошибок, потому что компилятор не поможет. Вы можете случайно написать p.name, p.sku вместо p.sku, p.name, и всё сломается в рантайме. Лечится дисциплиной: держать запрос и record рядом, не раздувать проекцию и не усложнять select new без необходимости.
Ошибка №4: не указали полное имя record’а в JPQL constructor expression.
Если написать select new ProductCatalogRow(...), провайдер чаще всего не поймёт, что это за тип. Для JPQL constructor expression обычно нужно FQDN вида select new com.example...ProductCatalogRow(...).
Ошибка №5: добавили алиасы внутри select new и ждёте, что “как-нибудь сработает”.
Алиасы (as something) полезны для interface-based projections, где имена связываются с getter-ами. Но в constructor expression важны позиция и тип, и алиасы там не нужны и могут быть запрещены. Spring Data отдельно предупреждает про это ограничение JPQL constructor expressions.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ