Spring AOP
Deep Dive
Production-grade answers for senior Java interviews. Every question backed by real exception scenarios and architectural reasoning.
Proxy Internals — JDK vs CGLIB
How does Spring decide between JDK Dynamic Proxy and CGLIB?
Mid ▼Rule: If the bean's class implements at least one interface, Spring uses JDK Dynamic Proxy by default. If there's no interface, or if proxyTargetClass=true is set, Spring falls back to CGLIB.
public interface PaymentService { void process(String txId); } @Service public class PaymentServiceImpl implements PaymentService { public void process(String txId) { ... } } // Spring wraps this in a JDK proxy implementing PaymentService // InvocationHandler intercepts each method call
@Service public class ReportService { // No interface public void generate() { ... } } // Spring creates: ReportService$$EnhancerBySpringCGLIB$$abc123 // Cannot be final class or final methods — CGLIB needs to subclass!
| Aspect | JDK Dynamic Proxy | CGLIB |
|---|---|---|
| Mechanism | Implements same interface via reflection | Subclasses the target class at runtime |
| Requires | At least one interface | Non-final class, non-final methods |
| Speed | Slower (reflection per call) | Faster (bytecode generation, cached) |
| Default in Spring Boot | No — CGLIB is default since Spring Boot 2.x | Yes, spring.aop.proxy-target-class=true |
@Configuration and @Transactional. Autowiring by concrete type will now work, but final classes will break. If you declare class FooService final, CGLIB will throw at startup.
Production: BeanCreationException — Cannot subclass final class. How do you debug this?
Hard ▼org.springframework.beans.factory.BeanCreationException: Error creating bean 'paymentService': CGLIB subclassing failed for ... Cannot subclass final class
Root cause: CGLIB is the default proxy strategy in Spring Boot 2+. If your service class is declared final, CGLIB cannot create a subclass — startup fails.
- Remove
finalfrom the class or use an interface + switch to JDK proxy - Add
spring.aop.proxy-target-class=falseinapplication.propertiesto force JDK proxy - If using Kotlin — Kotlin classes are final by default! Add
kotlin-allopenplugin or annotate withopen
// build.gradle.kts plugins { kotlin("plugin.allopen") version "1.9.0" } allOpen { annotation("org.springframework.stereotype.Service") annotation("org.springframework.transaction.annotation.Transactional") }
What does InvocationHandler do internally in JDK proxy? Walk me through a method call.
Hard ▼When Spring creates a JDK Dynamic Proxy, it calls Proxy.newProxyInstance() with a custom InvocationHandler. Every method call on the proxy goes through handler.invoke(proxy, method, args).
class SpringAopInvocationHandler implements InvocationHandler { private final Object target; private final List<MethodInterceptor> interceptors; public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 1. Build interceptor chain (aspects in order) MethodInvocation invocation = new ReflectiveMethodInvocation(target, method, args, interceptors); // 2. Proceed through chain — each aspect can wrap/intercept return invocation.proceed(); // eventually calls target.method(args) } } // Usage: proxy is wired into beans, target is the real service PaymentService proxy = (PaymentService) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class[]{PaymentService.class}, new SpringAopInvocationHandler(target, interceptors) );
The chain: Caller → Proxy → Interceptor 1 (Logging) → Interceptor 2 (Transaction) → Real Method. This is the Chain of Responsibility pattern.
Pointcut Expressions — Full Breakdown
Break down a complex pointcut expression token by token.
Mid ▼@Pointcut("execution(* com.myapp.service.*.*(..)) && !execution(* com.myapp.service.*Repo.*(..))") /* execution( → match method execution join points * → any return type com.myapp.service. → package * → any class in that package . → separator * → any method name (..) → any args (0 or more, any type) ) && !execution(...) → AND NOT — exclude Repo classes */
| Token | Meaning | Example |
|---|---|---|
| * | Any single element (type, method name, one arg) | *.service.* |
| .. | Any number of things (packages or args) | com.app.. or (..) |
| + | Type plus all subtypes | BaseService+ |
| &&, ||, ! | Logical composition | A() && !B() |
| within() | Match all methods in a type/package | within(com.app.service.*) |
| @annotation() | Match methods with annotation | @annotation(Audited) |
| args() | Match by arg type | args(String, ..) |
| bean() | Spring-specific: match bean name | bean(*Service) |
// All public methods in service layer "execution(public * com.app.service..*(..))" // Methods returning void "execution(void com.app.service.*.*(..))" // Methods with @Transactional (Spring-managed classes only) "@annotation(org.springframework.transaction.annotation.Transactional)" // Any method that throws an exception type — use @AfterThrowing for this "execution(* com.app..*(..))" // Methods in classes annotated with @RestController "@within(org.springframework.web.bind.annotation.RestController)"
Production: Aspect fires on unwanted beans — causing performance issues in prod. How did you track it?
Hard ▼This is a very common production issue. A timing aspect with a broad pointcut like execution(* *.*(..)) accidentally intercepts Spring infrastructure beans, repository calls, and even Hibernate internal methods — causing enormous overhead.
Debugging steps:
- Enable
logging.level.org.springframework.aop=DEBUGto see which proxies are created - Use
-Dspring.aop.proxy-target-class=trueand check proxy chain depth - Add a
within()scope constraint to narrow down the pointcut
@Pointcut("execution(* *.*(..))") // catches EVERYTHING including Spring internals
@Pointcut("execution(* com.mycompany.app..*(..)) " + "&& !within(com.mycompany.app.config..*)" + "&& !within(com.mycompany.app.aspect..*)") // exclude aspects themselves! public void applicationLayer() {}
execution(* *.*(..)) in any environment. Aspects intercepting other aspects cause infinite loops.
@Around vs @Before vs @After — When to Use What
Explain all advice types with execution order and real use cases.
Mid ▼// Call sequence for a single method execution: // 1. @Around (before proceed) // 2. @Before // 3. TARGET METHOD EXECUTES // 4. @AfterReturning (if success) OR @AfterThrowing (if exception) // 5. @After (always — like finally) // 6. @Around (after proceed, return value)
| Advice | When | Can change return? | Use Case |
|---|---|---|---|
| @Before | Before method call | No | Validation, auth check, param logging |
| @AfterReturning | After success | No (read only) | Audit logging return value |
| @AfterThrowing | After exception | No (read exception) | Error reporting, alerting |
| @After | Always (finally) | No | Resource cleanup |
| @Around | Wraps everything | Yes | Timing, caching, retry, circuit breaker |
@Around("execution(* com.app.service.*.*(..))") public Object timingAndRetry(ProceedingJoinPoint pjp) throws Throwable { int maxRetries = 3; for (int i = 0; i < maxRetries; i++) { try { long start = System.currentTimeMillis(); Object result = pjp.proceed(); // MUST be called or method never runs! long elapsed = System.currentTimeMillis() - start; log.info("{} took {}ms", pjp.getSignature(), elapsed); return result; } catch (TransientException e) { if (i == maxRetries - 1) throw e; } } throw new RuntimeException("Should never reach here"); }
@Around, if you forget to call pjp.proceed(), the actual method NEVER executes. Your service returns null silently. No exception, no log — just null. This is a silent data loss bug in production.
Production: @AfterThrowing not catching RuntimeException — aspect silently fails
Hard ▼A common trap: @AfterThrowing will only fire if the exception propagates out of the proxied method. If the method catches it internally and swallows it, the aspect never sees it.
public void processPayment() { try { gateway.charge(); } catch (Exception e) { log.error("Payment failed", e); // swallowed! Aspect never sees this } } @AfterThrowing(pointcut = "execution(* com.app.service.*.*(..))", throwing = "ex") public void alertOnException(Exception ex) { // This NEVER fires because exception was caught above alertingService.notify(ex); }
@Around with try-catch instead if you need to observe ALL exceptions regardless of internal handling. Or ensure the service re-throws after logging.
Self-Invocation Bug — The #1 Production AOP Pitfall
Explain the self-invocation proxy bypass problem and all 3 ways to fix it.
Hard ▼This is the most asked AOP question in senior Java interviews — and the most common production bug.
The Problem: Spring AOP works by wrapping beans in proxies. When code OUTSIDE the bean calls a method, it goes through the proxy (aspects fire). But when a method inside the bean calls another method of the same bean using this, it bypasses the proxy entirely — no aspects, no @Transactional.
@Service public class OrderService { public void placeOrder(Order order) { // Called from controller → goes through proxy ✓ saveOrder(order); // ← this.saveOrder() — BYPASSES PROXY ✗ // @Transactional on saveOrder does NOTHING } @Transactional public void saveOrder(Order order) { // Called from placeOrder() via 'this' — not the proxy // No transaction context! Any DB write here can fail silently repo.save(order); } }
Fix 1: Inject self (ApplicationContext)
@Service public class OrderService { @Autowired private OrderService self; // Spring injects the PROXY, not 'this' public void placeOrder(Order order) { self.saveOrder(order); // ← goes through proxy ✓ TX active! } @Transactional public void saveOrder(Order order) { repo.save(order); // Now has proper transaction boundary } }
Fix 2: AopContext.currentProxy()
@EnableAspectJAutoProxy(exposeProxy = true) // needed in @Configuration public class AppConfig {} @Service public class OrderService { public void placeOrder(Order order) { ((OrderService) AopContext.currentProxy()).saveOrder(order); } @Transactional public void saveOrder(Order order) { repo.save(order); } }
Fix 3: Refactor to separate bean (Recommended)
@Service public class OrderService { @Autowired private OrderPersistenceService persistSvc; public void placeOrder(Order order) { persistSvc.saveOrder(order); // external call → proxy → TX fires ✓ } } @Service public class OrderPersistenceService { @Transactional public void saveOrder(Order order) { repo.save(order); } }
@Transactional — Traps & Production Scenarios
Why does @Transactional on a private method not work? What exception do you get (or not get)?
Hard ▼Spring AOP proxies can only intercept public methods. A @Transactional on a private, protected, or package-private method is silently ignored — no exception, no warning by default.
@Service public class UserService { @Transactional // ← IGNORED. Spring AOP can't proxy private methods private void createUserInternal(User u) { userRepo.save(u); // No rollback on exception. Data corruption risk. auditRepo.log(u); } public void createUser(User u) { createUserInternal(u); // This is just a plain method call } }
Enable this warning explicitly:
# application.properties spring.jpa.open-in-view=false logging.level.org.springframework.transaction=DEBUG # Use AspectJ compile-time weaving to support private method TX # (rarely justified — refactor is always better)
@Transactional rollback doesn't happen on checked exceptions — why?
Hard ▼By default, Spring only rolls back on unchecked exceptions (RuntimeException and Error). Checked exceptions are treated as "expected" failures — transaction commits!
@Transactional public void transferFunds(long fromId, long toId, BigDecimal amount) throws InsufficientFundsException { // ← checked debit(fromId, amount); // DB write succeeds if (balance < 0) { throw new InsufficientFundsException("Not enough funds"); // TX COMMITS! debit already happened. Money gone. } credit(toId, amount); }
@Transactional(rollbackFor = Exception.class) public void transferFunds(...) throws InsufficientFundsException { // Now checked exceptions also trigger rollback } // Or be specific: @Transactional(rollbackFor = {InsufficientFundsException.class, PaymentException.class}) public void transferFunds(...) { ... }
rollbackFor = Exception.class or convert all domain exceptions to unchecked. Partial transaction commits are financial data corruption.
Explain @Transactional propagation levels — REQUIRES_NEW vs NESTED with a real scenario.
Hard ▼| Propagation | Behavior | Use Case |
|---|---|---|
| REQUIRED | Join existing TX or create new | Default — most operations |
| REQUIRES_NEW | Always new TX; suspend outer | Audit logs, independent operations |
| NESTED | Savepoint within outer TX | Partial rollback within same TX |
| MANDATORY | Must have TX; exception if none | Internal-only methods |
| NEVER | Must NOT have TX; exception if one exists | Read-only reporting |
| SUPPORTS | Join TX if exists, else run without | Optional TX context |
| NOT_SUPPORTED | Suspend TX if exists, run without | Non-TX operations in TX context |
@Service public class OrderService { @Autowired private AuditService auditService; @Transactional // TX-1 public void placeOrder(Order order) { orderRepo.save(order); auditService.log(order); // runs in separate TX-2 throw new RuntimeException("Order failed"); // TX-1 rolls back — order not saved // TX-2 already committed — audit IS saved ✓ } } @Service public class AuditService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(Order order) { auditRepo.save(new AuditEntry(order)); // always persisted } }
@Transactional // Outer TX public void processBatch(List<Item> items) { for (Item item : items) { try { itemProcessor.processItem(item); // NESTED TX } catch (Exception e) { // NESTED rolls back to savepoint only // Outer TX continues — rest of batch still processes log.warn("Skipping item {}", item.getId()); } } } @Transactional(propagation = Propagation.NESTED) public void processItem(Item item) { itemRepo.save(item); // savepoint created before this }
Exception Scenarios — What Interviewers Actually Ask
LazyInitializationException in production — root cause and 3 solutions
Hard ▼org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: User.orders — could not initialize proxy — no Session
Root cause: The Hibernate session closes when the @Transactional method returns. Accessing lazy-loaded collections in the controller/view layer (after TX ends) triggers this.
@Transactional public User getUser(long id) { return userRepo.findById(id).get(); // session closes after return } // In controller — session is GONE User user = userService.getUser(1L); user.getOrders().size(); // LazyInitializationException!
Solution 1: Eager fetch in query (best for specific use cases)
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id") User findByIdWithOrders(@Param("id") Long id);
Solution 2: Use DTOs — never expose entities to the view layer
@Transactional(readOnly = true) public UserDTO getUser(long id) { User u = userRepo.findById(id).get(); return new UserDTO(u.getId(), u.getOrders()); // accessed within TX }
Solution 3: @Transactional(readOnly=true) on controller (anti-pattern — avoid)
// Never use open-session-in-view = true in prod (lazy loads on serialization — N+1) spring.jpa.open-in-view=false // Always set this in application.properties
UnexpectedRollbackException in microservices — service B rolled back service A's TX
Hard ▼org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
Root cause: Inner @Transactional (REQUIRED) method catches an exception and rethrows it. Spring marks the TX as rollback-only. The outer caller catches it and tries to commit — but TX is already poisoned.
@Transactional // Outer — TX-1 public void outerMethod() { try { innerService.innerMethod(); // throws → TX-1 marked rollback-only } catch (Exception e) { // we handle it — but TX is already poisoned log.warn("Handled", e); } orderRepo.save(order); // TX-1 commit attempted // → UnexpectedRollbackException! TX was rollback-only } @Transactional // REQUIRED — joins TX-1, not a new TX public void innerMethod() { throw new RuntimeException("Inner failed"); // marks TX-1 rollback-only }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void innerMethod() { // Separate TX-2 — if this rolls back, TX-1 is unaffected }
Aspect Ordering — @Order & Ordered Interface
When multiple aspects apply to the same method, what determines order? How do you control it?
Hard ▼Without explicit ordering, aspect execution order is undefined — it depends on class loading order which varies per JVM run. In production this means non-deterministic behavior: sometimes Security runs before Logging, sometimes after.
@Aspect @Component @Order(1) // runs first (outermost) — wraps everything inside public class SecurityAspect { @Before("execution(* com.app.service.*.*(..))") public void checkAuth() { ... } } @Aspect @Component @Order(2) public class LoggingAspect { @Before("execution(* com.app.service.*.*(..))") public void log() { ... } } @Aspect @Component @Order(3) public class TransactionAspect { // @Transactional = @Order(Integer.MAX_VALUE - 2) by default // Security → Logging → TX → Method → TX → Logging → Security }
JoinPoint vs ProceedingJoinPoint
What information can you extract from a JoinPoint? Walk through a real logging scenario.
Mid ▼@Aspect @Component public class AuditAspect { @AfterReturning( pointcut = "@annotation(com.app.annotation.Auditable)", returning = "returnValue") public void audit(JoinPoint jp, Object returnValue) { MethodSignature sig = (MethodSignature) jp.getSignature(); String className = jp.getTarget().getClass().getSimpleName(); String methodName = sig.getName(); String[] paramNames = sig.getParameterNames(); Object[] args = jp.getArgs(); // Build audit log with arg name→value pairs StringBuilder sb = new StringBuilder(); for (int i = 0; i < paramNames.length; i++) { sb.append(paramNames[i]).append("=").append(args[i]).append(","); } auditLog.info("[{}#{}] args=[{}] return=[{}]", className, methodName, sb, returnValue); // jp.getThis() → proxy object reference // jp.getTarget() → actual target bean // jp.getArgs() → method arguments array // jp.getSignature() → MethodSignature with full type info // jp.getKind() → "method-execution" } }
@Around advice. Adds proceed() and proceed(Object[]) methods. The proceed(Object[]) variant lets you replace method arguments — useful for input sanitization aspects.
Custom Annotation Aspects — Production Patterns
Design a @RateLimit annotation backed by an AOP aspect and Redis. Show full implementation.
Hard ▼This is a senior-level design question that tests your ability to combine AOP with real infrastructure. Rate limiting via AOP is used in API gateway services, fintech platforms, and any high-traffic microservice.
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { int requests() default 100; // max requests int windowSeconds() default 60; // per time window String keyPrefix() default "rl"; // Redis key prefix }
@Aspect @Component @Order(1) // highest priority — check rate before anything public class RateLimitAspect { @Autowired private StringRedisTemplate redis; @Around("@annotation(rateLimit)") public Object enforce(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String userId = SecurityContextHolder.getContext() .getAuthentication().getName(); String method = pjp.getSignature().getName(); String redisKey = String.format("%s:%s:%s", rateLimit.keyPrefix(), userId, method); Long current = redis.opsForValue().increment(redisKey); if (current == 1) { // first request — set expiry redis.expire(redisKey, rateLimit.windowSeconds(), TimeUnit.SECONDS); } if (current > rateLimit.requests()) { throw new TooManyRequestsException( "Rate limit exceeded for user: " + userId); } return pjp.proceed(); } } // Usage @RateLimit(requests = 10, windowSeconds = 60) @PostMapping("/transfer") public ResponseEntity<?> transferFunds(@RequestBody TransferRequest req) { ... }
Weaving Types — Compile-time vs Load-time vs Runtime
Compare weaving types and when would you choose AspectJ over Spring AOP?
Mid ▼| Type | When | How | Supports |
|---|---|---|---|
| Compile-time | At javac time | AspectJ compiler (ajc) | Private, static, constructor |
| Load-time (LTW) | Class loading | Java agent + AspectJ weaver | Private, static, constructor |
| Runtime | Startup | Spring AOP proxies | Public methods on Spring beans only |
Choose AspectJ (compile/LTW) when:
- You need to advise private methods (e.g., domain model methods in DDD)
- You need to intercept
newconstructors or static methods - Maximum performance needed — no proxy overhead
- You want to apply aspects to non-Spring-managed objects
Choose Spring AOP (runtime) when:
- You only need to advise public Spring bean methods (95% of cases)
- Simpler setup, no special compiler or JVM agent needed
- Your team is comfortable with proxy limitations
// application.properties spring.aop.auto=false // disable Spring AOP proxies // JVM startup flag -javaagent:/path/to/aspectjweaver.jar // META-INF/aop.xml — define aspects for LTW <aspectj> <weaver options="-verbose"> <include within="com.myapp..*"/> </weaver> </aspectj>
Rapid Fire — Real Interview Q&A
Can AOP be applied to Spring Data JPA repositories?
Mid ▼Yes — Spring Data JPA repositories are Spring beans so Spring AOP proxies apply. However, Spring wraps them in its own proxy chain already (for transactions, query execution), so you get a double-proxy. Use within(org.springframework.data.repository.Repository+) pointcut but be aware of performance impact. Usually better to add an intermediate service layer and put aspects there.
What happens if an aspect itself throws an exception?
Mid ▼In @Before — the target method never executes. The exception propagates to the caller as-is. In @Around — if the exception is thrown before pjp.proceed(), same effect. If after, the return value from the method is discarded. In @AfterReturning/@After — exception propagates but the method has already returned. If the method returned a value, it's lost.
UndeclaredThrowableException — a confusing runtime exception with no meaningful stack trace for the caller.
How do you test aspects in isolation without Spring context?
Mid ▼@Test void testTimingAspectFires() { TimingAspect aspect = new TimingAspect(); MyService target = new MyServiceImpl(); AspectJProxyFactory factory = new AspectJProxyFactory(target); factory.addAspect(aspect); MyService proxy = factory.getProxy(); proxy.doWork(); // aspect fires on this call // verify aspect behavior: timing logged, etc. Mockito.verify(aspect.getLogger()).info(Mockito.contains("ms")); }
StackOverflowError caused by an aspect — how and why?
Hard ▼java.lang.StackOverflowError at ... TimingAspect.time(TimingAspect.java:12)
Two causes: (1) Aspect pointcut matches the aspect itself — the aspect advises its own methods, creating infinite recursion. Fix: add && !within(com.app.aspect..*) to exclude aspect package. (2) Aspect calls a Spring bean method that is also matched by the same pointcut — the aspect triggers itself through the proxy chain.
@Pointcut("execution(* com.app..*(..)) && !within(com.app.aspect..*)") public void appMethods() {} // Cause 2 — aspect calls service that matches its own pointcut // Fix: use a separate logger/service that is outside the pointcut scope // or inject via @Autowired and check isProxy before calling
What is the difference between @DeclareParents (Introduction) and regular advice?
Hard ▼Introduction (Mixin) allows you to add new interfaces and their implementations to existing beans at runtime — without modifying the class. Useful in legacy codebases where you can't change the original class.
public interface Auditable { String getLastModifiedBy(); void setLastModifiedBy(String user); } public class DefaultAuditable implements Auditable { private String lastModifiedBy; // getters/setters } @Aspect @Component public class AuditIntroductionAspect { @DeclareParents( value = "com.app.service.*+", // all service beans defaultImpl = DefaultAuditable.class // mixin impl ) private Auditable auditable; } // Now you can cast any service bean to Auditable: PaymentService svc = context.getBean(PaymentService.class); ((Auditable) svc).setLastModifiedBy("admin"); // works via proxy!