JavaRush /Курси /Spring Data JPA /Коли entity є надли...

Коли entity є надлишковою: модель читання

Spring Data JPA
Рівень 12 , Лекція 0
Відкрита

1. Проблема: entity як «універсальна пігулка»

Початківці дуже люблять один заспокійливий рефлекс: «якщо треба прочитати дані — повернемо entity». Це виглядає логічно, бо сутність уже є, репозиторій уже вміє її повертати, і навіть код компілюється. Проблема в тому, що так ми непомітно перетворюємо сутність на універсальну форму даних для будь-якого сценарію — і дуже швидко починаємо за це платити.

Давайте чесно подивимося на типовий шлях — як це зазвичай трапляється.

У нас є сутність Product (каталог), і в якийсь момент нам потрібен список товарів, наприклад «усі активні». Новачок пише репозиторій так:

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

// Репозиторій повертає сутності: це зручно, але для читання списку часто надлишково
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data побудує запит за назвою методу: тут ми явно просимо «дай сутності Product»
    List<Product> findByStatus(ProductStatus status);
}

Далі в сервісі ми беремо ці Product і… використовуємо лише 3–4 поля. Решта просто «їде пасажиром»:

import java.math.BigDecimal;

public class CatalogService {

    public BigDecimal sumPricesOfActiveProducts() {
        // Тут сервісний шар читає список сутностей, хоча для суми потрібен лише price
        var products = productRepository.findByStatus(ProductStatus.ACTIVE);

        // Зверніть увагу: фактично ми використовуємо тільки getPrice(), решта графа нам не потрібна
        return products.stream()
                .map(Product::getPrice)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

На цьому місці багато хто каже: «Ну і що? Подумаєш, зайві поля…». І ось тут починається найцікавіше: в ORM «зайве» — це часто не лише поля, а ще й зв’язки, і потенційні додаткові запити, і пам’ять, і іноді навіть випадкова архітектурна розбещеність («давайте просто повернемо Product, хай той, хто викликає, сам розбирається»).

2. entity є надлишковою для читання

Коли ми кажемо «надлишкова», важливо не скочуватися в філософію. Ми не сваримо entity і не оголошуємо їх застарілими. Сутність — основний тип для запису та для повноцінної роботи з моделлю. Але в читанні в сутності є особливість: вона несе на собі занадто багато відповідальності, якої read-сценарію найчастіше не потрібно. І саме через цю «зайву потужність» сутність іноді стає дорогою й небезпечною.

У сутності є щонайменше три причини бути «занадто товстою» для простого читання.

Перша причина — кількість полів. Якщо Product з часом обросте деталями (описами, виробником, різними прапорцями, мітками часу, статусними полями), а вам потрібен «рядок у каталозі» (sku + name + price + status), то повертати всю сутність — це як приходити по хліб на фурі. Фура хліб привезе, але дорогою випадково зачепить кілька припаркованих машин.

Друга причина — зв’язки. У Product є Category, може бути ProductDetails, може бути StockItem. Навіть якщо прямо зараз ви ці зв’язки не чіпаєте, сама модель уже «просить» вас торкнутися їх десь далі в коді. І щойно хтось додасть в обробку рядка каталогу щось на кшталт product.getCategory().getName(), ви отримаєте додаткове навантаження на базу. Деталі lazy/eager зараз не потрібні; тут важливо інше: сутність — це двері у великий об’єктний граф.

Третя причина — межі. Сутність — це write-модель з інваріантами, життєвим циклом і змістом для persistence. Якщо ви починаєте використовувати її як «контейнер даних для всього», то дуже легко потрапити в ситуацію, де read-код починає залежати від внутрішніх деталей persistence-моделі. А потім ви хочете щось змінити в Product, і раптом «зламалася півпроєкту», хоча ви начебто «лише додали поле».

3. Модель читання: projection

Щоб не боротися із симптомами, нам потрібно назвати й прийняти саму ідею: читання — це не просто «отримати об’єкт». Читання — це отримати конкретну форму даних під конкретний сценарій використання. Іноді ця форма збігається із сутністю, але далеко не завжди. Коли ми це визнаємо, з’являється природний інструмент — projection.

Модель читання можна розуміти дуже просто: це «як саме виглядають дані, які потрібні цьому сценарію». Наприклад, «рядок каталогу» — це не Product як сутність. Це набір полів, потрібних, щоб показати список. А «короткий підсумок замовлення» — це не CustomerOrder цілком, а кілька полів для списку замовлень.

Projection у Spring Data JPA — це спосіб сказати репозиторію: «Поверни мені не сутність, а ось таку форму результату». І так, це саме контракт читання, а не «хитрий трюк навколо @Query».

Давайте зафіксуємо різницю у вигляді невеликої таблиці — без релігії, просто інженерна карта:

Характеристика entity projection (модель читання)
Головна роль persistence-модель (read + write) read-модель під сценарій використання
Чи можна save(...) так ні (і не повинно хотітися)
Чи є зв’язки та lifecycle так зазвичай ні (або лише потрібні поля)
«Розмір» за даними часто великий навмисно маленький
Небезпека «випадково затягнути зайве» висока помітно нижча
Хто задає форму сутність як клас return type методу репозиторію

Тут важливо окремим рядком: projection не замінює entity. Вона просто знімає з сутності роль «універсального об’єкта для читання», щоб сутність залишалася тим, чим і має бути: стійкою persistence-моделлю.

4. Return type репозиторію — контракт читання

Одна з найсильніших ідей Spring Data (і водночас одна з найбільш недооцінених у початківців) полягає в тому, що сигнатура методу — це не просто «тип, який зручно повернути». Це контракт, який впливає на те, які дані і в якій формі будуть прочитані. Якщо ви повертаєте Product, ви буквально кажете: «дай мені сутність». Якщо ви повертаєте projection — ви кажете: «дай мені саме цю форму даних».

Почнемо з нашого знайомого варіанта, який повертає сутність:

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

// Варіант «за замовчуванням»: метод повертає entity, тобто тягне потенційно великий об'єктний граф
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Контракт читання в цій сигнатурі звучить так: «мені потрібна сутність Product»
    List<Product> findByStatus(ProductStatus status);
}

Тепер припустімо, для списку товарів нам достатньо sku, name і price. Ми можемо описати контракт читання окремим типом. Найпростіший варіант — інтерфейс із гетерами.

import java.math.BigDecimal;

// Інтерфейсна projection: Spring Data підставить реалізацію, читаючи лише потрібні поля
public interface ProductListItem {

    // Імена гетерів мають збігатися з іменами полів/аліасів, щоб projection зібралася коректно
    String getSku();

    String getName();

    BigDecimal getPrice();
}

І метод репозиторію може повернути не Product, а ProductListItem:

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

// Те саме «пошук за статусом», але контракт читання тепер вузький: лише поля для списку
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Важливо: змінюємо лише тип, що повертається, — і тим самим змінюємо форму читання даних
    List<ProductListItem> findByStatus(ProductStatus status);
}

З погляду сервісу це виглядає майже так само зручно, але мисленнєва модель змінюється радикально: тепер сервіс не «отримав сутності й якось їх використовує», а отримав саме те, що йому потрібно для читання списку.

І ще один важливий момент для мозку: один і той самий пошук може існувати у двох формах — як entity-результат і як projection-результат. Це нормально. Ми не зобов’язані робити один метод «на всі випадки». Ми проєктуємо read-контракти під сценарій використання.

Наприклад:

import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Форма читання «для повноцінної роботи»: повертаємо entity
    Optional<Product> findBySku(String sku);

    // Форма читання «для легкого перегляду»: повертаємо projection і не даємо випадково чіпати зв’язки
    Optional<ProductListItem> findProjectedBySku(String sku);
}

Назва findProjectedBySku тут просто підкреслює намір (можна назвати інакше, головне — щоб команді було зрозуміло, що це саме читання у формі projection).

5. Приклади в mini-shop

Щоб projection не залишилася абстракцією, давайте приземлимо її на наш проєкт. У mini-shop у нас дуже природно виникають різні сценарії читання, і майже жоден із них не потребує «усю сутність цілком». Це нормальна ситуація в комерційній розробці: read-сценарії часто вузькі, а write-модель — багата.

Уявімо два сценарії.

Перший — сторінка каталогу або сервісне читання «показати асортимент». Для нього зазвичай потрібні поля продукту: sku, name, price, status. Іноді ще хочеться categoryName, але навіть без цього сценарій уже працює.

Якщо ми повертаємо List<Product>, ми несемо ризик, що десь «випадково» почнемо лазити по зв’язках, тягнути зайве й перетворювати каталог на центр світу. Якщо ж ми повертаємо projection, то каталог стає простим і дешевим читанням.

Другий — список замовлень користувача або «коротка картка замовлення» (для адмінки, звіту, листа — неважливо). Дуже часто потрібні orderNumber, totalAmount, status, createdAt. Вам не потрібні OrderItem цілком, не потрібні товари, не потрібна адреса доставки цілком (або потрібне лише місто).

Якщо ви повернете CustomerOrder як сутність, вам буде дуже легко «прив’язатися» до об’єктного графа замовлення. А потім, коли ви вирішите оптимізувати читання або трохи змінити модель, ви зрозумієте, що «всюди використовується сутність». Projection допомагає провести межу: ось легка форма читання, ось повноцінна сутність для write-сценаріїв.

Схематично це можна уявити так:

flowchart TD
    A["Сценарій використання: список товарів"] --> B["Метод репозиторію"]
    B -->|"return Product"| C["Сутність: багато полів + зв'язки"]
    B -->|"return ProductListItem"| D["Projection: 3-5 полів"]
    C --> E["Спокуса тягнути зайве"]
    D --> F["Контракт читання обмежений"]

Так, це виглядає як «менше можливостей», але насправді це «менше випадковостей». У backend-і це зазвичай комплімент.

6. Вибір між entity і projection

Будь-яка корисна техніка легко перетворюється на фан-клуб: «повертаємо лише projections, сутності взагалі не читаємо». Так теж можна наступити на граблі, просто іншого кольору. Тому нам потрібна не мантра, а спокійне рішення за контекстом.

Якщо ви пишете сценарій запису (створення/зміна), то майже завжди працюєте із сутністю. Вам потрібні інваріанти, вам потрібно змінювати стан, і ви хочете, щоб JPA/Hibernate відстежували зміни. Projection тут тільки заважатиме, тому що вона не призначена для save(...) і не живе в persistence context як managed entity.

Якщо ви пишете сценарій читання, то є просте правило: чим вужчий сценарій читання і чим менше полів реально потрібно, тим сильніше projection виправдана. Каталог, списки, короткі картки, «вивантажити кілька колонок для звіту» — усе це класичні кандидати.

Але є тонкість: іноді read-сценарій усе одно потребує повноцінної сутності. Наприклад, якщо у вас сценарій «відкрити картку товару для редагування в адмінці», то ви хочете завантажити сутність цілком, показати її в UI, потім змінити та зберегти. Так, ми сьогодні не будуємо web-layer, але логіка «читаю заради зміни» в будь-якому разі про сутність.

Ще один добрий практичний маркер: якщо ви, отримавши Product, одразу робите product.getSku(), product.getName(), product.getPrice() і збираєте «маленький об’єкт даних», — це майже завжди сигнал, що можна читати одразу в маленькій формі. Проєкція дозволяє перестати писати один і той самий ручний мапінг і перестати тягнути в пам’ять те, що ви все одно викидаєте.

І ще один маркер — дисципліна меж. Якщо команда постійно «повертає сутність назовні», дуже швидко сутність перетворюється на транспортний тип, і будь-яка зміна сутності починає ламати неочікувані місця. Projection допомагає зробити залежності локальнішими: зламався read-сценарій — значить, зламалася конкретна projection, а не «всі місця, де хтось колись торкався Product».

Projection — не обов’язково web-DTO

Дуже легко переплутати projection із DTO, тому що зовні це справді схоже: «маленький об’єкт із полями». Ба більше, class/record projection технічно може виглядати рівно як DTO. Але тут важлива не форма об’єкта, а його роль. Projection у цьому випадку — це насамперед контракт читання на рівні репозиторію, а не автоматично зовнішній API-контракт.

Ми свідомо тримаємо web-layer тонким і не робимо з курсу курс із REST. Тому projections нам потрібні не «щоб гарно віддавати JSON», а щоб ваш data-layer був інженерно адекватним: читав рівно те, що потрібно сценарію використання, і не змушував сервісний код удавати з себе мапер.

Саме тому сьогодні ми говоримо: «не кожен метод репозиторію має повертати entity». У репозиторію є право — а іноді й обов’язок — повертати форму даних, яка відповідає читанню.

Якщо поверх такого data-layer з’явиться тонкий web-адаптер, вузький контракт читання зазвичай лише допомагає: назовні вже не витікає сутність. Але головний виграш тут усередині backend-а — залежності стають вужчими й зрозумілішими.

7. Типові помилки під час роботи з projection

Помилка №1: повертати entity «про всяк випадок», бо так простіше.
Це найчастіший «гріх молодості». На старті проєкту справді простіше повернути Product і забути. Але потім неминуче з’являються нові поля, нові зв’язки, нові сценарії використання, і сутність стає величезною. У підсумку ви платите складністю там, де за змістом був простий список із чотирьох колонок.

Помилка №2: намагатися «оновлювати projection» так, ніби це сутність.
Projection — це не managed entity. Її не можна чесно «змінити й зберегти через save(...)», бо вона не є persistence-моделлю. Якщо вам потрібна зміна даних — беріть сутність, змінюйте її в транзакції та зберігайте правильним способом. Projection залиште для читання, інакше ви швидко отримаєте кашу в голові й у коді.

Помилка №3: робити projection, яка повторює майже всю сутність.
Іноді розробник начебто почув ідею «давайте проєкції», але робить інтерфейс/DTO на 18 полів, включно з половиною зв’язків, і радіє, що тепер це «не entity». Це не перемога, а перейменування проблеми. Якщо вам потрібна майже вся сутність — чесніше повернути сутність. Якщо потрібна вузька форма — робіть її вузькою.

Помилка №4: намагатися однією projection закрити всі сценарії читання.
Коли з’являється перший ProductListItem, виникає спокуса розширювати його під кожен новий екран: додати поле категорії, додати залишок, додати опис, додати виробника… У підсумку ви знову отримуєте «універсальну пігулку», тільки тепер вона називається не Product, а ProductListItem. Нормальна практика — кілька маленьких типів projection, кожен під свій сценарій використання. Це виглядає як «більше файлів», але майже завжди робить код простішим і чеснішим.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ