💳 Розділ 11 · Питання #22

Що станеться при виклику @Transactional методу з іншого методу того ж класу

Якщо всередині сервісного класу викликати метод, позначений анотацією @Transactional, з іншого методу того самого класу (через неявний або явний покажчик this), то анотація @Tra...


🟢 Junior Level

Якщо всередині сервісного класу викликати метод, позначений анотацією @Transactional, з іншого методу того самого класу (через неявний або явний покажчик this), то анотація @Transactional буде повністю проігнорована, і нова транзакція НЕ розпочнеться.

В архітектурі Spring це явище називається Self-Invocation Problem (проблема самовиклику).

Суть у 30 секундах

Spring Framework керує транзакціями за допомогою AOP-проксі (динамічних об’єктів-обгорток):

  1. Коли зовнішній клієнт (контролер або інший сервіс) викликає метод біна, виклик спочатку потрапляє в Spring AOP Proxy, який відкриває транзакцію та передає керування в реальний об’єкт.
  2. Але коли всередині одного методу викликається інший метод того самого класу (this.innerMethod()), виклик відбувається безпосередньо всередині JVM повз проксі-обгортку.
  3. TransactionInterceptor просто не отримує керування. Налаштування @Transactional (включно з propagation = REQUIRES_NEW, isolation, timeout) не застосовуються!
[ Зовнішній виклик від Контролера ]:
Client ───> [ Spring AOP Proxy ] ───> BEGIN TX ───> [ Target Bean: methodA() ] ───> COMMIT TX
                    ^
                    │ (Транзакція перехоплена і відкрита!)

[ Внутрішній самовиклик (Self-Invocation) ]:
[ Target Bean: methodA() ] ──(this.methodB())──> [ Target Bean: methodB() ]
                                                         │
                                                         v
                                              ❌ Проксі оминуто стороною!
                                              Транзакція НЕ створюється!

Простий приклад проблеми

package com.example.service;

import com.example.entity.Order;
import com.example.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    // Зовнішній метод без транзакції:
    public void processOrder(Order order) {
        // ПОМИЛКА! Виклик this.saveOrderInNewTx() омине Spring Proxy!
        saveOrderInNewTx(order);
    }

    // Розробник очікує відкриття нової ізольованої транзакції:
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOrderInNewTx(Order order) {
        // ФАКТИЧНО: Виконається БЕЗ транзакції (у режимі autocommit),
        // анотацію @Transactional повністю проігноровано!
        orderRepository.save(order);
    }
}

🟡 Middle Level

Коли Self-Invocation стає критичною аварією

Існує 2 типові виробничі сценарії, в яких самовиклик призводить до втрати даних або деградації системи:

Сценарій 1: Батьківський метод ВЖЕ в транзакції, а дочірній вимагає REQUIRES_NEW

@Service
public class BillingService {

    @Transactional // Транзакція #1
    public void executePayment() {
        debitAccount();
        // Розробник хотів записати аудит незалежно в новій транзакції:
        this.logAuditInNewTx(); 
        throw new RuntimeException("Crash");
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAuditInNewTx() {
        auditRepo.save(new AuditEntry("Платіж розпочато"));
    }
}

Що відбудеться: Виклик this.logAuditInNewTx() піде повз проксі. Атрибут REQUIRES_NEW буде проігноровано. Запис аудиту виконається всередині Транзакції #1. У разі виникнення Crash Транзакція #1 відкотиться цілком, і запис аудиту буде стерто з бази даних!

Сценарій 2: Батьківський метод без транзакції, а дочірній виконує складний запис

Якщо батьківський метод public void importBatch() без транзакції викликає в циклі this.saveRow(), позначений @Transactional, кожна операція INSERT виконуватиметься в режимі JDBC autoCommit = true. При збої на середині пачки частина рядків залишиться в базі, порушивши атомарність даних.


🔴 Senior Level

4 Промислові способи розв’язання проблеми Self-Invocation

Senior Engineer обирає рішення залежно від архітектурної чистоти та вимог до продуктивності:

1. Винесення методу в окремий сервіс (Best Practice — 100% рекомендація)

Найчистіше рішення з погляду принципу єдиної відповідальності (Single Responsibility Principle):

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderAuditService auditService; // Окремий бін-проксі!

    @Transactional
    public void executeOrder() {
        // Виклик іде через Spring Proxy іншого біна — REQUIRES_NEW спрацює ідеально!
        auditService.logAuditInNewTx();
    }
}

2. Впровадження біна самого в себе (Self-Injection з @Lazy)

Якщо поділ класу на кілька бінів є недоцільним:

@Service
public class OrderService {

    @Autowired
    @Lazy // @Lazy запобігає циклічній залежності (Circular Dependency)
    private OrderService self;

    public void processOrder(Order order) {
        // Виклик через інжектований проксі-об'єкт:
        self.saveOrderInNewTx(order); // @Transactional перехоплюється!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOrderInNewTx(Order order) {
        orderRepository.save(order);
    }
}

3. Використання TransactionTemplate (Програмне керування)

Повна відмова від проксіювання на користь явних меж транзакції:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final TransactionTemplate transactionTemplate;
    private final OrderRepository orderRepository;

    public void processOrder(Order order) {
        // Програмне відкриття транзакції без викликів через проксі:
        transactionTemplate.execute(status -> {
            orderRepository.save(order);
            return true;
        });
    }
}

4. AspectJ Load-Time / Compile-Time Weaving (Байткод-інструментування)

Якщо в проєкті налаштовано компілятор AspectJ (ajc) або Java Agent:

  • AspectJ не використовує динамічні проксі CGLIB/JDK. Він впроваджує інструкції транзакцій безпосередньо в байткод класів (Bytecode Weaving).
  • При AspectJ виклики this.method() і навіть виклики private-методів перехоплюються й працюють транзакційно! Проте цей підхід суттєво ускладнює збірку та налагодження застосунку.

4 Tricky Questions

1. Що станеться, якщо батьківський метод outer() уже перебуває в @Transactional, і викликає this.inner() з @Transactional(propagation = Propagation.REQUIRES_NEW)?

Відповідь: Метод inner() виконається всередині поточної відкритої батьківської транзакції, ніби для нього було задано Propagation.REQUIRED! Причина: Оскільки виклик виконується через покажчик this, AOP-проксі повністю виключений з ланцюжка викликів. Анотація над inner() не аналізується перехоплювачем TransactionInterceptor. Призупинення батьківської транзакції (suspend) та відкриття нового фізичного з’єднання не відбудеться. Якщо батьківський метод надалі впаде й відкотиться, зміни, внесені методом inner(), також будуть повністю скасовані.

2. Чому стандартні Unit-тести на Mockito (наприклад, @ExtendWith(MockitoExtension.class)) часто маскують цю помилку та не виявляють проблему?

Відповідь: Тому що в легковагих модульних тестах на Mockito тестується чистий Java-об’єкт без розгортання Spring ApplicationContext та без генерації CGLIB/JDK-проксі. Коли в тесті викликається orderService.processOrder(), виклик this.saveOrderInNewTx() відпрацьовує безпосередньо в пам’яті, моковані репозиторії (@Mock Repository) успішно зберігають сутності, і тести проходять зі статусом «зелений». Помилка самовиклику розкривається лише в інтеграційних тестах із середовищем Spring (@SpringBootTest / @DataJpaTest) з перевіркою реального відкату транзакції в базі даних або вже на продакшені.

3. Як гарантовано заборонити самовиклики методів з @Transactional у всьому проєкті за допомогою ArchUnit?

Відповідь: За допомогою архітектурного тесту ArchUnit на рівні CI/CD:

package com.example.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.transaction.annotation.Transactional;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.example")
public class TransactionArchitectureTest {

    @ArchTest
    public static final ArchRule no_self_invocation_of_transactional_methods =
        noClasses().should().callMethodWhere(
            target -> target.isAnnotatedWith(Transactional.class) 
                   && target.getOwner().equals(target.getDeclaringClass())
        ).because("Виклик @Transactional методу з того самого класу оминає Spring AOP Proxy!");
}

Цей тест статично аналізує байткод усіх класів і зупиняє збірку проєкту з помилкою, якщо виявляє виклик транзакційного методу через this.

4. У чому фундаментальна відмінність роботи @Transactional при CGLIB-проксіюванні від AspectJ Weaving у контексті викликів через this?

Відповідь:

  • CGLIB / JDK Dynamic Proxy: Базується на патерні Decorator/Wrapper. Створюється окремий синтетичний клас-проксі (Service$$SpringCGLIB), який перехоплює зовнішні виклики. Посилання this всередині методів оригінального класу завжди вказує на сам реальний об’єкт, а не на проксі-обгортку, тому виклики this.method() у принципі неможливо перехопити проксі-об’єктом.
  • AspectJ Weaving (LTW/CTW): Модифікує байткод цільового класу безпосередньо. Виклики this.innerMethod() на рівні байткоду огортаються у виклики аспекту TransactionAspect.aj. Тому при AspectJ транзакції працюють за будь-яких внутрішніх викликів і навіть на private та protected методах.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«При виклику @Transactional методу з іншого методу того самого класу анотація ігнорується, і нова транзакція не відкривається. Це відбувається тому, що Spring реалізує декларативні транзакції через AOP-проксі. При виклику через this керування передається напряму всередині оригінального екземпляра класу, оминаючи проксі та TransactionInterceptor. Найкраще архітектурне рішення — винести метод в окремий бін. Альтернативи: впровадження біна самого в себе (@Autowired @Lazy self), використання TransactionTemplate або перехід на AspectJ Weaving».

Чек-лист ключових понять

  1. Причина проблеми: Виклик через this іде в обхід Spring AOP Proxy.
  2. REQUIRES_NEW ігнорується: Внутрішній виклик із REQUIRES_NEW мовчки виконується в батьківській транзакції.
  3. Рекомендоване рішення: Винесення методу в окремий клас за Single Responsibility Principle.
  4. Self-Injection: Впровадження самого себе з анотацією @Lazy для отримання посилання на власний проксі.
  5. ArchUnit: Архітектурні тести дозволяють виявляти самовиклики ще на етапі компіляції.

Червоні прапорці (чого в жодному разі не можна говорити)

  • ❌ «Якщо поставити @Transactional(propagation = REQUIRES_NEW), то самовиклик спрацює» (REQUIRES_NEW також вимагає проксі й так само ігнорується).
  • ❌ «AopContext.currentProxy() — це найкращий спосіб вирішення проблеми» (Це антипатерн, який створює жорстку зв’язність зі Spring AOP і вимагає небезпечного exposeProxy).
  • ❌ «CGLIB вміє перехоплювати виклики через this» (CGLIB створює підклас, але this всередині методів все одно вказує на цільовий екземпляр).

Посилання на пов’язані теми