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

Як налаштувати rollback для checked винятків

За замовчуванням у Spring Framework анотація @Transactional НЕ відкочує транзакцію в разі виникнення перевірюваних винятків (Checked Exceptions, тобто нащадків java.lang.Excepti...


🟢 Junior Level

За замовчуванням у Spring Framework анотація @Transactional НЕ відкочує транзакцію в разі виникнення перевірюваних винятків (Checked Exceptions, тобто нащадків java.lang.Exception, крім RuntimeException). При виникненні checked-винятку Spring за замовчуванням виконує COMMIT.

Щоб змусити Spring відкочувати транзакцію при перевірюваних винятках, використовується атрибут rollbackFor.

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

Головний промисловий стандарт (Best Practice):

@Transactional(rollbackFor = Exception.class)

Вказівка rollbackFor = Exception.class повідомляє перехоплювачу Spring AOP:

«Відкочуй транзакцію в разі виникнення абсолютно будь-якого винятку (Exception), включно з IOException, SQLException та будь-якими власними перевірюваними бізнес-винятками».

package com.example.service;

import com.example.exception.InsufficientFundsException; // Checked exception: extends Exception
import com.example.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;

    // БЕЗПЕЧНА КОНФІГУРАЦІЯ:
    @Transactional(rollbackFor = Exception.class)
    public void withdraw(Long accountId, BigDecimal amount) throws InsufficientFundsException {
        var account = accountRepository.findById(accountId).orElseThrow();
        
        if (account.getBalance().compareTo(amount) < 0) {
            // Завдяки rollbackFor = Exception.class транзакція гарантовано ВІДКОТИТЬСЯ!
            throw new InsufficientFundsException("Недостатньо коштів на рахунку");
        }

        account.setBalance(account.getBalance().subtract(amount));
        accountRepository.save(account);
    }
}

Варіанти використання rollbackFor

  1. Для всіх перевірюваних винятків (рекомендовано):
    @Transactional(rollbackFor = Exception.class)
    
  2. Для конкретного перевірюваного винятку:
    @Transactional(rollbackFor = InsufficientFundsException.class)
    
  3. Для кількох конкретних винятків:
    @Transactional(rollbackFor = {IOException.class, InsufficientFundsException.class})
    

🟡 Middle Level

Три архітектурні стратегії роботи з Checked Exceptions

Якщо в кодовій базі присутні перевірювані винятки, Senior Engineer обирає одну з трьох стратегій:

Стратегія 1: Явний rollbackFor (Збереження Checked Exceptions)
  -> @Transactional(rollbackFor = Exception.class)
  -> Плюси: не ламає наявний код та сигнатури throws.
  -> Мінуси: легко забути вказати атрибут над новим методом.

Стратегія 2: Рефакторинг на RuntimeException (Сучасний стандарт)
  -> Замінити: public class BusinessException extends Exception
  -> На:       public class BusinessException extends RuntimeException
  -> Плюси: Spring відкочує з коробки; сигнатури чисті без throws; сумісно зі Stream API.
  -> Мінуси: вимагає рефакторингу кодової бази.

Стратегія 3: Програмний відкат (setRollbackOnly)
  -> TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  -> Плюси: працює при ручному перехопленні помилок у блоках try-catch.
  -> Мінуси: зв'язування коду з внутрішнім API Spring Framework.

Приклад програмного відкату в блоці catch

Якщо метод зобов’язаний перехопити перевірюваний виняток, записати лог і повернути об’єкт відповіді клієнту, але при цьому базу даних необхідно повернути до вихідного стану:

package com.example.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;

import java.io.IOException;

@Slf4j
@Service
public class DocumentExportService {

    @Transactional
    public boolean processAndArchiveDocument(Long docId) {
        updateDocumentStatus(docId, "PROCESSING");

        try {
            writePdfToNetworkDisk(docId); // кидає checked IOException
            return true;
        } catch (IOException ex) {
            log.error("Мережевий диск недоступний: {}", ex.getMessage());
            // Програмно виставляємо прапорець відкату:
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            return false; // Повертаємо false клієнту, транзакція буде відкочена!
        }
    }
}

🔴 Senior Level

Глобальне налаштування rollbackFor для всього проєкту

Щоб не дублювати rollbackFor = Exception.class над сотнями сервісних методів і виключити людський фактор (коли розробник забув дописати атрибут), Senior-інженери застосовують один із двох глобальних підходів:

Варіант А: Кастомна мета-анотація (чисто та типобезпечно)

У Spring анотації є компонованими (Meta-Annotations):

package com.example.annotation;

import org.springframework.core.annotation.AliasFor;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Transactional(rollbackFor = Exception.class) // Базове правило: відкат на ВСЕ!
public @interface Tx {

    @AliasFor(annotation = Transactional.class, attribute = "readOnly")
    boolean readOnly() default false;

    @AliasFor(annotation = Transactional.class, attribute = "propagation")
    Propagation propagation() default Propagation.REQUIRED;

    @AliasFor(annotation = Transactional.class, attribute = "timeout")
    int timeout() default -1;
}

Тепер у сервісах використовується лаконічна анотація @Tx, яка за замовчуванням гарантує відкат для всіх винятків!

Варіант Б: Перевизначення біна TransactionInterceptor

Налаштування глобального правила в конфігурації Spring:

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.interceptor.NameMatchTransactionAttributeSource;
import org.springframework.transaction.interceptor.RollbackRuleAttribute;
import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute;
import org.springframework.transaction.interceptor.TransactionInterceptor;

import java.util.Collections;

@Configuration
public class GlobalTransactionConfig {

    @Bean
    public TransactionInterceptor transactionInterceptor(PlatformTransactionManager txManager) {
        RuleBasedTransactionAttribute defaultRule = new RuleBasedTransactionAttribute();
        // Додаємо глобальне правило відкату для всіх Exception:
        defaultRule.setRollbackRules(Collections.singletonList(new RollbackRuleAttribute(Exception.class)));

        NameMatchTransactionAttributeSource source = new NameMatchTransactionAttributeSource();
        source.addTransactionalMethod("*", defaultRule);

        return new TransactionInterceptor(txManager, source);
    }
}

Алгоритм Rule Matching при композиції правил

Коли на методі поєднуються rollbackFor та noRollbackFor:

@Transactional(
    rollbackFor = Exception.class,
    noRollbackFor = EntityNotFoundBusinessException.class
)
public void syncCatalog() throws Exception { ... }
  1. Spring обчислює дистанцію за ієрархією спадкування (depth) через рекурсивний виклик: \(\text{depth}(TargetClass, CurrentClass) = \begin{cases} 0, & \text{якщо } TargetClass == CurrentClass \\ 1 + \text{depth}(TargetClass.superclass), & \text{інакше} \end{cases}\)
  2. Якщо викинуто EntityNotFoundBusinessException, глибина для правила noRollbackFor дорівнює 0 (точний збіг).
  3. Глибина для rollbackFor = Exception.class дорівнює 2 (EntityNotFoundBusinessException $\rightarrow$ BusinessException $\rightarrow$ Exception).
  4. Правило з мінімальною глибиною (depth = 0) перемагає: транзакція комітиться, незважаючи на наявність загального rollbackFor = Exception.class!

4 Tricky Questions

1. У чому різниця між rollbackFor = Exception.class та rollbackFor = Throwable.class?

Відповідь:

  • Exception.class охоплює всі перевірювані винятки (Checked Exceptions) та всі неперевірювані (RuntimeException). Проте він не охоплює прямих нащадків класу Throwable, які не успадковують Exception (хоча на практиці в Java безпосередньо наслідувати Throwable категорично не рекомендується).
  • Throwable.class є коренем усієї ієрархії помилок у JVM (Throwable $\rightarrow$ Exception та Error). Вказівка rollbackFor = Throwable.class гарантує абсолютний відкат у разі викидання взагалі будь-якого об’єкта, який у принципі можна викинути оператором throw в Java. На практиці rollbackFor = Exception.class достатньо в 99.9% випадків, оскільки Error уже включено до правил відкату за замовчуванням.

2. Що станеться, якщо батьківський метод з rollbackFor = Exception.class викличе дочірній метод з noRollbackFor = IOException.class (обидва методи Propagation.REQUIRED), і дочірній метод викине IOException?

Відповідь: Транзакція відкотиться (ROLLBACK)! Механіка:

  1. Обидва методи виконуються в межах однієї й тієї самої фізичної транзакції.
  2. Коли дочірній метод викидає IOException, його TransactionInterceptor бачить локальне правило noRollbackFor = IOException.class. Тому дочірній інтерцептор не викликає setRollbackOnly().
  3. Виняток прокидається вище й вилітає з батьківського методу.
  4. Батьківський TransactionInterceptor перехоплює IOException та звіряє його зі своїми правилами. У батька сконфігуровано rollbackFor = Exception.class. Оскільки IOException instanceof Exception, батьківський інтерцептор ініціює connection.rollback().

3. Чому використання rollbackForClassName = "MyException" вважається прихованим джерелом багів у продакшені?

Відповідь: Тому що Spring не перевіряє валідність рядка імені класу під час компіляції або запуску застосунку. Зв’язування відбувається ліниво за збігом підрядка імені класу в рантаймі. Якщо розробник припуститься оддруку (наприклад, rollbackForClassName = "IOExcepshon"), Spring не викине помилку ініціалізації контексту. При викиданні справжнього IOException строкове правило не спрацює, застосується дефолтна політика, і транзакція буде закомічена замість відкату. Завжди слід використовувати строго типізовані посилання на літерали класів: rollbackFor = IOException.class.

4. Чому в сучасній архітектурі на Spring Boot 3+ рекомендується взагалі відмовитися від Checked Exceptions на користь RuntimeException або Result-типів?

Відповідь:

  1. Несумісність із функціональним стилем: Стандартні функціональні інтерфейси Java 8+ (Function, Consumer, Supplier, Stream API) не оголошують throws CheckedException. Спроба викинути checked-виняток усередині .map() або .filter() вимагає громіздких обгорток у try-catch або sneakyThrows.
  2. Ризик дефолтного коміту: При використанні checked-винятків будь-яка забута анотація rollbackFor призводить до часткового збереження некоректних даних у БД.
  3. Чистота сигнатур: RuntimeException дозволяє відокремити бізнес-логіку від контрактів інтерфейсів, централізовано обробляючи помилки на рівні @RestControllerAdvice.

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

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

«За замовчуванням Spring не відкочує транзакцію при перевірюваних винятках (Checked Exceptions), вважаючи їх штатними альтернативними результатами бізнес-логіки. Щоб налаштувати відкат, використовують атрибут @Transactional(rollbackFor = Exception.class). В enterprise-проєктах рекомендується або стандартизувати цей атрибут через власну мета-анотацію, або повністю відмовитися від checked-винятків на користь RuntimeException, для яких Spring виконує відкат автоматично».

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

  1. Розв’язання проблеми: @Transactional(rollbackFor = Exception.class).
  2. Програмна альтернатива: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
  3. Правило переваги: При конфлікті rollbackFor та noRollbackFor перемагає правило з найменшою глибиною спадкування до цільового класу (depth).
  4. Типобезпека: Категорично уникати rollbackForClassName через ризик одруків у рядках.
  5. Modern Best Practice: Міграція бізнес-винятків на ієрархію RuntimeException.

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

  • ❌ «Якщо оголосити throws MyCheckedException у сигнатурі методу, Spring автоматично зрозуміє, що потрібен відкат» (Оголошення в throws ніяк не впливає на AOP-проксі, транзакція закомітиться).
  • ❌ «rollbackForClassName перевіряється Spring Boot під час старту застосунку» (Перевірка відбувається ліниво в рантаймі, помилка в рядку призводить до тихого коміту).
  • ❌ «noRollbackFor скасовує відкат навіть для RuntimeException» (Тільки якщо вказаний у noRollbackFor клас розташований ближче в ієрархії, ніж правила відкату).

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