Open almost any Spring codebase and you will find @Transactional stamped on every public method of every service class. Nobody decided this. It accreted, one code review at a time, because it looks responsible and nothing visibly breaks.
It is worth pushing back on, because the annotation is not free and the default placement is usually wrong.
What the annotation actually costs
A transaction holds a database connection for its entire duration. Not the duration of your queries — the duration of the method. So when a service method opens a transaction, runs one quick SELECT, then calls a payment gateway over HTTP and waits 900ms for a response, that connection sits idle and unavailable to anyone else for the whole 900ms.
Do that under load and you exhaust the pool. The symptom is miserable to diagnose: timeouts acquiring connections, on endpoints that barely touch the database, while your database itself looks completely healthy and bored.
The rule that follows is simple. Never make a network call inside a transaction. If a method has to talk to an external system, do the database work, commit, then make the call — or move the call out of the transactional method entirely.
The self-invocation trap
Here is the part that bites people who assume the annotation always works. Spring implements @Transactional with a proxy. Calls that arrive from outside the bean pass through that proxy and get a transaction. Calls from inside the same class do not.
@Service
public class OrderService {
public void processAll(List<Order> orders) {
for (Order o : orders) {
save(o); // NOT transactional — internal call bypasses the proxy
}
}
@Transactional
public void save(Order o) {
repository.save(o);
}
}
That code compiles, runs, passes a happy-path test, and has no transaction where you think it does. There is no warning. The annotation is simply ignored, and you find out during an incident when a partial failure leaves half the orders written.
Blanket-annotating makes this worse, not better, because when every method carries the annotation you stop reading it as a meaningful signal and start treating it as decoration.
A shorter rule
Put @Transactional where the unit of work is — the operation that must succeed or fail as a whole — and nowhere else. Usually that is one method per use case, and it is often on the application service that orchestrates a single command, not on every repository-adjacent helper beneath it.
Two habits make this concrete. Mark read paths with @Transactional(readOnly = true), which lets Hibernate skip dirty-checking and lets some drivers route to a replica. And when you genuinely need a nested operation to commit independently, use REQUIRES_NEW deliberately and call it through a separate bean so the proxy is actually involved.
The rollback default deserves a mention too, because it surprises people: Spring rolls back on unchecked exceptions only. A checked exception escaping a transactional method will commit. If you throw checked exceptions for business failures, you need rollbackFor, or you will quietly persist the state you meant to discard.
Where blanket annotation is defensible
I will grant one case. In a small CRUD service with no external calls, no long-running methods, and a connection pool that will never be the bottleneck, annotating broadly costs you almost nothing and saves an argument. Plenty of internal tools live there quite happily.
But the moment a service starts calling other services, that convenience turns into idle connections and invisible boundaries. At that point the annotation needs to move to where the actual unit of work is, and every method that no longer needs it should lose it.
If your transactions are already tangled up with an ORM you are fighting, it may be worth looking at whether the persistence model is the real problem — Spring Data JDBC’s aggregate model makes the unit of work explicit by construction, which removes most of the guesswork about where the boundary belongs.