Skip to main content

How to Use AOP Annotations Correctly

· 6 min read
Linh Nguyen
T-90MS Main Battle Tank
thumbnail

So you've discovered the magical world of @Transactional, @Async, @Cacheable, and their friends? Wonderful. You're living in the future now.

Under the hood, Spring AOP works by wrapping your beans in proxy objects. Those fancy annotations only fire when a call passes through that proxy. Break the rules and Spring silently does nothing: no error, no warning, just broken behavior at 2 AM while your on-call phone buzzes and the server fans spin up to jet engine levels.

The Uncompromised Rules​

Must Be a Spring-Managed Bean​

No bean, no proxy. No proxy, no magic. It's really that simple.

// ❌ Plain instantiation -> annotation silently ignored
OrderService svc = new OrderService();
svc.processPayment();

// ✅ Spring-managed -> proxy is active, magic is real
@Autowired
OrderService svc;
A blessing disguised as a curse

If Spring Boot refuses to start because your bean isn't wired correctly, consider it a gift. It's saving you from silent production failures and very strongly worded customer emails.

Method Must Not Be private​

Spring's proxy can only intercept public methods. As of Spring 6, protected and package-private join the party too, but private is always a dead end, no exceptions.

@Transactional
private void saveOrder(Order order) { ... } // ❌ Silently ignored. Tragic.

@Transactional
public void saveOrder(Order order) { ... } // ✅ Happy proxy noises
Magic for private methods?

Need AOP on private methods? You'd need AspectJ compile-time or load-time weaving. Trust me, that's ugly, and not worth your effort. Just make it public.

No Self-Invocation, Seriously!​

This is the classic trap. When a method calls another method within the same class, it's a direct this.method() call, and it bypasses the proxy entirely. Spring looks at you with disappointed eyes and says: nope, no magic for you.

@Service
@RequiredArgsConstructor
public class OrderService {

private final OrderProcessor processor;

public void processOrder(Order order) {
processor.saveOrder(order); // ✅ Goes through the proxy. Peace restored.
}
}

@Component
public class OrderProcessor {

@Transactional
public void saveOrder(Order order) {
// Happy proxy noises
orderRepository.save(order);
}
}

Don't Try to Inject Yourself​

Use ApplicationContext instead.

Self-injection (@Autowired on the same class) is another workaround for self-invocation, but it breaks by default in Spring Boot 2.6+ because circular references are disabled. Committing this gets you a startup crash and a ticket in the backlog of shame.

// ❌ Circular reference -> Spring Boot 2.6+ will not tolerate this heresy
@Autowired
private OrderService self;

The safe workaround is resolving the proxy via ApplicationContext:

@Service
@RequiredArgsConstructor
public class OrderService {

private final ApplicationContext applicationContext;

public void processOrder(Order order) {
applicationContext.getBean(OrderService.class).saveOrder(order); // ✅
}

@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}

ApplicationContext is a framework dependency, and Spring doesn't treat it as a circular reference. Problem sidestepped, dignity preserved.

Still want to run in circle?

You can re-enable circular references via spring.main.allow-circular-references=true. Please don't. It's a code smell and future-you will not be happy about it.

Don't Make It final (CGLIB Will Cry)​

When your class doesn't implement an interface, Spring uses CGLIB to subclass it at runtime. A final class or final method can't be subclassed or overridden. Therefore, CGLIB gives up, and so does your annotation.

@Service
public final class OrderService { ... } // ❌ CGLIB can't subclass this

@Transactional
public final void saveOrder() { ... } // ❌ CGLIB can't override this
CGLIB Annotations

If your class implements an interface, Spring uses a JDK dynamic proxy instead, which is less picky about final on the class itself, but annotated methods still can't be final.

Flip the Right Switch First​

Each AOP feature needs to be explicitly enabled. Spring Boot auto-configuration covers most of these, but in a custom setup you have to declare them yourself, or wonder why nothing works.

AnnotationRequired Enabler
@Transactional@EnableTransactionManagement, which should be on by default if you use auto configuration for datasource
@Async@EnableAsync
@Cacheable / @CachePut / @CacheEvict@EnableCaching
@Scheduled@EnableScheduling
Custom @Aspect@EnableAspectJAutoProxy

Know Your Rollback Rules (@Transactional)​

@Transactional only rolls back on unchecked exceptions and errors (RuntimeException and Error) by default. Checked exceptions? Silent commit. Your data is gone and the transaction is nowhere to be found.

// ❌ IOException is checked, and the transaction COMMITS despite the exception
@Transactional
public void process() throws IOException { ... }

// ✅ Explicitly tell it what to roll back on
@Transactional(rollbackFor = IOException.class)
public void process() throws IOException { ... }

When Everything Burns​

Get any of the above wrong and the failures are completely silent. No exception. No warning. Your code compiles, runs, and quietly betrays you:

  • @Transactional ignored → no rollback on failure, data corruption, fun incident reports.

  • @Cacheable ignored → every call hammers the database; that third-party API you're calling? You're basically DDoS-ing them now.

  • @Async ignored → methods run synchronously like it's 1999, threads blocked, throughput drops faster than you can say "throughput".

  • Custom AOP ignored → security checks, audit logs, rate limiting, etc... all silently skipped, all your problem now!

Beautiful chaos. Total and utter heresy.

This Applies to Your Custom Annotations Too​

Don't think you can outsmart the proxy gods with a custom @Aspect. The same rules apply, no exceptions, they're always watching.

@Aspect
@Component
public class AuditAspect {

@Around("@annotation(Audited)")
public Object audit(ProceedingJoinPoint pjp) throws Throwable {
// This only runs if ALL the rules above are respected
return pjp.proceed();
}
}

Private method? Ignored. Self-invocation? Ignored. Not a bean? You already know.

The Checklist (Annotate Responsibly)​

Before you open a Stack Overflow tab in desperation, run through this:

Details
  • Is the object a Spring-managed bean (not created with new)?

  • Is the method public (or at least non-private in Spring 6+)?

  • Is the call coming from outside the bean, and no self-invocation?

  • If working around self-invocation, using ApplicationContext.getBean() and not self-injection?

  • Are the class and method non-final (especially with CGLIB proxies)?

  • Is the required @Enable* annotation present?

  • For @Transactional: does the exception type actually trigger a rollback?

Still broken after all that? Enable debug logging and let Spring confess:

logging.level.org.springframework.aop=DEBUG
logging.level.org.springframework.transaction=DEBUG

Now go forth and annotate responsibly.