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

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

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

Мовні версії: English Russian Ukrainian

🟢 Junior Level

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

Це називається Self-invocation problem (проблема самовиклику).

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

@Service
public class OrderService {

    public void createOrderExternal() {
        saveOrder();  // Виклик всередині класу — @Transactional НЕ працює!
    }

    @Transactional
    public void saveOrder() {
        // Ця логіка виконається БЕЗ транзакції
        orderRepository.save(order);
    }
}

Аналогія

Уявіть що ви телефонуєте другу через секретаря (Proxy). Секретар записує розмову (управляє транзакцією). Але якщо ви крикнете другу через стіну (this) — секретар нічого не знає про розмову.

Чому це відбувається

Spring управляє транзакціями через Proxy-об’єкт (обгортку). Коли зовнішній код викликає метод — він йде через проксі. Але коли метод викликає інший метод того ж класу — виклик йде напряму через this,минаючи проксі.

Механізм: Spring створює проксі-об’єкт, який перехоплює виклики ззовні. Але коли метод A викликає метод B того ж об’єкта, this.methodB() йде напряму — проксі не бере участі, TransactionInterceptor не спрацьовує.

Важливі правила

  • @Transactional працює тільки при виклику ззовні класу
  • this.method() — завжди bypass proxy
  • Private/protected методи ніколи не перехоплюються

Як вирішити

  1. Винести метод в інший бін (найкращий варіант)
  2. Self-injection: внедрити сервіс сам в себе з @Lazy
  3. TransactionTemplate: програмне управління транзакціями

Коли self-invocation НЕ проблема

  1. Метод не потребує транзакції — якщо @Transactional стоїть для консистентності, але метод read-only
  2. Зовнішній виклик вже в транзакції — якщо outerMethod вже @Transactional, innerMethod працює в тій самій транзакції (propagation = REQUIRED за замовчуванням)
  3. Використовуєте AspectJ mode — compile-time/load-time weaving перехоплює this виклики

🟡 Middle Level

Механізм Proxy

Зовнішній виклик:
Client → Proxy → TransactionInterceptor → TransactionManager → Реальний метод
                       ↓
               Відкрити транзакцію → Виконати → COMMIT/ROLLBACK

Внутрішній виклик (this):
Метод A → this.метод B() → Реальний метод (минаючи Proxy!)
                              ↓
                        Немає транзакції!

Spring створює бін-обгортку (Proxy) навколо вашого об’єкта. Коли зовнішній клієнт викликає метод, він викликає його у Proxy. Proxy перехоплює виклик, відкриває транзакцію і передає управління.

Виклик через this йде напряму до реального об’єкта — proxy повністю bypassed.

Способи вирішення

А. Винос метода в інший бін (Рекомендований)

@Service
public class OrderService {
    @Autowired
    private OrderRepositoryService orderRepositoryService;

    public void createOrderExternal() {
        orderRepositoryService.saveOrder();  // Через інший бін — працює!
    }
}

@Service
public class OrderRepositoryService {
    @Transactional
    public void saveOrder() {
        // Тепер транзакція працює
    }
}

Б. Self-injection

@Service
public class OrderService {

    @Autowired
    @Lazy  // Щоб уникнути циклічної залежності
    private OrderService self;

    public void createOrderExternal() {
        self.saveOrder();  // Через proxy — транзакція працює!
    }

    @Transactional
    public void saveOrder() {
        // Тепер транзакція працює
    }
}

В. TransactionTemplate

@Service
public class OrderService {

    @Autowired
    private TransactionTemplate transactionTemplate;

    public void createOrderExternal() {
        transactionTemplate.execute(status -> {
            saveOrder();  // Програмна транзакція
            return null;
        });
    }

    public void saveOrder() {
        // Виконується в транзакції через TransactionTemplate
    }
}

Г. AspectJ

Перехід на AspectJ weaving модифікує байт-код при компіляції, і виклики всередині класу перехоплюються коректно. Вимагає складної настройки.

Поширені помилки

Помилка Що відбувається Як виправити
Не знати про self-invocation Код працює в тестах, падає в production Винести в окремий бін
Self-injection без @Lazy BeanCurrentlyInCreationException Додати @Lazy
AopContext.currentProxy() без exposeProxy IllegalStateException: Cannot find current proxy @EnableAspectJAutoProxy(exposeProxy = true)
Змішування підходів Непередбачувана поведінка Обрати один підхід

Порівняння рішень

Рішення Складність Self-invocation Private методи Рекомендується
Винос в окремий бін Низька ✅ Найкращий
Self-injection (@Lazy) Низька Хороший
AopContext.currentProxy() Середня OK
TransactionTemplate Середня Хороший для складної логіки
AspectJ weaving Висока Тільки якщо потрібно

Коли НЕ використовувати рішення

Ситуація Чому Альтернатива
Метод не потребує окремої транзакції Self-invocation не проблема якщо caller вже в tx Залишити як є
Simple read-only метод ReadOnly не потребує складної транзакції readOnly = true на caller
Метод — утилітарний, не бізнес-логіка Не belongs в сервісному шарі Винести в util class

🔴 Senior Level

Proxy-Based AOP — Root Cause Analysis

How Spring Creates Proxies

JDK Dynamic Proxy:

UserService proxy = (UserService) Proxy.newProxyInstance(
    classLoader,
    new Class[]{UserService.class},
    new TransactionalInvocationHandler(target, interceptor)
);

CGLIB:

UserService proxy = (UserService) Enhancer.create(
    UserService.class,
    new TransactionalMethodInterceptor(target, interceptor)
);

В обох випадках proxy огортає target object. Зовнішні виклики йдуть через proxy → interceptor → target. Внутрішні виклики (this.method()) йдуть напряму: target → target. Це працює і для JDK Dynamic Proxy, і для CGLIB — в обох випадках this посилається на цільовий об’єкт, не на проксі. CGLIB створює підклас, але this всередині методів підкласу все одно вказує на цільовий об’єкт.

The this Reference Problem

public class OrderService {
    // this = OrderService@123 (the TARGET object, not the proxy)

    public void createOrderExternal() {
        // this.saveOrder() викликає target напряму
        // Bypasses: OrderServiceProxy@456.saveOrder()
        saveOrder();
    }
}

this завжди вказує на actual object, never на proxy. Немає способу для Spring перехопити this.*() виклики зі стандартним AOP.

Solution Deep Dive

Solution 1: Self-Injection with @Lazy

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void createOrderExternal() {
        // self = proxy (OrderService$$EnhancerBySpringCGLIB$$...)
        self.saveOrder();  // Goes through proxy → interceptor
    }
}

Why @Lazy: Без @Lazy, Spring намагається інжектувати OrderService під час його створення → circular dependency error. @Lazy створює lazy proxy, який резолвить реальний бін при першому використанні.

Solution 2: AopContext.currentProxy()

@EnableAspectJAutoProxy(exposeProxy = true)  // Required!

@Service
public class OrderService {
    public void createOrderExternal() {
        ((OrderService) AopContext.currentProxy()).saveOrder();
    }
}

How it works: exposeProxy = true зберігає proxy в ThreadLocal. AopContext.currentProxy() вилучає його.

Drawbacks: Tight coupling до Spring API, ThreadLocal — не працює across threads, exposeProxy має performance overhead.

// exposeProxy = true додає overhead на кожен виклик (ThreadLocal). // Не використовуйте в highload. Альтернатива: винесіть метод в окремий бін.

Solution 3: Programmatic Transactions

@Service
public class OrderService {
    @Autowired
    private PlatformTransactionManager transactionManager;

    public void createOrderExternal() {
        TransactionTemplate template = new TransactionTemplate(transactionManager);
        template.execute(status -> {
            saveOrder();  // In transaction
            return null;
        });
    }
}

Advantages: No proxy needed, explicit transaction boundaries, працює в будь-якому контексті. Disadvantages: More verbose, mixing transaction management з business logic.

AspectJ Weaving — The Nuclear Option

Compile-Time Weaving

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <configuration>
        <aspectLibraries>
            <aspectLibrary>
                <groupId>org.springframework</groupId>
                <artifactId>spring-aspects</artifactId>
            </aspectLibrary>
        </aspectLibraries>
    </configuration>
</plugin>

Модифікує bytecode at compile time:

// BEFORE
public void createOrderExternal() { saveOrder(); }

// AFTER (AspectJ-woven)
public void createOrderExternal() {
    TransactionInterceptor.invoke(this, "saveOrder", ...);
    saveOrder();
}

Benefits: Self-invocation працює, private/protected методи можуть бути @Transactional, немає proxy limitations. Drawbacks: Complex build config, bytecode modification (debugging harder), limited IDE support, не сумісний зі всіма Spring Boot features.

Edge Cases (мінімум 3)

  1. @Transactional на інтерфейсі + CGLIB: Якщо proxy — CGLIB (class-based), анотація на інтерфейсі не видна. Self-invocation вже не працює, плюс анотація не застосовується до proxy. Подвійна проблема.

  2. Nested self-invocation: methodA()this.methodB() (no tx) → this.methodC() (has @Transactional). Ні B, ні C не будуть транзакційними. Весь ланцюжок bypassed.

  3. @Transactional(propagation = REQUIRES_NEW) при self-invocation: Навіть REQUIRES_NEW не спрацює при this.method() виклику. Нова транзакція не відкриється — метод виконається в контексті caller (без транзакції якщо caller теж не в tx).

  4. Self-invocation та @Async: @Async методи теж йдуть через proxy. this.asyncMethod() — метод виконається синхронно, не асинхронно. Подвійний proxy bypass: і транзакція, і async.

  5. Constructor виклики: Якщо конструктор викликає @Transactional метод — proxy ще не створено (bean initialization не завершено). Виклик завжди йде напряму.

JDK Proxy vs CGLIB для Self-Invocation

Характеристика JDK Dynamic Proxy CGLIB
Self-invocation ❌ Не працює ❌ Не працює
Final методи N/A (interface) ❌ Не перехоплюються
Private методи ❌ Не перехоплюються ❌ Не перехоплюються
Performance overhead Низький Середній
Bytecode generation Ні Так (генерує subclass)

Обидва типи proxy не вирішують self-invocation проблему. Тільки AspectJ weaving вирішує.

Performance Numbers

Операція Час
Proxy invocation overhead ~1-2 μs
AopContext.currentProxy() lookup ~0.5 μs (ThreadLocal get)
exposeProxy overhead ~0.1 μs per call
Self-injection (@Lazy) resolution ~1 μs (first call), 0 (cached)

Memory Implications

  • exposeProxy = true: ThreadLocal для кожного proxy — ~1 KB per active thread
  • Self-injection: додатковий proxy reference в біні — negligible (~8 bytes)
  • AspectJ weaving: Bytecode modification — larger class files, no runtime memory impact

Thread Safety

AopContext.currentProxy() використовує ThreadLocal — не працює across threads. @Async методи в іншому потоці не отримають proxy з ThreadLocal caller. Self-injection proxy — thread-safe (singleton proxy reference).

Production War Story

Сервіс обробки замовлень: createOrder() викликав this.validateOrder()@Transactional). Валідація писала audit log в БД. В тестах (один потік, без конкуренції) — працювало. В production — audit log не писався, бо транзакція не відкривалася. Дані втрачалися. Fix: validateOrder() винесено в окремий OrderValidationService.

Monitoring

Detect self-invocation at runtime:

@Bean
public BeanPostProcessor selfInvocationDetector() {
    return new BeanPostProcessor() {
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) {
            if (AopUtils.isAopProxy(bean)) {
                // Log: this bean has a proxy — self-invocation will bypass it
            }
            return bean;
        }
    };
}

Actuator:

GET /actuator/beans  # Показує які біни обгорнуті в proxy

Highload Best Practices

  1. Extract to separate bean — always: Це не workaround, це proper separation of concerns. Якщо методу потрібна своя транзакція — у нього має бути своя responsibility.
  2. Avoid AopContext.currentProxy(): Tight coupling, ThreadLocal issues, performance overhead. Не для production.
  3. Self-invocation = code smell: Якщо боретеся з self-invocation — сервіс, ймовірно, робить занадто багато. Split по SRP.
  4. Test self-invocation behavior: Unit test з Mockito не ловить цю проблему (Mockito mocks йдуть через proxy). Потрібен Spring integration test.
  5. Consider programmatic transactions: Для складної умовної логіки TransactionTemplate чистіший ніж fighting з proxy.
  6. Architecture rule: Ввести ArchUnit test: noClass().should().callMethodWhere(target declaresAnnotation(Transactional.class)).fromSameClass().

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

Обов’язково знати:

  • Self-invocation problem: виклик @Transactional методу через this bypass-ить Spring Proxy
  • Spring управляє транзакціями через AOP Proxy (JDK Dynamic Proxy або CGLIB)
  • this.method() йде напряму до target object — TransactionInterceptor не спрацьовує
  • 4 рішення: винести в інший бін (best), self-injection @Lazy, AopContext.currentProxy(), TransactionTemplate
  • REQUIRES_NEW теж НЕ працює при self-invocation — нова транзакція не відкриється
  • @Async при self-invocation виконується синхронно — подвійний proxy bypass

Часті уточнюючі запитання:

  • Чому proxy не перехоплює this виклики? — this завжди посилається на target object, не на proxy
  • Чим JDK Proxy відрізняється від CGLIB в контексті self-invocation? — Обидва НЕ вирішують проблему self-invocation
  • Коли self-invocation НЕ проблема? — Якщо caller вже в @Transactional (propagation REQUIRED join-ить)
  • Чому Mockito не ловить цю проблему? — Mockito mocks = proxy, self-invocation працює на mock-і

Червоні прапорці (НЕ говорити):

  • “Self-invocation працює в production” — proxy bypassed, транзакція не відкривається
  • “REQUIRES_NEW вирішує self-invocation” — REQUIRES_NEW теж вимагає proxy
  • “AopContext.currentProxy() = best practice” — tight coupling до Spring API, ThreadLocal overhead

Пов’язані теми:

  • [[16. Що таке анотація @Transactional]]
  • [[13. Що таке Propagation в Spring]]
  • [[17. На якому рівні можна використовувати @Transactional]]
  • [[15. В чому різниця між REQUIRED та REQUIRES_NEW]]