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.

mid senior Spring Framework 6

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

ConcernWithout AOPWith AOP
TransactionsManual begin/commit in every service method@Transactional — one annotation
SecurityAuth checks duplicated in controllers/services@PreAuthorize, security filters
CachingCache get/put boilerplate everywhere@Cacheable
Logging / metricstry/finally timing in every methodLogging aspect with @Around
Retry / circuit breakerwhile loops around external callsResilience4j or custom @Retry aspect

AOP vocabulary

TermSpring meaning
AspectModule of cross-cutting logic — class annotated @Aspect
AdviceAction taken at a join point — @Before, @Around, etc.
Join pointPoint in execution — Spring AOP supports method execution only (not field access, constructors)
PointcutPredicate matching join points — AspectJ expression or annotation
Target objectBean being advised — the real instance behind the proxy
WeavingLinking aspects to targets — Spring uses runtime proxy weaving (not compile-time AspectJ by default)
🔬 Under the Hood

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.

AnnotationRunsCan block execution?
@BeforeBefore method entryNo — throw exception to abort
@AfterAfter method (finally semantics)N/A
@AfterReturningAfter normal returnN/A — access return value
@AfterThrowingAfter exception thrownN/A — access exception
@AroundWraps entire methodYes — skip proceed() or call it conditionally
All advice types on one pointcut
@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.

Modify return value
@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;
}
💡 Pro Tip

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 typeMechanismWhen 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
Force CGLIB — class-based proxy
@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 callthis.method() bypasses proxy (see self-invocation)
🔬 Under the Hood

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).

🎯 Interview Tip

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.

Broken — @Transactional ignored on internal call
@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

  1. Extract to another beanOrderPersistenceService.save() — cleanest
  2. Self-injection — inject own interface proxy (use sparingly; document why)
  3. AspectJ compile-time weaving — weaves bytecode, no proxy bypass (heavy setup)
  4. Refactor entry point — only public facade methods call transactional internals from outside
Self-injection fix (interface required)
@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); }
}
⚠️ Pitfall

#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?)

PatternMatches
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

Package and type patterns
@Pointcut("within(com.acme.service..*)")
void inServiceLayer() {}

@Pointcut("within(@org.springframework.stereotype.Repository *)")
void inRepositoryLayer() {}

@annotation() — methods with marker

Advise custom annotation
@Pointcut("@annotation(com.acme.Audited)")
void auditedMethod() {}

@Around("auditedMethod()")
Object audit(ProceedingJoinPoint pjp) throws Throwable { /* ... */ }

bean() — Spring bean name

Match by bean id
@Pointcut("bean(orderService)")
void orderServiceBean() {}

// SpEL bean reference: bean(*Service) — all beans ending in Service

Combining pointcuts

OperatorMeaningExample
&&ANDinServiceLayer() && auditedMethod()
||ORexecution(* *..*Repository.*(..)) || inServiceLayer()
!NOTinServiceLayer() && !@annotation(com.acme.NoLog)

Named pointcuts — @Pointcut for reuse

Layered pointcut composition
@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 { /* ... */ }
}
💡 Pro Tip

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

Structured service logging
@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

Timer per service method
@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

@Around with retry loop
@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

Persist who did what
@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.

@Audited marker
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
  String action();
}

@Service
class PaymentService {
  @Audited(action = "REFUND")
  public void refund(long paymentId) { /* ... */ }
}
@RateLimit with token bucket
@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();
}
📦 Real World

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:

  1. Resolves transaction attributes from annotation/XML
  2. Calls PlatformTransactionManager.getTransaction()
  3. Invokes proceed() (your method)
  4. Commits or rolls back based on exception type and rollbackFor
Conceptual equivalent (simplified)
// 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:

  1. Evaluates cache key (default: method params via SpEL)
  2. Cache hit → return cached value, skip proceed()
  3. Cache miss → proceed(), store result in cache
  4. @CachePut always calls proceed and updates cache
  5. @CacheEvict removes entries before/after proceed
Cacheable behavior
@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

AnnotationInterceptorSelf-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
🎯 Interview Tip

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.

🔬 Under the Hood

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.