Backend engineer path · A request's journey · Topic 9 of 18

Transactions: @Transactional and its traps

Not started

@Transactional looks like a keyword but is an AOP proxy. When another bean calls a transactional method, it is really calling a generated subclass that asks the PlatformTransactionManager to begin (or join) a transaction, binds the database connection to the current thread, calls your method, and then commits, or rolls back if a qualifying exception escaped. Everything the method does through Spring-managed data access uses that one connection.

Why it matters: A misplaced @Transactional is silent; the code works in a demo, then half-writes data the first time something fails in production.

  1. 1It is a proxy

    @Transactional works because callers hold a proxy that opens a transaction, calls your method, then commits or rolls back.

  2. 2Self-invocation skips it

    Calling a transactional method from another method of the same bean goes around the proxy, so no transaction starts.

  3. 3Unchecked exceptions roll back

    By default RuntimeException and Error roll back; checked exceptions commit unless you say rollbackFor.

  4. 4Propagation decides nesting

    REQUIRED joins the caller's transaction; REQUIRES_NEW suspends it and runs an independent one.

1

How it works

The explanation, with examples you can run.

What the proxy does

@Serviceclass TransferService {  private final AccountRepository accounts;  TransferService(AccountRepository accounts) { this.accounts = accounts; }   @Transactional  public void transfer(long from, long to, BigDecimal amount) {    accounts.debit(from, amount);    accounts.credit(to, amount);      // if this throws, the debit is undone too  }   @Transactional(readOnly = true)  public BigDecimal balance(long id) { return accounts.balanceOf(id); }}

In pseudo-code, the proxy's version of transfer is: get or start a transaction, call the real method, commit; on RuntimeException or Error, roll back and rethrow. readOnly = true is an optimisation hint: Hibernate can skip dirty checking, and the JDBC connection is flagged read-only, which some setups use to route to a replica. It is not a security boundary.

The self-invocation trap

Because the behaviour lives in the proxy, a call on this bypasses it:

public void importAll(List<Row> rows) {  for (Row r : rows) importOne(r);   // no transaction per row: this.importOne} @Transactionalpublic void importOne(Row r) { ... }

Fixes, in order of preference: put the annotation on the outer method if that is the unit you want; move importOne to a separate bean; or use a TransactionTemplate to make the boundary explicit in code. The same trap applies to @Async, @Cacheable and @PreAuthorize.

Other quiet failures of the same kind: the bean was created with new instead of by the container, or the method is private or final, which a subclass proxy cannot override. Since Spring 6.0, protected and package-private methods are proxied for class-based proxies, but public remains the safe habit.

Rollback rules

@Transactional(rollbackFor = PaymentDeclinedException.class)public void charge(...) throws PaymentDeclinedException { ... }

Checked exceptions commit by default. Catching an exception inside the method and not rethrowing also means commit, because the proxy never sees it.

Propagation

SettingBehaviour
REQUIRED (default)Join the current transaction, or start one
REQUIRES_NEWSuspend the current one, start an independent one
MANDATORYFail if there is no current transaction
NOT_SUPPORTEDSuspend the current one, run without
NESTEDOne physical transaction with a savepoint the inner scope can roll back to (needs JDBC savepoint support)

One subtlety with REQUIRED: if an inner method marks the shared transaction rollback-only and the outer method catches the exception and carries on, the commit at the end fails with UnexpectedRollbackException. The inner and outer methods are one physical transaction; they succeed or fail together.

Keep transactions short

A transaction holds a pooled connection from start to finish. Calling a slow HTTP service inside one ties up a connection for the duration; with 10 connections, ten slow calls stall every other request that needs the database. Do the remote call first, then open the transaction to write the result.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.What happens when placeOrder() runs?
    @Serviceclass OrderService {  public void placeOrder(Order o) {    save(o);          // called on this  }  @Transactional  public void save(Order o) { ... }}
  2. 2.A @Transactional method throws IOException (a checked exception) after two inserts. What happens to the inserts?
  3. 3.The outer method (REQUIRED) calls audit() marked REQUIRES_NEW, then throws. What is kept?

0 of 3 right so far. Get all three to finish this topic.