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
}
@transaction.atomic
def place_order(cart):
order = Order.objects.create(customer=cart.customer, total=cart.total)
send_confirmation_email(order) # BUG: sent even if the transaction later rolls back
try:
LedgerEntry.objects.create(order=order, amount=cart.total)
except IntegrityError:
log.warning("ledger failed") # BUG: swallowing a DB error inside atomic(): the
# transaction is now broken, and the order still commits
DB::beginTransaction();
$order = Order::create([...]);
if (! $this->ledger->write($order)) {
return response()->json(['error' => 'ledger'], 500); // BUG: returns without rollBack():
} // connection left inside an open transaction
DB::commit();
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: cartToOrder(cart) });
await prisma.ledgerEntry.create({ data: { orderId: order.id } }); // BUG: uses `prisma`, not `tx`:
}); // runs OUTSIDE the transaction
Reproduce it
A tiny proxy shows why Spring's self-invocation and checked exceptions skip the rollback:
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
@Transactionalthrough a proxy. Calls from inside the same class skip it, and by default onlyRuntimeException/Errortrigger rollback. - Django marks an
atomic()block as broken after a database error; catching the error inside the block hides the problem (use a nestedatomic()savepoint around the risky statement instead). - Laravel's manual
beginTransaction()needs arollBack()on every exit path. - Prisma interactive transactions only cover queries made through the
txclient.
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
}
}
def place_order(cart):
with transaction.atomic():
order = Order.objects.create(customer=cart.customer, total=cart.total)
try:
with transaction.atomic(): # savepoint: only this part rolls back
LedgerEntry.objects.create(order=order, amount=cart.total)
except IntegrityError:
raise OrderFailed("ledger write failed") # re-raise → the outer block rolls back too
transaction.on_commit(lambda: send_confirmation_email.delay(order.id))
return order
$order = DB::transaction(function () use ($cart) { // commits or rolls back on any exception
$order = Order::create($cart->toOrder());
$this->ledger->write($order); // throw on failure, don't return false
OrderConfirmation::dispatch($order)->afterCommit();
return $order;
});
const order = await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: cartToOrder(cart) });
await tx.ledgerEntry.create({ data: { orderId: order.id, amount: order.total } }); // tx, not prisma
return order;
});
await queue.add("order-confirmation", { orderId: order.id }); // after commit
Trade-offs
rollbackFor = Exception.classeverywhere 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
@Transactionalself-invocation; lint forprisma.calls inside$transactioncallbacks.
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.