JavaRush /Курси /Hibernate deep-dive /Propagation і self-i...

Propagation і self-invocation у Spring

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

1. Propagation у звичайному сервісному коді

Межа операції вже зрозуміла: операція запису живе в сервісній транзакції, а операцію читання можна чесно позначати через readOnly=true. Тепер потрібно зрозуміти, як Spring узагалі застосовує цю межу, коли сервіси викликають один одного. Інакше дуже легко написати код, де анотація є, а транзакція поводиться зовсім не так, як ви очікуєте.

На прикладі замовлення та складу це видно одразу. Сценарій оформлення замовлення — це не один orderRepository.save(), а ланцюжок: прочитати клієнта, перевірити залишки, зарезервувати товар, створити PurchaseOrder, створити OrderItem, змінити стани. Такий ланцюжок неминуче розкладається на кілька методів і класів. І саме propagation — це мова, якою Spring каже: «коли метод увійшов, він приєднується до вже розпочатої транзакції або створює нову».

Щоб не занурюватися в енциклопедію Spring, сьогодні нам достатньо двох режимів: Propagation.REQUIRED (поведінка за замовчуванням) і Propagation.REQUIRES_NEW. Інші режими існують, але якщо почати розбирати їх тут, ми або втратимо фокус на Hibernate, або втратимо студентів — або й те, й інше.

2. Transactional proxy і межа @Transactional

Коли початківець-розробник уперше бачить @Transactional, він часто уявляє собі щось на кшталт «усередині методу Spring автоматично вставляє begin/commit». Майже так, ніби анотація переписує байткод. Таке в реальному світі буває, але ми сьогодні в цю нору не падаємо. У типовому Spring Boot-застосунку все простіше й підступніше: Spring створює проксі-об’єкт (transactional proxy), який обгортає ваш сервіс і перехоплює виклик методу ззовні. Якщо виклик не пройшов через проксі, транзакція може взагалі не стартувати — навіть якщо анотація на місці й виглядає дуже впевнено.

Найзручніше це уявити так: у вас є «реальний» CheckoutService, а поруч стоїть «охоронець» — проксі. Коли зовнішній код викликає метод, він потрапляє до охоронця; охоронець каже: «Ага, тут @Transactional, зараз відкрию транзакцію», і лише потім пропускає до реального методу. Але якщо ви всередині класу викликали метод напряму через this.someMethod(), ви обійшли охоронця, і він навіть не помітив, що хтось хотів транзакцію.

Ось спрощена схема (без усіх деталей Spring AOP, але достатньо для інженерного розуміння):

flowchart TD
    A[Зовнішній код] --> P[Транзакційний проксі]
    P -->|"відкрити/приєднати транзакцію"| S[Реальний сервісний bean]
    S -->|"виконати бізнес-логіку"| S
    S --> P
    P -->|"commit/rollback"| A

Важливий практичний висновок: @Transactional працює на межі виклику Spring bean-а, а не «всередині того самого об’єкта з будь-якого приводу». Це безпосередньо впливає і на propagation, і на self-invocation, який ми розберемо трохи пізніше.

3. Propagation.REQUIRED: «якщо транзакція вже є — беріть участь у ній»

Режим REQUIRED — це поведінка за замовчуванням і найчастіший сценарій, який ви хочете бачити у звичайній бізнес-операції. Сенс дуже простий: якщо зовні вже розпочато транзакцію, то внутрішній метод не повинен запускати ще одну поверх неї, він має стати частиною тієї самої операції. Якщо транзакції немає — тоді метод створить нову. Тому REQUIRED чудово підходить для викликів від сервісу до сервісу в межах одного сценарію: ми не дробимо unit of work, а збираємо його.

Подивімося на мінімальний приклад оформлення замовлення, де CheckoutService викликає InventoryService. Важливо: обидва сервіси — окремі Spring beans, тобто виклик між ними проходить через проксі, і @Transactional справді спрацьовує.

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

@Service
public class CheckoutService {
    private final InventoryService inventoryService;

    public CheckoutService(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }

    @Transactional // Транзакція відкриється, якщо placeOrder() викликано ззовні через проксі
    public void placeOrder(Long productId, int qty) {
        // Виклик іншого Spring bean-а проходить через його проксі -> REQUIRED зможе "приєднатися"
        inventoryService.reserve(productId, qty);
    }
}

А ось InventoryService:

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

@Service
public class InventoryService {

    @Transactional // За замовчуванням Propagation.REQUIRED: приєднатися, якщо транзакція вже є
    public void reserve(Long productId, int qty) {
        // Тут зазвичай буде:
        // 1) завантаження сутності (вона стане managed у поточному persistence context)
        // 2) зміна полів (dirty checking збере зміни)
        // 3) flush/commit відбудеться "на межі" транзакції
    }
}

Що станеться на практиці? placeOrder() відкриє транзакцію. Потім reserve() буде викликано всередині вже активної транзакції, і Spring скаже: «Окей, транзакція вже є — я приєднуюся». З погляду Hibernate це означає один unit of work: один persistence context, один сценарій flush/commit/rollback.

Це дуже важливо, якщо ви мислите станами сутностей. Якщо всередині placeOrder() ви завантажили InventoryItem як managed-entity, і далі в reserve() ви з ним працюєте через той самий EntityManager, то все це один керований світ. Dirty checking збере зміни, flush надішле потрібні UPDATE, commit завершить транзакцію — і на цьому сценарій закінчиться.

Якщо ж ви випадково розірвете операцію на дві транзакції, наприклад через REQUIRES_NEW без чіткої потреби, у вас раптово з’являться два persistence context, і об’єкт, який був managed в одному, стане detached відносно іншого. А це вже знайома нам тема з усіма бонусами на кшталт merge() і неочікуваних SELECT.

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

4. Propagation.REQUIRES_NEW: окрема транзакція

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

Семантика REQUIRES_NEW така: якщо зовні вже є транзакція, Spring її призупиняє (suspend), відкриває нову транзакцію для внутрішнього методу, доводить її до commit/rollback, а потім повертається до зовнішньої транзакції. Тобто ви отримуєте два незалежні мінісвіти. У кожного свій commit. І, що особливо важливо для нас як для Hibernate-людей: у кожного зазвичай буде свій persistence context (EntityManager/Session), тому що новий JPA-транзакційний контекст прив’язується окремо.

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

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

@Service
public class TechnicalLogService {

    @Transactional(propagation = Propagation.REQUIRES_NEW) // Завжди створюємо окрему транзакцію
    public void writeMessage(String message) {
        // Важливо: commit/rollback цього запису НЕ залежить від зовнішньої бізнес-транзакції
        // Зазвичай тут короткий запис у БД: insert у таблицю технічних подій
    }
}

І використання в сценарії:

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

@Service
public class CheckoutService {
    private final TechnicalLogService technicalLogService;

    public CheckoutService(TechnicalLogService technicalLogService) {
        this.technicalLogService = technicalLogService;
    }

    @Transactional // Основна транзакція бізнес-операції оформлення замовлення
    public void placeOrderWithLog(Long productId, int qty) {
        // Лог записується у власній транзакції і може пережити rollback основного сценарію
        technicalLogService.writeMessage("placeOrder started");

        // далі основна логіка замовлення (резерви, створення сутностей, стани тощо)
    }
}

Тепер увага, важлива інженерна частина. Якщо після writeMessage() основна транзакція впаде й відкочується, запис "placeOrder started" може залишитися, тому що його було закомічено у власній транзакції. Це не «баг propagation», а чесне виконання вашого запиту: ви самі попросили окремий сценарій завершення.

З цього народжується типове запитання: «А можна так само зробити й замовлення — щоб частини комітилися в міру готовності?» Можна, але це вже архітектурне рішення з дуже високою ціною, і для звичайного монолітного backend-а це часто призводить до неконсистентних даних. У нашому навчальному проєкті (і в звичайному бізнес-коді) основний сценарій «оформлення замовлення» має мати одну транзакційну межу. REQUIRES_NEW доречний лише там, де ви справді хочете незалежний сценарій для підоперації й готові пояснити це на code review людською мовою.

Щоб закріпити різницю, корисна маленька таблиця:

Параметр
REQUIRED
REQUIRES_NEW
Якщо зовнішня транзакція є бере участь у ній призупиняє її і створює нову
Доля commit/rollback спільна незалежна
Persistence context (зазвичай) той самий інший
Ризик часткових комітів низький високий (у цьому й полягає сенс режиму)
Типовий сенс частина однієї бізнес-операції маленька ізольована підоперація

Якщо ви мислите в термінах Hibernate, то формулювання ще жорсткіше: REQUIRES_NEW — це майже гарантований спосіб винести шматок логіки в окремий persistence context. А отже, ви повинні пам’ятати про managed/detached і про те, що один і той самий Java-об’єкт може бути managed лише в одному контексті одночасно.

5. Self-invocation і пастка this.someMethod()

Self-invocation — це ситуація, коли метод класу викликає інший метод того самого класу через this.someMethod(). Для Java це абсолютно нормально і виглядає як «ну, викликав helper-метод». Для Spring-транзакцій це часто означає «обійшов проксі». А якщо проксі обійдено, анотація @Transactional на методі, що викликається, може не спрацювати так, як ви очікуєте.

Виглядає це зазвичай так (і так, це дуже схоже на приклад із плану дня, бо в житті воно саме так і трапляється):

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

@Service
public class ProductService {

    public void refreshCatalog(Long productId) {
        // Self-invocation: виклик методу всередині того самого об’єкта, проксі тут НЕ бере участі
        this.recalculateOneProduct(productId);
    }

    @Transactional
    private void recalculateOneProduct(Long productId) {
        // Важливо: private-метод проксі не перехоплює, тож транзакція "не зʼявиться"
        // Ви очікуєте транзакцію... але виклик обійшов проксі
    }
}

Тут одразу два червоні прапорці. По-перше, метод recalculateOneProductprivate. Проксі в Spring (у звичайному proxy-based режимі) не може перехопити приватний метод як зовнішню точку входу. По-друге, навіть якби він був public, виклик this.recalculateOneProduct() усе одно відбувається всередині об’єкта, минаючи проксі.

Що бачить Spring? Він бачить зовнішній виклик refreshCatalog(). На ньому транзакції немає — отже, транзакції не буде. А анотація на приватному методі лишається гарною наліпкою «для підвищення самооцінки», але не робочим механізмом. Підсумок часто звучить так: «чому в мене падає ліниве завантаження?» або «чому зміни не зберігаються?» — а причина в тому, що транзакції не було, хоча «на методі ж стоїть анотація».

Як виправляти це правильно в проєктному коді? Найчистіший навчальний варіант — винести транзакційну точку входу в окремий сервіс (окремий bean), щоб виклик проходив через проксі.

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

@Service
public class ProductRecalculationService {

    @Transactional // Транзакція справді почнеться при зовнішньому виклику (з іншого bean-а) через проксі
    public void recalculateOneProduct(Long productId) {
        // Тут виконуємо перерахунок уже всередині чесної транзакційної межі
    }
}

А в початковому сервісі залишити оркестрацію:

import org.springframework.stereotype.Service;

@Service
public class ProductService {
    private final ProductRecalculationService recalculationService;

    public ProductService(ProductRecalculationService recalculationService) {
        this.recalculationService = recalculationService;
    }

    public void refreshCatalog(Long productId) {
        // Виклик іде з одного bean-а в інший — проксі відпрацює як треба
        recalculationService.recalculateOneProduct(productId);
    }
}

Тепер виклик іде з одного bean-а в інший, тобто через transactional proxy. І анотація починає означати те, що в ній написано.

Чи можна «обдурити систему» й змусити self-invocation пройти через проксі? Технічно так, способи є, але як навчальний і проєктний стиль це майже завжди гірше, ніж простий поділ відповідальності на два сервіси. На code review самовиклик із хитромудрими обходами виглядає як «магічний ритуал», а ми в цьому курсі якраз виробляємо імунітет до магії.

6. Дизайн сервісів і propagation без лотереї

На цьому місці зазвичай хочеться написати список правил із 12 пунктів, але ми домовилися не перетворювати курс на бюрократію. Тож скажімо простіше: propagation — це не тюнінг, а частина дизайну межі операції. Якщо ви ставите REQUIRES_NEW, ви повинні вміти пояснити, чому підоперація має незалежний сценарій commit/rollback. Якщо ви ставите REQUIRED, ви повинні бути впевнені, що метод справді є частиною однієї операції, а не «прихованою другою операцією».

Практично це можна сприймати як проєктування «точок входу». Сервісний метод, який ви вважаєте unit of work, повинен бути публічною точкою входу (у сенсі архітектури: викликається ззовні bean-а). Внутрішні helper-методи всередині того самого класу можуть існувати, але на них не можна «вішати» сенс транзакції, якщо їх викликають через self-invocation. У такому разі анотація стає хибною обіцянкою.

Ще один корисний погляд: REQUIRED допомагає збирати одну бізнес-операцію з кількох кроків, а REQUIRES_NEW допомагає ізолювати маленький крок, який ви свідомо хочете виконати незалежно. Але якщо ви почнете лікувати REQUIRES_NEW усі проблеми підряд, ви швидко отримаєте ситуацію, де замовлення «наполовину оформили»: залишки вже змінилися, а замовлення не створилося; лог записався, але статус не оновився; і далі ви починаєте писати код «компенсацій», хоча насправді хотіли просто оформити замовлення. У підсумку з однієї зрозумілої транзакції ви будуєте мінісеріал зі спін-офами.

Якщо хочеться дуже короткої інженерної формули, то вона така: propagation — це спосіб керувати межами persistence context. А межі persistence context — це, по суті, межі того, де ваші сутності managed, а де вони вже «просто об’єкти».

7. Типові помилки при propagation і self-invocation

Помилка № 1: «Анотація є — значить транзакція точно є».
Це найпоширеніша ілюзія. У proxy-based моделі Spring транзакція з’являється не через сам факт наявності @Transactional у початковому коді, а через те, що виклик пройшов через transactional proxy. Якщо ви робите self-invocation, якщо метод приватний, якщо виклик не зовнішній — ви легко отримуєте «анотовану нетранзакційність».

Помилка № 2: self-invocation як «зручний helper», який раптом стає «транзакційною межею».
Класичний сценарій: розробник пише public doWork() і викликає всередині this.doWorkInNewTx(), вішає на doWorkInNewTx() @Transactional(Propagation.REQUIRES_NEW) і чекає окремої транзакції. Але окрема транзакція не стартує, бо проксі не брав участі. У підсумку частина коду виконується взагалі без очікуваної межі, а діагностується це зазвичай за дивними SQL-логами й неочікуваними lazy/flush-ефектами.

Помилка № 3: використовувати REQUIRES_NEW як «універсальну пігулку від проблем».
Іноді здається: «якщо зробити окрему транзакцію, то буде надійніше». На практиці це часто створює часткові коміти й розриває цілісність unit of work. Якщо основна операція має бути «все або нічого», то REQUIRES_NEW усередині неї — це не «надійність», а потенційна неконсистентність, яку потім доведеться пояснювати бізнесу й собі майбутньому.

Помилка № 4: передавати managed-сутності із зовнішньої транзакції в REQUIRES_NEW і очікувати, що вони залишаться managed.
У Hibernate та сама сутність не може бути managed у двох persistence context одночасно. Якщо ви запускаєте REQUIRES_NEW, у вас зазвичай новий EntityManager. Отже, об’єкт, який був managed «ззовні», усередині вже не managed. Далі починаються знайомі ефекти merge(), додаткові SELECT і «чому воно взагалі полізло в базу?».

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

1
Задача
Hibernate deep-dive, 17 рівень, 3 лекція
Недоступна
Технічний лог в окремій транзакції
Технічний лог в окремій транзакції
1
Задача
Hibernate deep-dive, 17 рівень, 3 лекція
Недоступна
Окремий сервіс замість self-invocation
Окремий сервіс замість self-invocation
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ