What Really Happens When You Annotate @Transactional

by Eric Hanson, Backend Developer at Clean Systems Consulting

The proxy that makes @Transactional work

@Transactional works through Spring AOP. When Spring creates a bean that has @Transactional methods, it creates a proxy that wraps the bean. The proxy intercepts method calls, begins a transaction before the method executes, and commits or rolls back when the method returns.

Caller → Proxy → begin transaction → Target bean method → commit/rollback → return to caller

The proxy is generated by CGLIB (subclass proxy) or JDK dynamic proxy (interface proxy). For most Spring beans, CGLIB subclassing is used — the proxy is a subclass of your bean that overrides the transactional methods with transaction-management logic.

The self-invocation trap. Because @Transactional works through a proxy, calling a @Transactional method on this bypasses the proxy entirely:

@Service
public class OrderService {

    public void processOrder(Order order) {
        // Direct call on 'this' — bypasses the proxy
        // @Transactional on saveOrder has NO EFFECT here
        saveOrder(order);
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
        auditRepository.save(new AuditEntry(order));
    }
}

processOrder calls saveOrder directly on this — the proxy is not involved. saveOrder runs without a transaction. The @Transactional annotation is silently ignored.

The fix: inject the service into itself (Spring handles this without circular dependency issues in Spring 4.3+) or refactor to avoid self-invocation:

@Service
public class OrderService {

    @Autowired
    private OrderService self; // inject proxy, not 'this'

    public void processOrder(Order order) {
        self.saveOrder(order); // goes through proxy — @Transactional applies
    }

    @Transactional
    public void saveOrder(Order order) { ... }
}

Or extract the transactional method to a separate bean — the cleaner architectural solution.

When the transaction starts and when it ends

@Transactional does not begin a transaction when the method is entered. It begins a connection when the first database operation executes within the transaction scope.

In practice this means:

@Transactional
public void processOrder(Order order) {
    // No database connection acquired yet

    PaymentResult payment = paymentGateway.charge(order.getTotal()); // no connection

    Order saved = orderRepository.save(order); // connection acquired HERE
    auditRepository.save(new AuditEntry(order)); // same connection
} // connection released HERE — commit or rollback

The connection is held from the first database operation to the end of the method. Any non-database work — HTTP calls, file I/O, computation — within the transaction scope holds the connection open during that work. As covered in the connection leaks article, external I/O inside @Transactional is a connection pool exhaustion source.

Propagation — what happens when transactions nest

Transaction propagation controls what happens when a @Transactional method is called from within an existing transaction. The six behaviors:

REQUIRED (default) — joins the existing transaction if one exists; creates a new one if not. The most common behavior. A @Transactional(propagation = REQUIRED) method called from within a transaction participates in that transaction — if the inner method throws, the outer transaction rolls back too.

REQUIRES_NEW — always creates a new transaction, suspending the current one. The new transaction commits or rolls back independently. Use for operations that must complete even if the outer transaction rolls back (audit logs, notification records):

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(AuditEntry entry) {
    auditRepository.save(entry); // commits independently of caller's transaction
}

SUPPORTS — joins the existing transaction if one exists; runs non-transactionally if not.

NOT_SUPPORTED — runs non-transactionally, suspending any existing transaction.

MANDATORY — requires an existing transaction; throws IllegalTransactionStateException if none exists.

NEVER — must run without a transaction; throws if one exists.

For application code, REQUIRED and REQUIRES_NEW cover the significant cases. The others are useful for specific infrastructure scenarios.

The REQUIRED participation subtlety. When an inner method with REQUIRED joins the outer transaction, the inner method's exception handling interacts with the outer:

@Transactional
public void processOrder(Order order) {
    try {
        notificationService.sendEmail(order); // @Transactional(REQUIRED) — joins this transaction
    } catch (EmailException e) {
        log.warn("Email failed, continuing");
    }
    orderRepository.save(order);
}

@Transactional  // REQUIRED — joins caller's transaction
public void sendEmail(Order order) {
    throw new EmailException("SMTP unavailable");
    // Because this participates in the outer transaction,
    // throwing marks the transaction as rollback-only
}

When sendEmail throws, Spring marks the current transaction as rollback-only — even if the caller catches the exception and continues. When processOrder tries to commit, Spring finds the rollback-only mark and throws UnexpectedRollbackException. The orderRepository.save is also rolled back, even though the caller thought it handled the exception.

The fix: either let exceptions propagate and handle rollback correctly, or use REQUIRES_NEW for sendEmail to isolate the email failure from the order transaction.

Rollback rules — which exceptions trigger rollback

By default, @Transactional rolls back only for RuntimeException and its subclasses (unchecked exceptions). Checked exceptions do not trigger rollback by default:

@Transactional
public void processOrder(Order order) throws OrderProcessingException {
    orderRepository.save(order);
    throw new OrderProcessingException("processing failed");
    // Checked exception — transaction COMMITS despite the exception
    // The save persists even though an exception was thrown
}

This is the most surprising default behavior for developers coming from other frameworks. A checked exception thrown from a @Transactional method commits the transaction unless rollback is explicitly configured.

Configure rollback for specific exceptions:

@Transactional(rollbackFor = {OrderProcessingException.class, IOException.class})
public void processOrder(Order order) throws OrderProcessingException { ... }

// Roll back for all exceptions
@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception { ... }

Or configure no-rollback for specific runtime exceptions you expect and want to commit:

@Transactional(noRollbackFor = OptimisticLockingFailureException.class)
public void processOrder(Order order) {
    // OptimisticLockingFailureException won't roll back — handle it at the caller
}

The practical rule: if your method throws checked exceptions that represent failure states that should not persist data, add rollbackFor. Most application code benefits from rollbackFor = Exception.class to make the behavior explicit and safe.

Read-only transactions

@Transactional(readOnly = true) is an optimization hint with two concrete effects:

  • Hibernate disables dirty checking — no flush at transaction end, no scan of loaded entities for changes
  • Spring sets a read-only flag on the JDBC connection — some databases and JDBC drivers can optimize for this (route to read replicas, skip undo log generation)
@Transactional(readOnly = true)
public List<OrderSummary> getOrderSummaries(String userId) {
    // Hibernate won't track changes to entities loaded here
    return orderRepository.findByUserId(userId);
}

Applying readOnly = true to all query methods is a meaningful optimization for JPA applications with heavy read traffic. The dirty checking pass at transaction end can be expensive when many entities are loaded and tracked.

readOnly = true does not prevent writes — Hibernate will write if you explicitly call save() or persist() within a read-only transaction. It's a hint, not enforcement. Using readOnly = true on methods that accidentally write will commit the writes normally.

Transaction timeout

@Transactional(timeout = 30) rolls back the transaction if it hasn't completed within 30 seconds:

@Transactional(timeout = 30)
public void processLargeOrder(Order order) {
    // If this takes longer than 30 seconds, transaction is rolled back
    // with TransactionTimedOutException
}

Transaction timeout is set when the transaction begins. It counts the total time from transaction start to commit, not just database time. For methods that involve I/O, computation, and database operations, the timeout must be generous enough to cover all of it under normal conditions.

Use transaction timeouts for operations that should fail fast if a lock wait runs long, or for batch operations with known upper bounds.

Testing @Transactional behavior

@Transactional in tests has a different behavior than in production: test transactions are rolled back after each test by default. Data changes made in a test are never committed — the database is clean for the next test:

@SpringBootTest
@Transactional  // all test methods run in a rolled-back transaction
class OrderServiceIntegrationTest {

    @Test
    void processOrder_savesOrderAndAuditEntry() {
        Order order = orderService.processOrder(new CreateOrderRequest(...));

        assertThat(orderRepository.findById(order.getId())).isPresent();
        assertThat(auditRepository.findByOrderId(order.getId())).isNotEmpty();
        // After test: entire transaction rolls back — no cleanup needed
    }
}

The test @Transactional is convenient but causes a specific false positive: tests that verify @Transactional(propagation = REQUIRES_NEW) behavior don't work correctly — the inner REQUIRES_NEW transaction joins the test's outer transaction rather than creating an independent one, because the test transaction has already begun.

For testing REQUIRES_NEW behavior, use @Commit on the test method or @Rollback(false) to allow the test to actually commit, then clean up in @AfterEach.

The annotation placement decision

@Transactional on the interface method vs the implementation method:

// Interface
public interface OrderService {
    @Transactional  // spring-tx will pick this up in some configurations
    void processOrder(Order order);
}

// Implementation
@Service
public class OrderServiceImpl implements OrderService {
    @Transactional  // preferred — closer to the implementation
    public void processOrder(Order order) { ... }
}

Spring recommends annotating the implementation class or its methods, not the interface. Proxy creation for interfaces (JDK dynamic proxy) may not honor @Transactional on interface methods correctly in all configurations. Annotating the concrete class is always correct.

For the same reason, @Transactional on private methods does nothing — the proxy can only intercept public and protected method calls. Spring logs a warning when it detects @Transactional on private methods in newer versions. If you need transaction management for logic that's currently private, extract it to a public method on a separate bean.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

What a Professional Contract Should Cover Before You Start Any Work

A contract is not bureaucracy. It is the document that prevents the most predictable and painful problems in contracting — the ones that come up in every engagement that does not have one.

Read more

Java Generics Beyond `List<T>` — Wildcards, Bounds, and When They Actually Matter

Most Java developers use generics as glorified type-safe containers and stop there. Wildcards and bounds solve real API design problems — here is what they are, when they help, and when they make things worse.

Read more

Optimistic Locking in Hibernate — @Version, Retry Strategies, and Conflict Resolution

Concurrent updates to the same entity without coordination produce lost updates — the last write wins and intermediate changes are silently discarded. Optimistic locking detects this at commit time. Here is how it works and how to handle the conflicts it surfaces.

Read more

Spring Cloud Vault in Production — Configuration, Failover, and the Secrets You Shouldn't Store

Getting Spring Cloud Vault working in development is straightforward. Running it reliably in production requires understanding lease renewal behavior, startup failure modes, high availability configuration, and the categories of secrets that Vault handles well versus those where it adds complexity without benefit.

Read more