Transações no Spring: @Transactional e as armadilhas comuns

1. Visão geral

@Transactional é a annotation mais utilizada no Spring Boot. Ela também é o local onde surgem muitos bugs difíceis de ver: dados não sofrem rollback, transactions “desaparecem”, ou UnexpectedRollbackException aparece sem motivo claro.

Este artigo explica como @Transactional funciona por meio de proxy. Depois, passamos por cada armadilha comum, cada uma com código de exemplo e como corrigir. Após ler, você saberá quando uma transaction é realmente aberta e quando sofre rollback.

2. Configuração

Usamos Spring Data JPA com H2 em memória:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Ative o log do interceptador de transaction para ver quando uma transaction é aberta e fechada:

logging.level.org.springframework.transaction.interceptor=TRACE

Entity e repository usados no artigo:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue
    private Long id;
    private String customerName;
    private BigDecimal amount;

    protected Order() {
    }

    public Order(String customerName, BigDecimal amount) {
        this.customerName = customerName;
        this.amount = amount;
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public BigDecimal getAmount() { return amount; }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
}

A tabela é nomeada orders porque order é uma palavra-chave SQL.

3. Como @Transactional funciona

3.1. Exemplo básico

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("Limite de pagamento excedido");
        }
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

Quando chamamos placeOrder("An", new BigDecimal("5000")), o pedido é save primeiro. Depois charge lança IllegalStateException. Como esta é uma RuntimeException, Spring faz rollback de tudo, então a tabela orders permanece vazia.

3.2. Proxy é o centro

Spring não modifica o bytecode de OrderService. Ele cria um proxy (por padrão, um subclass CGLIB no Spring Boot) que envolve o bean. Outros beans que injetam OrderService na verdade recebem este proxy. Cada chamada passa pelo TransactionInterceptor:

chamador → proxy → [abre transaction] → OrderService.placeOrder() → [commit/rollback] → chamador

O log TRACE mostra claramente isso:

TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: Limite de pagamento excedido

A maioria das armadilhas abaixo surge de um princípio: uma chamada que não passa pelo proxy não tem transaction.

4. Armadilha 1: Self-invocation

@Service
public class OrderService {

    // ... constructor como acima

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // chamada via this
        }
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        System.out.println("Transaction ativa: "
                + TransactionSynchronizationManager.isActualTransactionActive());
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

Chamar orderService.placeOrders(List.of("An", "Bình")) a partir do controller imprimirá:

Transaction ativa: false
Transaction ativa: false

placeOrders não tem @Transactional, então o proxy passa a chamada diretamente para o objeto real. Dentro, placeOrder(...) é equivalente a this.placeOrder(...), mas this é o objeto original e não o proxy. A annotation é completamente ignorada. Cada save executa na transaction própria de SimpleJpaRepository e faz commit imediatamente. Se o segundo pedido falhar, o primeiro ainda está no banco de dados.

Há três maneiras de corrigir, listadas por ordem de prioridade:

  1. Mova placeOrder para um bean diferente, por exemplo OrderPlacementService, depois injete-o.
  2. Coloque @Transactional no próprio placeOrders, se você quiser que todo o lote seja uma transaction.
  3. Auto-injete via @Lazy. Isso funciona mas é difícil de ler, use apenas quando não puder separar o bean.
@Service
public class OrderBatchService {

    private final OrderService orderService; // isto é um proxy

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void placeOrders(List<String> customers) {
        customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
    }
}

Agora cada chamada passa pelo proxy, e o log imprime Transaction ativa: true.

5. Armadilha 2: Método private ou final

A partir do Spring Framework 6.0, ao usar proxy baseado em classe (CGLIB), @Transactional funciona em métodos public, protected e package-private. Porém, ainda há dois casos onde não funciona:

  • Método private: a subclass não pode fazer override, então o proxy não consegue interceptar a chamada.
  • Método final: CGLIB também não consegue fazer override. A chamada vai direto para o código original, sem transaction.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // SEM transaction
    return orderRepository.save(new Order(customer, amount));
}

Spring não lança erro neste caso. Você verá apenas uma linha de log em nível INFO ou DEBUG, muito fácil de perder.

Nota: Kotlin é final por padrão em toda classe e método. Use o plugin kotlin-spring (all-open) para que os beans Spring sejam automaticamente abertos.

6. Armadilha 3: Checked exception não causa rollback

Por padrão, Spring faz rollback apenas quando encontra RuntimeException ou Error. Checked exception deixa a transaction fazer commit.

public class PaymentDeclinedException extends Exception {
    public PaymentDeclinedException(String message) {
        super(message);
    }
}

@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
    Order order = orderRepository.save(new Order(customer, amount));
    if (amount.signum() <= 0) {
        throw new PaymentDeclinedException("Valor inválido");
    }
    return order;
}

Chamar com amount = -5 e a exception ainda é lançada para o chamador, mas o pedido negativo já foi salvo. A forma de corrigir é declarar explicitamente:

@Transactional(rollbackFor = PaymentDeclinedException.class)

Muitos times têm a convenção de usar rollbackFor = Exception.class em todo o serviço. Uma forma mais simples é projetar exceptions de negócio que herdem de RuntimeException. O contrário também funciona: noRollbackFor = SomeRuntimeException.class mantém o commit mesmo com erro.

7. Armadilha 4: Capturar exception e UnexpectedRollbackException

Esta é a armadilha mais confusa. Considere um serviço que reserva itens do inventário:

@Service
public class InventoryService {

    @Transactional // propagation padrão: REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("Sem estoque: " + sku);
    }
}

E OrderService o chama, depois captura o erro com a intenção de “se a reserva falhar, crie o pedido mesmo assim”:

@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
    Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
    try {
        inventoryService.reserve(sku);
    } catch (IllegalStateException ex) {
        System.out.println("Ignorando erro de inventário: " + ex.getMessage());
    }
    return order;
}

Resultado:

Ignorando erro de inventário: Sem estoque: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

O que acontece é o seguinte. reserve participa da mesma transaction física com placeOrderWithInventory. Quando a exception passa pelo proxy de InventoryService, Spring marca a transaction como rollback-only. Seu catch aqui fora não consegue remover esta marcação. No momento de fazer commit, Spring detecta a marcação, faz rollback, e lança UnexpectedRollbackException para informar que o chamador acreditava que iria fazer commit mas na verdade não fez.

A forma de corrigir depende de sua intenção:

  • Se o erro de inventário deve cancelar o pedido inteiro, não capture. Deixe a exception subir.
  • Se reserve é independente, dê a ele sua própria transaction usando REQUIRES_NEW (veja seção 8).
  • Se reserve não precisa gravar no banco de dados, remova @Transactional dele.

Nota: Capturar exception dentro do mesmo método não marca a transaction. A marcação só acontece quando a exception passa através de um proxy @Transactional.

8. Propagation REQUIRES_NEW: útil mas caro

Um caso legítimo para REQUIRES_NEW é gravar log de auditoria. O log precisa ser salvo mesmo que o negócio principal sofra rollback:

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action) {
        auditLogRepository.save(new AuditLog(action));
    }
}

Spring suspende a transaction externa e abre uma nova em uma connection diferente. Quando record termina, esta transaction faz commit independentemente.

O preço a pagar:

  • Cada request segura simultaneamente duas connections. Sob alta carga, o pool (HikariCP tem 10 connections por padrão) pode se esgotar e causar deadlock no nível do pool: toda thread segura uma connection e espera por uma segunda.
  • A nova transaction não consegue ver dados ainda não commitados da transaction externa. Se AuditLog tem uma chave estrangeira para Order que acabou de ser criada, você terá um erro ou ficará em espera por um lock.

Use REQUIRES_NEW apenas quando realmente precisar fazer commit independente, não o use como “remendo de emergência” para todo erro de rollback-only.

9. readOnly e transactions muito longas

@Transactional(readOnly = true) diz ao Hibernate para definir o flush mode como MANUAL e pular dirty checking. Para consultas que retornam muitas entidades, isso reduz significativamente memória e CPU:

@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
    return orderRepository.findAll().stream()
            .filter(o -> o.getCustomerName().equals(customer))
            .toList();
}

Há duas coisas para lembrar:

  • readOnly é uma dica de otimização, não um mecanismo de proteção. Dependendo do driver e banco de dados, um UPDATE nativo ainda pode ser executado.
  • Não chame APIs HTTP externas, envie emails ou faça Thread.sleep dentro de uma transaction. A connection fica presa o tempo todo, e locks nas linhas já gravadas também. Busque dados de fora primeiro, depois abra uma transaction curta para gravar.

10. Testando o comportamento de rollback

Uma armadilha mais está nos testes. Se você colocar @Transactional na classe de teste, tudo executa dentro da transaction do teste e sofre rollback no final. Isso oculta se o serviço está fazendo commit ou rollback corretamente. Para testes sobre transactions, remova esta annotation e limpe os dados você mesmo:

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @AfterEach
    void cleanUp() {
        orderRepository.deleteAll();
    }

    @Test
    void whenPaymentFails_thenOrderIsRolledBack() {
        assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
        assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
                .isInstanceOf(UnexpectedRollbackException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
        assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
                .isInstanceOf(PaymentDeclinedException.class);

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

O terceiro teste “fixa” o comportamento padrão com checked exception. Quando você adicionar rollbackFor, este teste ficará vermelho, e você muda a expectativa para isZero(). Esta é uma boa forma de documentar a decisão de rollback diretamente no código.

11. Conclusão

@Transactional parece simples, mas todo seu comportamento depende do proxy. Resumindo as armadilhas principais:

  • Self-invocation: chamar via this ignora o proxy. Separe o bean.
  • Método private/final: não é interceptado, e Spring não reporta erro.
  • Checked exception: por padrão faz commit. Use rollbackFor ou use RuntimeException.
  • Rollback-only: exception passando pelo proxy internamente marca toda a transaction, catch lá fora não salva.
  • REQUIRES_NEW: usa uma connection extra, use apenas quando precisar fazer commit independente.
  • Teste com @Transactional: oculta o comportamento real de commit/rollback.

Quando tiver dúvida, ative o log de TransactionInterceptor em nível TRACE e verifique TransactionSynchronizationManager.isActualTransactionActive(). Estas duas ferramentas dizem exatamente se uma transaction realmente existe ou não.

spring-boottransaçãojpaspring-datatestes