How Spring applies @Transactional through a proxy
@Transactional lets you declare transaction boundaries on a method instead of writing begin/commit/rollback code by hand. Teams use it because it keeps business logic free of transaction plumbing and applies the same rules everywhere. It works only because of how Spring wires the bean.
When the container creates a bean that has @Transactional methods, it does not hand callers the bean itself. It hands them a proxy. That proxy is either a JDK dynamic proxy, which implements the bean's interfaces, or a class-based proxy, which is a generated subclass. Spring Boot generally configures class-based proxies by default. Either way, the proxy routes each call through TransactionInterceptor. The interceptor asks the transaction manager to start a transaction or join an existing one, then invokes the real method. If the method returns normally, it commits. If an exception matching the rollback rules is thrown, it rolls back.
The core rule is that transaction behavior applies only to calls that enter through the proxy. Once execution is inside the target object, a call such as this.save() goes directly to the target. The interceptor never sees it, so that method's annotation is ignored.
Caller ──► Proxy ──► TransactionInterceptor ──► Target bean
(begin / commit / │
rollback) │ this.save()
▼
Target bean.save()
(no proxy, no interceptor)
This matters to any backend engineer who relies on annotations for data integrity. The code compiles, tests often pass, and the missing transaction only shows up when a partial write reaches production.
┌───────────┐ ┌───────────────────────────────────────────────────┐
│ Caller │ │ Proxy (JDK dynamic proxy or generated subclass) │
│ (another │────►│ │
│ bean) │ │ ┌─────────────────────────────────────────────┐ │
└───────────┘ │ │ TransactionInterceptor │ │
│ │ 1. begin or join a transaction │ │
│ │ 2. invoke the target method │ │
│ │ 3. commit on normal return │ │
│ │ rollback on matching exception │ │
│ └──────────────────────┬──────────────────────┘ │
└─────────────────────────┼─────────────────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ Target bean (the real object) │
│ │
│ placeOrder() ──── this.save() ────► save() │
│ │
│ placeOrder(): runs inside the transaction │
│ save(): reached directly, interceptor never │
│ runs; its @Transactional is ignored │
└───────────────────────────────────────────────────┘
External calls pass through the proxy and TransactionInterceptor, but the internal this.save() call goes straight to the target object, so save()'s @Transactional annotation is never applied.
Transactional self-invocation and other proxy blind spots
Consider a OrderService with a public placeOrder() method that has no annotation and calls this.save(order), where save() is marked @Transactional. No transaction starts. The caller reached placeOrder() through the proxy, but placeOrder() carries no transaction attribute, so the interceptor passes the call straight to the target object. From that point execution is inside the real bean, and this refers to the target, not the proxy. The call to save() never goes back through the interceptor, so the annotation is never read. Each repository call then runs in its own short-lived transaction, or in auto-commit mode, depending on the data access setup. If a later step throws, the earlier writes stay committed.
Several other cases produce the same silent result:
- Non-public methods. The default proxy-based setup historically applied transaction attributes only to public methods. Depending on your Spring version, an annotated
protectedor package-private method may be ignored without any warning. - Final methods with class-based proxies. A subclass proxy cannot override a final method, so the call runs on the proxy instance itself. That instance's injected fields are never populated. A
NullPointerExceptionon a repository field that is clearly injected is the telltale sign. - Final classes. A class-based proxy cannot subclass a final class, so this case usually fails at startup rather than silently.
new. An object you construct yourself never passes through the container, so it has no proxy at all.
To tell these cases apart, enable DEBUG logging for org.springframework.transaction and check whether a "creating new transaction" line appears for the method you expect. You can also assert TransactionSynchronizationManager.isActualTransactionActive() inside the method.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
// No @Transactional here, so the proxy passes the call straight to the target.
public void placeOrder(Order order) {
this.save(order); // 'this' is the target, not the proxy, so no interceptor runs
notifyWarehouse(order); // if this throws, the save above is already committed
}
@Transactional
public void save(Order order) {
// Diagnostic: prints false when reached via placeOrder(),
// true when save() is called on the injected bean from another bean.
System.out.println("tx active in save(): "
+ TransactionSynchronizationManager.isActualTransactionActive());
orderRepository.save(order);
}
private void notifyWarehouse(Order order) {
// placeholder for a step that may fail
}
}
// Same symptom, different cause: an instance built with 'new' has no proxy at all.
// OrderService manual = new OrderService(orderRepository);
// manual.save(order); // also prints false: the container never wrapped this object
A public non-transactional method calling this.save() skips the transactional proxy, and TransactionSynchronizationManager.isActualTransactionActive() shows that no transaction is active inside save().
Fixing self-invocation: four options and their costs
Once you have confirmed the inner call never reaches the proxy, there are four ways to restore the transaction boundary. Each one moves the cost somewhere different.
Move the method to a separate bean. The call now crosses a bean boundary, so it passes through the proxy. This is the most explicit fix. The cost is design pressure: teams end up with thin *TxService classes that exist only to carry an annotation, and related logic gets split across two files.
Inject the bean's own proxy. A @Lazy self-reference or an ObjectProvider of the same type lets you call self.method(). It is a small diff, but correctness now depends on a field that looks redundant. A reviewer may "clean it up" and silently remove the transaction. It also creates a circular reference, which can fail at startup depending on how the container is configured to handle cycles.
Use TransactionTemplate. You wrap the block in execute(...), which makes the boundary visible and independent of proxies. The cost is verbosity, and transaction plumbing ends up mixed into business code.
Switch to AspectJ weaving. With mode = AdviceMode.ASPECTJ, the advice is woven into the class itself, so self-calls and non-public methods are covered. You pay for this in the build or at load time: you need a compiler plugin or a JVM agent, and every environment, including tests, has to apply it consistently.
| Option | Effort | Readability | Maintenance risk | Right choice when |
|---|---|---|---|---|
| Separate bean | Low–medium | High | Low | Default; the method has a distinct responsibility |
| Self-injection | Low | Low | Medium–high | Short-term fix in legacy code |
TransactionTemplate |
Low | Medium | Low | Boundaries are fine-grained or conditional |
| AspectJ weaving | High | High at call site | Medium (tooling) | Many self-calls, and the team already runs AspectJ |
// Two of the four fixes side by side (imports omitted for brevity).
@Service
public class OrderService {
private final OrderRepository orders;
private final TransactionTemplate tx;
// Option 2: self-injection. @Lazy injects a lazy proxy to break the cycle.
// It looks redundant, but removing it silently drops the transaction.
private final OrderService self;
public OrderService(OrderRepository orders,
PlatformTransactionManager txManager,
@Lazy OrderService self) {
this.orders = orders;
this.tx = new TransactionTemplate(txManager);
this.self = self;
}
public void importBatch(List<Order> batch) {
// Broken: this.saveAll(batch) bypasses the proxy, so there is no transaction.
self.saveAll(batch); // Goes through the proxy, so @Transactional applies.
}
@Transactional
public void saveAll(List<Order> batch) {
orders.saveAll(batch);
}
// Option 3: programmatic boundary. No proxy is involved, and the scope is visible.
public int importBatchExplicit(List<Order> batch) {
return tx.execute(status -> {
orders.saveAll(batch);
return batch.size(); // A runtime exception thrown here rolls back the transaction.
});
}
}
Two self-invocation fixes in one service: a @Lazy self-reference that routes the call through the transactional proxy, and a TransactionTemplate that marks the boundary in code without relying on proxies.
Propagation REQUIRED vs REQUIRES_NEW
REQUIRED, the default, joins whatever transaction is already bound to the thread. If none exists, it starts one. Inner and outer work then share one connection and one commit, so they succeed or fail together. REQUIRES_NEW suspends the outer transaction, takes a second connection from the pool, and commits or rolls back independently. That independence is useful for records that must survive a failed business operation, such as an audit entry for a rejected payment or an outbox row recording a failed attempt. The example below writes an audit log this way. The audit row commits when the inner method returns, even if the caller later throws.
| Aspect | REQUIRED |
REQUIRES_NEW |
|---|---|---|
| Existing transaction | Joined | Suspended |
| Connections held | One | Two (outer stays open) |
| Inner commit | With the outer | Immediately, on its own |
| Outer rollback undoes inner work | Yes | No |
| Sees outer's uncommitted writes | Yes | No (normal isolation applies) |
The cost is that the outer connection stays checked out, idle, while the inner one runs. Two failure modes follow from that:
- Lock deadlock. If the inner transaction updates a row the outer one has already locked, the inner call waits for the outer to release the lock. The outer is suspended and waiting for the inner call to return, so neither proceeds until a lock timeout fires.
- Pool starvation. Each request needs a second connection while it already holds one.
Illustrative estimate, assumed inputs: a pool of 10 connections and 10 concurrent requests, each calling one REQUIRES_NEW method. Peak demand is 20 connections. If all 10 outer transactions acquire a connection first, every thread blocks waiting for a second one that never frees up. The requests stall until the pool's acquisition timeout expires.
// AuditService.java: a separate bean, so the call goes through the Spring proxy
@Service
public class AuditService {
private final AuditLogRepository auditLogs;
public AuditService(AuditLogRepository auditLogs) { this.auditLogs = auditLogs; }
// Suspends the caller's transaction and commits on its own connection when this returns.
// Write only to the audit table. Updating rows the outer transaction has locked would deadlock.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String paymentId, String outcome) {
auditLogs.save(new AuditLog(paymentId, outcome, Instant.now()));
}
}
// PaymentService.java
@Service
public class PaymentService {
private final PaymentRepository payments;
private final AuditService audit;
public PaymentService(PaymentRepository payments, AuditService audit) {
this.payments = payments;
this.audit = audit;
}
@Transactional // REQUIRED (default): starts or joins the outer transaction
public void charge(String paymentId, BigDecimal amount) {
Payment payment = payments.findById(paymentId).orElseThrow();
if (amount.compareTo(payment.getLimit()) > 0) {
// While this runs, the outer connection stays checked out, so two are held.
audit.record(paymentId, "REJECTED_OVER_LIMIT");
// Rolls back the outer transaction only. The audit row is already committed.
throw new IllegalStateException("Amount exceeds limit for " + paymentId);
}
payment.capture(amount);
}
}
A REQUIRES_NEW audit method in a separate bean commits a rejection record that survives the rollback of the outer REQUIRED payment transaction, while holding a second pooled connection for the duration of the call.
Why the outer commit fails with UnexpectedRollbackException
Consider the opposite failure: one transaction, with the inner method throwing. OrderService.placeOrder() is @Transactional and calls auditService.record(), a separate bean whose method is also @Transactional with the default REQUIRED propagation. Because the call crosses a bean boundary, it goes through the proxy and joins the outer transaction. record() throws a RuntimeException. placeOrder() catches it, logs it, and returns normally.
The caller expects the order to commit, but it doesn't. When the exception passed through the inner proxy, the interceptor applied the rollback rules. The inner method does not own the transaction, so it cannot roll back on its own. Instead, it marks the shared transaction as rollback-only. Catching the exception afterward does not clear that flag. When placeOrder() returns, the outer proxy tries to commit, the transaction manager finds the flag, rolls back, and throws UnexpectedRollbackException. Spring throws this instead of rolling back silently because the caller asked for a commit and did not get one.
Catching exceptions inside transactional code has two possible outcomes. If the catch happens in the same method that threw, before any proxy sees the exception, the rollback is suppressed and partial writes commit. If the catch happens above a participating proxy, you get the error described here. Your options are:
- Let it propagate. If the work can't succeed without that step, the whole unit should fail.
- Use
REQUIRES_NEWdeliberately when the inner work is truly independent, such as audit records. Its failure then rolls back only its own transaction. This costs a second connection, as described in the previous section. - Restructure. Validate inputs or check preconditions before entering the transactional call. That way, recoverable failures become return values instead of exceptions that pass through a proxy.
Rollback rules: checked exceptions, rollbackFor and noRollbackFor
Spring's default rule is narrow. A RuntimeException or Error that escapes a transactional method marks the transaction for rollback. A checked exception does not: the transaction commits whatever was written before the throw, and the exception still propagates to the caller. This default follows the EJB convention, where checked exceptions were treated as anticipated business outcomes rather than failures. Engineers coming from transaction APIs that roll back on any exception leaving the block are often caught out. A method that throws IOException halfway through a multi-row update leaves the first rows committed.
rollbackFor adds exception types that should trigger rollback. Use it when a checked exception genuinely means the unit of work failed. For example, rollbackFor = PaymentGatewayException.class fits a service that must not persist an order if the charge call fails. noRollbackFor does the opposite: it lets a specific unchecked exception commit. Both settings match subclasses. When several rules match, the one closest in the exception's inheritance hierarchy wins.
| Exception thrown | Annotation settings | Outcome |
|---|---|---|
RuntimeException or Error |
defaults | Rollback |
| Checked exception | defaults | Commit |
Checked PaymentGatewayException |
rollbackFor = PaymentGatewayException.class |
Rollback |
Unchecked QuotaWarningException |
noRollbackFor = QuotaWarningException.class |
Commit |
| Any checked exception | rollbackFor = Exception.class |
Rollback |
Adding rollbackFor = Exception.class everywhere is tempting because it removes the surprise. It has two costs. First, it discards work that a recoverable business exception was meant to keep, such as an audit row recording a rejected request. Second, it hides the decision about what counts as failure, which should be made per use case and kept visible in the code.
Verifying transaction behavior and the impact on engineering time
Verifying this behavior with logs and tests is more reliable than reading annotations. Set org.springframework.transaction to DEBUG, and org.springframework.orm.jpa as well if you use JPA. The logs then show when a transaction is created, joined, suspended, committed or rolled back. If a method you expected to be transactional never produces a "Creating new transaction" line, the proxy was bypassed. In a test, call TransactionSynchronizationManager.isActualTransactionActive() inside the code path. That asserts a transaction is really open at that point and does not rely on the annotation being present.
Be careful with tests annotated with @Transactional. Spring's test support wraps each test method in one transaction and rolls it back at the end. Because everything runs in that one transaction:
- Propagation bugs:
REQUIREDjoins the test's transaction, so you never see what a real caller would see. - Commit-time failures: a deferred constraint or a flush-time violation never fires.
- Lazy-loading bugs: lazy associations load fine because the persistence context stays open for the whole test.
For transaction semantics, write integration tests without the test-level transaction. Trigger the failure, then query the database in a fresh transaction to check which rows exist, such as the audit row that should survive and the order that should not. Clean up explicitly between tests.
The payoff is mostly time. Engineers who reason from proxy boundaries, propagation and rollback rules can diagnose a failure from one log trace instead of trial and error. Partial writes that commit silently are often found only after release, and repairing that data is far more expensive than preventing it. Teams that understand the mechanics can also write them down as review conventions, for example "no self-invoked transactional methods" and "rollback rules stated explicitly." That way the same fix is not rediscovered in every incident.
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.
- Adding or removing a column goes through one review with the inheriting services listed, instead of ad-hoc messages to each team.
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.
- Services that depend on a column get the chance to object before it disappears, avoiding emergency fixes.
- 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.