JavaRush /Курси /Spring Data JPA /Propagation: REQUIRED

Propagation: REQUIRED vs REQUIRES_NEW

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

1. Propagation у композиції сервісів

Propagation майже завжди зʼявляється не тому, що ви «любите анотації», а тому, що ви почали будувати нормальну архітектуру: один сервіс збирає use case, а інші сервіси допомагають йому виконати кроки всередині цього use case. Щойно зʼявляється ланцюжок OrderService -> InventoryService -> ..., питання «це одна транзакція чи кілька?» перестає бути абстракцією і перетворюється на долю ваших даних.

У Spring слово propagation відповідає на дуже конкретне запитання: що робити, якщо метод із @Transactional викликається всередині іншого методу з @Transactional. Це не «розмноження транзакцій», не «складна тема для DBA» і не «особлива магія Hibernate». Це звичайна інженерна ситуація: у нас є зовнішній outer сервісний метод і внутрішній inner метод, і обидва позначені як транзакційні (або принаймні хочуть бути такими).

Під час розмови про ізоляцію ми дивилися на незалежні транзакції та питали, що вони бачать одна в одній. Тут питання інше: бізнес-операція рідко живе в одному методі. placeOrder() кличе reserve(), потім зберігає замовлення, і тепер важливо зрозуміти вже не паралельну видимість даних, а долю однієї операції, розтягнутої на кілька викликів @Transactional.

Давайте подивимося на найтиповішу картину для нашого mini-shop:

sequenceDiagram
    %% Важливо: тут показано типовий use case, де один сервіс викликає інший всередині однієї операції.
    participant OS as "OrderService.placeOrder()"
    participant IS as "InventoryService.reserve()"
    participant DB as PostgreSQL

    OS->>DB: читаємо товари/залишки
    OS->>IS: reserve(productId, quantity)
    IS->>DB: UPDATE stock_item ...
    IS-->>OS: ok / виняток
    OS->>DB: INSERT customer_order / order_item ...

Виглядає просто. Але в який момент починається транзакція? У placeOrder()? У reserve()? В обох? І якщо в обох — це «дві транзакції» чи «одна й та сама транзакція, просто два методи»?

Ось тут і зʼявляється propagation.

Важливо одразу проговорити дві речі, щоб далі не було «містики».

По-перше, транзакція в Spring — це не обʼєкт, який ви передаєте параметром. Вона зазвичай «привʼязана» до потоку виконання (thread-bound) і керується PlatformTransactionManager.

По-друге, різниця між режимами propagation проявляється лише якщо зовнішній метод уже відкрив транзакцію. Якщо транзакції немає, то і REQUIRED, і REQUIRES_NEW зрештою створять нову (у цьому місці вони виглядають однаково).

2. Propagation.REQUIRED: одна транзакція

Propagation.REQUIRED звучить як «потрібна транзакція», і по суті так і є: якщо транзакція вже є — ми до неї приєднуємося, якщо її немає — створюємо нову. Це настільки поширений і «нормальний» сценарій, що в Spring він є значенням за замовчуванням для @Transactional. У більшості випадків, коли ви пишете звичайний backend, вам потрібен саме він.

REQUIRED на пальцях: «беремо участь, якщо транзакція вже триває»

У нашому проєкті shop-data-jpa use case placeOrder() — це ідеальний кандидат на одну спільну транзакцію: або замовлення оформилося коректно, списалися залишки і зʼявилися позиції замовлення, або нічого не змінилося.

Зовнішній сервіс:

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

@Service
public class OrderService {

    @Transactional // propagation = REQUIRED за замовчуванням
    public void placeOrder(Long productId, int quantity) {
        // Входимо в транзакцію use case: це зовнішня межа операції оформлення замовлення.
        inventoryService.reserve(productId, quantity); // У REQUIRED-режимі внутрішній виклик приєднається до тієї самої TX.
        orderRepository.save(buildOrder(productId, quantity)); // Зберігаємо замовлення в межах тієї самої транзакції.
    }
}

Внутрішній сервіс:

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

@Service
public class InventoryService {

    @Transactional // теж REQUIRED за замовчуванням
    public void reserve(Long productId, int quantity) {
        // Цей метод не відкриває "другу" транзакцію: він бере участь у зовнішній, якщо вона вже є.
        StockItem stock = stockRepository.findByProductId(productId).orElseThrow(); // Читаємо поточні залишки.
        stock.reserve(quantity); // Доменна логіка: перевірка та зменшення залишку.
        stockRepository.save(stock); // Фіксація змін відбудеться разом із COMMIT зовнішнього use case.
    }
}

Хоча на обох методах стоїть @Transactional, у режимі REQUIRED ми отримуємо одну фізичну транзакцію (один BEGIN, один COMMIT або ROLLBACK). Внутрішній сервіс не «відкриває нове життя», а продовжує ту саму операцію.

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

REQUIRED і композиція сервісів

Щойно ви починаєте будувати проєкт «по-дорослому», у вас неминуче зʼявляються сервіси, які викликають один одного. І це нормально: OrderService відповідає за оформлення замовлення, але він не зобовʼязаний знати всі деталі резервування залишків, тому викликає InventoryService.

Propagation REQUIRED робить цей стиль архітектури безпечним, бо він зберігає єдину долю операції. Якщо резервування не вдалося, то й замовлення не повинно зʼявитися (або навпаки: якщо замовлення не зʼявилося — не повинні лишитися зміни по залишку).

Саме тому в більшості звичайних сервісних сценаріїв ви починаєте з REQUIRED і лише потім, за дуже чіткої вимоги, думаєте про щось інше.

Важливий нюанс: «зловили виняток — не означає врятували транзакцію»

Ось тут багато новачків помиляються — і це нормально: Spring уміє навчати через біль. У REQUIRED внутрішній метод бере участь у зовнішній транзакції. Якщо внутрішній метод кинув runtime-виняток, Spring на межі цього внутрішнього методу зазвичай позначає поточну транзакцію як rollback-only.

І далі ви можете зробити «логічну» (але неправильну) річ: зловити виняток і продовжити.

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

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long productId, int quantity) {
        try {
            inventoryService.reserve(productId, quantity); // Внутрішній крок бере участь у тій самій транзакції.
        } catch (OutOfStockException e) {
            // Навіть якщо ми зловили виняток, транзакція може бути вже позначена як rollback-only.
            System.out.println("Немає залишків, але продовжуємо..."); // Немає залишків, але продовжуємо...
        }
        // Наприкінці методу Spring спробує виконати COMMIT, але може отримати rollback і кинути UnexpectedRollbackException.
        orderRepository.save(buildOrder(productId, quantity));
    }
}

На рівні Java ви справді «продовжили». Але на рівні транзакції ви вже живете у світі «ця транзакція має бути відкочена». І наприкінці ви можете отримати неприємний сюрприз під час завершення методу: замість коміту Spring зробить rollback і кине UnexpectedRollbackException.

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

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

3. Propagation.REQUIRES_NEW: окремий commit

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

Як це працює: «зовнішню транзакцію ставимо на паузу»

Оскільки сьогодні ми не заглиблюємося у внутрішню кухню Spring, опишемо поведінку людською мовою. Коли зовнішній метод уже перебуває в транзакції, і ви викликаєте внутрішній метод з REQUIRES_NEW, Spring робить приблизно таку історію:

# outer — це "основна" транзакція use case (оформлення замовлення).
outer: BEGIN
outer: ... робимо кроки ...
# inner — окрема транзакція, яка не залежить від долі outer.
inner(REQUIRES_NEW): SUSPEND outer
inner: BEGIN
inner: ... робимо свої SQL ...
inner: COMMIT (або ROLLBACK)
inner: RESUME outer
outer: ... продовжуємо ...
outer: COMMIT (або ROLLBACK)

Тобто зʼявляються дві незалежні точки завершення. І ось тут починається доросла частина: ви зобовʼязані розуміти, яку долю ви хочете для даних.

Щоб побачити різницю візуально, зручно порівняти REQUIRED і REQUIRES_NEW в одній схемі:

flowchart TD
    %% Ідея діаграми: REQUIRED = одна TX, REQUIRES_NEW = дві незалежні TX з SUSPEND/RESUME.
    A["OrderService (зовнішній) BEGIN"] --> B["InventoryService.reserve()"]
    B -->|REQUIRED| C["працюємо в тій самій TX"]
    B -->|REQUIRES_NEW| D["пауза зовнішньої TX, BEGIN внутрішньої TX"]
    D --> E["COMMIT внутрішньої"]
    E --> F["повернення в зовнішню TX"]
    C --> G["COMMIT зовнішньої (якщо все добре)"]
    F --> G

Приклад: логуємо невдале замовлення

Для нашого mini-shop логічний і корисний сценарій, де REQUIRES_NEW справді виправданий: ми хочемо зафіксувати факт помилки в окремій таблиці, навіть якщо основна операція відкочується.

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

Нехай у нас є невеликий сервіс логування помилок замовлення:

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

@Service
public class OrderFailureLogService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logFailure(Long productId, int quantity, String reason) {
        // Лог пишемо в окремій транзакції: він має зберегтися навіть при відкаті основного use case.
        failureLogRepository.save(new OrderFailureLog(productId, quantity, reason));
    }
}

Тут важливо не те, як улаштована сутність OrderFailureLog (це ми вже вміємо робити за попередніми модулями), а те, що лог зберігається у своїй транзакції.

Тепер зовнішній placeOrder() може при помилці записати лог і все одно відкотити основну операцію:

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

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long productId, int quantity) {
        try {
            inventoryService.reserve(productId, quantity); // Основний крок use case: бере участь у зовнішній транзакції.
            orderRepository.save(buildOrder(productId, quantity)); // Також частина зовнішньої транзакції.
        } catch (OutOfStockException e) {
            // Важливо: логуємо причину в REQUIRES_NEW, щоб запис пережив відкат зовнішньої транзакції.
            failureLogService.logFailure(productId, quantity, e.getMessage());
            throw e; // Повторно кидаємо далі, щоб зовнішній use case чесно відкотився.
        }
    }
}

Зверніть увагу на дві ідеї, які тут важливо тримати разом.

Перша ідея: failureLogService.logFailure(...) завершиться своїм комітом, навіть якщо зовнішній placeOrder() потім відкочується (а він відкочується, бо ми повторно кинули виняток далі).

Друга ідея: ми не намагаємося «продовжувати оформлення замовлення» після помилки. Ми фіксуємо важливий факт (лог) і чесно падаємо, щоб основний use case не лишив напівправди в базі.

З погляду інваріантів домену це акуратно: замовлення не зʼявилося, залишки не змінилися — але запис про невдалу спробу залишився.

REQUIRES_NEW — не «надійніше», а «інакше»

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

Щоб відчути, чому це небезпечно, давайте зробимо анти-приклад. Уявімо, що хтось вирішив «покращити» reserve() і зробив його REQUIRES_NEW:

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

@Service
public class InventoryService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reserve(Long productId, int quantity) {
        // УВАГА: так резервування отримає свій незалежний COMMIT і може "пережити" відкат замовлення.
        StockItem stock = stockRepository.findByProductId(productId).orElseThrow();
        stock.reserve(quantity);
        stockRepository.save(stock);
    }
}

Тепер сценарій стає неприємним: резервування залишків може успішно завершити коміт, а потім оформлення замовлення впаде на іншому кроці (наприклад, через помилку під час збереження замовлення, помилку перевірки інваріанта, щось інше). Зовнішня транзакція відкочується, а резерв уже лишається.

І ви отримаєте стан «залишки зарезервували, а замовлення немає». Це як покласти товар у коробку, а потім забути записати адресу доставки. На складі хаос, у голові — туга, у базі — inconsistent state.

Ось і ціна REQUIRES_NEW: він створює незалежну долю даних. Іноді це правильно (лог помилок), іноді це прямий шлях до багів.

4. Вибір між REQUIRED і REQUIRES_NEW

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

Давайте зведемо відмінності в компактну таблицю. Таблиця — не для заучування, а щоб мозку було легше не плутатися.

Ситуація REQUIRED REQUIRES_NEW
Зовнішня транзакція вже є Внутрішній метод бере участь у ній Зовнішню «ставимо на паузу», запускаємо нову
Коли відбувається COMMIT Один спільний коміт наприкінці зовнішнього use case Окремий коміт внутрішнього кроку, а потім коміт або rollback зовнішнього
Що буде, якщо зовнішній use case відкочується Усе відкочується разом Внутрішній коміт лишається як окремий факт
Добре підходить для Кроків однієї бізнес-операції (placeOrder) Технічних «побічних ефектів», які мають зберегтися, наприклад логування помилки
Небезпечно використовувати для Основних доменних змін, які мають бути атомарними

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

5. Типові помилки під час роботи з REQUIRED і REQUIRES_NEW

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

Помилка № 2: ставити REQUIRES_NEW «для надійності», щоб «точно збереглося».
Це виглядає логічно на рівні емоцій і дуже погано на рівні даних. REQUIRES_NEW — це не «підсилювач транзакції», а окремий коміт. Якщо так оформити резервування залишків, збереження замовлення і ще кілька кроків, ви отримаєте систему, де частина даних комітиться навіть при провалі use case. Потім такі баги виправляються довго і зазвичай сумно.

Помилка № 3: ловити виняток у внутрішньому REQUIRED-методі й думати, що зовнішня транзакція «продовжить працювати нормально».
Коли внутрішній транзакційний метод бере участь у зовнішній транзакції та падає з runtime-винятком, транзакція може бути позначена як rollback-only. Якщо зовнішній метод потім «передумав» і вирішив усе-таки комітити, він раптово отримає rollback наприкінці. Тому винятки всередині unit of work потрібно трактувати серйозно: або ви справді можете продовжувати (тоді внутрішній крок має бути спроєктований інакше), або ви відкочуєте всю операцію.

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

Помилка № 5: вибирати propagation, не сформулювавши use case.
Propagation — це налаштування поведінки use case, а не налаштування «методу репозиторію» чи якогось helper. Якщо ви не можете сказати фразу «я хочу, щоб цей внутрішній крок комітився незалежно від основної операції» або навпаки «я хочу, щоб це було однією атомарною дією» — значить, ви ще не готові вибирати REQUIRES_NEW. У такій ситуації майже завжди правильніше лишитися на REQUIRED і доуточнити вимоги.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ