Skip navigation

Demystifying Spring AOP Proxies: Why Annotations Like @Transactional Work

Table of Contents

  • When @Transactional Leaves the Database Unprotected
  • The Wrapper Spring Injects Instead of Your @Service
  • Interface Proxies Compared with CGLIB Subclasses
  • Why this.saveRow() Skips Every Advisor
  • Mock Exam Variants Built on Interception
  • Send the Join Point Through Another Bean
  • Sources

When @Transactional Leaves the Database Unprotected

Teams treat @Transactional, @Cacheable, and method-security annotations as if those markers rewrite the target method. Production outages and java certifications both charge a price for that habit.

The expected story is simple. Place the annotation on saveRow(), ship the service, and assume Spring starts a transaction for every call. The check that actually matters is narrower: did the invocation cross a Spring-managed proxy? An annotation sitting on the target method is metadata for interceptors. It does not rewrite the Java method, and it is not evidence that advice ran.

Where the Spring Framework AOP reference describes proxy interception, rollback, isolation, and cache lookup live on the advisor chain. If the call misses the proxy, TransactionInterceptor is absent from the stack. Propagation never binds. Isolation never changes. No interceptor-driven rollback decision occurs.

JDBC writes then run under connection auto-commit, or under a transaction established somewhere else on the thread. Partial persistence becomes possible. Mock exams recycle this pattern because the source looks correct while the interceptor chain never runs.

Unprotected JDBC Writes

A silent no-op transaction leaves the same SQL in place and strips the boundary that was supposed to guard it. Diagnose the call path before inspecting timeout, isolation, or rollbackFor settings.

The Wrapper Spring Injects Instead of Your @Service

Spring registers a proxy as the bean collaborators inject. The original @Service instance is the target behind that wrapper. Transaction, cache, and authorization behavior follow from those two runtime objects, not from treating annotations as executable statements.

Image showing aop call path

The path is client to proxy, proxy to an ordered advisor chain, chain to the target method, then back along the advisors to the client. TransactionInterceptor sits on that chain for @Transactional methods. Caching and method security reuse the same interception path in the spring framework.

Commit and Rollback on the Return Path

With default REQUIRED propagation, TransactionInterceptor starts a transaction when none exists. It participates in the current transaction when one is already bound to the thread.

After a normal return, transaction completion is requested. After an exception, rollback rules are evaluated. RuntimeException and Error trigger rollback by default. Checked exceptions do not, unless configuration says otherwise.

The join point is the proxy method execution. Advisors run before the target body and again on the way out. That object-level model explains why an annotation on the target can look complete while the interceptor never sees the call.

Interface Proxies Compared with CGLIB Subclasses

Two construction facts belong on the table before any talk of injection surprises.

If the bean implements an interface, Spring can use java.lang.reflect.Proxy. The result is an instance of a generated class that implements the selected interfaces. That object is a JDK dynamic proxy. A class-based proxy is a generated CGLIB subclass of the concrete class, which Spring typically builds when no suitable interface is in play.

A final class cannot be subclassed, so a CGLIB subclass proxy cannot be created for it. Final methods cannot be overridden, so they cannot supply an interceptable join point on a class-based proxy. A private method has the same obstruction: a subclass cannot override it to insert advice.

Cast Boundaries at Injection Time

A JDK dynamic proxy is not assignment-compatible with the concrete service class. Code that requests the implementation type can fail during dependency resolution when the bean is represented by an interface-based proxy. Requesting the service interface avoids that cast boundary.

Injecting the concrete class versus the interface therefore changes which proxy Spring can build, and it changes whether the application context starts at all. Visibility of the advised method still has to match what the chosen proxy can represent. Public methods on an interface are the straightforward case for JDK proxies. Class-based proxies can advise public methods on the concrete type, provided those methods are overridable.

Inject the Interface

Ask the container for the type the rest of the developer community actually programs against. Forcing a concrete class while the container holds an interface wrapper is a type mismatch, not a transaction-tuning problem.

Why this.saveRow() Skips Every Advisor

Trace the receiver at the call site. That single fact separates a transaction that starts from a transaction that never exists.

A collaborator holding the injected bean reference invokes the proxy. Code already executing on the target that writes this.doWork() or this.saveRow() keeps the receiver inside the target object. For this.doWork(), JVM dispatch starts from the current target instance. It does not return to the separate proxy object that originally received the outer call.

Spring proxy AOP advises public method execution on the proxy. Private, package-private, and final methods are ordinary reasons advice never binds. Interface-based proxies expose calls through proxy interfaces, so a method that exists only on the concrete class may never be a join point for that proxy type.

Self-invocation bypasses the entire advisor chain. Cache lookup, authorization checks, retry advice, and transaction creation can all be skipped together. The contrast is mechanical: another Spring bean calling an eligible public method goes through the proxy; a same-class this call does not.

Mock Exam Variants Built on Interception

Earlier items on java certifications left the annotation on the callee as the whole story. Candidates then had no procedure for self-invocation, private methods, or a JDK proxy stuffed into a concrete-class injection point. Classify three facts in sequence instead: who owns the caller reference, which proxy type represents the bean, and whether the invoked method is interceptable. The prediction is transaction started or joined, advice skipped, or a failure while the application context is created.

Work a service whose non-transactional importBatch() entry point later calls its own @Transactional saveRow(). If importBatch() is already executing on the target and calls this.saveRow(), the inner annotation does not start a transaction. The interceptor chain never runs for that inner call.

  • Annotation on a private method: the method is not advised through proxy overriding, so no transaction starts.
  • Call through this from a non-transactional entry point: advice is skipped even when saveRow() is public.
  • Call through an injected collaborator to an eligible public saveRow(): TransactionInterceptor runs before the target method, and a transaction starts or joins according to REQUIRED.
  • Injecting a concrete implementation while forcing an interface-based JDK proxy: the failure is an unsatisfied dependency or bean-type mismatch during context creation, not a runtime transaction result.

Mock exams built around modern frameworks reward that prediction. They do not reward diving the framework sources during the clock.

Send the Join Point Through Another Bean

Self-invocation left the transaction boundary on the wrong object. Move the unit of work onto a second Spring bean the caller injects, so the caller holds a proxy and the call is external.

A typical split is ImportService.importBatch() calling RowWriter.saveRow(), where RowWriter is a separate Spring bean and saveRow() carries @Transactional. The object graph then matches the call path. Container lookup of the proxied bean remains available when splitting the class is impractical. AopContext.currentProxy() requires proxy exposure to be enabled and couples the service implementation to the active AOP invocation; direct container lookup likewise introduces framework-aware code.

Boundary on the Collaborator

Keep the transactional method on a bean the caller did not instantiate with new. The injected reference is the proxy Spring already put in the context.

AspectJ compile-time or load-time weaving is an opt-in alternative, not the default Spring AOP model used on most certification items. Weaving modifies or instruments execution join points and can advise self-invocation, unlike the default wrapper-proxy path. The self-invocation rule here is the proxy-based Spring AOP rule; an application explicitly configured for AspectJ weaving has a different execution model.

JDK dynamic proxies have existed since Java 1.3. Spring’s default AOP is that wrapper object, so a same-class this call never enters TransactionInterceptor even when @Transactional is on the callee.

Subscribe to Updates

Get the best content delivered to your inbox.

No spam. Unsubscribe anytime.

Join the Conversation

No comments yet.

Write a Comment

Your cookie choices