🍃 Розділ 5 · Питання #19

Як вирішити проблему з self-invocation

Проблема self-invocation (само-виклику) полягає в тому, що під час виклику методу того самого класу через this виклик іде в обхід AOP-проксі, тому анотації @Transactional, @Asyn...


🟢 Junior Level

Проблема self-invocation (само-виклику) полягає в тому, що під час виклику методу того самого класу через this виклик іде в обхід AOP-проксі, тому анотації @Transactional, @Async, @Cacheable повністю ігноруються.

Найпростіше та рекомендоване рішення: Винесення методу в окремий сервіс

Розділіть відповідальність класів: перенесіть транзакційний метод у допоміжний сервіс.

// ❌ БУЛО: self-invocation всередині одного сервісу (транзакція не працює)
@Service
public class OrderService {
    public void processOrder(Long orderId) {
        saveOrder(orderId); // Виклик через this оминає проксі!
    }

    @Transactional
    public void saveOrder(Long orderId) { ... }
}

// ✅ СТАЛО: два незалежні біни (транзакція працює через проксі)
@Service
public class OrderService {

    private final OrderDatabaseService orderDatabaseService;

    public OrderService(OrderDatabaseService orderDatabaseService) {
        this.orderDatabaseService = orderDatabaseService;
    }

    public void processOrder(Long orderId) {
        // Зовнішній виклик через проксі впровадженого біна!
        orderDatabaseService.saveOrder(orderId); 
    }
}

@Service
public class OrderDatabaseService {

    @Transactional
    public void saveOrder(Long orderId) {
        // Успішно виконується в межах відкритої транзакції
    }
}

🟡 Middle Level

5 способів вирішення проблеми: порівняння

Спосіб вирішення Принцип роботи Плюси Мінуси Рекомендація
1. Винесення в окремий бін Розділення класів (SRP) Чиста архітектура, легке модульне тестування Збільшує кількість класів Рекомендується (Best Practice)
2. TransactionTemplate Програмні транзакції Не залежить від проксі, точковий контроль Шаблонний код Рекомендується для коротких транзакцій
3. Self-injection (@Lazy) Впровадження проксі самого себе Не вимагає створення нових класів Циклічна залежність (хоча й лінива) Допустимо при рефакторингу легасі
4. ObjectProvider<Self> Отримання проксі з контексту Типобезпечно, явний виклик Залежність від Spring API Сучасна альтернатива @Lazy
5. AopContext.currentProxy() Отримання проксі з ThreadLocal Швидкий костиль без ін’єкцій Жорстка зв’язність, оверхед, легко зламати ❌ Не рекомендується (Anti-pattern)

🔴 Senior Level

Спосіб 2: Програмне керування через TransactionTemplate

Використання TransactionTemplate позбавляє необхідності генерувати AOP-проксі для допоміжних методів і запобігає утриманню з’єднання з БД під час тривалих операцій (I/O, мережеві виклики зовнішніх REST API):

package com.example.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class InvoiceService {

    private final TransactionTemplate transactionTemplate;
    private final ExternalPaymentGatewayClient paymentClient;
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(TransactionTemplate transactionTemplate,
                          ExternalPaymentGatewayClient paymentClient,
                          InvoiceRepository invoiceRepository) {
        this.transactionTemplate = transactionTemplate;
        this.paymentClient = paymentClient;
        this.invoiceRepository = invoiceRepository;
    }

    public void processInvoice(Long invoiceId) {
        // 1. Повільний виклик до зовнішнього шлюзу БЕЗ утримання з'єднання з БД
        PaymentResult result = paymentClient.charge(invoiceId);

        // 2. Коротка ізольована транзакція суворо для запису в БД
        transactionTemplate.executeWithoutResult(status -> {
            Invoice invoice = invoiceRepository.findById(invoiceId).orElseThrow();
            invoice.markAsPaid(result.transactionId());
            invoiceRepository.save(invoice);
        });
    }
}

Спосіб 3: Ліниве впровадження самого себе (Self-Injection через @Lazy)

Якщо рефакторинг із розділенням класу неможливий через стислі терміни або обсяг легасі-коду, можна впровадити проксі самого себе через @Lazy:

package com.example.service;

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

@Service
public class UserService {

    private final UserService self; // Посилання на AOP-проксі цього ж біна!

    public UserService(@Lazy UserService self) {
        this.self = self;
    }

    public void registerUser(UserDto dto) {
        // Виклик методу через проксі-посилання self активує TransactionInterceptor!
        self.saveUserInTransaction(dto);
    }

    @Transactional
    public void saveUserInTransaction(UserDto dto) {
        // Успішно виконується під транзакцією
    }
}

Чому це працює під капотом:

  1. Без анотації @Lazy ін’єкція самого себе призвела б до помилки BeanCurrentlyInCreationException (циклічна залежність у конструкторі).
  2. З анотацією @Lazy Spring впроваджує в конструктор синтетичну CGLIB-заглушку.
  3. При першому реальному виклику self.saveUserInTransaction() заглушка запитує у BeanFactory повністю ініціалізований синглтон-проксі біна UserService.

Спосіб 4: ObjectProvider<Self>

Сучасна типобезпечна альтернатива:

@Service
public class NotificationService {

    private final ObjectProvider<NotificationService> selfProvider;

    public NotificationService(ObjectProvider<NotificationService> selfProvider) {
        this.selfProvider = selfProvider;
    }

    public void sendBulkNotifications() {
        // Запит актуального проксі з контексту в момент виклику
        selfProvider.getObject().sendSingleInTransaction();
    }

    @Transactional
    public void sendSingleInTransaction() { ... }
}

Антипатерн: AopContext.currentProxy()

Застарілий механізм, що вимагає увімкнення налаштування @EnableAspectJAutoProxy(exposeProxy = true):

// ❌ АНТИПАТЕРН: жорстка прив'язка до Spring AOP API
public void outerMethod() {
    ((MyService) AopContext.currentProxy()).innerTransactionalMethod();
}

Чому це визнано антипатерном:

  1. Прихована крихкість: Якщо хтось забуде виставити прапорець exposeProxy = true у конфігурації, код викине фатальний IllegalStateException: Cannot find current proxy лише в рантаймі.
  2. Накладні витрати пам’яті та CPU: Прапорець exposeProxy = true змушує проксі зберігати поточний об’єкт у ThreadLocal при кожному виклику будь-якого методу кожного біна в системі.
  3. Неможливість модульного тестування: Звичайний new MyService() в JUnit-тестах впаде з помилкою, оскільки AopContext не ініціалізований.

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

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

Проблема self-invocation вирішується 4 основними шляхами:

  1. Винесення методу в окремий Spring-бін (Best Practice): дотримується принципу єдиної відповідальності (SRP) та забезпечує чистий виклик через проксі впровадженої залежності.
  2. Використання TransactionTemplate: програмне відкриття транзакцій, що не залежить від AOP-проксі (ідеально для ізоляції від повільних HTTP-запитів).
  3. Self-Injection через @Lazy конструктор або ObjectProvider: дозволяє викликати метод на проксі самого себе (self.inner()).
  4. AspectJ Weaving: заміна динамічних проксі на модифікацію байт-коду, де само-виклик працює автоматично. Використання AopContext.currentProxy() вважається застарілим антипатерном.

4 каверзних питання з відповідями

1. Яке рішення проблеми self-invocation вважається архітектурно еталонним?

Відповідь: Винесення транзакційного методу в окремий сервіс (клас). Це відповідає принципу Single Responsibility Principle (SRP): клас бізнес-оркестрації відокремлюється від класу, що безпосередньо працює зі сховищем даних. Такий код найпростіше покривати модульними тестами зі звичайними моками (Mockito).

2. Чим TransactionTemplate кращий за @Transactional при роботі із зовнішніми REST API?

Відповідь: @Transactional утримує фізичне з’єднання з базою даних протягом усього часу роботи методу (включаючи очікування мережевої відповіді від REST API). TransactionTemplate дозволяє виконати повільний мережевий запит до початку транзакції, після чого точково відкрити транзакцію рівно на час збереження даних і миттєво повернути з’єднання в пул HikariCP.

3. Як @Lazy розв’язує проблему циклічної залежності при self-injection у конструктор?

Відповідь: Анотація @Lazy вказує Spring впровадити динамічний проксі-замінник замість реального біна. Конструктор біна успішно завершує виконання без взаємних блокувань. При першому виклику методу через цей проксі відбувається фактичне отримання цільового біна з контексту.

4. Чому AopContext.currentProxy() вважається антипатерном?

Відповідь: Він вимагає явного увімкнення налаштування exposeProxy = true, що створює постійний оверхед на запис у ThreadLocal для всіх викликів у застосунку. Крім того, він намертво зв’язує бізнес-код зі статичним API Spring AOP, унеможливлюючи прості модульні тести без підняття контексту.


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

  • ❌ «Найкращий спосіб вирішити self-invocation — викликати AopContext.currentProxy()» — грубий антипатерн, що призводить до падіння в модульних тестах і рантайм-помилок.
  • ❌ «Self-injection через @Autowired private MyService self без @Lazy чудово працює» — у конструкторі це спричинить помилку циклічної залежності BeanCurrentlyInCreationException.
  • ❌ «TransactionTemplate працює через AOP-проксі» — ні, TransactionTemplate працює програмно через прямий виклик PlatformTransactionManager, без генерації проксі.
  • ❌ «Для вирішення проблеми слід перемкнути проєкт на JDK Dynamic Proxy» — JDK Dynamic Proxy підпадає під проблему self-invocation так само, як і CGLIB.

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