← Back to all posts

Why I Stopped Using @Transactional on Everything (And What I Do Instead)

Longin Koziolkiewicz March 2026 15 min read

If you've worked in a Spring Boot codebase for more than a few weeks, you've probably seen it — @Transactional sprinkled across service methods like a protective charm. Put it everywhere, the thinking goes, and your data will be safe.

I used to do this too. Then I spent a week debugging a production incident that came down to a misunderstanding of how Spring's proxy-based AOP actually works. This post is what I wish I'd read before that week.

We'll go from the JDBC fundamentals all the way through propagation, rollback rules, the @Async trap, and debugging — with the goal of turning @Transactional from a magic annotation into a precise tool.

It's All Just JDBC

Before touching Spring at all, it helps to understand what a transaction actually is at the wire level. Everything Spring does maps back to four lines of JDBC:

Connection connection = dataSource.getConnection();
try (connection) {
    connection.setAutoCommit(false);   // begin
    // ... your SQL ...
    connection.commit();               // success
} catch (SQLException e) {
    connection.rollback();             // failure
}

That's it. setAutoCommit(false) starts the transaction. commit() or rollback() ends it. When you use @Transactional, Spring generates this code for you — but it's always this underneath. Isolation levels map to connection.setTransactionIsolation(). Nested transactions map to connection.setSavepoint(). There's no magic, just generated boilerplate.

Keeping this mental model in mind makes every Spring transaction behaviour obvious rather than surprising.

How @Transactional Works: Proxies

Spring can't rewrite your bytecode on the fly (unless you use AspectJ weaving, which most projects don't). Instead, it wraps your bean in a proxy. When you call a @Transactional method, you're calling the proxy, which opens a connection, delegates to your real method, then commits or rolls back.

// What you write:
@Service
public class UserService {
    @Transactional
    public void registerUser(User user) {
        userRepository.save(user);
    }
}

// What Spring generates around it (conceptually):
public void registerUser(User user) {
    connection.setAutoCommit(false);
    try {
        realUserService.registerUser(user);
        connection.commit();
    } catch (RuntimeException e) {
        connection.rollback();
        throw e;
    }
}

The proxy is the key. Everything else — propagation, rollback rules, isolation — only works because of it. And as we'll see, that's exactly where most bugs hide.

Physical vs Logical Transactions

Spring distinguishes between physical transactions (actual JDBC connections with autoCommit=false) and logical transactions (@Transactional-annotated method calls in your code).

Multiple logical transactions can share a single physical one. This is the default when a transactional method calls another transactional method — one real database transaction, two logical boundaries. Understanding this distinction makes propagation intuitive rather than confusing.

Propagation: What Actually Happens

Propagation defines what Spring does at the JDBC level when a @Transactional method is called from inside another @Transactional method. There are seven modes, but two cover 95% of real use cases.

REQUIRED (default) — join the existing transaction if one is open, otherwise start a new one. One getConnection() → setAutoCommit(false) → commit() cycle. Right choice for the vast majority of service methods.

REQUIRES_NEW — always start a fresh, independent transaction. Maps to a second getConnection() → setAutoCommit(false) → commit(). The original transaction is suspended until this one finishes. Classic use case: audit logs or technical events that must persist even if the main transaction rolls back.

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    auditService.log(order);           // REQUIRES_NEW — persists even on rollback
    throw new PaymentFailedException();  // order rolled back, audit log stays
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) {
    auditRepository.save(new AuditEntry(order));
}

NESTED — creates a savepoint inside the current transaction via connection.setSavepoint(). If the nested part fails, only the work since the savepoint rolls back — the outer transaction can still commit. Support depends on your database driver.

The remaining modes — MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER — control whether Spring throws an exception based on whether a transaction already exists. Worth knowing they exist; rarely worth reaching for them in typical applications.

The Pitfalls

1. Self-invocation silently drops transaction boundaries

If a method calls another method in the same bean, the call never goes through the proxy. Spring never sees it. No transaction is opened, no propagation happens — silently.

@Service
public class InvoiceService {

    @Transactional
    public void generateInvoice(InvoiceRequest req) {
        attachPdf(req);      // ⚠️ internal call — proxy bypassed entirely
        invoiceRepository.save(req.toInvoice());
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void attachPdf(InvoiceRequest req) {
        // You expect a new transaction. You get nothing.
        pdfRepository.save(req.toPdf());
    }
}

Fix: move attachPdf into a separate Spring bean so the call goes through the proxy.

2. @Transactional on private methods

Spring proxies intercept public method calls only. Annotating a private method does nothing — no exception, no warning, silently ignored.

@Transactional  // ❌ ignored — method is private
private void chargeCard(Payment p) {
    paymentRepo.save(p);
}

Transactional methods must be public. Always.

3. Checked exceptions don't trigger rollback by default

Spring's default rollback policy triggers only on RuntimeException and Error. A checked exception commits the transaction — even if your data is in an inconsistent state.

@Transactional
public void transferFunds(Transfer t) throws InsufficientFundsException {
    debit(t.from(), t.amount());
    credit(t.to(), t.amount()); // throws InsufficientFundsException
    // ⚠️ debit committed. Account is now overdrawn.
}

// Fix option 1: be explicit
@Transactional(rollbackFor = InsufficientFundsException.class)
public void transferFunds(Transfer t) throws InsufficientFundsException { ... }

// Fix option 2: model failures as unchecked exceptions
public class InsufficientFundsException extends RuntimeException { }

Extending RuntimeException is the cleaner long-term approach — the transaction rolls back automatically and the intent is explicit.

4. Swallowed exceptions prevent rollback

Spring rolls back only if the exception escapes the transactional boundary. Catch it inside the method and the transaction commits normally.

@Transactional
public void sendWelcomeEmail(User user) {
    userRepository.save(user);
    try {
        emailService.send(user.getEmail());
    } catch (EmailException e) {
        log.error("Email failed", e);
        // ⚠️ exception swallowed — transaction commits, user saved anyway
    }
}

Either rethrow, or mark the transaction for rollback explicitly:

TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

5. Long-running transactions

Transactions hold database locks for their entire duration. Doing I/O, calling external services, or sending emails inside a transaction holds those locks the whole time. Under load this causes contention, timeouts, and deadlocks.

Keep transactions focused on database work. The timeout attribute is a useful safety net:

@Transactional(timeout = 5) // rolls back if not done in 5 seconds
public void generateReport(ReportRequest req) { ... }

@Async + @Transactional: A Common Trap

Transactions are thread-bound. A transaction lives and dies on the thread that started it. @Async executes the method on a different thread. These two facts together mean an async method never participates in the caller's transaction.

@Transactional
public void registerUser(User user) {
    userRepository.save(user);
    notificationService.sendWelcome(user);  // @Async — different thread, no transaction
    throw new RuntimeException("Validation failed");
}

Result: user save rolled back, welcome notification sent anyway. Inconsistency with no error and no warning.

The right pattern is @TransactionalEventListener. Publish an event inside the transaction; handle the side effect only after commit:

@Transactional
public void registerUser(User user) {
    userRepository.save(user);
    eventPublisher.publishEvent(new UserRegisteredEvent(user));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserRegistered(UserRegisteredEvent event) {
    notificationService.sendWelcome(event.getUser()); // only runs after commit
}

This guarantees side effects only happen when the database is actually in the expected state. One of the most useful patterns in the Spring ecosystem.

Debugging Transactions

Transactional bugs tend to be silent — no stack traces, just data in the wrong state. Turn on transaction logging:

# application.yml
logging:
  level:
    org.springframework.transaction: DEBUG
    org.hibernate.SQL: DEBUG

You'll see exactly when transactions open, which methods own them, and when they commit or roll back:

Creating new transaction with name [UserService.registerUser]
Participating in existing transaction
Rolling back JDBC transaction on Connection
Committing JDBC transaction on Connection

When you hit UnexpectedRollbackException: Transaction silently rolled back, look for a setRollbackOnly event earlier in the logs — not at the commit site. The rollback is always set before the exception surfaces.

One more thing worth knowing: a log.info("User registered") inside a transactional method might appear even if the transaction later rolls back. For logs that reflect what actually committed, use @TransactionalEventListener with AFTER_COMMIT.

What I Do Instead

One transaction per business operation

The transactional boundary should map to one meaningful business action — place an order, transfer funds, register a user. If a single transaction is touching five different aggregates, that's a design smell. Coordinate across aggregates using domain events and eventual consistency, not a shared transaction.

Be explicit before adding @Transactional

Ask: what am I protecting here? Which operations need to succeed or fail together? If you can't answer clearly, the boundary is probably wrong. Use readOnly = true on query-only methods — it signals intent and gives the persistence layer room to optimise.

Keep transactions short

Open a transaction, do database work, close it. Don't call external services, send emails, or make HTTP calls inside a transaction. Finish the database work, then trigger side effects via events or async processing after commit.

Quick Reference


Transaction management is one of those areas where Spring does a lot of work to make things feel simple — which is exactly why it's easy to get wrong. The annotation is a tool, not a guarantee. Once you understand the proxy model and the JDBC underneath, the behaviour stops feeling magical and starts feeling obvious.

If you found this useful or have a production horror story of your own, feel free to reach out at longin.koziolkiewicz@gmail.com.