The problem: the annotation is there, but nothing is atomic
A payment service marks a method @Transactional. A test shows that when the third step fails, the first two steps are still committed. In another case, an audit record that should survive a failure disappears together with the main operation. In a third, the database connection pool is exhausted because requests wait for a slow payment gateway while holding open transactions.
All three are transaction-boundary mistakes, and each follows from how Spring implements @Transactional.
How it works: a proxy around your bean
Spring does not change your class. When a bean has @Transactional methods, Spring wraps the bean in a proxy (a JDK dynamic proxy or a CGLIB subclass). Callers get the proxy. A call goes like this:
caller --> [ proxy ] --> your bean method
|
begin transaction (or join existing)
call the method
commit on success / rollback on failureThe transaction manager binds a connection to the current thread, so every repository call inside the method uses the same connection and the same transaction. This explains several rules that surprise people:
- Only calls that go through the proxy are intercepted.
- Proxy-based interception works for
publicmethods called from outside the bean (private and package-private handling depends on the proxying mode, so keep annotated methods public and the bean non-final). - The transaction is thread-bound: work you hand to another thread does not take part in it.
Pitfall 1: self-invocation
@Serviceclass ReportService { public void generate() { // ... saveResult(); // direct call on "this": the proxy is bypassed } @Transactional public void saveResult() { /* ... */ }}generate() calls saveResult() on this, not on the proxy, so no transaction starts. The annotation is ignored without any warning. Fixes, from simplest: move saveResult() into another bean and inject it; or restructure so the transactional method is the entry point; or use TransactionTemplate for programmatic control inside a single class. Injecting a bean into itself works but hides a design problem.
Pitfall 2: rollback rules
By default Spring rolls back on unchecked exceptions (RuntimeException and its subclasses) and on Error. A checked exception does not trigger rollback; the transaction commits.
@Transactionalpublic void pay(Order order) throws PaymentException { // checked orders.markPaid(order); gateway.charge(order); // throws PaymentException}// markPaid is COMMITTED, because PaymentException is checkedFix by stating the rule:
@Transactional(rollbackFor = PaymentException.class)or make the domain exception unchecked. Related traps: catching an exception inside the method and not rethrowing it means the proxy sees a normal return and commits; and if an inner transactional method marks the shared transaction rollback-only, the outer commit fails with UnexpectedRollbackException.
Pitfall 3: propagation
Propagation says what happens when a transactional method is called while a transaction already exists.
- REQUIRED (default): join the existing transaction, or start one if none exists. Inner and outer share fate: a rollback in the inner method rolls back everything.
- REQUIRES_NEW: suspend the current transaction and start a new, independent one on a separate connection. It commits or rolls back on its own.
- Others exist (SUPPORTS, MANDATORY, NEVER, NESTED), and you need them rarely.
REQUIRES_NEW suits an audit log that must be saved even if the business operation fails:
@Serviceclass AuditService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(String event) { auditRepository.save(new AuditEntry(event)); }}Remember that AuditService must be a separate bean (see self-invocation), and that REQUIRES_NEW takes a second connection while the first is still held. Under load, many nested new transactions can deadlock the pool: every thread holds one connection and waits for another.
Pitfall 4: readOnly
@Transactional(readOnly = true) is a hint. With Hibernate it typically skips dirty checking and sets the flush mode so the session does not flush; some drivers and databases can use the hint to route to a replica or avoid writes. It is not a security mechanism, and no rule says a write must fail on every database. Use it on query methods to make intent clear and to save work.
A related trap: lazy loading needs an open session. Reading order.getLines() outside the transaction throws LazyInitializationException. The right answer is to fetch what you need inside the service (fetch join, entity graph or DTO projection), not to keep the session open across the web layer.
Pitfall 5: long transactions and external calls
@Transactionalpublic void checkout(Order order) { orders.save(order); paymentGateway.charge(order); // HTTP call, may take seconds emailClient.sendConfirmation(order); // another network call}While this runs, the database connection is held, row locks stay open and the pool shrinks. Worse, the problem is not only speed: if the email succeeds and the transaction later rolls back, the customer got a confirmation for an order that does not exist. A network call cannot be rolled back.
Better structure:
- Do the database work in a short transaction.
- Commit.
- Perform the external call after commit, for example with
@TransactionalEventListener(phase = AFTER_COMMIT), or record the intent in an outbox table in the same transaction and send it from a separate process. - Make external calls idempotent so retries are safe.
Common mistakes
- Putting
@Transactionalon the controller or on every method by habit. The boundary belongs around one use case in the service. - Expecting
privateor same-class calls to be transactional. - Swallowing exceptions in a
catchblock and returning normally. - Using
REQUIRES_NEWto "fix" an error without understanding the data consistency it gives up. - Starting a thread or reactive pipeline inside a transaction and assuming it participates.
- Relying on the default isolation level without knowing what the database default is. See the isolation levels article for what each level prevents.
- Mixing
@Transactionalfrom Spring andjakarta.transaction.Transactionalwithout knowing the differences in rollback defaults and attributes.
Trade-offs
Declarative transactions are compact and readable, but they hide the boundary. TransactionTemplate is more explicit and avoids the proxy limitations; it costs a little more code. Short transactions reduce lock contention and pool pressure but force you to think about consistency across steps, which leads to patterns like outbox and idempotent handlers. Large transactions are simpler to reason about and worse to operate.
Testing rollback
Transaction behaviour cannot be verified with a mock, because a mock has no real transaction. Write an integration test against a real database (for example with Testcontainers): simulate the failure and check by query that the write was rolled back.
@Testvoid paymentFailureRollsBackOrderStatus() { given(gateway.charge(any())).willThrow(new PaymentException("declined")); assertThatThrownBy(() -> orderService.pay(orderId)).isInstanceOf(PaymentException.class); assertThat(orderRepository.findById(orderId).orElseThrow().getStatus()) .isEqualTo(OrderStatus.NEW); // must not be PAID}Note: if you make the test method itself @Transactional, the test runs all the work in a single transaction and can hide the rollback behaviour. Leave such tests without a transaction and clean the data up at the end.
Checklist
- The transaction boundary is the service method of one use case.
- No call to remote systems inside it, unless you have decided and documented why.
- Checked exceptions that should roll back are listed in
rollbackFor, or exceptions are unchecked. - Transactional methods are public and called through another bean.
readOnly = trueon query methods.- A test exists that proves rollback on failure, using a real database.
Where to go next
- What each isolation level prevents: SQL isolation levels.
- Retries and duplicates after a failed external call: idempotency patterns.
- Lazy loading and query counts: N+1 queries in JPA.
- See the middle Java developer roadmap for the wider path.