AOP & Proxies
@Transactional, @Cacheable, and @Async are not magic annotations—they are around advice applied by runtime proxies. This chapter explains Spring AOP's proxy model, pointcut syntax, practical aspect patterns, and why calling this.save() from the same bean skips all of it.
AOP concepts
Aspect-Oriented Programming separates cross-cutting concerns from business logic—behavior that spans many classes but doesn't belong in every method body.
Cross-cutting concerns in Spring apps
| Concern | Without AOP | With AOP |
|---|---|---|
| Transactions | Manual begin/commit in every service method | @Transactional — one annotation |
| Security | Auth checks duplicated in controllers/services | @PreAuthorize, security filters |
| Caching | Cache get/put boilerplate everywhere | @Cacheable |
| Logging / metrics | try/finally timing in every method | Logging aspect with @Around |
| Retry / circuit breaker | while loops around external calls | Resilience4j or custom @Retry aspect |
AOP vocabulary
| Term | Spring meaning |
|---|---|
| Aspect | Module of cross-cutting logic — class annotated @Aspect |
| Advice | Action taken at a join point — @Before, @Around, etc. |
| Join point | Point in execution — Spring AOP supports method execution only (not field access, constructors) |
| Pointcut | Predicate matching join points — AspectJ expression or annotation |
| Target object | Bean being advised — the real instance behind the proxy |
| Weaving | Linking aspects to targets — Spring uses runtime proxy weaving (not compile-time AspectJ by default) |
Spring AOP is proxy-based, not full AspectJ. It only advises Spring-managed beans on public method calls through the proxy. For compile-time weaving (aspects on private methods, non-Spring objects), use AspectJ with @EnableLoadTimeWeaving — rare in typical Boot apps.
sequenceDiagram participant Client as Caller participant Proxy as Spring proxy participant Aspect as Aspect advice participant Target as Target bean Client->>Proxy: call saveOrder Proxy->>Aspect: before or around advice Aspect->>Target: proceed or invoke Target-->>Aspect: result or exception Aspect-->>Proxy: after advice Proxy-->>Client: return value
Advice types
Each advice type runs at a different point relative to the join point. @Around is the most powerful—it wraps the entire call.
| Annotation | Runs | Can block execution? |
|---|---|---|
| @Before | Before method entry | No — throw exception to abort |
| @After | After method (finally semantics) | N/A |
| @AfterReturning | After normal return | N/A — access return value |
| @AfterThrowing | After exception thrown | N/A — access exception |
| @Around | Wraps entire method | Yes — skip proceed() or call it conditionally |
@Aspect
@Component
class DiagnosticAspect {
@Before("within(com.acme.service..*)")
void logEntry(JoinPoint jp) {
log.debug("Enter: {}.{}", jp.getSignature().getDeclaringTypeName(), jp.getSignature().getName());
}
@AfterReturning(pointcut = "within(com.acme.service..*)", returning = "result")
void logReturn(JoinPoint jp, Object result) {
log.debug("Return: {} -> {}", jp.getSignature().getName(), result);
}
@AfterThrowing(pointcut = "within(com.acme.service..*)", throwing = "ex")
void logError(JoinPoint jp, Throwable ex) {
log.warn("Exception in {}: {}", jp.getSignature().getName(), ex.getMessage());
}
@Around("within(com.acme.service..*)")
Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
Object result = pjp.proceed(); // invoke target method
return result; // can modify return value
} finally {
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("{} took {}ms", pjp.getSignature(), ms);
}
}
}
@Around — ProceedingJoinPoint
proceed() invokes the next interceptor or the target method. proceed(args) can modify arguments. You control whether the target runs at all—use for retries, caching, transactions.
@Around("@annotation(com.acme.MaskSensitive)")
Object maskSensitive(ProceedingJoinPoint pjp) throws Throwable {
Object result = pjp.proceed();
if (result instanceof UserDto user) {
return user.withEmail("***@***");
}
return result;
}
Enable AOP with @EnableAspectJAutoProxy (Boot auto-configures when spring-boot-starter-aop on classpath). Aspect beans must be Spring components (@Component + @Aspect).
Spring AOP proxy mechanics
The bean you inject is often not the raw class instance—it is a proxy that delegates to the target while running advice.
| Proxy type | Mechanism | When Spring uses it |
|---|---|---|
| JDK dynamic proxy | Implements target's interfaces; dispatches interface methods to advice + target | Bean implements at least one interface; proxyTargetClass=false (default when interface exists) |
| CGLIB subclass | Extends concrete class; overrides non-final methods with interceptor callbacks | No interface, or proxyTargetClass=true, or class-based proxy forced |
@EnableAspectJAutoProxy(proxyTarget = true) // Spring Framework 6.0+
// Boot 2+: spring.aop.proxy-target-class=true
@Service
public class OrderService { // no interface — CGLIB proxy subclass
@Transactional
public void placeOrder() { /* ... */ }
}
Limitations that break proxy creation
- Final class — CGLIB cannot subclass → proxy fails or skips advice
- Final method — cannot override → advice not applied to that method
- Private method — not overridable; Spring AOP does not advise private methods
- Internal call — this.method() bypasses proxy (see self-invocation)
AbstractAutoProxyCreator (infrastructure) wraps beans matching pointcuts during BeanPostProcessor.afterInitialization — same lifecycle stage as transaction proxy creation. Debug proxy type: AopUtils.isJdkDynamicProxy(bean) vs AopUtils.isCglibProxy(bean).
JDK proxy: target bean must implement interface; injected type should be interface for full proxy transparency. CGLIB: proxy is subclass — casting to concrete class may fail if expecting exact type without proxy awareness.
Self-invocation problem
A method calling another method on this goes directly to the target object—not through the proxy. All AOP advice is skipped.
@Service
public class OrderService {
public void processOrder(long id) {
Order order = load(id);
save(order); // direct call — NO transaction advice!
}
@Transactional
public void save(Order order) {
orderRepository.save(order);
}
}
Fixes
- Extract to another bean — OrderPersistenceService.save() — cleanest
- Self-injection — inject own interface proxy (use sparingly; document why)
- AspectJ compile-time weaving — weaves bytecode, no proxy bypass (heavy setup)
- Refactor entry point — only public facade methods call transactional internals from outside
@Service
public class OrderService implements OrderServiceApi {
private final OrderServiceApi self;
OrderService(@Lazy OrderServiceApi self) { this.self = self; }
public void processOrder(long id) {
self.save(load(id)); // goes through proxy
}
@Transactional
public void save(Order order) { orderRepository.save(order); }
}
#1 production mystery: "My @Transactional doesn't work." First question: are you calling from the same class? Same applies to @Cacheable, @Async, @PreAuthorize on private methods.
Pointcut expressions
Spring AOP uses a subset of AspectJ pointcut designators. Master execution() and @annotation() first—they cover most production aspects.
execution() — method signature patterns
Syntax: execution(modifiers? ret-type-pattern declaring-type? name(param-pattern) throws?)
| Pattern | Matches |
|---|---|
| execution(* *(..)) | Any method, any class |
| execution(public * com.acme.service.*.*(..)) | All public methods in service package |
| execution(* com.acme..*Service.*(..)) | Methods on classes ending in Service, any subpackage |
| execution(* *..*Repository.find*(..)) | Any find* method on *Repository classes |
| execution(void com.acme.OrderService.placeOrder(..)) | Specific method signature |
Wildcards: * (any single), .. (zero or more args / package segments). Parameters: (String, ..) — String first, any rest.
within() — class-level
@Pointcut("within(com.acme.service..*)")
void inServiceLayer() {}
@Pointcut("within(@org.springframework.stereotype.Repository *)")
void inRepositoryLayer() {}
@annotation() — methods with marker
@Pointcut("@annotation(com.acme.Audited)")
void auditedMethod() {}
@Around("auditedMethod()")
Object audit(ProceedingJoinPoint pjp) throws Throwable { /* ... */ }
bean() — Spring bean name
@Pointcut("bean(orderService)")
void orderServiceBean() {}
// SpEL bean reference: bean(*Service) — all beans ending in Service
Combining pointcuts
| Operator | Meaning | Example |
|---|---|---|
| && | AND | inServiceLayer() && auditedMethod() |
| || | OR | execution(* *..*Repository.*(..)) || inServiceLayer() |
| ! | NOT | inServiceLayer() && !@annotation(com.acme.NoLog) |
Named pointcuts — @Pointcut for reuse
@Aspect
@Component
class CommonPointcuts {
@Pointcut("within(com.acme..*) && !within(com.acme.config..*)")
public void applicationPackage() {}
@Pointcut("execution(* *(..)) && applicationPackage()")
public void applicationMethod() {}
}
@Aspect
@Component
class LoggingAspect {
@Around("com.acme.aop.CommonPointcuts.applicationMethod()")
Object log(ProceedingJoinPoint pjp) throws Throwable { /* ... */ }
}
Prefer @annotation over broad execution(*..*) — explicit opt-in prevents advising getters/setters, toString, and framework callbacks accidentally.
Practical AOP examples
Production aspects follow the same pattern: pointcut + advice. These are templates you will see—or should add—in real codebases.
Logging aspect — entry, exit, duration, exceptions
@Aspect
@Component
@Slf4j
class ServiceLoggingAspect {
@Around("execution(* com.acme.service..*(..)) && !@annotation(com.acme.NoLog)")
Object logServiceCall(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
log.debug("START {}", method);
long start = System.nanoTime();
try {
Object result = pjp.proceed();
log.debug("END {} ({}ms)", method, elapsedMs(start));
return result;
} catch (Throwable ex) {
log.warn("FAIL {} ({}ms): {}", method, elapsedMs(start), ex.toString());
throw ex;
}
}
private static long elapsedMs(long start) {
return (System.nanoTime() - start) / 1_000_000;
}
}
Performance monitoring — @Around + Micrometer
@Aspect
@Component
class MetricsAspect {
private final MeterRegistry registry;
MetricsAspect(MeterRegistry registry) { this.registry = registry; }
@Around("execution(* com.acme.service..*(..))")
Object record(ProceedingJoinPoint pjp) throws Throwable {
String name = pjp.getSignature().getDeclaringType().getSimpleName()
+ "." + pjp.getSignature().getName();
return registry.timer("service.method", "method", name).record(() -> {
try { return pjp.proceed(); }
catch (Throwable t) { throw new RuntimeException(t); }
});
}
}
Retry aspect
@Aspect
@Component
class RetryAspect {
@Around("@annotation(retry)")
Object retry(ProceedingJoinPoint pjp, Retry retry) throws Throwable {
int attempts = 0;
Throwable last = null;
while (attempts < retry.maxAttempts()) {
try {
return pjp.proceed();
} catch (Throwable ex) {
last = ex;
attempts++;
if (attempts >= retry.maxAttempts()) break;
Thread.sleep(retry.delayMs() * attempts);
}
}
throw last;
}
}
Audit trail aspect
@Aspect
@Component
class AuditAspect {
private final AuditLogRepository auditLogRepository;
@AfterReturning(pointcut = "@annotation(audited)", returning = "result")
void auditSuccess(JoinPoint jp, Audited audited, Object result) {
String user = SecurityContextHolder.getContext().getAuthentication().getName();
auditLogRepository.save(new AuditLog(user, audited.action(), jp.getSignature().toShortString(), "SUCCESS"));
}
@AfterThrowing(pointcut = "@annotation(audited)", throwing = "ex")
void auditFailure(JoinPoint jp, Audited audited, Throwable ex) {
String user = SecurityContextHolder.getContext().getAuthentication().getName();
auditLogRepository.save(new AuditLog(user, audited.action(), jp.getSignature().toShortString(), "FAIL: " + ex.getMessage()));
}
}
Custom annotation + AOP
Declarative markers (@Audited, @RateLimit) keep business code clean—behavior lives in one aspect class.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action();
}
@Service
class PaymentService {
@Audited(action = "REFUND")
public void refund(long paymentId) { /* ... */ }
}
@Around("@annotation(rateLimit)")
Object enforceRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = rateLimit.key().isEmpty()
? pjp.getSignature().toShortString()
: rateLimit.key();
if (!rateLimiter.tryAcquire(key, rateLimit.permitsPerSecond())) {
throw new TooManyRequestsException("Rate limit exceeded: " + key);
}
return pjp.proceed();
}
Spring's own annotations follow this pattern: @Transactional, @Cacheable, @Scheduled, @PreAuthorize are all metadata processed by infrastructure advice—not special compiler magic.
@Transactional & @Cacheable as AOP
Understanding these as around-advice explains propagation, rollback, cache misses, and the self-invocation trap in one mental model.
sequenceDiagram
participant Client as Caller
participant Proxy as Transaction proxy
participant TM as TransactionManager
participant Target as Service method
Client->>Proxy: call save
Proxy->>TM: getTransaction begin
Proxy->>Target: proceed
Target-->>Proxy: return or throw
alt success
Proxy->>TM: commit
else RuntimeException
Proxy->>TM: rollback
end
Proxy-->>Client: result
@Transactional — TransactionInterceptor
@EnableTransactionManagement registers BeanFactoryTransactionAttributeSourceAdvisor with TransactionInterceptor as advice—effectively @Around that:
- Resolves transaction attributes from annotation/XML
- Calls PlatformTransactionManager.getTransaction()
- Invokes proceed() (your method)
- Commits or rolls back based on exception type and rollbackFor
// What TransactionInterceptor does conceptually:
Object invoke(Method method, Object target, Object[] args) {
TransactionStatus status = txManager.getTransaction(attributes);
try {
Object result = method.invoke(target, args);
txManager.commit(status);
return result;
} catch (RuntimeException ex) {
txManager.rollback(status);
throw ex;
}
}
@Cacheable — CacheInterceptor
@EnableCaching registers CacheInterceptor — around advice that:
- Evaluates cache key (default: method params via SpEL)
- Cache hit → return cached value, skip proceed()
- Cache miss → proceed(), store result in cache
- @CachePut always calls proceed and updates cache
- @CacheEvict removes entries before/after proceed
@Cacheable(value = "orders", key = "#id")
public OrderDto getOrder(long id) {
return expensiveLookup(id); // NOT called on cache hit
}
// Self-invocation — cache bypassed:
public OrderDto getWithLines(long id) {
return getOrder(id); // direct call — no CacheInterceptor!
}
Why self-invocation breaks both
| Annotation | Interceptor | Self-call symptom |
|---|---|---|
| @Transactional | TransactionInterceptor | No transaction; partial commits; wrong propagation |
| @Cacheable | CacheInterceptor | Cache never hit; method always executes |
| @Async | AsyncExecutionInterceptor | Runs synchronously on caller thread |
| @PreAuthorize | Method security interceptor | Security check skipped |
Single answer for three bugs: "Spring applies advice via proxy; internal calls use this not the proxy." Mention TransactionInterceptor and CacheInterceptor by name to show depth.
Order of advisors matters when multiple apply to one method—transaction typically runs before cache (populate inside TX) or after depending on config. Use @Order on aspect beans or @Transactional(order) / advisor order for explicit precedence.