Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Spring AOP (Aspect Oriented Programming)

AOP is one of the most powerful (and misunderstood) parts of Spring. It allows you to separate Cross-Cutting Concerns (logging, security, transactions) from your Business Logic. Real-world analogy: Think of AOP like airport security. Every passenger (method call) goes through the same security checkpoint (aspect), regardless of their destination gate (business logic). The security staff do not need to know where you are flying — they apply the same screening rules to everyone. Your gate agent (service method) does not need to know about security — they focus purely on boarding. AOP lets you separate “things that apply to many methods” from “the actual work each method does.” Without AOP, every gate agent would need their own security screening — duplicated, inconsistent, and error-prone.

1. The Concepts

  • Aspect: A module that encapsulates a concern (e.g., LoggingAspect). Think of it as a blueprint for what to do and where to do it.
  • Join Point: A point in execution where an aspect can be applied (e.g., “Method execution of saveUser”). In Spring AOP, join points are always method executions — unlike AspectJ, which can also intercept field access and constructor calls.
  • Advice: The action taken at a join point (e.g., “Log ‘Enter method’ before the method runs”). The “what to do.”
  • Pointcut: A predicate expression that matches join points (e.g., “All methods in com.service package”). The “where to do it.”
  • Weaving: The process of linking aspects with application objects to create an advised (proxied) object. Spring does this at runtime using dynamic proxies. AspectJ can do it at compile time for better performance.

2. Advice Types

AdviceDescriptionUse Case
@BeforeRuns before the method.Auth checks, Logging arguments.
@AfterReturningRuns after successful execution.Audit logging “Success”.
@AfterThrowingRuns if exception is thrown.Error reporting.
@AfterRuns finally (finally block).Cleanup.
@AroundMost Powerful. Wraps the method. Can change args, return value, or stop execution.Transaction Mgmt, Performance Monitoring.

3. Implementation Example: Performance Monitoring

@Aspect     // Marks this class as an aspect -- Spring scans for this annotation
@Component  // Must also be a Spring bean to be discovered
@Slf4j      // Lombok: generates a 'log' field (SLF4J logger)
public class PerformanceAspect {

    // Pointcut expression breakdown:
    //   execution(          -- match method execution join points
    //     *                 -- any return type
    //     com.devweekends.demo.service  -- in this package
    //     .*                -- any class in that package
    //     .*                -- any method name
    //     (..)              -- with any number/type of arguments
    //   )
    @Pointcut("execution(* com.devweekends.demo.service.*.*(..))")
    public void serviceMethods() {} // Empty body -- this is just a named reference

    @Around("serviceMethods()") // @Around wraps the entire method execution
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        
        // proceed() calls the actual target method.
        // Without this call, the target method NEVER executes.
        // This is what makes @Around so powerful -- you control whether
        // the method runs, and you can modify the arguments or return value.
        Object result = joinPoint.proceed(); 
        
        long end = System.currentTimeMillis();
        log.info("{} took {} ms", joinPoint.getSignature().getName(), (end - start));
        
        // You must return the result, or the caller gets null.
        return result;
    }
}
Production tip: For real performance monitoring, use Micrometer’s @Timed annotation instead of building your own aspect. It integrates with Prometheus/Grafana and gives you percentiles, histograms, and alerting for free. The custom aspect above is great for understanding how AOP works, but in production you want metrics that are aggregated, exported, and dashboarded — not log lines.

4. How it Works: The Proxy Pattern

Spring AOP uses Dynamic Proxies.

JDK vs CGLIB

  • JDK Dynamic Proxy: Used if the target implements an Interface. Only creates proxies for interfaces.
  • CGLIB (Code Generation Library): Used if the target is a Class (no interface). Spring generates a subclass of your bean at runtime.
Interview Tip: Since Spring Boot 2.0, CGLIB is the default (even for interfaces) to ensure consistency (prevents ClassCastException if you autowire the impl class).

5. Pitfall: Self-Invocation

This is the single most common AOP bug in Spring, and it bites developers at every experience level. Problem: Using @Transactional or @Async on a method called from within the same class does not work.
@Service
public class OrderService {
    public void createOrder() {
        // ...
        sendEmail(); // THIS WILL NOT BE ASYNC! The @Async annotation is ignored.
    }

    @Async
    public void sendEmail() { ... }
}
Reason: When Spring creates the OrderService bean, it wraps it in a proxy. External callers (controllers, other services) call the proxy, which intercepts @Async and runs it on a separate thread. But inside createOrder(), the call to sendEmail() is this.sendEmail()this refers to the Target Object (the real OrderService), not the proxy. The proxy is completely bypassed, and the @Async annotation is invisible. Solutions (pick one):
// Solution 1: Move the method to a separate service (cleanest)
@Service
public class EmailService {
    @Async
    public void sendEmail() { ... } // Now external call goes through proxy
}

// Solution 2: Self-inject via ApplicationContext (use sparingly)
@Service
public class OrderService {
    @Lazy
    @Autowired
    private OrderService self; // Spring injects the PROXY, not 'this'

    public void createOrder() {
        self.sendEmail(); // Goes through the proxy -- @Async works
    }
}
A senior engineer would note: This same self-invocation trap applies to @Transactional, @Cacheable, @Retryable, and every other annotation that relies on Spring AOP proxies. If you call an annotated method from within the same class, the annotation is silently ignored. When you see mysterious “transaction not rolling back” or “cache not working” bugs, self-invocation should be the first thing you check.

Interview Deep-Dive

Strong Answer:
  • JDK Dynamic Proxies work by implementing the same interfaces as the target bean. The proxy is created using java.lang.reflect.Proxy and implements all interfaces the target class declares. Calls to interface methods are intercepted; calls to non-interface methods bypass the proxy entirely. This only works if the target bean implements at least one interface.
  • CGLIB (Code Generation Library) works by generating a subclass of the target bean at runtime using bytecode manipulation. The proxy IS-A subclass of your actual class, so it can intercept any non-final, non-private method. Since Spring Boot 2.0, CGLIB is the default even when the bean implements interfaces, to prevent ClassCastException when someone autowires the concrete class instead of the interface.
  • CGLIB failure mode 1: final classes cannot be proxied. CGLIB generates a subclass, and Java prohibits subclassing final classes. Marking a @Service as final with @Transactional methods causes a startup error. Kotlin classes are final by default, which is why the kotlin-spring compiler plugin opens annotated classes.
  • CGLIB failure mode 2: final methods are not intercepted. CGLIB overrides methods in the subclass, and final methods cannot be overridden. So @Transactional on a final method is silently ignored — no error, no transaction. This is one of the most subtle bugs in Spring applications.
  • CGLIB failure mode 3: constructor called twice in older Spring versions. CGLIB creates a subclass requiring a superclass constructor call. Modern Spring uses Objenesis to avoid this, but on older versions, constructor side effects (logging, incrementing counters) execute twice.
Follow-up: Can Spring AOP intercept private methods? What about AspectJ load-time weaving — how does it differ?Spring AOP cannot intercept private methods because both JDK proxies (interface methods only) and CGLIB (cannot override private) are limited by Java’s access control. AspectJ load-time weaving (LTW) modifies class bytecode at class-load time using a Java agent. Because it modifies the actual class rather than wrapping it, it can intercept private methods, static methods, field access, and object construction. The trade-off: LTW adds configuration complexity (agent setup, classloader issues), harder debugging (bytecode no longer matches source), and most Spring apps never need it. I would only reach for AspectJ LTW when advising third-party code or when self-invocation interception is architecturally required.
Strong Answer:
  • Reason 1 (most common): Self-invocation. The @Async method is called from another method in the same class via this.asyncMethod(). The this reference bypasses the proxy. Debug: check the call site. If the caller is in the same class, move the async method to a separate bean.
  • Reason 2: Missing @EnableAsync. Without this on a @Configuration class, Spring does not register the AsyncAnnotationBeanPostProcessor. The @Async annotation is inert. Debug: search your configuration classes for @EnableAsync.
  • Reason 3: The method is private. CGLIB cannot override private methods, so the proxy does not intercept the call. Spring silently ignores it. Debug: check method visibility — it must be public.
  • Reason 4: No TaskExecutor bean configured. Spring falls back to SimpleAsyncTaskExecutor, which creates a new thread per invocation (no pooling). Technically it IS async, but under load it creates thousands of threads and the JVM crashes with OOM. Configure a ThreadPoolTaskExecutor explicitly.
  • Reason 5: With void return type, exceptions are swallowed by SimpleAsyncUncaughtExceptionHandler and only logged at WARN level. It looks like the method “did nothing.”
  • Debug approach: Enable logging.level.org.springframework.scheduling=DEBUG. Check if the bean is a proxy: AopUtils.isAopProxy(orderService). Verify the executing thread name differs from the request thread (async-1 vs http-nio-8080-exec-1).
Follow-up: How does the SecurityContext propagate to @Async methods, and what happens if you forget to handle it?By default, it does not propagate. SecurityContextHolder uses ThreadLocal, and @Async runs on a different thread. The async method sees an empty SecurityContext — the user is anonymous. If it calls @PreAuthorize methods, you get AccessDeniedException. Fix: use DelegatingSecurityContextExecutor to wrap your thread pool, which copies the SecurityContext to worker threads. The same issue applies to MDC logging context — trace IDs disappear in async threads unless you wrap the executor with MdcTaskDecorator.
Strong Answer:
  • At startup: @EnableTransactionManagement (auto-configured) registers TransactionAttributeSourceAdvisor. The InfrastructureAdvisorAutoProxyCreator BPP wraps matching beans in CGLIB proxies with TransactionInterceptor in their interceptor chain.
  • At invocation: external code calls orderService.placeOrder(). The CGLIB proxy intercepts. TransactionInterceptor.invoke() reads the annotation attributes via TransactionAttributeSource (propagation, isolation, rollbackFor, readOnly, timeout).
  • Transaction begin: the interceptor calls PlatformTransactionManager.getTransaction(definition). For JPA, JpaTransactionManager gets a Connection from the DataSource, calls setAutoCommit(false), sets isolation level, and binds the connection to the current thread via TransactionSynchronizationManager (a ThreadLocal registry). Downstream @Repository methods retrieve this thread-bound connection.
  • Method execution: invocation.proceed() calls your actual method. orderRepository.save() uses the thread-bound EntityManager. Hibernate queues SQL in the persistence context.
  • Commit: if no exception, Hibernate flushes (dirty checking generates SQL), SQL executes, connection commits. Connection is unbound and returned to the pool.
  • Rollback: on RuntimeException, the connection rolls back. On checked exception, the default is to COMMIT — the single most surprising behavior in Spring transaction management.
Follow-up: What does @Transactional(readOnly = true) actually do? Is it just a hint?It has real effects at three levels. JDBC: Spring calls connection.setReadOnly(true), which some drivers use to route to a read replica. Hibernate: the persistence context disables dirty checking, saving CPU and memory for large result sets (loading 10,000 entities skips snapshot comparison). Database: PostgreSQL uses a lighter snapshot mode for read-only transactions. It is not a hint — it is a compounding performance optimization. Always use it on query-only methods.
Strong Answer:
  • As an Aspect: define @RateLimited annotation. Create @Around advice matching @annotation(RateLimited). Extract user from SecurityContextHolder, build key rateLimit:{userId}:{method}, INCR with EXPIRE in Redis. Either proceed or throw 429. Advantage: method-level granularity. You can rate limit expensive operations (report generation) without affecting cheap reads. Access to method arguments enables per-resource limiting.
  • As a Filter: intercept at HTTP level before Spring MVC processing. Advantage: rejected requests consume minimal resources. Catches all requests including non-Spring endpoints.
  • Trade-off: Filters are better for broad URL-based protection (global DDoS defense). Aspects are better for fine-grained business-logic-aware limiting. In practice, use both: filter for global limits (1000 req/min), aspect for specific operations (5 reports/hour).
  • Hidden gap: AOP aspects only intercept Spring-managed bean calls. Calls via reflection, from @Scheduled, or within the same class bypass the aspect. Filters have no such gap for HTTP traffic.
Follow-up: How do you handle it if your Redis instance is down? Fail open or fail closed?Context-dependent. For a public API, fail open — a Redis outage should not become an API outage. For financial APIs preventing brute-force attacks, fail closed. Implement with try-catch around the Redis call, controlled by a config flag (rateLimit.failOpen=true). Log at ERROR and alert. Consider a local in-memory fallback (Guava RateLimiter) for degraded-but-functional rate limiting when Redis is unavailable.