Spring AOP: The Mechanism Behind @Transactional
Pointcuts, advice types, and why proxy-based AOP cannot intercept a self-call — the limitation that explains three earlier guides at once.
The Pattern You've Already Met Three Times
@Transactional opens a transaction around your method. @Cacheable returns early on a hit. @PreAuthorize throws before your method runs. @Timed records how long it took.
None of those behaviours are in your method body, and all four share one limitation: a self-invocation bypasses them. That isn't a coincidence or four separate bugs. It's one mechanism — aspect-oriented programming — and understanding it explains all four and lets you write your own.
Vocabulary, Briefly
AOP has more jargon than concepts:
| Term | Meaning |
|---|---|
| Join point | A point where behaviour could be added. In Spring AOP: a method call |
| Pointcut | An expression selecting which join points |
| Advice | The code to run at a matched join point |
| Aspect | A class bundling pointcuts and advice |
| Weaving | Connecting aspects to target code |
So: an aspect contains advice that runs at join points selected by a pointcut.
Spring AOP Is Proxies
Spring does not rewrite your bytecode. It wraps your bean in a proxy that implements the same interface (or subclasses the class), and the proxy runs the advice before delegating.
The dotted arrow is the whole story. A call to this.other() goes straight to the target, never through the proxy, so no advice runs.
Two proxy mechanisms, chosen automatically:
- JDK dynamic proxies — when the bean implements an interface. The proxy implements the same interface.
- CGLIB — otherwise. The proxy is a generated subclass.
Spring Boot defaults to CGLIB for everything (spring.aop.proxy-target-class: true), which avoids the classic "expected OrderService but got a Proxy" injection failure.
What proxying cannot intercept
| Not intercepted | Why |
|---|---|
Self-invocation (this.method()) | Doesn't go through the proxy |
private methods | Not visible to a subclass proxy |
final methods or classes | CGLIB cannot override them |
static methods | No instance to proxy |
| Constructors | The proxy wraps an already-constructed object |
Calls on objects you created with new | Not beans; no proxy exists |
That first row is the one that causes production bugs, and it's worth being able to spot in review. @Transactional on a self-called method means no transaction. @PreAuthorize on a self-called method means no authorisation check — a security hole that looks like working code.
The failure is silent in every case. Nothing logs a warning, nothing fails at startup, and the annotation sits there reading as though it works. This is why the fix recommended in the transactions guide — move the method to a separate bean — is worth preferring over the self-injection workaround: it makes the call go through a proxy structurally, rather than relying on everyone remembering.
Writing an Aspect
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
}A custom annotation plus an aspect that acts on it:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action();
}@Aspect
@Component
public class AuditAspect {
private static final Logger log = LoggerFactory.getLogger(AuditAspect.class);
private final AuditRepository repository;
public AuditAspect(AuditRepository repository) { // aspects are beans
this.repository = repository;
}
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
String user = SecurityContextHolder.getContext().getAuthentication().getName();
long start = System.nanoTime();
try {
Object result = joinPoint.proceed(); // call the target
repository.save(AuditEntry.success(audited.action(), user,
Duration.ofNanos(System.nanoTime() - start)));
return result;
} catch (Throwable ex) {
repository.save(AuditEntry.failure(audited.action(), user, ex.getClass().getSimpleName()));
throw ex; // never swallow
}
}
}@Audited(action = "ORDER_CANCELLED")
public void cancel(Long orderId) { }Two things that must be right in @Around advice. Call proceed() exactly once — forget it and the target method never runs, which is a confusing bug because the method appears to succeed and do nothing. And rethrow, or you convert every exception into a silent success.
Advice types
| Advice | Runs | Can it stop the method? |
|---|---|---|
@Before | Before | Only by throwing |
@After | After, always | No |
@AfterReturning | After success | No |
@AfterThrowing | After an exception | No |
@Around | Wraps the call | Yes |
@Around is the most powerful and the only one that can skip the method, change the arguments, or alter the return value — which is how @Cacheable returns a cached value without executing anything.
Pointcut expressions
@Pointcut("execution(* com.example.shop..*Service.*(..))")
void anyServiceMethod() {}
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
void transactionalMethod() {}
@Pointcut("within(com.example.shop.order..*)")
void inOrderPackage() {}
@Pointcut("@within(org.springframework.stereotype.Service)")
void inAnyServiceClass() {}
@Before("anyServiceMethod() && !transactionalMethod()")
void warnOnUntransactionalServiceCall() {}The execution wildcards read as: return type, package (.. for any depth), class, method, arguments (.. for any). Named @Pointcut methods compose with &&, || and !, which keeps expressions readable — an inline expression repeated across six advice methods is six places to update.
Prefer @annotation(...) pointcuts to broad execution(...) patterns. execution(* com.example..*Service.*(..)) silently changes which methods are advised when someone renames a class or adds a package — behaviour appearing or disappearing as a side effect of refactoring. An annotation makes the behaviour visible at the method it applies to, which is also kinder to the next reader wondering why this method logs and its neighbour doesn't.
Ordering
Multiple aspects on one method need a defined order:
@Aspect
@Component
@Order(1) // lower runs first on the way in, last on the way out
public class AuditAspect { }Ordering is not cosmetic when aspects interact with transactions. An audit aspect ordered inside @Transactional has its write rolled back with the business transaction — which may be exactly wrong for a failure audit. @Transactional runs at Ordered.LOWEST_PRECEDENCE by default, so your aspects are typically outside it unless you say otherwise. If the audit must survive, combine the ordering with Propagation.REQUIRES_NEW as the transactions guide describes.
Check yourself
An aspect with @Around advice is applied to a service method. The method is called and returns normally, but its work never happens — no database rows change. What is the most likely cause?
When to Use AOP
The honest answer is: rarely, and for a narrow class of problem.
Good uses share a shape — behaviour that is genuinely orthogonal to business logic, applies uniformly across many methods, and would otherwise be copy-pasted:
- Audit logging of specific operations
- Timing and metrics (
@Timedis this) - Retry around a declared annotation
- Enforcing an architectural rule in tests
Poor uses are where AOP hides something a reader needs to know:
- Business logic. An aspect that modifies a result or adds a rule makes behaviour invisible at the call site. Someone debugging will read the method, see the expected code, and not find the cause.
- Universal logging. An aspect that logs entry and exit of every method produces unreadable volume and duplicates what tracing already does better, with context.
- Anything already provided.
@Transactional,@Cacheable,@Retryable,@Timedand method security are all AOP, already written and tested.
The cost of an aspect is non-local behaviour. A method's observable effects are no longer determined by reading it, and nothing at the call site hints that something else runs. That's an acceptable price for audit or metrics, which a reader can assume are orthogonal. It's a bad price for anything affecting correctness — the next person to debug it will not think to look for an aspect.
AspectJ, If You Need More
Spring AOP's limitations come from proxies. Full AspectJ weaves advice into bytecode and so can intercept self-calls, private methods, field access and constructors.
It costs a compile-time or load-time weaving step, an extra toolchain, and considerably harder debugging — stack traces include woven code that isn't in your source.
For the overwhelming majority of applications, proxy-based AOP is sufficient and the right call. If you find yourself needing AspectJ to intercept a self-call, the structural fix — splitting the bean — is usually better than the weaving.
The Mental Model, Restated
- Spring AOP is proxies, not bytecode rewriting.
- Self-invocation,
private,finalandstaticmethods cannot be intercepted — silently. @Transactional,@Cacheable,@PreAuthorizeand@Timedare all AOP, which is why they share that limitation.@Aroundmust callproceed()once and rethrow.- Prefer
@annotationpointcuts to broadexecutionpatterns. - Mind the ordering relative to
@Transactional. - Use AOP for orthogonal concerns, never for business logic.
What's Next
Aspects decouple cross-cutting behaviour from a method, but the call is still synchronous and in-process. The next guide covers decoupling in a different direction: Spring's application events for in-process publish/subscribe — including the transactional event listener that solves a problem you've already met — and the step up to real message brokers.