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.