JavaRush /Курсы /Spring Data JPA /Проекции mini-shop: Catalog и Summary

Проекции mini-shop: Catalog и Summary

Spring Data JPA
12 уровень , 4 лекция
Открыта

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.

1
Задача
Spring Data JPA, 12 уровень, 4 лекция
Недоступна
`ProductCatalogRow` для страницы каталога mini-shop
`ProductCatalogRow` для страницы каталога mini-shop
1
Задача
Spring Data JPA, 12 уровень, 4 лекция
Недоступна
`OrderSummaryRow` для краткого чтения заказа mini-shop
`OrderSummaryRow` для краткого чтения заказа mini-shop
1
Опрос
JPA Проекции, 12 уровень, 4 лекция
Недоступен
JPA Проекции
DTO для чтения данных
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ