How @Transactional actually works: the proxy in front of your bean

@Transactional is declarative transaction demarcation. You mark where a unit of work starts and ends, and Spring opens, commits, or rolls back the transaction for you instead of you calling begin and commit on a connection. The annotation does nothing by itself. It is metadata, and something has to read it at call time.

That something is a Spring AOP proxy. When the container creates a bean with transactional methods, it registers a proxy in place of your object. The proxy is a JDK dynamic proxy implementing your interfaces, or a CGLIB-generated subclass, depending on configuration. Other beans that inject your service receive the proxy. On each call, the proxy hands control to TransactionInterceptor. The interceptor resolves the method's transaction attributes and asks the PlatformTransactionManager to start or join a transaction. It binds the connection to the current thread, invokes your real method, and then commits or rolls back based on the outcome.

External call (transactional):

  Caller ──> Proxy ──> TransactionInterceptor ──> Target bean ──> DataSource connection
                       (begin / commit / rollback   (your code)     (bound to thread)
                        via PlatformTransactionManager)

Internal call (not transactional):

  Target bean: methodA() ──> this.methodB()
               (the call never leaves the object, so no proxy and no interceptor)

The rule that follows is that transactional behavior applies only when a call passes through the proxy. A this.method() call inside the bean goes straight to the target, so it skips the interceptor. Anyone who writes service-layer code against a relational database needs to keep this model in mind, because nothing at compile time tells you which calls are intercepted.

 EXTERNAL CALL: another bean calls service.placeOrder()
 +-----------+     +------------------------------------------------+
 |  Caller   |---->|  Proxy (JDK dynamic proxy or CGLIB subclass)   |
 | (other    |     |  +------------------------------------------+  |
 |  bean)    |     |  | TransactionInterceptor                   |  |
 +-----------+     |  |  1. read @Transactional attributes       |  |
                   |  |  2. PlatformTransactionManager: begin    |  |
                   |  |     or join; bind connection to thread   |  |
                   |  |  4. commit, or roll back on exception    |  |
                   |  +--------------------+---------------------+  |
                   +-----------------------|------------------------+
                                           | 3. invoke
                                           v
                      +----------------------------+     +--------------+
                      | Target bean (your code)    |---->|  DataSource  |
                      |  placeOrder() runs here    |     |  connection  |
                      +----------------------------+     +--------------+

 INTERNAL CALL: methodA() calls this.methodB() (proxy skipped)
  +--------------------------------------------------+
  | Target bean (raw object, not the proxy)          |
  |                                                  |
  |  methodA() ----- this.methodB() -----+           |
  |                                      |           |
  |  @Transactional methodB() <----------+           |
  |  (no TransactionInterceptor, no new tx/rollback) |
  +--------------------------------------------------+

An external call passes through the proxy, where TransactionInterceptor uses the PlatformTransactionManager to begin, commit, or roll back around the target method, while a this.methodB() call inside the bean never reaches the proxy, so its @Transactional settings are ignored.

Self-invocation, private methods, and final classes: when the annotation does nothing

Consider an OrderService with a public importBatch(List<OrderDto>) method that has no annotation and loops over the input, calling this.saveOrder(dto). saveOrder is marked @Transactional. When a controller calls importBatch, it goes through the proxy, finds no transactional advice, and enters the target. Every later saveOrder call goes through this, which is the raw object, not the proxy. No transaction is opened. Each repository call runs in its own auto-committed unit, so a failure halfway through leaves partial rows behind.

Visibility causes a related failure. A CGLIB proxy works by subclassing your bean and overriding its methods. A private method cannot be overridden, so the proxy cannot intercept it. A final method has the same problem: the proxy inherits it unchanged, and calls to it run without the transaction interceptor. A final class cannot be subclassed at all, so CGLIB cannot create the proxy. In all of these cases, Spring accepts the annotation and gives no error at the call site.

Fixes and their costs

  • Extract a separate bean. Move saveOrder into an OrderWriter and inject it into OrderService. This is the simplest fix. The cost is extra indirection, and a cohesive domain operation can end up split across classes for technical reasons.
  • Self-injection. Inject a lazy reference to the bean into itself, or call AopContext.currentProxy() with proxy exposure enabled. Both work, but they tie business code to Spring's proxy mechanism.
  • TransactionTemplate. Wrap the boundary explicitly in importBatch. The transaction scope is visible in the code, and it no longer depends on how the method is called.
  • AspectJ weaving. Use mode = AdviceMode.ASPECTJ, which weaves advice into the bytecode. This fixes self-invocation and the private-method limit. The trade-off is that you must set up compile-time or load-time weaving in your build and runtime.
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class OrderService {
    private final OrderRepository orders;
    private final TransactionTemplate tx;

    public OrderService(OrderRepository orders, PlatformTransactionManager txManager) {
        this.orders = orders;
        this.tx = new TransactionTemplate(txManager);
    }

    // BUG: the caller enters through the proxy, but importBatch has no advice.
    // `this.saveOrder` calls the raw target, so no transaction is ever opened.
    public void importBatch(List<OrderDto> dtos) {
        for (OrderDto dto : dtos) {
            this.saveOrder(dto); // each save auto-commits; a mid-loop failure leaves partial rows
        }
    }

    @Transactional // only applied when the call arrives through the proxy
    public void saveOrder(OrderDto dto) {
        orders.save(Order.from(dto));
    }

    // FIX: declare the boundary in code so it no longer depends on how the method is called.
    // Any exception thrown inside the callback rolls back the whole batch.
    public void importBatchAtomically(List<OrderDto> dtos) {
        tx.executeWithoutResult(status -> {
            for (OrderDto dto : dtos) {
                orders.save(Order.from(dto));
            }
        });
    }
}

A self-invocation call to a @Transactional method silently runs without a transaction, and TransactionTemplate fixes it by making the batch boundary explicit.

Rollback rules and the rollback-only trap

By default, Spring rolls back when a RuntimeException or Error escapes a transactional method, and commits when a checked exception escapes. This default comes from the EJB convention, which treats checked exceptions as expected business outcomes and unchecked ones as failures. The behavior surprises teams that model domain errors as checked exceptions: a thrown InsufficientFundsException still commits any writes made before the throw. rollbackFor adds exception types that trigger rollback, and noRollbackFor exempts types that would otherwise roll back. Both rules also match subclasses of the listed types.

The rollback-only trap

Suppose OrderService.place() calls AuditService.record() on a separate bean, so the call goes through the proxy. Both methods use REQUIRED, so they share one physical transaction. record() throws a RuntimeException. The interceptor around record() does not own the transaction, so it cannot roll it back. It marks the shared transaction rollback-only instead. place() catches the exception, logs it, and continues as if the audit failure were harmless. When place() returns, its interceptor attempts a commit. The transaction manager sees the rollback-only flag, rolls back everything, and throws UnexpectedRollbackException with the message "Transaction silently rolled back because it has been marked as rollback-only." Catching an exception does not undo the rollback-only mark. If the inner work should fail independently of the outer work, it needs its own transaction, which section 4 covers.

Broad versus narrow rules

Setting rollbackFor = Exception.class everywhere gives consistent behavior, but it also rolls back on expected business exceptions where you may want the partial work kept. Narrow rules let you keep that work, but every new exception type requires someone to decide its rollback behavior and update the annotations. That discipline tends to erode during refactors.

@Service
public class OrderService {
    private final AuditService auditService; // separate bean, so calls go through its proxy
    private final OrderRepository orders;

    public OrderService(AuditService auditService, OrderRepository orders) {
        this.auditService = auditService;
        this.orders = orders;
    }

    // Checked exceptions commit by default; rollbackFor makes this one roll back too
    @Transactional(rollbackFor = InsufficientFundsException.class)
    public void place(Order order) throws InsufficientFundsException {
        orders.save(order);
        try {
            auditService.record(order); // joins the same physical transaction (REQUIRED)
        } catch (RuntimeException e) {
            // Swallowing the exception does NOT clear the rollback-only mark
            log.warn("Audit failed, continuing", e);
        }
        // On return, the outer interceptor tries to commit and gets:
        // UnexpectedRollbackException: Transaction silently rolled back
        // because it has been marked as rollback-only
    }
}

@Service
class AuditService {
    @Transactional // REQUIRED by default: participates in the caller's transaction
    public void record(Order order) {
        // Unchecked exception escapes the proxy; this interceptor does not own
        // the transaction, so it marks the shared transaction rollback-only
        throw new IllegalStateException("audit store unavailable");
    }
}

An inner REQUIRED method throws a RuntimeException that the outer method catches, yet the shared transaction stays marked rollback-only, so the outer commit fails with UnexpectedRollbackException.

Propagation and readOnly: choosing the right semantics

Propagation decides what happens when a transactional method is called while another transaction may already be bound to the thread. The default is not always the right answer.

Propagation Existing transaction No transaction Connection usage Typical use
REQUIRED Joins it Starts one Shares one Default service methods
REQUIRES_NEW Suspends it, starts new Starts one Second connection while outer waits Audit logs, independent work
NESTED Savepoint inside it Starts one Shares one Partial rollback of a step
SUPPORTS Joins it Runs without one Shares or none Optional reads
MANDATORY Joins it Throws Shares one Enforcing caller-owned boundaries
NOT_SUPPORTED Suspends it Runs without one Outer connection held idle Slow non-transactional calls
NEVER Throws Runs without one None Code that must not run inside a transaction

REQUIRES_NEW fits work that must survive an outer rollback, such as recording a failed payment attempt. The cost is that the outer transaction keeps its connection while it is suspended, and the inner transaction takes a second one. Under load, a small pool can run out because every request holds two connections. If the inner transaction updates rows the outer one has already locked, it waits on its own caller and never finishes. Depending on the database, the result is a lock-wait timeout or a request that hangs.

NESTED uses JDBC savepoints. Whether it works depends on the transaction manager and the JDBC driver. Test it against your actual stack before you rely on it.

readOnly is a hint, not a guarantee. With JPA it can switch flushing off, so dirty entities are not written to the database. It can also be passed to the driver or database as a read-only hint. Whether a write is actually rejected depends on the driver and database, so enforce write protection with database permissions.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentService {
    private final PaymentRepository payments;
    private final AuditService audit; // separate bean so the call goes through the proxy

    public PaymentService(PaymentRepository payments, AuditService audit) {
        this.payments = payments;
        this.audit = audit;
    }

    @Transactional // REQUIRED: joins or starts the outer transaction (connection #1)
    public void charge(long orderId, long amountCents) {
        Payment p = payments.lockForUpdate(orderId); // outer now holds a row lock
        try {
            p.capture(amountCents);
        } catch (PaymentDeclinedException e) {
            // Must survive the outer rollback, so it runs in its own transaction.
            audit.recordFailure(orderId, e.getMessage());
            throw e; // outer rolls back; the audit row stays committed
        }
    }
}

@Service
class AuditService {
    private final AuditRepository auditLog;

    AuditService(AuditRepository auditLog) { this.auditLog = auditLog; }

    // Suspends the outer transaction and borrows a SECOND pooled connection.
    // Insert into a separate table only: updating the payment row locked above
    // would make this wait on its own caller until a lock timeout or a hang.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordFailure(long orderId, String reason) {
        auditLog.save(new AuditEntry(orderId, "PAYMENT_DECLINED", reason));
    }
}

A REQUIRES_NEW audit write in a separate bean commits even when the outer payment transaction rolls back, at the cost of holding a second connection while the outer one is suspended.

Transaction boundaries, lazy loading, and side effects

A transaction also bounds the persistence context. When the method returns and the transaction commits, the entity manager closes, and any lazy association that was never initialized becomes a proxy with no session behind it. Touching it later, in a controller or a JSON serializer, throws LazyInitializationException.

Spring Boot enables open-session-in-view by default through spring.jpa.open-in-view, which keeps the entity manager open until the web request finishes. The exception goes away, but the cost moves elsewhere. Lazy loads now run outside any transaction, one query per access, so serializing a list of fifty orders can quietly issue fifty extra queries. The request can also hold a database connection through view rendering and serialization. Disabling it makes the fetch plan explicit. Use fetch joins or entity graphs where you need the entity, and DTO projections where you only need a few columns. Missing data then fails in tests instead of turning into slow queries in production.

Work inside the boundary has its own cost. An HTTP call, a broker publish, or a long computation inside a transaction keeps a connection checked out and keeps row locks held. Under load this drains the connection pool. Move side effects after commit with @TransactionalEventListener, whose default phase is AFTER_COMMIT, or register a TransactionSynchronization and implement afterCommit.

Running after commit has a catch: the data is already durable. If the hook throws or the process dies, the email or message is lost, and nothing rolls back. When delivery matters, write an outbox row in the same transaction and have a separate relay publish it with retries.

Verifying transaction behavior instead of trusting the annotation

Annotations describe intent; the transaction manager's logs show what actually happened. Set logging.level.org.springframework.transaction=DEBUG in your application properties, plus the package of the transaction manager you use: org.springframework.orm.jpa for JPA, or org.springframework.jdbc.datasource for plain JDBC. The output shows each transaction being created, joined, suspended, committed or rolled back, with the method name it is attached to. If a method you annotated never appears there, the proxy was bypassed.

For checks inside code, TransactionSynchronizationManager.isActualTransactionActive() tells you whether a real transaction is bound to the current thread. getCurrentTransactionName() tells you which method started it. A temporary assertion or log line using these is the quickest way to confirm a suspected self-invocation bug.

Prove rollback with integration tests against the same database engine you run in production. An in-memory substitute may enforce constraints and locking differently. Call the service, force a failure, then query the table from outside the service and assert that no rows were written.

There is a trap here. When the test method is itself @Transactional, Spring's test framework rolls that transaction back after each test. Your service then joins the test's transaction under REQUIRED, so you never exercise its real commit. A JPA provider may also never flush, which means unique or foreign-key violations that would fail at commit time pass silently. To avoid this, call flush() on the entity manager before asserting. To test real commit behavior, leave @Transactional off the test, or use @Commit or TestTransaction, and clean up the data explicitly afterward.

Alternatives, when to avoid declarative transactions, and impact on engineering time

Once tests exercise real commits, the remaining question is whether the annotation is the right tool for a given code path.

Approach Boundary visibility Flexibility Setup cost Typical failure mode
@Transactional (proxy mode) Implicit, depends on the call path Per method, fixed at design time Minimal Silently skipped on self-invocation or non-public methods
TransactionTemplate Explicit in the code Boundaries can depend on runtime conditions Low Callback grows into a large, hard-to-test block
PlatformTransactionManager directly Fully explicit Full control over begin, commit, and rollback Moderate Missed rollback or leaked transaction on an unhandled path
AspectJ mode Implicit, but applies to every call Annotation semantics without proxy limits High (weaving in the build or at load time) Build or agent misconfiguration hides advice entirely

When to avoid the annotation

Use TransactionTemplate when the boundary depends on runtime data, such as committing per batch only when a threshold is met. Use it when one method mixes database writes with HTTP calls or message publishing and only part of that work belongs inside the transaction. Use it when self-invocation is built into the design and you can't split the bean cleanly. In these cases a visible boundary is easier to review than an annotation whose effect depends on how the method was reached.

Engineering time

Declarative transactions remove repetitive begin/commit/rollback code, and most service methods need nothing more. The cost appears when the proxy is bypassed. Nothing fails. Data ends up partially written, and tracing that back to a call path can take far longer than the boilerplate would have. A short review checklist catches most of these problems before production:

  • Proxy path: Is the method public and called through the injected bean?
  • Exception type: Will a checked exception commit when it should roll back?
  • Propagation: Does an inner REQUIRED call risk marking the outer transaction rollback-only?
  • Side effects: Do external calls run inside the boundary, before the commit happens?
  • Committing test: Does at least one test verify the data after a real commit?

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot transactional pitfalls, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.