Spring AOP

Spring AOP

AOP Concepts (JoinPoint, Pointcut, Advice, Aspect)

AOP (Aspect-Oriented Programming) modularizes cross-cutting concerns — logic that would otherwise be scattered across many unrelated classes (logging, security checks, transaction boundaries, metrics) — into a single reusable unit, woven into the call graph at runtime rather than hard-coded into every method that needs it.

Term Definition
JoinPoint A specific point during program execution where an aspect can be applied — in Spring AOP, this is always a method call (no constructor/field interception, unlike full AspectJ)
Pointcut An expression that selects which JoinPoints an advice applies to (e.g., “all public methods in the service layer annotated @Auditable“)
Advice The actual code that runs at a matched JoinPoint — the “what to do”
Aspect A class that bundles one or more pointcut+advice pairs into a coherent cross-cutting module (@Aspect)
Weaving The process of linking aspects into the target object’s call chain — Spring does this at runtime via proxies, not at compile/load time
@Aspect
@Component
public class LoggingAspect {

    // Pointcut: defines WHERE
    @Pointcut("@annotation(com.acme.annotation.Loggable)")
    public void loggableMethods() {}

    // Advice: defines WHAT, tied to the pointcut via method reference
    @Around("loggableMethods()")
    public Object logExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().toShortString();
        try {
            Object result = joinPoint.proceed(); // actually invokes the target method
            log.info("{} executed in {}ms", methodName, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable ex) {
            log.error("{} threw {}", methodName, ex.getClass().getSimpleName());
            throw ex;
        }
    }
}

Advice types — ordered by how they relate to the JoinPoint:

Advice Runs Can short-circuit / modify return?
@Before Before the method No — can throw to prevent execution, can’t alter return value
@After After (regardless of outcome — like finally) No
@AfterReturning After successful return Can read return value, not modify it
@AfterThrowing After an exception is thrown Can inspect the exception, doesn’t suppress it by default
@Around Wraps the entire invocation Yes — full control via ProceedingJoinPoint.proceed(); only advice type that can replace the return value or suppress/transform exceptions

Pointcut expression syntax (the part interviewers actually probe on):

@Pointcut("execution(public * com.acme.service.*.*(..))")        // by signature pattern
@Pointcut("within(com.acme.service..*)")                          // by package
@Pointcut("@annotation(com.acme.annotation.Auditable)")           // by annotation on method
@Pointcut("@within(org.springframework.stereotype.Service)")     // by annotation on class
@Pointcut("args(java.lang.String,..)")                            // by argument types

Combining: @Pointcut("loggableMethods() && within(com.acme.service..*)").


Proxy-Based AOP

This is the architectural detail that distinguishes Spring AOP from full AspectJ, and the source of nearly every “why isn’t my aspect firing” production bug.

How it works: Spring AOP does not modify bytecode at compile/load time (that’s what full AspectJ does via a weaver). Instead, at bean creation time, a BeanPostProcessor wraps the target bean in a proxy — the same mechanism that powers @Transactional and @PreAuthorize. The proxy intercepts calls that arrive through the Spring-managed bean reference and delegates to advice before/after invoking the real method.

JDK Dynamic Proxy CGLIB Proxy
Requires Target implements an interface No interface required — works on concrete classes
Mechanism java.lang.reflect.Proxy, implements the same interface(s) Subclasses the target class at runtime, overrides methods
Spring Boot default Used only if the bean has no interface alternative configured Default since Spring Boot 2.0 (proxyTargetClass=true) regardless of interface presence, for consistency
Limitation Can’t proxy classes without interfaces Can’t proxy final classes or final/private/static methods (can’t be overridden)

The defining limitation — proxies only intercept external calls:

@Service
public class OrderService {
    public void process(Order order) {
        this.audit(order);   // self-invocation — calls the RAW object, not the proxy. Advice never fires.
    }
    @Auditable
    public void audit(Order order) { ... }
}

This is the exact same root cause behind the @Transactional self-invocation pitfall and the @PreAuthorize self-invocation pitfall covered earlier — all three are symptoms of one underlying mechanism (proxy wraps the bean reference, not the class), which is precisely why interviewers reuse this question across topics: it tests whether you understand the mechanism, not just memorized per-annotation trivia.

Fixes:

  • Split the called method into a separate bean, inject it, call through the injected reference.
  • Self-inject (@Autowired private OrderService self; referencing its own proxy) — works but is a code smell most teams avoid.
  • AopContext.currentProxy() with @EnableAspectJAutoProxy(exposeProxy = true) — retrieves the current proxy explicitly; more invasive, rarely the first choice.
  • Switch to full AspectJ compile-time/load-time weaving if self-invocation interception is a hard requirement — but this trades simplicity for build complexity and is uncommon outside specific high-rigor codebases.

Architect-level framing: Spring AOP’s proxy-based approach is a deliberate simplicity/scope trade-off — it covers the overwhelming majority of real cross-cutting needs (method-level concerns on Spring-managed beans) without requiring a custom classloader or build-time weaver, at the cost of not being able to intercept constructors, field access, or self-invocation. Knowing why this trade-off was made — not just that the limitation exists — is what separates a senior answer from a memorized one.


Use Cases: Logging, Auditing, Transactions

Logging / performance instrumentation:

@Around("@annotation(com.acme.annotation.Timed)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.nanoTime();
    Object result = joinPoint.proceed();
    long elapsedMs = (System.nanoTime() - start) / 1_000_000;
    metricsRegistry.timer(joinPoint.getSignature().toShortString()).record(elapsedMs, TimeUnit.MILLISECONDS);
    return result;
}

Centralizes method-timing/logging without littering every business method with Stopwatch/Timer boilerplate — apply once via annotation, reuse everywhere.

Auditing (capturing who-did-what-when on sensitive operations):

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;

    @AfterReturning(pointcut = "@annotation(com.acme.annotation.Audited)", returning = "result")
    public void recordAudit(JoinPoint joinPoint, Object result) {
        String user = SecurityContextHolder.getContext().getAuthentication().getName();
        auditLogRepository.save(new AuditLog(
            user, joinPoint.getSignature().toShortString(), Instant.now()));
    }
}

Keeps the audit trail logic entirely out of business methods — @Audited is the only footprint in OrderService, the aspect owns how auditing is recorded (DB, Kafka event, file), which can change without touching a single business method.

Transactions — already covered structurally under @Transactional, but worth stating explicitly in the AOP context: Spring’s transaction management is itself just a built-in aspect (TransactionInterceptor) using @Around-style advice around methods matching the @Transactional pointcut. This is the strongest illustration that AOP isn’t an exotic feature reserved for custom logging — it’s the load-bearing mechanism behind some of Spring’s most heavily used annotations (@Transactional, @Cacheable, @Async, @PreAuthorize, @Retryable), all implemented as aspects under the same proxy infrastructure.

Architect-level guidance on when to reach for custom AOP vs. not:

  • Good fit: concerns that are genuinely orthogonal to business logic and apply uniformly across many methods/classes — logging, auditing, metrics, retry, rate-limiting, caching.
  • Poor fit: business-rule logic, anything where the “side effect” is the actual point of the method (that’s just… the method), or logic needed in only one or two places (a well-named private helper method is clearer than an aspect for a one-off).
  • Debugging cost trade-off to flag in design discussions: AOP makes call stacks less linear — a method appears to “magically” log/audit/transact without that logic visible at the call site. This is the right trade for concerns touching dozens of methods uniformly; it’s the wrong trade for anything bespoke, where the indirection cost outweighs the boilerplate it saves.