Transactions, Locking & Concurrency in Code · Transactions in Application Code

Framework Transaction Pitfalls

The @Transactional, atomic() and $transaction mistakes that silently commit what should have rolled back.

Scenario

Example app: an order service. placeOrder() saves the order, charges the card, then writes a ledger entry. If the ledger write fails, the whole order must disappear.

What you see in production

Finance finds orders without ledger entries, and customers receive "order confirmed" emails for orders that don't exist. The code looks transactional — every method has an annotation or a transaction block.

The naive code

Four classic ways the transaction is not what it seems:

@Service
public class OrderService {
    public void checkout(Cart cart) {
        placeOrder(cart);                  // BUG: self-invocation: bypasses the proxy, NO transaction
    }

    @Transactional
    public void placeOrder(Cart cart) throws LedgerException {
        orders.save(Order.from(cart));
        ledger.write(cart);                // throws LedgerException (a checked exception)
    }                                      // BUG: checked exceptions do NOT trigger rollback by default
}

Reproduce it

A tiny proxy shows why Spring's self-invocation and checked exceptions skip the rollback:

Self-invocation and checked exceptions bypass the rollbackTry it yourself
class CheckedError(Exception): pass          # like a Java checked exception
class UncheckedError(RuntimeError): pass     # like a RuntimeException

log = []

class TransactionalProxy:
    # Spring wraps beans in a proxy; only calls that go THROUGH the proxy get a transaction.
    def __init__(self, target): self.target = target
    def place_order(self, fail_with=None):
        log.append("BEGIN")
        try:
            self.target.place_order(fail_with)
            log.append("COMMIT")
        except UncheckedError:
            log.append("ROLLBACK"); raise
        except CheckedError:
            log.append("COMMIT (checked exception: no rollback by default!)"); raise

class OrderService:
    def place_order(self, fail_with=None):
        log.append("insert order")
        if fail_with: raise fail_with("ledger write failed")
    def checkout(self):
        self.place_order(fail_with=UncheckedError)   # self-call: never goes through the proxy

service = OrderService()
proxy = TransactionalProxy(service)
for label, call in [("proxy + unchecked", lambda: proxy.place_order(UncheckedError)),
                    ("proxy + checked", lambda: proxy.place_order(CheckedError)),
                    ("self-invocation", lambda: service.checkout())]:
    log.clear()
    try: call()
    except Exception as e: log.append(f"error: {type(e).__name__}")
    print(f"{label:18} -> {' | '.join(log)}")

Output

proxy + unchecked  -> BEGIN | insert order | ROLLBACK | error: UncheckedError
proxy + checked    -> BEGIN | insert order | COMMIT (checked exception: no rollback by default!) | error: CheckedError
self-invocation    -> insert order | error: UncheckedError

Root cause

Framework transactions are conventions layered on top of the database:

  • Spring applies @Transactional through a proxy. Calls from inside the same class skip it, and by default only RuntimeException/Error trigger rollback.
  • Django marks an atomic() block as broken after a database error; catching the error inside the block hides the problem (use a nested atomic() savepoint around the risky statement instead).
  • Laravel's manual beginTransaction() needs a rollBack() on every exit path.
  • Prisma interactive transactions only cover queries made through the tx client.

The fix

@Service
public class CheckoutFacade {                 // separate bean → the call goes through the proxy
    private final OrderService orders;
    public void checkout(Cart cart) { orders.placeOrder(cart); }
}

@Service
public class OrderService {
    @Transactional(rollbackFor = Exception.class)     // roll back on checked exceptions too
    public void placeOrder(Cart cart) throws LedgerException {
        Order order = orderRepo.save(Order.from(cart));
        ledger.write(order);
        events.publishEvent(new OrderPlaced(order.getId()));   // email via @TransactionalEventListener
    }
}

Trade-offs

  • rollbackFor = Exception.class everywhere is simple but may roll back on exceptions you meant as normal control flow — be deliberate.
  • Savepoints (nested atomic(), Propagation.NESTED) let part of a transaction fail safely, but each savepoint has a small cost in hot loops.
  • REQUIRES_NEW (Spring) opens a second connection and commits independently — useful for audit logs, dangerous for business data, and it can exhaust the pool under load.

Detect it in production

  • Data invariants as scheduled checks: "every paid order has a ledger entry".
  • Postgres: SELECT pid, state, xact_start FROM pg_stat_activity WHERE state = 'idle in transaction'; catches transactions left open by a missing rollback.
  • Tests that force the failure path (throw from the ledger) and assert that no order row exists afterwards.
  • Static analysis: IntelliJ flags @Transactional self-invocation; lint for prisma. calls inside $transaction callbacks.

Key points

  • Know how your framework applies transactions: proxies, exception types, block scope, transaction clients.
  • Re-raise database errors inside transactional blocks; use savepoints for partial recovery.
  • Every side effect goes after commit.
  • Test the failure path, not just the happy path.

Practice questions

Why does calling a @Transactional method from another method in the same Spring bean not start a transaction?

Answer

Spring implements @Transactional with a proxy around the bean. A call from inside the class (this.placeOrder()) goes straight to the target object and never passes through the proxy, so no transaction interceptor runs. Move the method to another bean or call it through the proxy.

A Spring @Transactional method throws a checked exception. Is the transaction rolled back?

Answer

Not by default. Spring rolls back on RuntimeException and Error only; checked exceptions commit unless you configure rollbackFor.

Inside a Prisma interactive transaction, a query uses `prisma.x` instead of `tx.x`. What happens?

Answer

That query runs on a different connection outside the transaction, so it commits independently and is not rolled back with the rest.

Open in the app to mark lessons complete, take notes and track your progress.