Transactions and @Transactional
Some operations must happen completely or not at all — record a delivery and charge for it, or neither.
Spring makes this almost invisible with @Transactional: annotate a method and everything it does runs
in one database transaction that commits if the method returns and rolls back if it throws. That
convenience hides real behaviour, though, and the course's promise is to show it — because the most common
transaction bugs come from not knowing exactly when Spring commits, rolls back, and does neither. This
lesson is @Transactional and its rules, verified against a running Dakiya.
The problem: partial writes
A method that makes several database changes can fail halfway:
public void deliverParcel(Long parcelId) {
markDelivered(parcelId); // write 1
recordDeliveryCharge(parcelId); // write 2 — what if THIS throws?
}
If write 2 fails, write 1 already happened: the parcel is marked delivered but no charge exists. The two must be atomic — both or neither. Without a transaction, any failure between related writes leaves broken, half-updated data that no other code can repair.
@Transactional: all or nothing
Annotate the method @Transactional and Spring wraps it in one transaction:
import org.springframework.transaction.annotation.Transactional;
@Service
public class DeliveryService {
@Transactional
public void deliverParcel(Long parcelId) {
markDelivered(parcelId);
recordDeliveryCharge(parcelId);
// commits together when the method returns; rolls back entirely if it throws
}
}
If the method returns normally, Spring commits all its changes. If it throws, Spring rolls back —
the database returns to its state before the method ran. Verified against a running app: a @Transactional
method that saved two rows and then threw a RuntimeException left the row count unchanged (0 → 0) — both
saves were rolled back. That is the guarantee: inside a @Transactional method, related writes are atomic.
The rollback rule that trips everyone: checked vs unchecked
Here is the behaviour you must know, because it silently causes half-written data: by default, Spring
rolls back only on unchecked exceptions (RuntimeException and Error) — NOT on checked exceptions.
@Transactional
public void deliver() throws IOException {
save();
throw new IOException("..."); // CHECKED — by default Spring COMMITS the save! (does not roll back)
}
If your method throws a checked exception (like IOException), the transaction commits the work done
so far — the opposite of what you probably want. The verified rollback above worked because it threw a
RuntimeException (unchecked). To roll back on a checked exception, say so explicitly:
@Transactional(rollbackFor = Exception.class) // roll back on ANY exception, checked included
This checked-vs-unchecked default is a genuine footgun: a method that throws a checked exception and quietly
commits half its work is a data-corruption bug that looks like it should have rolled back. Know the default,
and use rollbackFor when you catch or throw checked exceptions in a transactional method.
Where to put @Transactional: the service layer
Put @Transactional on service methods, not on repositories or controllers:
- Not the repository — a single repository
saveis already its own transaction; the point of@Transactionalis to group several operations, which is a service-layer concern. - Not the controller — the controller is HTTP plumbing; the transaction is a business-operation boundary, which is the service's job (the layering lesson).
- On the service method that represents one business operation ("deliver a parcel") — so that the whole operation, all its writes, commit or roll back together.
A subtlety worth knowing: @Transactional works via a proxy (like the repositories), so it only applies
when the method is called from outside the bean. A @Transactional method called by another method of the
same class bypasses the proxy and the annotation does nothing — a classic "why isn't my transaction
working?" bug. Call transactional methods across bean boundaries.
Read-only transactions, and keeping them short
Two practical habits:
@Transactional(readOnly = true)for methods that only read. It lets Hibernate skip dirty-checking (it will not try to flush changes) and can hint the database/driver for optimisation. Mark query-only service methods read-only.- Keep transactions short. A transaction holds database resources (and, with locking, locks) for its duration. A transactional method that also calls a slow external API holds the transaction open across that call — do the slow, non-database work outside the transaction. Long transactions are a real source of contention under load (the concurrency module returns to this).
The mental model to carry: @Transactional on a service method makes that business operation atomic —
commit on normal return, roll back on an unchecked exception (use rollbackFor for checked ones), via a
proxy so it must be called across beans. Get those rules right and transactions do exactly what you expect;
get the rollback default or the proxy rule wrong and you get the half-written data that transactions were
supposed to prevent.
Check your work
The problem. Related writes that fail halfway leave broken half-updated data; they must be atomic — both or neither.
@Transactional. Wraps a method in one transaction: commit on normal return, roll back on throw.
Verified: a method saving two rows then throwing a RuntimeException left the count unchanged (0 → 0).
The rollback default (the footgun). Rolls back only on unchecked exceptions (RuntimeException/
Error) by default; a checked exception commits the work so far. Use
@Transactional(rollbackFor = Exception.class) to roll back on checked exceptions.
Where to put it. On service methods (one business operation) — not repositories (already transactional) or controllers (HTTP plumbing). And it works via a proxy, so a self-invocation within the same class bypasses it.
Read-only and short. @Transactional(readOnly=true) for query-only methods; keep transactions short and
do slow external calls outside them, to avoid holding resources/locks.
Practice
- Write a
@Transactionalservice method that makes two saves then throws aRuntimeException; confirm (count before/after) that both rolled back (reproduce 0 → 0). - Change it to throw a checked exception and confirm the saves are wrongly committed; add
rollbackFor = Exception.classand confirm rollback returns. - Put
@Transactionalon a method and call it from another method of the same class; observe (it does nothing) the self-invocation/proxy trap, then fix it by moving the call across beans. - Mark a query-only service method
@Transactional(readOnly = true)and reason about what Hibernate skips. - Put a slow
Thread.sleep(standing in for an external call) inside a transactional method and reason about why holding the transaction across it is bad under load. - Decide, for three Dakiya operations, whether each needs
@Transactionaland why (single write vs multi-write).
Official documentation
- Spring — Transaction management (@Transactional) — Declarative transactions and the rollback rules.
- Spring — @Transactional settings —
rollbackFor,readOnly, propagation, isolation. - Spring — Understanding the proxy (self-invocation) — Why self-calls bypass
@Transactional.
Next: schema migrations with Flyway.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship