1. Правильна мета виправлення lazy‑проблем
Коли застосунок падає з LazyInitializationException, навіть дуже втомлений мозок підказує швидкі рішення рівня «приклеїти скотч і зробити вигляд, що так і було». Це нормально: у всіх буває. Але в persistence‑шарі такі «швидкі перемоги» часто перетворюються на повільні поразки: SQL починає виконуватися в неочікуваних місцях, з’являються затримки, які важко пояснити, а код перестає бути передбачуваним.
Правильне виправлення починається зі зміни мети. Ми не «прибираємо виняток» як симптом, а робимо читання даних частиною завершеної сервісної операції, усередині якої живе транзакція і доступний persistence context. Тоді lazy‑механізм стає просто інструментом економії, а не пасткою. І найприємніше: SQL знову «живе» там, де йому й належить, — поруч із тим кодом, який реалізує use case.
Якщо сказати однією фразою, то lazy loading схожий на доставку їжі: поки ресторан відкритий (є активна сесія), ви можете замовити ще одну страву; коли ресторан закритий (транзакція завершилася), намагатися «дозамовити» — марно і трохи сумно.
Після розмови про no Session і спокусу просто залишити Session жити довше картина вже доволі зрозуміла. Нормальне виправлення лежить не у веб-шарі й не в конфігураційному прапорці, а у самій формі сервісної операції: читання має завершуватися там, де в Hibernate ще є робочий persistence context. Тому виправлятимемо не симптом, а межу читання.
Визначаємо потрібні дані для читання
Найчастіша логічна помилка — думати, що якщо десь потрібне «замовлення», то треба повернути назовні все PurchaseOrder. Якщо десь потрібен «товар», то треба віддати назовні весь Product. Це зручно… до першого lazy-зв’язку. А потім у нас з’являється сутність, яка виглядає «справжньою», але всередині містить лише повідомлення: «ще не завантажено, приходьте з активною транзакцією».
Тому перед тим, як писати код, корисно й, головне, недорого за часом відповісти на запитання: що саме потрібно зовні? Не «яка сутність», а «які поля/значення». Наприклад, «для картки товару потрібен рядок опису» або «для короткого підсумку замовлення потрібні номер і кількість позицій». І тут раптом з’ясовується, що назовні не обов’язково повертати entity взагалі.
Нижче — невелика таблиця‑шпаргалка для мислення (це не догма, а зручний спосіб формулювати вимогу):
| Сценарій читання (use case) | Що реально потрібно зовні | Де це краще підготувати | Що буде, якщо повернути entity і сподіватися на lazy |
|---|---|---|---|
| Картка товару | sku, name, description із ProductDetails | У CatalogService всередині @Transactional(readOnly=true) | product.getDetails().getDescription() може впасти поза транзакцією |
| Підсумок замовлення | orderNumber, itemCount | У OrderQueryService всередині транзакції | order.getItems().size() може впасти поза транзакцією |
| Список назв позицій | список productNameSnapshot | У сервісі: прочитати order.getItems() і зібрати список | у контролері чи зовнішньому коді обхід колекції легко спричинить виняток |
Зверніть увагу на спільний мотив: ми не сперечаємося з lazy як із явищем. Ми просто переносимо момент доступу до даних туди, де в Hibernate є право і можливість виконати SQL.
3. Межа читання: сервіс + @Transactional(readOnly=true)
Дуже хочеться сприймати @Transactional як «анотацію для запису», а читання вважати «безпечним» і таким, що не потребує транзакції. У Hibernate це небезпечна ілюзія: для lazy-завантаження потрібне не моральне право, а конкретний факт — сесія має бути відкрита. А сесія у типовому Spring‑застосунку живе саме в межах транзакції сервісного методу.
Тому коректна межа читання виглядає так: зовнішній код — контролер, консольна команда, тест або інший сервіс — викликає один сервісний метод, і цей метод всередині транзакції отримує всі потрібні дані та повертає назовні вже готовий результат. Причому readOnly = true тут корисний не як «прискорювач на турборежимі», а як декларація наміру: «я читаю і не планую змінювати». Це знижує ризик випадкових змін керованої сутності (а ми пам’ятаємо, що dirty checking уміє робити сюрпризи).
Схематично правильний потік можна подати так:
flowchart TD
A["Зовнішній код (контролер/тест/команда)"] --> B["@Transactional(readOnly=true) — метод сервісу"]
B --> C["EntityManager.find(...)"]
B --> D["Доступ до lazy-зв’язків усередині транзакції (proxy/колекції ініціалізуються)"]
B --> E["Збирання результату (String/DTO/List)"]
E --> A
Ключове: ми не «тягаємо» напівініціалізовану сутність назовні, сподіваючись, що вона «якось» сама дочитає відсутнє. Ми одразу повертаємо те, що справді потрібно.
4. Повернути значення, а не entity: приклад із ProductDetails
Іноді виправлення lazy‑проблеми — це буквально один рядок… якщо заздалегідь визнати, що назовні потрібен не весь об’єкт. Уявіть, що UI або зовнішній код хоче показати лише опис товару. Повертати Product заради одного рядка — це як орендувати вантажівку, щоб привезти одну булочку. Технічно працює, але виглядає підозріло.
Який саме lazy to-one у вас у проєкті — customer, details чи щось інше — не так важливо. Принцип один і той самий: потрібні дані треба дочитати всередині транзакції, а назовні повернути вже готовий результат.
У Commerce Persistence Lab це можна оформити як метод читання в сервісі каталогу. Зверніть увагу: ми спеціально читаємо опис всередині транзакції. Навіть якщо details — lazy proxy, він встигне ініціалізуватися «на місці».
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogReadService {
private final EntityManager entityManager;
public CatalogReadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public String loadProductDescription(Long productId) {
// Важливо: читання сутності відбувається всередині транзакції, отже persistence context доступний
Product product = entityManager.find(Product.class, productId);
// Важливо: доступ до lazy-зв’язку робимо тут же, всередині транзакції (а не десь зовні)
return product.getDetails().getDescription();
}
}
Тут важливо вловити дві думки. По-перше, product.getDetails() не зобов’язане одразу робити SQL: це може бути просто посилання або проксі. По-друге, getDescription() — це вже «справжній доступ до даних», і саме він може ініціювати SELECT. Але ми робимо це там, де в Hibernate є відкрита сесія, отже проблем не буде.
Якщо вам потрібно кілька полів, ідея не змінюється: «дістали сутність, торкнулися потрібних lazy‑полів усередині транзакції, повернули назовні готові значення».
5. Зібрати маленький DTO всередині транзакції: приклад OrderSummary
Трохи реалістичніший сценарій: зовнішній код хоче не все замовлення, а короткий підсумок. Наприклад, номер замовлення і кількість позицій. Це типовий випадок, де повертати entity незручно: по-перше, вона тягне за собою lazy‑колекцію items; по-друге, зовні почнуть з’являтися спокуси «ще трохи залізти всередину» і випадково викликати SQL поза транзакцією.
Тут добре працює простий DTO. У Java 25 (і взагалі у сучасній Java) зручно використовувати record: це компактно і читабельно.
// DTO для короткого підсумку: назовні виходить лише те, що справді потрібно use case
public record OrderSummary(String orderNumber, int itemCount) {
}
А тепер сервісний метод. Важливо: order.getItems().size() — це не «дешева операція за замовчуванням». Для lazy‑колекції це може бути момент ініціалізації. Тому ми зобов’язані робити це всередині транзакції.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final EntityManager entityManager;
public OrderQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderSummary loadOrderSummary(Long orderId) {
// Читаємо замовлення всередині транзакції
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Важливо: size() може ініціювати завантаження lazy-колекції items (тобто виконати SQL)
int itemCount = order.getItems().size();
// Повертаємо DTO: зовнішній код уже не зможе «випадково» торкнутися lazy-зв’язків
return new OrderSummary(order.getOrderNumber(), itemCount);
}
}
Технічно тут усе дуже просто: сервіс прочитав дані, підготував результат, повернув його назовні. Зовнішній код тепер не може випадково «доторкнутися» до lazy‑колекції — він отримав уже число, а не потенційну міну.
6. Ініціалізація lazy‑зв’язків у сервісі
В ідеальному світі назовні виходять DTO і примітиви, а entity залишаються в межах шару даних і сервісу. У реальності інколи ви можете опинитися на проміжній стадії: код уже написаний так, що назовні повертають сутність, але ви хочете хоча б зробити поведінку передбачуваною й уникнути падіння. Це не «найкращий стиль назавжди», але як перехідне рішення воно трапляється.
Найпростіший спосіб «ініціалізувати колекцію» — торкнутися її всередині транзакції. Наприклад, викликати size() або пройтися циклом. Так, це виглядає трохи як ритуал (Hibernate іноді такі ритуали любить), але сенс раціональний: ви змушуєте ORM зробити потрібний SQL там, де це дозволено.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderLoadService {
private final EntityManager entityManager;
public OrderLoadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PurchaseOrder loadOrderInitialized(Long orderId) {
// Читаємо замовлення всередині транзакції
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Ініціалізація lazy-колекції: змушуємо ORM завантажити items тут, а не десь «зовні»
order.getItems().size();
// Повертаємо entity як перехідне рішення (краще повертати DTO, але інколи так доводиться)
return order;
}
}
Плюс цього підходу в тому, що він справді прибирає LazyInitializationException для конкретного зв’язку items. Мінус у тому, що він не розв’язує проблему системно: код, який викликає метод, усе ще може полізти в інший lazy‑зв’язок (наприклад, у order.getCustomer().getEmail()), і все повториться. Тому сприймайте це як «точкове знешкодження», а не як кінцеву архітектуру.
Іноді розробники намагаються зробити «ще більш явну магію» та використовують Hibernate‑специфічні методи. Наприклад, так:
import org.hibernate.Hibernate;
// Ініціалізація через Hibernate API: працює, але прив’язує код до Hibernate
Hibernate.initialize(order.getItems());
Це працює за тією ж ідеєю — ініціалізувати всередині транзакції — але прив’язує код до Hibernate API. У навчальному проєкті ми намагаємося триматися ближче до зрозумілого JPA‑мислення, тому частіше показуємо «звичайне» звернення до колекції як тригер.
7. Скрізний приклад читання деталей замовлення
Тепер зберімо все в максимально практичний сценарій, близький до того, що реально потрібно в Commerce Persistence Lab. Припустімо, зовнішньому коду — контролеру, тесту чи консольній команді — потрібно показати «деталі замовлення»: номер замовлення і позиції. Нам не потрібен увесь граф сутностей, нам не потрібно віддавати назовні OrderItem як entity, а для позицій нам особливо корисні snapshot‑поля, які вже живуть у OrderItem (наприклад, productNameSnapshot, productSkuSnapshot).
Зробімо два маленькі DTO:
// DTO одного рядка замовлення: використовуємо snapshot-поля, щоб не тягнути пов’язані сутності
public record OrderItemLine(String productSku, String productName, int quantity) {
}
import java.util.List;
// DTO «деталі замовлення»: номер + готовий список рядків (без persistent collections і проксі)
public record OrderDetailsDto(String orderNumber, List<OrderItemLine> items) {
}
І сервіс, який всередині транзакції ініціалізує items, обходить їх і збирає DTO. Тут важливо, що ми беремо саме snapshot‑поля, щоб не провокувати додаткові lazy‑читання пов’язаних сутностей.
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadFacade {
private final EntityManager entityManager;
public OrderReadFacade(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderDetailsDto loadOrderDetails(Long orderId) {
// Знаходимо замовлення всередині транзакції (поки «ресторан відкритий»)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Збираємо DTO всередині транзакції, щоб не винести lazy-об’єкти назовні
List<OrderItemLine> lines = new ArrayList<>();
for (OrderItem item : order.getItems()) {
// Важливо: беремо snapshot-поля з OrderItem, щоб не тригерити lazy-завантаження Product
lines.add(new OrderItemLine(
item.getProductSkuSnapshot(),
item.getProductNameSnapshot(),
item.getQuantity()
));
}
// Повертаємо вже готовий результат use case
return new OrderDetailsDto(order.getOrderNumber(), lines);
}
}
Що ми отримали на практиці? Зовнішній код отримує об’єкт, який уже повністю готовий до використання: у ньому немає lazy‑proxy, немає persistent collections, немає ризику «випадково торкнутися і впасти». А Hibernate, якщо йому потрібно сходити в базу за items, зробить це всередині транзакції, у сервісі, у зрозумілому місці.
Це і є центральна думка лекції: прочитати дані — частина use case. Якщо ви будуєте read‑операцію, вона має завершуватися тим, що назовні виходить «готовий результат», а не «об’єкт, який теоретично може ще дочитати дані, якщо буде магічно правильно розташований у просторі-часі».
8. Перевірка виправлення: SQL та ініціалізація
Після виправлення ви хочете бути впевненими не лише в тому, що «помилка зникла», а й у тому, що SQL став передбачуваним. Бо можна «виправити» так, що все перестане падати, але почне мовчки робити запити в неочікуваних місцях — і ви просто поміняєте один біль на інший, зазвичай дорожчий.
Перший і найчесніший спосіб — дивитися SQL‑лог у профілі sql-trace та переконатися, що запити йдуть під час виконання сервісного методу, а не після нього. У persistence‑шарі видимість SQL — обов’язкова частина мислення, тож це не «додаткова опція», а робочий інструмент.
Другий спосіб — точково перевіряти, чи ініціалізовано зв’язок, коли ви цього очікуєте. Hibernate дає утиліту Hibernate.isInitialized(...). Вона не має ставати «бізнес‑логікою», але для лабораторного розуміння механіки — корисна.
import org.hibernate.Hibernate;
// Діагностична перевірка: чи ініціалізувалася колекція (всередині транзакції це особливо показово)
boolean initialized = Hibernate.isInitialized(order.getItems());
System.out.println("items initialized = " + initialized); // items initialized = true/false
Сенс цієї перевірки простий: якщо ви всередині транзакції вже обійшли order.getItems(), то initialized має стати true. Якщо ви не чіпали колекцію — найімовірніше буде false. І тоді ви легко зрозумієте, чому при доступі зовні все падає.
Найважливіший критерій правильного виправлення звучить так: після нього у вас з’являється відповідь на запитання «де саме живе читання даних». Якщо відповідь звучить як «всередині сервісної операції», значить ви зробили те, що хотіли.
Тут важливо відокремити дві задачі. Ми вже виправили correctness: дані читаються в правильному місці й не валять код за межами транзакції. Але це ще не робить читання автоматично ефективним: у межах правильної сервісної межі цілком можна зібрати зайві запити, якщо fetch‑план обрано невдало.
9. Типові помилки під час виправлення lazy‑проблем
Помилка №1: увімкнути OSIV або перевести зв’язки в EAGER, щоб «не падало».
На короткій дистанції справді простіше. На довгій ви втрачаєте контроль над тим, коли і чому виконується SQL, і застосунок перетворюється на квест «знайди місце, де раптово сталося ще п’ять запитів».
Помилка №2: повернути entity із сервісу, а DTO зібрати зовні — у контролері або “форматері”.
Це виглядає невинно («я ж просто збираю відповідь»), але саме в цей момент ви й звертаєтеся до lazy‑зв’язків уже після транзакції. Виправлення тут майже завжди одне й те саме: перенести збирання результату всередину транзакції, тобто всередину сервісного методу.
Помилка №3: “виправити” один LazyInitializationException, ініціалізувавши одну колекцію через size(), і продовжити повертати сутність назовні.
Це створює ілюзію контролю: сьогодні ви заздалегідь ініціалізували items, завтра хтось полізе у customer, післязавтра — у details, і проблема знову оживе. Такий стиль швидко перетворюється на гру «здогадайся, яких полів ще потрібно торкнутися заздалегідь».
Помилка №4: випадково змінити керовану сутність у read‑сценарії.
Якщо ви забули readOnly=true, або всередині читання хтось викликав setter «просто для форматування», Hibernate може вирішити, що це реальна зміна, і надіслати UPDATE. Тоді ви виправляєте lazy‑помилку, а натомість отримуєте сюрприз у вигляді запису в базу.
Помилка №5: ловити LazyInitializationException і “проковтувати” її обробником винятків.
Це майже завжди призводить до поламаних даних на виході: частина полів порожня, частина невалідна, а користувач бачить дивні результати. У persistence‑шарі виняток корисний тим, що він чесно каже: «ви читаєте дані не там». Якщо його замовчати, ви просто втрачаєте сигнал про баг і продовжуєте жити в неправильній архітектурі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ