Proxy-based AOP vs AspectJ weaving, JDK dynamic proxies vs CGLIB in Spring, why private methods and self-invocation escape advice (@Transactional/@Cacheable), restricting aspects to specific beans, @Around vs @Before/@AfterReturning, ordering multiple aspects, exceptions thrown from advice, conditional aspects via profiles/properties, Spring AOP's limits compared with full AspectJ, and how @Async works behind the scenes.
Published September 25, 2026
Nearly every "Spring annotation didn't work" bug comes down to proxies:
@Transactional on a private method;this. call;final class.Explain the proxy mechanics once, and the other questions follow.
Short answer: Proxies, at runtime.
AnnotationAwareAspectJAutoProxyCreator, a BeanPostProcessor) checks each bean against all the advisors (pointcut + advice, from @Aspect classes and infrastructure advisors like the transaction or cache interceptors).ReflectiveMethodInvocation.proceed()), then invokes the target.Spring uses AspectJ's annotation and pointcut syntax, but not AspectJ weaving. Full AspectJ (compile-time or load-time weaving) modifies the bytecode of the target classes themselves. It's available in Spring (@EnableLoadTimeWeaving, or the AspectJ compiler), but rarely used.
Learn it in depth → Spring AOP
Short answer:
final classes or final methods, and for constructor calls Spring uses Objenesis, to avoid running the constructor twice.spring.aop.proxy-target-class=true, so CGLIB is the default in Boot applications.Short answer: No.
final or static ones).So advice on private methods is silently ignored: @Transactional, @Cacheable and @Async on a private method have no effect. Use full AspectJ weaving if you truly need to advise private methods, or refactor the method into a public method on a separate bean.
@Transactional or @Cacheable?Short answer: The annotation is bypassed. this.method() inside the bean calls the target object directly, not the proxy, so no transaction starts and no caching happens. It's a very common production bug: for example, a public @Transactional(REQUIRES_NEW) audit method, called from another method of the same class, runs in the outer transaction, or in none.
Fixes:
@Lazy @Autowired private MyService self;, then call self.method().AopContext.currentProxy(), with @EnableAspectJAutoProxy(exposeProxy = true).TransactionTemplate, or the cache API.@EnableTransactionManagement(mode = ASPECTJ)), which weaves the bytecode, so self-calls are advised.Short answer: Narrow the pointcut:
within(com.shopin.orders..*).bean(orderService) or bean(*Repository) (Spring-specific).@annotation(com.shopin.Audited) (methods), or @within(com.shopin.Audited) (types). This is the cleanest and most explicit option, because it's opt-in.target(com.shopin.PaymentGateway) or this(...).execution(public * *(..)) && @within(org.springframework.stereotype.Service).@Aspect @Component
class AuditAspect {
@Around("@annotation(audited)")
Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
long start = System.nanoTime();
try { return pjp.proceed(); }
finally { auditLog.record(audited.action(), pjp.getSignature().toShortString(), System.nanoTime() - start); }
}
}
@Around and @Before/@AfterReturning?Short answer:
@Before: runs before the method. It can't stop the call, except by throwing. Used for validation, security checks and logging.
@AfterReturning(returning = "result"): runs after normal completion, and can read the return value, but not replace it.
@AfterThrowing: runs on an exception.
@After: a finally-style advice.
@Around: wraps the invocation, with a ProceedingJoinPoint. It can:
proceed(args));It's the most powerful, and the most error-prone: you must call proceed(), and return its result.
Use the least powerful advice that does the job. Spring's own transaction, cache and retry interceptors are effectively around advice.
Short answer: Yes. All the matching advisors form one interceptor chain. The order is controlled by @Order or implementing Ordered on the aspect class:
Without an explicit order, the order is undefined. Within a single aspect, advice types run in a defined order (@Around, @Before, @After, @AfterReturning, @AfterThrowing), but two advice methods of the same type in one aspect have no guaranteed order. Split them into separate aspects, or merge them.
Key points to cover:
@Order (and @EnableTransactionManagement(order = ...)).Short answer: The exception propagates to the caller, like one thrown by the target method:
@Before: the target method isn't invoked.@AfterReturning or @After: the method already ran, and its side effects stay, but the caller still gets an exception. If a transaction advice is outside it, the transaction rolls back. If it's inside it, the commit may already have happened.@Around: anything thrown, or not caught around proceed(), propagates. An around advice can also swallow or translate exceptions from the target.UndeclaredThrowableException.Rule: cross-cutting advice (logging, metrics) should never break the business call. Catch and log inside it.
Short answer: Aspects are beans, so use bean conditions on the aspect class:
@Profile("!prod") for a debug or tracing aspect;@ConditionalOnProperty(name = "app.audit.enabled", havingValue = "true") (in Boot);@ConditionalOnClass for optional integrations.If the bean isn't created, the advisor doesn't exist, and there's no proxying overhead. For runtime toggling, check a feature flag inside the advice (a small cost on every call), and prefer the conditional bean when the decision is static.
@Aspect @Component
@ConditionalOnProperty(prefix = "app.tracing", name = "method-timing", havingValue = "true")
class MethodTimingAspect { /* ... */ }
Short answer:
new outside the container.getClass() surprises.Full AspectJ (compile-time or load-time weaving) removes all of these: any join point, any object, self-calls, and no proxy indirection. The costs are build and agent complexity, and harder debugging. Use it for cases like @Configurable domain objects, or advising third-party code.
@Async work behind the scenes?Short answer:
@EnableAsync registers the AsyncAnnotationBeanPostProcessor, which wraps beans that have @Async methods in a proxy, with an AnnotationAsyncExecutionInterceptor.@Async method through the proxy, the interceptor submits a task to an executor, and returns immediately. The executor is:
@Async("imageExecutor");AsyncConfigurer executor;TaskExecutor bean;applicationTaskExecutor (a ThreadPoolTaskExecutor, or a virtual-thread executor when spring.threads.virtual.enabled=true).void: exceptions go to the AsyncUncaughtExceptionHandler;Future/CompletableFuture<T>: the result or exception is delivered through the future.ThreadLocal-based context (security, MDC, transactions) doesn't carry over unless you add a TaskDecorator. A transaction doesn't span into the async method.The pitfalls:
void methods..get() right away defeats the purpose.Q: How can you tell whether a bean is a proxy?
A: AopUtils.isAopProxy(bean), isCglibProxy, isJdkDynamicProxy. AopUtils.getTargetClass(bean) gives the real class. ClassUtils.getUserClass strips the CGLIB suffix ($$SpringCGLIB$$).
Q: Why do JPA entities and Spring beans need non-final classes, or the Kotlin all-open plugin?
A: CGLIB (Spring) and Hibernate/ByteBuddy (lazy proxies) create subclasses at runtime. Final classes and methods prevent the overriding they need.
Q: What does proxyTargetClass = true change?
A: It forces CGLIB class-based proxies, even when interfaces exist, so the bean can be injected by concrete class. It's Boot's default, to avoid "bean is not of required type" errors.
Q: Is AOP a good fit for business logic? A: No. Keep aspects for true cross-cutting concerns (transactions, security, metrics, auditing, retries). Business rules hidden in aspects are hard to discover, test and debug.