How it works: Spring applies @Transactional through a proxy

@Transactional is declarative transaction management. Instead of writing begin, commit, and rollback calls around every unit of work, you annotate a method and Spring runs the transaction lifecycle for you. Teams prefer this because hand-written transaction code repeats the same try/catch/finally in every method, makes it easy to forget a rollback path, and mixes infrastructure into business logic. The annotation keeps the boundary visible and consistent.

Spring does not change your method's bytecode. When the container creates a bean that has transactional methods, it wraps the bean in a proxy and injects that proxy into every collaborator. If the bean implements interfaces and the configuration allows it, the proxy can be a JDK dynamic proxy. Otherwise it is a CGLIB-generated subclass. Spring Boot uses class-based proxies by default.

When a caller invokes a method on the proxy, the transaction interceptor runs first. It reads the transaction attributes, asks the PlatformTransactionManager to start or join a transaction bound to the current thread, and then delegates to the real bean. When the method returns or throws, the interceptor commits or rolls back.

All of that logic lives in the proxy, so a transaction exists only when a call passes through the proxy. A call that reaches the target object directly skips the interceptor and gets no transaction behavior.

Keep this model in mind for the rest of the article. Most "spring @transactional not working" reports are not framework bugs. They are the expected result of a call that never reached the proxy, or of interceptor rules the author did not expect.

+---------------------------+
| Caller (collaborator bean)|
| holds a reference to the  |
| proxy, not the target     |
+---------------------------+
              |
              | orderService.placeOrder()
              v
+----------------------------------------+     +-------------------------+
| Proxy: JDK dynamic proxy or CGLIB      |     | PlatformTransaction-    |
| subclass                               |---->| Manager                 |
|                                        |<----| begin/join, commit,     |
|  TransactionInterceptor                |     | rollback (thread-bound) |
|  1. read transaction attributes        |     +-------------------------+
|  2. start or join transaction          |
|  4. commit on return / rollback on     |
|     throw                              |
+----------------------------------------+
              |
              | 3. delegate
              v
+----------------------------------------+
| Target bean (your class)               |
|                                        |
|  placeOrder()  --- this.audit() ---+    |
|                                    |    |
|  @Transactional audit()  <--------+    |
|  self-call: never reaches the proxy,   |
|  no interceptor, no new transaction    |
+----------------------------------------+

Callers hold the proxy, whose TransactionInterceptor starts or joins a thread-bound transaction through the PlatformTransactionManager before delegating to the target bean, while a self-call inside the target bypasses the proxy and gets no transaction behavior.

Who should use @Transactional and this guide

@Transactional fits any Spring application where a unit of business work makes several writes that must succeed or fail together. Typical examples are placing an order and decrementing stock, or posting ledger entries and updating an invoice status. It fits best when that work runs through Spring-managed service beans backed by a transactional resource such as a JDBC DataSource or a JPA EntityManager.

This guide is for engineers who:

  • maintain Spring or Spring Boot services that write to a relational database through Spring's transaction managers;
  • review code that adds or changes @Transactional annotations, propagation settings, or exception handling around them;
  • are debugging data that was committed when it should not have been, or rolled back when it should have stayed, or an unexpected UnexpectedRollbackException.

Declarative transactions are a weaker fit in some cases. One is code that is not managed by the Spring container. Another is work that needs many fine-grained boundaries inside a single method, where TransactionTemplate is clearer. A third is a flow that spans systems the database transaction cannot cover, such as message brokers or remote APIs. Those flows need the patterns described in the side-effects section below.

Self-invocation and other silent no-op cases

Self-invocation is the most common case. Suppose OrderService.placeOrder() is unannotated and calls this.saveOrder(), which carries @Transactional. The caller holds a reference to the proxy, so placeOrder() enters the proxy, finds no transaction attribute, and delegates to the target. From then on, execution is inside the target object. this refers to the raw instance, not the proxy, so saveOrder() runs as a plain Java call. No transaction is opened, and no error or warning is logged. The same applies when both methods are annotated with different settings. The inner method's REQUIRES_NEW or readOnly is never read.

Several other cases fail the same way:

  • Private methods. A subclass proxy cannot override them, so they are never intercepted. Depending on the Spring version, other non-public visibility may also be ignored.
  • Final methods. CGLIB cannot override a final method, so the proxy runs its inherited body directly on the proxy instance. That instance's injected fields are typically null, so the symptom is often a NullPointerException rather than a missing transaction.
  • Final classes. These cannot be subclassed, so class-based proxying fails when the context starts.
  • Instances created with new. The container never saw the object, so there is no proxy.
  • Calls during initialization. A @PostConstruct method calling a transactional method goes through this. Proxies are usually created after initialization callbacks, so no proxy exists yet anyway.

Review checklist:

  • Is the annotated method called on this, implicitly or explicitly?
  • Is the method private, final, or in a final class?
  • Does any code call new on a service class instead of injecting it?
  • Is transactional work started from a constructor or @PostConstruct?
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
public class OrderService {
    private final OrderWriter writer;

    public OrderService(OrderWriter writer) { this.writer = writer; }

    // No @Transactional: the proxy finds no attribute and delegates to the raw target
    public void placeOrder(Order order) {
        this.saveOrder(order); // BROKEN: 'this' is the target, so the proxy is bypassed
        writer.save(order);    // WORKS: call goes through OrderWriter's proxy
    }

    @Transactional
    public void saveOrder(Order order) {
        // Prints false when reached via this.saveOrder(...) above
        System.out.println("saveOrder tx active: "
            + TransactionSynchronizationManager.isActualTransactionActive());
    }
}

@Service
class OrderWriter {
    @Transactional
    public void save(Order order) {
        // Prints true: the call arrived from another bean via the proxy
        System.out.println("save tx active: "
            + TransactionSynchronizationManager.isActualTransactionActive());
    }
}

// Also silently non-transactional: the container never saw this object, so there is no proxy
// OrderService manual = new OrderService(new OrderWriter());

record Order(long id) {}

Calling the @Transactional method through this runs without a transaction. Moving it to a separate injected bean sends the call through the proxy, and isActualTransactionActive() makes the difference visible at runtime.

Fixing self-invocation: separate beans, self-injection, TransactionTemplate or AspectJ

The most reliable fix moves the transactional method into a separate bean, so the call crosses the proxy. When the extracted class has a real responsibility, such as LedgerWriter or InvoicePersister, the result is easy to read. When the class exists only to hold one annotated method, you get thin services that split cohesive logic across files.

Self-injection keeps the code in place. The bean receives a reference to its own proxy, through a @Lazy field or an ObjectProvider, and calls self.method(). It works, but the reader cannot tell why the code calls self instead of this. It also creates a circular reference, which Spring Boot can reject at startup and newer versions reject by default.

TransactionTemplate wraps the work in a callback and does not depend on proxies, so self-invocation, private methods and new stop mattering. The cost is transaction plumbing inside business code. If some paths use the template and others use annotations, the result is inconsistent boundaries that reviewers miss.

AspectJ weaving (mode = AdviceMode.ASPECTJ) weaves the advice into the bytecode, which removes all proxy limits. You pay for that with compile-time or load-time weaving setup, more build complexity, and agents in tests and containers.

Fix Clarity Tooling cost Main failure mode
Separate bean High None Thin, fragmented services
Self-injection Low None Hidden dependency, circular-reference errors
TransactionTemplate High locally None Inconsistent use alongside annotations
AspectJ High Build or load-time weaving Weaving silently not applied in some environment

Use a separate bean by default. Use TransactionTemplate when you need several boundaries inside one method. Use AspectJ only when the team already runs weaving. Avoid self-injection except as a short-term patch.

// Three fixes side by side for illustration. A real codebase would normally pick one.
@Service
class InvoiceService {
    private final LedgerWriter ledgerWriter;            // Fix 1: separate bean
    private final ObjectProvider<InvoiceService> self;  // Fix 2: self-injection, resolved lazily
    private final TransactionTemplate requiresNew;      // Fix 3: programmatic boundary

    InvoiceService(LedgerWriter ledgerWriter,
                   ObjectProvider<InvoiceService> self,
                   PlatformTransactionManager txManager) {
        this.ledgerWriter = ledgerWriter;
        this.self = self;
        this.requiresNew = new TransactionTemplate(txManager);
        this.requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }

    public void close(long invoiceId) {
        // this.audit(invoiceId) would bypass the proxy, so REQUIRES_NEW would be ignored.
        ledgerWriter.post(invoiceId);       // call goes through the LedgerWriter proxy
        self.getObject().audit(invoiceId);  // call goes through this bean's own proxy
        requiresNew.executeWithoutResult(status -> {
            markClosed(invoiceId);          // own transaction; a private method is fine here
        });
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void audit(long invoiceId) { /* insert audit row */ }

    private void markClosed(long invoiceId) { /* update invoice status */ }
}

@Service
class LedgerWriter {
    @Transactional
    public void post(long invoiceId) { /* write ledger entries */ }
}

Each call in close() reaches a real transaction boundary in a different way. One goes through the separate LedgerWriter bean, one through the bean's own proxy obtained lazily via ObjectProvider, and one through a TransactionTemplate that does not depend on proxies at all.

Rollback rules: unchecked vs checked exceptions, rollbackFor and rollback-only

By default, Spring's TransactionInterceptor rolls back when a RuntimeException or an Error leaves the proxied method. It commits when the method throws a checked exception. This follows the old EJB convention: a checked exception is treated as an expected business outcome, and an unchecked one is treated as a failure. You can change the rule per method or per class. rollbackFor = SomeCheckedException.class adds a rollback trigger, and noRollbackFor = SomeRuntimeException.class removes one. Both also match subclasses of the listed type.

The interceptor only acts on exceptions that reach the proxy. If the method catches an exception, logs it, and returns normally, the proxy sees a successful call and commits. Any partial writes made before the failure are committed with it. To roll back without rethrowing, call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().

The rollback-only trap

Suppose an outer @Transactional method calls an inner bean method that also uses REQUIRED. The inner method joins the outer transaction. When it throws a RuntimeException, its interceptor cannot roll back a transaction it does not own. It marks the shared transaction rollback-only instead. If the outer method catches the exception and returns, the commit finds the flag, rolls back, and throws UnexpectedRollbackException ("Transaction silently rolled back because it has been marked as rollback-only"). The outer catch block did not make the failure go away. It only moved the error to commit time, where it is harder to trace.

rollbackFor = Exception.class everywhere

Setting this on every method makes behavior uniform: any exception that escapes rolls back. The cost is that some code throws a checked exception on purpose to report a business outcome after doing valid work, such as recording a failed payment attempt before throwing PaymentDeclinedException. With the blanket rule, that record is rolled back. If you adopt the rule, find those cases and mark them with noRollbackFor.

@Service
public class OrderService {
    private final PaymentService paymentService; // separate bean, so calls go through its proxy
    public OrderService(PaymentService paymentService) { this.paymentService = paymentService; }

    // Checked exception would normally commit; rollbackFor makes it roll back.
    @Transactional(rollbackFor = InsufficientStockException.class)
    public void reserve(Order order) throws InsufficientStockException { /* ... */ }

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        try {
            paymentService.charge(order); // REQUIRED: joins this transaction
        } catch (PaymentDeclinedException e) {
            // Too late: the inner interceptor already marked the shared tx rollback-only.
            // Returning normally makes commit throw UnexpectedRollbackException.
            log.warn("Payment declined for order {}", order.getId());
        }
    }

    @Transactional
    public void placeOrderExplicitly(Order order) {
        orderRepository.save(order);
        try {
            paymentService.charge(order);
        } catch (PaymentDeclinedException e) {
            // Roll back on purpose without rethrowing; no surprise exception at commit.
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }
}

@Service
class PaymentService {
    @Transactional // propagation defaults to REQUIRED
    public void charge(Order order) {
        // PaymentDeclinedException extends RuntimeException -> marks the tx rollback-only
        throw new PaymentDeclinedException(order.getId());
    }
}

A caught RuntimeException from an inner REQUIRED method still leaves the shared transaction rollback-only, so commit fails with UnexpectedRollbackException unless you mark rollback explicitly; rollbackFor makes a checked exception trigger rollback.

Choosing transaction propagation: REQUIRED, REQUIRES_NEW, NESTED and the guards

That usually means moving the write into a different transaction, which is what propagation controls.

Mode Existing transaction No transaction
REQUIRED (default) Joins it Starts one
REQUIRES_NEW Suspends it, starts and commits its own Starts one
NESTED Sets a savepoint inside it Starts one
SUPPORTS Joins it Runs without one
NOT_SUPPORTED Suspends it, runs without one Runs without one
MANDATORY Joins it Throws
NEVER Throws Runs without one

The last four are guards. They declare what a method expects of its caller, and the mismatch cases fail fast instead of quietly changing behavior.

For an audit record that must survive an outer rollback, put the write in its own bean, such as AuditWriter, and annotate it REQUIRES_NEW. The separate bean is required because a self-call bypasses the proxy, so the new propagation would never be applied. The audit insert commits as soon as the method returns, and a later failure in the payment flow cannot undo it.

The cost is a second connection. The outer connection stays checked out while it is suspended, so each thread on this path holds two connections at its peak. If the inner write touches rows the outer transaction has already locked, it waits on a lock held by its own suspended caller. The database cannot see this as a deadlock, so it waits until the lock timeout expires.

Illustrative estimate. Assume a pool of 10 connections and 10 concurrent requests on this path. All 10 threads take their outer connection, none can get a second one, and every request stalls until the pool's acquisition timeout. To rule out this starvation, the pool needs at least (threads × (connections per thread − 1)) + 1 connections. With 20 threads and two connections each, that is 21.

NESTED is not a portable alternative. It relies on JDBC savepoints, so support depends on the transaction manager and the driver. Under JPA, rolling back to a savepoint undoes the SQL but leaves the persistence context unchanged, so managed entities can still hold state that was rolled back.

Diagnosing and testing transactional behavior

Guessing at proxy and propagation behavior wastes more time than any other part of transactional debugging. Spring will show you what it is doing if you ask. Set org.springframework.transaction and org.springframework.orm.jpa to DEBUG, or org.springframework.jdbc.datasource if you use a plain JDBC transaction manager. The transaction manager then logs each decision: creating a new transaction under a named method, participating in an existing one, suspending the current transaction for REQUIRES_NEW, and initiating commit or rollback. If a method you expected to be transactional produces none of these lines, the proxy never intercepted the call.

For a quick check inside code, TransactionSynchronizationManager.isActualTransactionActive() tells you whether a real transaction is bound to the current thread. You can assert it in a test, or log it temporarily at the top of a suspect method. Remove the temporary checks once you have your answer.

Tests bring their own trap. A test annotated with @Transactional (as @DataJpaTest is by default) rolls back after each test method. That rollback hides several kinds of failure:

  • Commit-time failures. Nothing in the test ever commits, so errors that only occur at commit never fire.
  • Deferred constraint violations. Constraint violations that would surface at flush can go unnoticed if nothing forces a flush.
  • REQUIRES_NEW behavior. The test transaction wraps every call, so the inner transaction commits independently. Its data survives the rollback, and it cannot see uncommitted rows the test created.

For tests that exercise real boundaries, annotate the method with @Transactional(propagation = Propagation.NOT_SUPPORTED) so it runs without a surrounding test transaction. Then clean up the data explicitly.

How getting @Transactional right saves engineering time

The time savings come from two places. The first is the code you no longer write. The second is the debugging you no longer do.

Less boilerplate to write and review. Without declarative transactions, each unit of work needs its own block that gets a connection, begins a transaction, commits on success, rolls back on failure, and restores state in a finally. Every copy of that block is a place where a rollback path can be missed. With @Transactional, a reviewer only checks the annotation's attributes and where it sits. They do not have to re-verify hand-written control flow in every method.

Faster diagnosis when something goes wrong. Once the team knows the proxy model, most "not working" reports map to a short list of causes: self-invocation, a private or final method, an object created with new, a swallowed exception, a checked exception without rollbackFor, or a joined transaction marked rollback-only. An engineer can work through the review checklist and the DEBUG transaction logs and reach an answer directly. Without that model, the usual path is trial-and-error changes to annotations.

Fewer data-repair incidents. Transaction bugs rarely produce stack traces. They produce partial writes or missing rows, and those are found later and fixed by hand with cleanup scripts and reconciliation. Catching them at review time, or in tests that run without a wrapping test transaction, avoids that cleanup work entirely.

Less re-debate in code review. A written convention covering boundary placement, allowed propagation types, and rollbackFor on checked exceptions turns each review into a check against a known rule. Without it, each pull request reopens the same discussion.

Non-transactional side effects, transaction boundaries and the cost of getting them wrong

Transactions only cover resources enlisted in them, normally the JDBC connection. A Kafka message, an HTTP call to a payment provider, or an email sent inside a method that later rolls back stays sent. The database forgets the order while the downstream system still holds a record of it. To defer such work, publish an application event and handle it with @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT). The listener runs only after a successful commit.

This has limits. A failure in the listener cannot roll back the committed data. If no transaction is active, the event is dropped unless fallbackExecution = true is set. If the side effect must not be lost, write it to an outbox table in the same transaction and send it asynchronously.

Wide boundaries cost capacity. A connection is held for the whole transaction, so the number of connections in use is roughly throughput multiplied by transaction duration.

Illustrative estimate (assumed numbers, not a benchmark): connections in use ≈ requests/s × transaction seconds. At 50 req/s with 20 ms of database work, that is 50 × 0.02 = 1 connection. If the transaction also wraps a 300 ms remote call, it becomes 50 × 0.32 = 16. That exceeds a pool of 10, and row locks are held for the same extra time.

These bugs are expensive because they are silent. They surface as inconsistent data, not stack traces, so debugging an incident means reconstructing timelines. Each fix carries its own risk:

  • Splitting beans changes how the code is wired.
  • REQUIRES_NEW can cause partial commits.
  • AspectJ changes the build.

A written team convention keeps these choices consistent. It should state where boundaries sit (the service layer, no remote calls inside), which propagation types are allowed, and that checked exceptions declare rollbackFor. With that in place, reviews check code against a known rule instead of re-debating each annotation.

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 @transactional not working, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.