DEV Community

Thellu
Thellu

Posted on

Why `@Transactional` “Doesn’t Work” in Spring: 7 Proxy Gotchas (and Fixes)

You add @Transactional, expect a rollback… and the data still commits.

Most of the time, Spring transactions are working exactly as designed — it’s just that your code never actually enters a transactional boundary. The reason is simple:

Spring usually applies transactions via AOP proxies.

If your call bypasses the proxy, the transaction advice never runs.

Here are 7 common gotchas I see in real projects, plus practical fixes.


0) Quick mental model: where transactions actually start

With proxy-based AOP, a transaction starts when:

  • A Spring-managed bean method is called
  • and that call goes through the proxy
  • and the method has transactional metadata (@Transactional)

So the key question when debugging is always:

“Did this method call go through the Spring proxy?”


1) Self-invocation: calling your own method bypasses the proxy

This is the #1 reason people say “transaction didn’t work”.

@Service
public class OrderService {

  public void placeOrder() {
    saveOrder(); // self-invocation -> no proxy -> @Transactional not applied
  }

  @Transactional
  public void saveOrder() {
    // write to DB
    throw new RuntimeException("boom"); // you expect rollback
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens?

placeOrder() calls saveOrder() directly on this. No proxy involved. No transaction.

✅ Fix options (pick one):

Fix A (recommended): move transactional method to another bean

@Service
public class OrderWriter {
  @Transactional
  public void saveOrder() { ... }
}

@Service
public class OrderService {
  private final OrderWriter writer;
  public OrderService(OrderWriter writer) { this.writer = writer; }

  public void placeOrder() {
    writer.saveOrder(); // goes through proxy
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix B: use TransactionTemplate for programmatic transactions

@Service
public class OrderService {
  private final TransactionTemplate tx;

  public OrderService(PlatformTransactionManager tm) {
    this.tx = new TransactionTemplate(tm);
  }

  public void placeOrder() {
    tx.executeWithoutResult(status -> {
      // write to DB
      if (somethingBad()) throw new RuntimeException("boom");
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

2) Method visibility: non-public methods often won’t be transactional

By default (proxy-based AOP), Spring typically only applies @Transactional to public methods.

@Service
public class BillingService {

  @Transactional
  void charge() { // package-private: often ignored
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix: make the transactional boundary public, or switch to AspectJ weaving (advanced).

@Transactional
public void charge() { ... }
Enter fullscreen mode Exit fullscreen mode

3) final classes/methods: proxies can’t override them

If Spring uses CGLIB class-based proxies, final prevents overriding.

@Service
public final class PaymentService {

  @Transactional
  public void pay() { ... } // cannot be proxied if class/method is final
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix:

  • remove final, or
  • use interface-based proxies (JDK proxy) and call through the interface, but final methods still can’t be advised.

In practice: avoid final on Spring service classes/methods that need AOP.


4) Checked exceptions don’t rollback by default

By default, Spring rolls back on:

  • RuntimeException
  • Error

…but not on checked exceptions.

@Transactional
public void ship() throws Exception {
  // write DB
  throw new Exception("checked"); // by default: transaction may COMMIT
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix: configure rollback rules explicitly.

@Transactional(rollbackFor = Exception.class)
public void ship() throws Exception {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Or throw a runtime exception intentionally (be consistent in your codebase).


5) Catching exceptions (and swallowing them) commits the transaction

This one is sneaky.

@Transactional
public void updateProfile() {
  try {
    // write to DB
    riskyOperation();
  } catch (Exception e) {
    // log and swallow
  }
  // method ends normally -> transaction commits
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix options:

Fix A: rethrow

catch (Exception e) {
  throw e;
}
Enter fullscreen mode Exit fullscreen mode

Fix B: mark rollback-only

catch (Exception e) {
  TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
Enter fullscreen mode Exit fullscreen mode

Use rollback-only sparingly — rethrowing is often clearer.


6) @PostConstruct / constructor calls: proxy isn’t ready yet

If you call transactional methods during bean initialization, you can bypass the proxy lifecycle.

@Service
public class WarmUpService {

  @PostConstruct
  public void init() {
    warmUp(); // often not transactional the way you expect
  }

  @Transactional
  public void warmUp() { ... }
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix: run warmup after context is fully ready:

  • ApplicationReadyEvent
  • CommandLineRunner
  • scheduling after startup
@Component
public class WarmUpRunner implements ApplicationRunner {
  private final WarmUpService svc;
  public WarmUpRunner(WarmUpService svc) { this.svc = svc; }

  @Override
  public void run(ApplicationArguments args) {
    svc.warmUp(); // goes through proxy
  }
}
Enter fullscreen mode Exit fullscreen mode

7) Async / new threads: transactions are thread-bound

A transaction is bound to the current thread. If you start a new thread, it won’t share the same transaction context.

@Transactional
public void process() {
  // write in tx
  CompletableFuture.runAsync(() -> {
    // NOT in the same tx
    // writes here are separate
  });
}
Enter fullscreen mode Exit fullscreen mode

✅ Fix patterns:

  • Make the async method start its own transaction
  • Or use messaging / outbox pattern for consistency
  • Or redesign: keep DB transaction small, push slow work out of it

A quick debugging checklist

When you suspect “transaction didn’t work”, check these in order:

  1. Is the method public?
  2. Is it called from another bean (through proxy), not this.?
  3. Any final class/method involved?
  4. Did you throw a runtime exception? Or a checked exception?
  5. Did you catch and swallow exceptions?
  6. Was it invoked during initialization (@PostConstruct)?
  7. Any async/thread boundaries?

Also helpful: enable transaction logs (for Spring Boot):

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
Enter fullscreen mode Exit fullscreen mode

You should see logs like “Creating new transaction” / “Rolling back” / “Committing”.


Recommended rule of thumb

If you want fewer surprises:

  • Put transaction boundaries on public service methods
  • Keep them short
  • Avoid calling transactional methods inside the same class
  • Don’t mix DB transactions with slow network calls
  • Use TransactionTemplate if you need explicit control

Top comments (0)