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 людською мовою.
Щоб закріпити різницю, корисна маленька таблиця:
| Параметр | |
|
|---|---|---|
| Якщо зовнішня транзакція є | бере участь у ній | призупиняє її і створює нову |
| Доля 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-метод проксі не перехоплює, тож транзакція "не зʼявиться"
// Ви очікуєте транзакцію... але виклик обійшов проксі
}
}
Тут одразу два червоні прапорці. По-перше, метод recalculateOneProduct — private. Проксі в 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-метод — це окрема «операція».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ