JavaRush /Курси /Spring Data JPA /Правило вибору інструмента і

Правило вибору інструмента і OSIV

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

1. Правило вибору: форма результату → інструмент

Якщо ви запам’ятаєте з цієї лекції лише одну фразу, нехай це буде така: «Спочатку визначаємо, який результат потрібен сценарію використання, а вже потім обираємо спосіб читання». Проблема більшості N+1 і lazy-збоїв не в тому, що розробник не знає анотацій, а в тому, що він не сформулював форму відповіді й дозволив коду “дочитувати” дані де завгодно.

Уявіть, що ви прийшли до магазину (наш mini-shop) і кажете: «Дайте мені… е-е-е… щось». Продавець може принести вам і батарейки, і холодильник, і колекцію Java Concurrency in Practice. Так само й ORM: якщо ви не задали форму результату, вона почне “вгадувати” через ліниві завантаження, і кожен ваш виклик геттера стане потенційним SQL-запитом.

У нашому проєкті shop-data-jpa зручно тримати в голові щонайменше три базові форми сценарію читання:

1) Картка (один об’єкт, але “детальніше”). Наприклад: картка замовлення за orderNumber, де потрібні позиції й товари.
2) Список (багато об’єктів, але “коротко”). Наприклад: список замовлень у статусі PAID або список товарів у каталозі із сортуванням і сторінками.
3) Зведення / summary (ще коротше, можливо агрегати). Наприклад: номер замовлення + сума + кількість позицій.

І тут починається магія… але нормальна, інженерна: одна й та сама предметна область потребує різних моделей читання. Для картки може бути виправдане entity-based читання (із підвантаженням зв’язків), а для списку частіше потрібна projection. Інакше ви або отримаєте N+1, або просто прочитаєте вдесятеро більше даних, ніж потрібно.

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

import java.util.Optional;

public interface CustomerOrderRepository {

    // "Картка": повертаємо сутність, яку (найімовірніше) потрібно заздалегідь підготувати потрібними зв’язками.
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);

    // "Зведення": повертаємо projection/read-модель, тут немає лінивої навігації по сутностях.
    Optional<OrderSummaryRow> findSummaryByOrderNumber(String orderNumber);
}

Перший метод обіцяє «картку» (найімовірніше, зі зв’язками), другий — «зведення» (projection). І тут є важливий психологічний ефект: коли ви бачите OrderSummaryRow, рука вже не тягнеться до order.getItems() — бо order тут узагалі не існує.

2. Матриця вибору: fetch vs graph vs projection

Коли інструментів кілька, мозок новачка часто робить небезпечний фокус: обирає один улюблений і починає забивати ним усе підряд, як молотком. Виходить філософія: «Якщо у вас є join fetch, то все навколо — цвяхи». У житті це закінчується приблизно так само, як зі справжнім молотком: одного разу ви спробуєте ним поміняти батарейку в пульті.

Давайте зробимо компактну матрицю рішень, яка допомагає обрати інструмент за змістом сценарію використання. Це не закон природи і не «єдино правильна архітектура», але для junior-рівня це дуже робоча опора.

Сценарій читання (людськими словами) Що повертаємо Зазвичай обираємо Чому це логічно
«Потрібна картка товару, і в ній точно потрібна категорія» Product join fetch або @EntityGraph Сутність потрібна цілком, але один зв’язок треба підготувати відразу
«Потрібна картка замовлення з позиціями і товарами» CustomerOrder join fetch або @EntityGraph Картка — це “глибоке” читання, потрібен контроль завантаження
«Потрібен список замовлень: номер, статус, сума» projection (record / interface) читання на основі projection Список — це “ширина”, entity майже завжди надлишкова
«Потрібна зведена інформація: номер + сума + itemsCount» projection + агрегати projection (select new …) Можна взагалі не чіпати колекції, а зібрати результат запитом
«Після читання ми будемо змінювати об’єкт у межах транзакції» entity entity-based read + потрібний fetch plan Projections не managed, змінювати їх безглуздо

Тепер коротко про внутрішню різницю між join fetch і @EntityGraph, бо вони обидва розв’язують схожу задачу: підвантажити зв’язки в межах одного сценарію читання.

join fetch добрий, коли вам і так потрібен @Query, тому що сама логіка запиту не виражається простим derived-методом. Ви пишете JPQL і прямо в ньому задаєте, що саме підвантажується.

import org.springframework.data.jpa.repository.Query;
import java.util.Optional;

public interface ProductRepository {

    @Query("""
           select p
           from Product p
           join fetch p.category
           where p.id = :id
           """)
    // join fetch: явно кажемо Hibernate підвантажити category в тому самому запиті,
    // щоб далі не було лінивого SQL на p.getCategory().
    Optional<Product> findCardById(long id);
}

@EntityGraph зручніший, коли сам запит “і так простий” (наприклад, findById, findByOrderNumber, findByStatus), а ви хочете окремо вказати план завантаження. Тобто ви не змінюєте логіку вибірки, ви змінюєте лише план завантаження.

import org.springframework.data.jpa.repository.EntityGraph;
import java.util.Optional;

public interface ProductRepository {

    // EntityGraph: логіка вибірки залишається derived (find...),
    // а план завантаження задаємо окремо (що саме підтягнути).
    @EntityGraph(attributePaths = "category")
    Optional<Product> findCardById(long id);
}

А projection — це взагалі інший стиль мислення: замість того щоб сперечатися “які зв’язки підвантажувати”, ви кажете: “Мені не потрібна сутність”. Це часто найчесніший і найдешевший варіант для списку або зведення.

import java.math.BigDecimal;

// Projection/read-модель для зведення: лише те, що реально потрібно сценарію використання.
public record OrderSummaryRow(String orderNumber, BigDecimal totalAmount) {}

3. Одне замовлення за номером — різні форми читання

Найприємніше (і найпідступніше) у Spring Data JPA: ви майже завжди можете розв’язати задачу кількома способами. Новачок від цього спочатку радіє, а потім страждає: «То який же правильний?» Спойлер: правильний — той, який відповідає формі сценарію і не змушує застосунок читати зайве або “дочитувати потім”.

Візьмімо одну предметну точку входу з нашого mini-shop: замовлення за номером. Але далі важливо не обманювати себе: «картка замовлення» і «зведення по замовленню» — це вже різні сценарії читання. Тому правильніше порівнювати не три реалізації одного й того самого сценарію, а кілька чесних форм читання навколо одного й того самого замовлення.

Варіант A: join fetch і план у JPQL

Цей підхід добрий, коли ви хочете одним місцем (текстом запиту) зафіксувати і фільтрацію, і завантаження зв’язків. Зазвичай так роблять для карток: одне замовлення → багато деталей.

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface CustomerOrderRepository {

    @Query("""
           select distinct o
           from CustomerOrder o
           left join fetch o.items i
           left join fetch i.product
           where o.orderNumber = :orderNumber
           """)
    // distinct потрібен, бо fetch колекції розмножує рядки в SQL-результаті
    // (по рядку на кожну позицію замовлення), а нам логічно потрібен один CustomerOrder.
    Optional<CustomerOrder> findCardByOrderNumber(@Param("orderNumber") String orderNumber);
}

Зверніть увагу на distinct. Він тут не для краси і не “бо так заведено”. Коли ви fetch-ите колекцію, SQL-результат містить повторювані рядки по замовленню (по одному на кожну позицію). distinct на рівні JPQL допомагає Hibernate зібрати «логічно унікальні» CustomerOrder у результаті.

Варіант B: @EntityGraph і план завантаження

Якщо вам подобається, що метод репозиторію виглядає як звичайний derived query, але ви хочете підвантажити зв’язки під сценарій використання, @EntityGraph дуже приємний. Він робить намір “підвантаж ось це” видимим без переписування запиту.

import org.springframework.data.jpa.repository.EntityGraph;

import java.util.Optional;

public interface CustomerOrderRepository {

    // Кажемо: для картки нам потрібні items і product всередині items.
    // Критерій і далі derived за orderNumber, а імʼя методу чесно відображає картковий сценарій використання.
    @EntityGraph(attributePaths = {"items", "items.product"})
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);
}

Цей варіант часто читається легше, ніж довгий JPQL. Мінус у нього теж інженерний: якщо вам потрібно ускладнити саму фільтрацію, ви все одно підете в @Query. Тобто @EntityGraph — це не «завжди краще», а «краще, коли запит і так простий».

Варіант C: projection для зведення

А тепер важливий поворот сюжету: інколи здається, що потрібна картка замовлення, а насправді зовнішньому шару потрібна коротка інформація: номер, сума, кількість позицій. Якщо так, підвантажувати колекцію items як сутності може бути просто зайвим.

Тоді замість entity можна повернути read-модель. Наприклад, так:

import java.math.BigDecimal;

// Read-модель короткого зведення по замовленню, а не entity і не картка зі зв’язками.
public record OrderSummaryRow(String orderNumber, BigDecimal totalAmount, long itemsCount) {}

І запит:

import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface CustomerOrderRepository {

    @Query("""
           select new com.example.shopdatajpa.ordering.query.OrderSummaryRow(
               o.orderNumber, o.totalAmount, count(i)
           )
           from CustomerOrder o
           left join o.items i
           where o.orderNumber = :orderNumber
           group by o.orderNumber, o.totalAmount
           """)
    // Важливо: ми не підвантажуємо items як сутності.
    // Просимо БД одразу порахувати count(i) і повернути готове зведення.
    Optional<OrderSummaryRow> findSummaryByOrderNumber(@Param("orderNumber") String orderNumber);
}

Тут ми взагалі не “витягуємо” items як колекцію сутностей. Ми просимо базу одразу порахувати count(i) і повернути готове зведення. Це інший підхід, інший результат і інша вартість читання.

І ось де народжується правило вибору: якщо сценарій справді є зведенням, join fetch вам не потрібен не тому, що він поганий, а тому, що він розв’язує іншу задачу.

У всіх трьох гілках є одна спільна дисципліна: форму результату і план читання ми обираємо всередині сервісного сценарію, поки SQL ще під контролем. OSIV виглядає зручним рівно тому, що дозволяє це рішення відкласти: повернути entity назовні й сподіватися, що потрібні зв’язки якось дочитаються самі. Тепер подивімося, чому така “зручність” швидко перетворюється на проблему.

4. OSIV: що це і в чому підступ

OSIV (Open Session in View) — одна з тих речей, які здаються зручними рівно до того моменту, поки ви не починаєте розбиратися, де саме виконується SQL. Історично OSIV допомагав “ліниво” дочитувати зв’язки вже на рівні web-представлення (view), щоб не ловити LazyInitializationException. Але в сучасних backend-проєктах це часто перетворюється на «дозвіл на сюрпризи».

У Spring Boot це зазвичай виражається налаштуванням spring.jpa.open-in-view. Якщо його ввімкнено (або він увімкнений за замовчуванням у web-застосунку), Boot утримує EntityManager (а отже й persistence context) відкритим протягом усього HTTP-запиту. І тоді lazy-зв’язки можуть підвантажуватися навіть після того, як ваш сервісний метод уже завершився.

Щоб не говорити абстракціями, подивімося на поганий, але життєвий код. Припустімо, у нас є сервіс, який просто знаходить замовлення і повертає entity, не дбаючи про завантаження items.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {
    private final CustomerOrderRepository orderRepository;

    public OrderService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public CustomerOrder findByNumber(String orderNumber) {
        // Звичайний derived-метод: сутність повертається без оформленого карткового fetch plan.
        return orderRepository.findByOrderNumber(orderNumber).orElseThrow();
    }
}

А десь зовні (у контролері, у view, у будь-якому адаптері) ми робимо так:

CustomerOrder order = orderService.findByNumber(orderNumber);
// За ввімкненого OSIV цей виклик може виконати SQL уже поза сервісом/транзакцією.
int itemsCount = order.getItems().size(); // ось тут може виконатися SQL

З увімкненим OSIV це “просто працює”. З вимкненим OSIV — падає LazyInitializationException. І тут важливо: падіння — це не катастрофа, а сигнал, що ви дочитуєте дані за межами сценарію використання.

Щоб візуально закріпити, ось схема того, що відбувається за ввімкненого OSIV:

sequenceDiagram
    participant C as "Контролер / зовнішній шар"
    participant S as "Сервіс (@Transactional readOnly)"
    participant R as "Репозиторій"
    participant DB as PostgreSQL

    C->>S: findByNumber(orderNumber)
    S->>R: findByOrderNumber(...)
    R->>DB: select ... from customer_order ...
    DB-->>R: рядок замовлення
    R-->>S: CustomerOrder (items LAZY)
    S-->>C: CustomerOrder
    C->>DB: "select ... from order_item ... (ліниве завантаження через OSIV)"

Ключовий момент: SQL раптово виконується в контролері або зовнішньому шарі. А це щонайменше несподівано, а як максимум — дорога до N+1, якого ви навіть не помітили в сервісних логах.

5. OSIV — поганий default у data-layer-first проєкті

OSIV часто захищають фразою: «Ну зате не падає LazyInitializationException». Це приблизно як говорити: «Я не пристібаюся, зате ремінь не тисне». Формально проблему “не тисне” вирішено, але ціна дивна. У data-layer-first проєкті (яким і є наш shop-data-jpa) OSIV ламає головний методичний принцип: сценарій використання має бути закритий у сервісі, а не “дочитуватися де доведеться”.

Перша біда OSIV — він розмиває межу відповідальності. Якщо зовнішній шар може ліниво дочитати order.getItems(), то сервіс перестає бути місцем, де ухвалюють рішення, що потрібно цьому сценарію. Тобто план завантаження перестає бути планом і перетворюється на «хто першим смикнув геттер — той і заплатив за SQL». Це погано і для читабельності коду, і для розуміння навантаження.

Друга біда OSIV — він маскує N+1. Ви можете написати “нешкідливий” цикл у контролері, який викликає getItems() для кожного замовлення, і за ввімкненого OSIV ви не побачите винятку. Ви побачите код, що працює… але робить, наприклад, 1 запит на список замовлень + 20 запитів на позиції + ще 50 запитів на товари. Тобто N+1 перетворюється не на очевидну поломку, а на тихий витік продуктивності.

Третя біда OSIV — він робить SQL випадковим побічним ефектом звичайного Java-коду. У хорошому проєкті SQL знаходиться або в репозиторії, або щонайменше в межах сервісного @Transactional(readOnly = true) методу. З OSIV будь-який .getXxx() потенційно стає запитом до бази. Це неприємно навіть досвідченим розробникам, а новачкам особливо: вони починають боятися сутностей як «вибухонебезпечних об’єктів».

І ще один тонкий момент, який пов’язує OSIV із темою попередніх лекцій: ми вже знаємо про persistence context і dirty checking. Коли persistence context живе довше, ніж потрібно, зростає шанс випадково змінити сутність не там, де очікувалося, і в найневдаліший момент почути «чому воно оновилося?». Навіть якщо в цій лекції ви не робите жодних write-операцій, сама архітектурна звичка «нехай живе довше» зазвичай потім відгукується.

Отже, OSIV може рятувати від винятку, але ціною архітектурного “затуманювання”. У навчальному проєкті це особливо шкідливо, бо ви якраз вчитеся бачити причинно-наслідкові зв’язки між кодом, транзакцією та SQL.

6. Життя без OSIV: сервіс готує дані

Вимкнути OSIV — це як увімкнути добре світло в кімнаті, де ви до цього ходили з ліхтариком. Спочатку боляче: вилізають проблеми. Але потім раптово виявляється, що в кімнаті є стіни, двері й узагалі зрозуміла геометрія. Головне — не намагатися “повернути темряву”, а навчитися проєктувати читання нормально: через fetch plan і форму результату.

Вимикаємо OSIV

У нашому курсі web-layer не є центром, але thin-adapter іноді додають для smoke-перевірок. Якщо у вас підключено spring-boot-starter-web, то налаштування зазвичай таке:

spring:
  jpa:
    open-in-view: false # Забороняємо lazy-дочитування за межами сервісного сценарію

Сенс не в тому, щоб “перемогти” OSIV заради галочки. Сенс у тому, щоб будь-яке lazy-дочитування за межами сервісного сценарію одразу ставало помітним як помилка проєктування читання.

Сервіс повертає готові дані

Далі вмикається наше правило вибору. Якщо зовнішньому шару (контролеру, job, CLI) треба щось вивести, він має отримати вже готову форму даних. Або entity, у якої все потрібне завантажено всередині транзакції; або projection/DTO, яка взагалі не потребує lazy-навігації.

Ось приклад, де зовнішньому шару потрібне коротке зведення по замовленню: сервіс сам обирає готову форму читання, і назовні вже не витікає entity з лінивими сюрпризами.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {
    private final CustomerOrderRepository orderRepository;

    public OrderReadService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public OrderSummaryRow loadOrderSummary(String orderNumber) {
        // Рішення про форму результату ухвалюється тут, усередині сервісного сценарію.
        return orderRepository.findSummaryByOrderNumber(orderNumber).orElseThrow();
    }
}

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

Чому це працює добре:

- orderRepository.findSummaryByOrderNumber(...) повертає готову read-модель, а не сутність із lazy-зв’язками.
- Рішення про форму читання ухвалюється всередині сервісного методу, тому SQL не розповзається по зовнішніх шарах.
- Зовнішній шар отримує OrderSummaryRow, де ORM уже нічого “дочитувати на удачу”.

Зовнішній шар стає “тонким” автоматично

Коли сервіс повертає готову read-модель, зовнішній шар починає виглядати так, як і має виглядати в data-layer-first проєкті: без логіки fetch і без зайвого доступу до entity-графа.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {
    private final OrderReadService orderReadService;

    public OrderController(OrderReadService orderReadService) {
        this.orderReadService = orderReadService;
    }

    @GetMapping("/orders/{orderNumber}/summary")
    public OrderSummaryRow getOrderSummary(@PathVariable String orderNumber) {
        // Контролер не лазить по entity-графу і не запускає SQL.
        return orderReadService.loadOrderSummary(orderNumber);
    }
}

Так, це вже web — але зверніть увагу: ми не йдемо в REST-дизайн, не обговорюємо DTO-мепери й не будуємо повноцінний API. Ми просто показуємо принцип: контролер не має бути місцем, де раптово виконується SQL.

Швидкий вибір інструмента в голові

Ось маленька ментальна «блок-схема», яку зручно прокручувати, коли ви пишете черговий сценарій читання:

flowchart TD
    A[Новий сценарій читання] --> B{Потрібна сутність як керований об’єкт для змін?}
    B -->|Так| C[Читання на основі сутності]
    C --> D{Потрібні зв’язки в цьому сценарії?}
    D -->|Так| E[join fetch або @EntityGraph]
    D -->|Ні| F[Звичайний find...]
    B -->|Ні| G{Потрібен список / зведення?}
    G -->|Так| H[Читання на основі projection]
    G -->|Ні| I[За потреби: entity або projection залежно від форми відповіді]

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

7. Типові помилки під час OSIV і читань

Помилка №1: сприймати OSIV як “офіційний спосіб роботи з lazy”.
Це виглядає спокусливо: увімкнули налаштування — і LazyInitializationException зникла. Але разом із винятком зникає й розуміння, де саме виконується SQL. За кілька днів ви ловите N+1 не в сервісі, а в контролері, і починаєте підозрювати, що Hibernate “живе своїм життям”, хоча насправді винен сценарій читання.

Помилка №2: повертати entity назовні “бо так простіше”.
На короткій дистанції справді простіше повернути CustomerOrder і “де-небудь потім” дістати items. На середній дистанції ви отримуєте суміш із доменної моделі, fetch-логіки й випадкових SQL-запитів у місцях, де їх ніхто не очікує. Якщо зовнішньому шару потрібні дані — нехай отримує projection/DTO або entity, уже підготовлену сервісом.

Помилка №3: лікувати проблеми читання перенесенням @Transactional у контролер.
Після вимкнення OSIV новачок іноді робить “логічний” висновок: раз lazy падає поза транзакцією, то треба відкрити транзакцію ближче до місця падіння, тобто в контролері. Це майже завжди поганий default, бо ви перетворюєте web-запит на непередбачуваний unit of work і знову розмиваєте межу сценарію використання. Транзакція має бути в сервісі, а контролер — тонким адаптером.

Помилка №4: використовувати collection join fetch у списках із пагінацією “бо один запит же краще”.
Дуже легко написати findAllWithItems(Pageable pageable) і радіти, що N+1 зник. Але далі ви стикаєтеся з дублікатами, дивною пагінацією, роздутими результатами і «чому сторінка то порожня, то з повтореннями». Для списків зазвичай краще projection (або дуже вузький fetch plan), а collection fetch — залишити для карток.

Помилка №5: намагатися “оновлювати” projection як сутність.
Projection — це read-модель. Вона не перебуває в managed-стані й не бере участі в dirty checking. Якщо вам потрібно змінювати дані — завантажуйте сутність у транзакції, змінюйте її як managed entity і фіксуйте зміни через звичайну JPA-механіку. Projection у write-use-case майже завжди означає, що ви переплутали форму читання і форму зміни.

1
Задача
Spring Data JPA, 22 рівень, 4 лекція
Недоступна
Картка замовлення з вимкненим OSIV
Картка замовлення з вимкненим OSIV
1
Задача
Spring Data JPA, 22 рівень, 4 лекція
Недоступна
Вибір інструмента за формою результату для `Product`
Вибір інструмента за формою результату для `Product`
1
Опитування
План читання, рівень 22, лекція 4
Недоступний
План читання
Оптимізація читання й вибірок
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ