java5 min read

Spring AOP Tutorial: Learn Aspect Oriented from Scratch (2026)

Spring AOP Tutorial: Learn Aspect Oriented from Scratch (2026)

Published:  |  Category: Java  |  Reading time: ~15 min
Spring AOP Tutorial: Learn Aspect Oriented from Scratch (2026)

Aspect-Oriented Programming (AOP) lets you modularize cross-cutting concerns — logging, security, transactions, caching — that would otherwise be scattered across your codebase. Spring AOP implements AOP using proxy-based interception at method boundaries. I have used AOP to implement audit trails, performance monitoring, and declarative caching without touching business logic, and it consistently reduces code duplication.

This tutorial covers join points, pointcuts, advice types, aspect creation, proxying mechanisms, and integration with Spring's declarative model. You will learn to write aspects that are effective without being magical.

AOP Concepts and Proxies

Aspect-oriented programming separates cross-cutting logic into aspects. A join point is an execution point in the program — in Spring AOP, always a method execution. Pointcuts define which join points an advice applies to. Advice is the code that runs at matched join points.

Spring AOP uses JDK dynamic proxies (for interfaces) or CGLIB proxies (for classes). JDK proxies require the target class to implement an interface; CGLIB creates a subclass. Spring Boot defaults to CGLIB for all beans. Unlike AspectJ, Spring AOP only intercepts public non-static method calls within the Spring container — it cannot intercept private methods or method calls within the same class.

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(public * com.example.service.*.*(..))")
    public void logMethodCall(JoinPoint jp) {
        String method = jp.getSignature().toShortString();
        Object[] args = jp.getArgs();
        log.info("Calling {} with args: {}", method, args);
    }
}

@SpringBootApplication
@EnableAspectJAutoProxy
public class App { ... }

Pointcut Expressions

Pointcut expressions use AspectJ syntax to match join points. execution() matches method execution based on visibility, return type, class, and parameters. within() limits matching to types in a package. @annotation() matches methods annotated with a specific annotation — this is the most decoupled approach.

Combine pointcuts with &&, ||, and ! operators. Define named pointcuts with @Pointcut for reuse across multiple advices. Keep pointcuts in a single class or interface to maintain an overview of where aspects apply.

@Aspect
@Component
public class PerformanceAspect {
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceMethods() {}

    @Pointcut("@annotation(com.example.annotation.Monitored)")
    public void monitoredMethods() {}

    @Pointcut("serviceMethods() && monitoredMethods()")
    public void monitoredServiceMethods() {}

    @Around("monitoredServiceMethods()")
    public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long duration = System.nanoTime() - start;
            log.info("{} took {} ms", pjp.getSignature(), duration / 1_000_000);
        }
    }
}

Advice Types and Their Use Cases

@Before runs before the method — use for validation, logging pre-conditions, or setting thread-local context. @AfterReturning runs after successful completion — use for auditing success. @AfterThrowing handles exceptions — use for error logging or rollback logic. @After (finally) runs regardless of outcome — use for cleanup. @Around wraps the method entirely with proceed() control — the most powerful type, used for transactions, caching, retry logic.

@Around("@annotation(retryable)")
public Object retryOnFailure(ProceedingJoinPoint pjp, Retryable retryable) 
        throws Throwable {
    int maxAttempts = retryable.maxAttempts();
    
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return pjp.proceed();
        } catch (Throwable t) {
            if (attempt == maxAttempts) {
                throw t;
            }
            log.warn("Attempt {} failed for {}: {}", 
                attempt, pjp.getSignature(), t.getMessage());
            Thread.sleep(retryable.backoffDelay() * attempt);
        }
    }
    throw new IllegalStateException("Unreachable");
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
    int maxAttempts() default 3;
    long backoffDelay() default 100;
}

Declarative Transaction Management

Spring AOP underpins @Transactional — the most widely used declaration in enterprise Java. When you annotate a method with @Transactional, Spring wraps it with a transaction interceptor that begins, commits, or rolls back the transaction. The propagation attribute controls transaction boundaries: REQUIRED (join existing), REQUIRES_NEW (always create new), NESTED (savepoint).

Transaction rollback happens on RuntimeException and Error by default. Checked exceptions do not trigger rollback unless configured with rollbackFor. Use readOnly hints for read operations to allow database optimizations.

@Service
@Transactional
public class OrderService {
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request.customerId());
        repository.save(order);
        
        try {
            PaymentResult result = gateway.charge(order.getId(), request.amount());
            order.setPaymentStatus(result.status());
        } catch (PaymentException e) {
            throw new OrderCreationException("Payment failed", e);
        }
        
        return order;
    }
    
    @Transactional(readOnly = true)
    public List getOrdersByCustomer(Long customerId) {
        return repository.findByCustomerId(customerId);
    }
}

Best Practices and Common Pitfalls

AOP is powerful but easy to misuse. Keep aspects focused on one concern — a single aspect should do logging or security but not both. Avoid heavy logic in advices; they run on every matched method and become part of your application's critical path.

The most common pitfall: self-invocation bypasses AOP. When a method in the same class calls another method, the proxy is not involved and the advice does not execute. Use AopContext.currentProxy() as a workaround, or refactor to inject the proxy. Another pitfall: advising private or final methods — Spring AOP cannot proxy these.

// Self-invocation bypass — NO AOP
@Service
public class UserService {
    public void createUser(CreateUserRequest request) {
        sendWelcomeEmail(request.email()); // No @Async applied!
    }
    
    @Async
    public void sendWelcomeEmail(String email) { ... }
}

// Solution: Self-injection
@Service
public class UserService {
    @Autowired
    private UserService self;
    
    public void createUser(CreateUserRequest request) {
        self.sendWelcomeEmail(request.email()); // AOP applies
    }
}

AOP for Caching and Performance Monitoring

Spring's @Cacheable annotation is implemented via AOP proxies. When you annotate a method with @Cacheable, the AOP interceptor checks the cache before executing and stores the result after execution. Similarly, @CacheEvict and @CachePut manage cache invalidation and updates transparently.

For performance monitoring, create a @Around advice that records method execution time, success/failure rates, and captures stack traces for slow calls. Export these metrics via Micrometer to your monitoring system. This gives you production observability without polluting business code.

@Aspect
@Component
public class MonitoringAspect {
    private final MeterRegistry meterRegistry;

    @Around("@annotation(Monitored)")
    public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getSignature().toShortString();
        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            Object result = pjp.proceed();
            sample.stop(Timer.builder("method.execution")
                .tag("method", method)
                .tag("status", "success")
                .register(meterRegistry));
            return result;
        } catch (Exception e) {
            sample.stop(Timer.builder("method.execution")
                .tag("method", method)
                .tag("status", "error")
                .tag("exception", e.getClass().getSimpleName())
                .register(meterRegistry));
            meterRegistry.counter("method.errors",
                "method", method).increment();
            throw e;
        }
    }
}

Frequently Asked Questions

What is the difference between Spring AOP and AspectJ?

Spring AOP is proxy-based — it only intercepts public method calls on Spring beans. AspectJ is a full AOP framework with compile-time weaving that can intercept field access, construction, and private methods within the same class.

Can I use multiple advices on the same join point?

Yes. Priority is controlled via @Order annotation. Lower values run first for @Before (higher priority) and last for @After (wrapping order). Default order is the last-resolved order, which is unpredictable.

How do I pass data from @Before to the target method?

Use a request-scoped holder (ThreadLocal). The @Before advice sets a value in the holder, and the target method retrieves it. Do not modify method arguments to pass data — that violates the method contract.

Why is my @Transactional not working?

Common causes: calling the method from within the same class (self-invocation bypass), the method is private or final, the class is not managed by Spring, or the exception is a checked exception that does not trigger rollback by default.

Originally published on Ayodhyyya. Last updated June 1, 2026.