JavaRush /Курси /Hibernate deep-dive /Одна runtime-модель замість магії 🚀

Одна runtime-модель замість магії 🚀

Hibernate deep-dive
Рівень 1 , Лекція 1
Відкрита

1. Між сервісним методом і SQL — не порожній простір

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

Візьмімо ще раз дуже спокійний сервісний метод. Він майже навмисно виглядає безневинно:

@Service // Компонент Spring: клас буде створено як bean
public class CatalogService {

    private final ProductRepository products; // Репозиторій Spring Data як точка входу в роботу з даними

    public CatalogService(ProductRepository products) {
        this.products = products; // Впровадження залежності (DI)
    }

    @Transactional(readOnly = true) // Транзакційна межа навіть для читання (це важливо для контексту)
    public Product load(long id) {
        // findById може повернути Optional.empty(), тому orElseThrow
        return products.findById(id).orElseThrow();
    }
}

Зазвичай такий код читають так: «репозиторій сходив у базу і приніс Product». Для звичайного CRUD-мислення цього достатньо. Але для глибокого розбору — замало. Тут важливіше інше запитання: що саме відбувається між викликом load() і SQL-запитом у PostgreSQL?

Зручно тримати в голові таку спрощену схему:

flowchart TD
    %% Важливо: це не «магія», а послідовність рівнів і компонентів
    A[Сервісний метод] --> B[Репозиторій Spring Data]
    B --> C[EntityManager]
    C --> D[Hibernate Session]
    D --> E[Persistence Context]
    E --> F[Flush / генерація SQL]
    F --> G[(PostgreSQL)]

Ця схема потрібна не лише для наочності. Вона перетворює ORM із «чогось під капотом» на набір цілком спостережуваних рівнів. Репозиторій — зручний вхід. EntityManager — JPA-рівень керування сутностями та запитами. Session — нативний Hibernate-рівень. Persistence Context — робоча памʼять поточної операції. Flush — фаза синхронізації. А generated SQL — той самий слід у логах, який можна перевірити власними очима.

І ось тут зʼявляється перший момент прозріння: більшість ORM-симптомів — це не набір окремих багів, а прояви одного й того самого механізму роботи. Поки ви не бачите цю ланку, Hibernate здається магією. Щойно бачите — магія помітно здувається, і залишається інженерна система зі своєю логікою.

2. EntityManager і Session: два рівні одного стеку

Розробники часто чули слова EntityManager і Session, але не завжди чітко розуміють, як вони співвідносяться. Через це виникає хибне відчуття, ніби це дві конкуруючі моделі роботи з ORM. Насправді все простіше: EntityManager — це стандартний JPA-інтерфейс, а Session — нативний Hibernate-інтерфейс, який стоїть під ним. Вони не конкурують між собою. Вони описують один і той самий runtime на двох рівнях абстракції.

Якщо прибрати Spring Data і переписати читання ближче до JPA, вийде так:

@Service // Звичайний сервіс Spring
public class ProductFinder {

    private final EntityManager entityManager; // JPA-вхід: керує сутностями та запитами в межах контексту

    public ProductFinder(EntityManager entityManager) {
        this.entityManager = entityManager; // DI: сюди Spring підставить проксі або реальний EntityManager
    }

    @Transactional(readOnly = true) // Транзакція потрібна, щоб контекст був «живим» упродовж методу
    public Product load(long id) {
        // Прямий JPA-виклик: пошук за первинним ключем
        return entityManager.find(Product.class, id);
    }
}

Це вже допомагає побачити важливу річ: репозиторій Spring Data не замінює JPA — він працює через неї. А ключовий JPA-вхід в операції із сутностями та запитами — саме EntityManager. 📎

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

@Service // Сервіс-адаптер між JPA та Hibernate API
public class HibernateBridge {

    private final EntityManager entityManager; // Усе починається з EntityManager

    public HibernateBridge(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    public Session currentSession() {
        // unwrap: отримуємо нативний Hibernate Session із JPA EntityManager
        return entityManager.unwrap(Session.class);
    }
}

Цей рядок добре знімає зайву містичність зі стеку. Session — не «ще одна ORM поряд». Це той самий робочий контекст Hibernate, через який JPA в нашому застосунку і виконується. Для всього курсу цього розуміння поки що достатньо: працюючи через Spring Data, ви не обходите Hibernate — ви просто дивитеся на нього через вищий шар.

3. Persistence Context: робоча памʼять ORM 🧠

Тепер найважливіше слово цієї лекції — persistence context. Звучить трохи академічно, але за змістом це дуже практична річ. Hibernate не працює як примітивний проксі «викликав метод — одразу SQL — усе забули». Він тримає робочий стан усередині поточної операції. Цей стан і є persistence context.

У Spring є свій ApplicationContext, де він зберігає beans. А у Hibernate — свій робочий контекст, той самий persistence context, де живуть entities. Усе в цьому світі циклічне.

Якщо хочете зовсім простий образ, думайте про persistence context як про робочу памʼять ORM. У межах однієї операції Hibernate памʼятає, які сутності вже були завантажені, які з них зараз managed, які зміни відбулися, які звʼязки вже ініціалізовані, а які ще ліниві, і що взагалі потрібно буде синхронізувати з базою. Це не теоретична конструкція. Це реальна частина runtime-поведінки.

Звідси одразу випливають кілька дуже практичних наслідків. По-перше, усередині одного контексту Hibernate намагається тримати identity сутності стабільною: один і той самий рядок має відповідати одному й тому самому managed-обʼєкту. По-друге, повторні звернення до вже завантаженої сутності обробляються інакше, ніж перше читання. По-третє, зміна обʼєкта в памʼяті — це вже подія для ORM, навіть якщо ви поки не відправили жодного SQL.

Нам сьогодні не потрібно повністю розпаковувати стани transient, managed, detached і removed. Це окрема серйозна розмова, і вона незабаром стане центральною. Поки достатньо побачити сам принцип: між вашим кодом і БД існує живий контекст, а не порожній канал. Якщо ви не тримаєте це в голові, поведінка Hibernate справді здається спонтанною. Якщо тримаєте — багато речей раптово стають пояснюваними.

4. Transaction boundary: де живе unit of work

persistence context майже завжди варто бачити разом із ще одним поняттям — transaction boundary. У типовому Spring-застосунку цю межу задає @Transactional на сервісному методі. І це не просто «анотація для роботи з базою». За змістом це рамка однієї цілісної операції з даними. Саме всередині цієї рамки Hibernate і отримує шанс тримати свій робочий контекст і поводитися як ORM, а не як набір випадкових викликів SQL.

Дуже важливо не зводити транзакцію до формули «один SQL-запит». Типовий сценарій цілком може включати читання кількох сутностей, зміну частини графа, виконання додаткових перевірок і лише потім синхронізацію результату з базою. У цьому й полягає ідея unit of work: ви мислите не окремими командами JDBC, а однією узгодженою операцією застосунку.

Подивіться на простий приклад:

@Service // Сервісний рівень
public class OrderPlacementService {

    private final ProductRepository products; // Пошук товарів
    private final PurchaseOrderRepository orders; // Збереження замовлення

    public OrderPlacementService(ProductRepository products, PurchaseOrderRepository orders) {
        this.products = products;
        this.orders = orders;
    }

    @Transactional // Уся операція виконується в одній транзакції та в одному persistence context
    public void placeOrder(long productId) {
        // 1) Завантажуємо сутність: вона стає managed у поточному контексті
        Product product = products.findById(productId).orElseThrow();

        // 2) Створюємо новий обʼєкт (ще transient)
        PurchaseOrder order = new PurchaseOrder();
        order.setOrderNumber("ORD-1001"); // Заповнюємо поля доменної моделі

        // 3) Зберігаємо: обʼєкт стає persistent/managed (у термінах контексту)
        orders.save(order);

        // Тут важливий не синтаксис, а сама ідея:
        // одна бізнес-операція живе в одній транзакційній межі.
    }
}

Межа транзакції тут важливіша за конкретні рядки коду. Саме вона відповідає на запитання, де починається і закінчується поточна операція, де взагалі існує поточний persistence context і в який момент Hibernate зобовʼязаний привести його у відповідність із базою. Коли потім ви будете діагностувати lazy loading, merge, bulk side effects або locking, це запитання стане базовим. Без нього далі не зрушити.

5. Flush і generated SQL: момент істини

І ще одна ключова річ: Hibernate не зобовʼязаний надсилати SQL рівно в тому рядку Java, де ви подумки «здійснили дію». Це незвично, якщо ви довго жили в моделі «викликав update() — SQL пішов». В ORM між кодом і БД спочатку накопичується стан, а потім настає окрема фаза синхронізації. Ця фаза і називається flush.

Наразі нам не потрібен повний перелік flush-режимів. Важливо побачити принцип: flush — це не побутове «майже commit», а окремий момент, коли Hibernate переводить поточний стан контексту в реальні SQL-команди. Наприклад:

@Service // Сервісний рівень
public class ProductRenamer {

    private final EntityManager entityManager; // Працюємо безпосередньо через JPA

    public ProductRenamer(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional // Потрібна транзакція, щоб зміни відстежувалися й могли бути синхронізовані
    public void rename(long id, String newName) {
        // Завантажуємо сутність: далі Hibernate відстежуватиме зміни (dirty checking)
        Product product = entityManager.find(Product.class, id);
        product.setName(newName); // Змінюємо поле в managed-обʼєкта (SQL ще може не піти)

        // Ми змінили managed-обʼєкт у памʼяті.
        // SQL UPDATE зʼявиться в момент синхронізації, а не тут як окремий ручний виклик.
    }
}

У логах це потім може проявитися так:

-- 1) Спершу читання: Hibernate завантажує рядок у persistence context
select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.id=?
-- 2) Потім UPDATE: результат flush/dirty checking (момент може бути не в рядку setName)
update product set name=? where id=?

І тут варто знову повернутися до фрази з минулої лекції: SQL — детектор брехні для ORM. Ви можете думати, що «нічого особливого не сталося». Але якщо в логах пішов UPDATE, значить для Hibernate це вже була цілком реальна операція синхронізації. Ви можете думати, що читання було «одним викликом репозиторію». Але якщо в логах видно десять запитів, значить use case для бази даних виглядав зовсім інакше. 🔬

Тому generated SQL у цьому курсі — не декоративний супровід. Це джерело правди. Ми не будемо вірити Java-коду на слово там, де можна власними очима перевірити, що застосунок насправді відправив у PostgreSQL. Це одна з найкорисніших звичок, яку ви заберете вже з першого дня.

6. Діагностична лінза першого дня

Якщо після цієї лекції у вас і має залишитися один переносний артефакт, нехай це буде не список термінів, а коротка діагностична лінза. Її можна тримати поруч із будь-яким дивним ORM-кейсом — від LazyInitializationException до зайвого SELECT. Вона не замінює глибокого розуміння, але чудово спрямовує погляд.

Питання Що це допомагає зʼясувати
Де проходить transaction boundary? Без цього не можна зрозуміти, чи ще живий поточний контекст і чому lazy loading взагалі можливий або вже неможливий
Через який рівень іде робота — репозиторій, EntityManager, Session? Допомагає побачити, на якому шарі ви зараз працюєте і де шукати фактичну поведінку
Що вже знаходиться в persistence context? Пояснює повторні читання, identity map і реакцію Hibernate на зміни
Чи була зміна managed-обʼєкта, чи лише читання? Саме тут починається розмова про dirty checking і можливий UPDATE
Чи міг тут відбутися flush? Допомагає перестати чекати SQL «саме в цьому рядку коду»
Який generated SQL насправді пішов? Зіставляє вашу інтуїцію з реальністю
Чи збігається SQL-профіль із use case? Підказує, проблема у fetching, mapping, проєктуванні транзакцій чи в самому способі читання

Ця таблиця хороша тим, що одразу робить курс практичним. Ви не просто почули терміни EntityManager, Session, flush і persistence context. У вас зʼявився робочий спосіб ставити запитання до поведінки Hibernate. І це вже не мотиваційна балаканина, а реальна практична користь.

7. Типові помилки розуміння runtime-моделі

Помилка № 1: думати, що репозиторій і є вся робота з БД.
Репозиторій — зручний фасад. Але під ним працює JPA-рівень, а під JPA — конкретний runtime Hibernate. Якщо цього не бачити, майже будь-яка нетривіальна проблема здаватиметься «аномалією».

Помилка № 2: сприймати EntityManager і Session як різні світи.
Насправді це різні рівні одного стеку. Плутанина тут зазвичай заважає не коду, а мисленню розробника: він просто не розуміє, де шукати фактичне джерело поведінки.

Помилка № 3: вважати persistence context абстракцією «для теорії».
Щойно ви бачите неочікуваний UPDATE, повторне завантаження, lazy proxy або identity-поведінку, ви вже маєте справу з контекстом. Він реальний, навіть якщо напряму не видно.

Помилка № 4: зводити flush до слова «commit».
Commit і flush повʼязані, але це не одне й те саме. На рівні першої робочої моделі достатньо запамʼятати: flush — окрема фаза синхронізації, і саме вона часто пояснює, «чому SQL пішов зараз».

1
Задача
Hibernate deep-dive, 1 рівень, 1 лекція
Недоступна
Фіксація baseline в Gradle-проєкті
Фіксація baseline в Gradle-проєкті
1
Задача
Hibernate deep-dive, 1 рівень, 1 лекція
Недоступна
PostgreSQL і Flyway як єдине джерело схеми
PostgreSQL і Flyway як єдине джерело схеми
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ