Spring AOP Tutorial: Separate Logging and Security Logic from Business Code in Java

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Problem: Your Service Methods Are Doing Too Much

Look at this typical Java service method:

public class OrderService {
    public Order placeOrder(User user, Cart cart) {
        // Security check
        if (!user.hasRole("CUSTOMER")) {
            throw new AccessDeniedException("Not authorized");
        }
        // Logging
        logger.info("placeOrder called by user: " + user.getId());
        // Actual business logic
        Order order = createOrderFromCart(cart);
        orderRepository.save(order);
        // Logging again
        logger.info("Order placed: " + order.getId());
        return order;
    }
}

Multiply that pattern across 50 service methods and you have a maintenance nightmare. Every method starts with a permission check, logs on entry and exit, and wraps in try-catch for auditing. The actual business logic ends up buried under layers of boilerplate that has nothing to do with the feature itself.

AOP was built precisely for this. Spring’s implementation drops in without framework rewiring or bytecode configuration — it just works. Here’s how.

Quick Start: Get AOP Working in 5 Minutes

Step 1: Add the Dependency

Spring Boot bundles AOP support in spring-boot-starter-aop. Add it to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

For Gradle:

implementation 'org.springframework.boot:spring-boot-starter-aop'

Step 2: Enable AspectJ Auto-Proxy (if needed)

Spring Boot enables this automatically. For plain Spring, add it to your config class:

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}

Step 3: Create Your First Aspect

@Aspect
@Component
public class LoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

    @Before("execution(* com.example.service.*.*(..))")
    public void logMethodEntry(JoinPoint joinPoint) {
        logger.info("Calling: {}", joinPoint.getSignature().getName());
    }
}

That’s it. Every method in your service package logs on entry now — zero changes to any service class. OrderService is back to pure business logic.

Deep Dive: Understanding the Core Concepts

Cross-Cutting Concerns — The Root Cause

Logging, security, caching, transaction management — none of these belong to any single feature. They cut across the entire codebase. Object-oriented design has no clean mechanism for pulling them out without repetition, which is exactly why they pile up as boilerplate in every class that needs them.

AOP introduces a new abstraction: an aspect, which is code that runs around your existing code based on matching rules. Three terms are central to understanding it:

  • Join Point — a moment in execution (a method call, for example)
  • Pointcut — a predicate that selects which join points to intercept
  • Advice — the code that executes at those join points (before, after, or around)

Pointcut Expressions

The execution(...) expression is the most common way to define a pointcut. Here is the anatomy:

execution(modifiers? return-type declaring-type? method-name(params) throws?)

Practical examples:

// All methods in the service package
"execution(* com.example.service.*.*(..))"

// Only public methods
"execution(public * com.example.service.*.*(..))"

// Methods with names starting with 'get'
"execution(* get*(..))"

// All methods in any subpackage
"execution(* com.example..*.*(..))"

You can also name and combine pointcuts:

@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}

@Pointcut("execution(* com.example.repository.*.*(..))")
public void repositoryLayer() {}

@Before("serviceLayer() || repositoryLayer()")
public void logAll(JoinPoint joinPoint) {
    // runs for both layers
}

Advice Types

Spring AOP provides five advice types, each running at a different point relative to the intercepted method:

@Aspect
@Component
public class AuditAspect {

    // Before the method executes
    @Before("execution(* com.example.service.*.*(..))")
    public void before(JoinPoint jp) {
        System.out.println("Before: " + jp.getSignature());
    }

    // After the method — regardless of outcome
    @After("execution(* com.example.service.*.*(..))")
    public void after(JoinPoint jp) {
        System.out.println("After: " + jp.getSignature());
    }

    // Only on successful return
    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) {
        System.out.println("Returned: " + result);
    }

    // Only when an exception is thrown
    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))", throwing = "ex")
    public void afterThrowing(JoinPoint jp, Exception ex) {
        System.out.println("Exception in " + jp.getSignature() + ": " + ex.getMessage());
    }

    // Wraps the method — you control whether it proceeds
    @Around("execution(* com.example.service.*.*(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed(); // calls the actual method
        long elapsed = System.currentTimeMillis() - start;
        System.out.println(pjp.getSignature() + " took " + elapsed + "ms");
        return result;
    }
}

@Around is the most flexible — you can modify arguments, skip the method entirely, or alter the return value. Use it for timing, caching, and retry logic.

Advanced Usage: Security Aspect with Custom Annotations

Custom annotations paired with AOP give you declarative method-level security. Spring Security covers standard auth flows, but when your permission rules are domain-specific, this pattern keeps service classes completely free of checking code.

Define the Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRole {
    String value();
}

Apply It to Methods

@Service
public class AdminService {

    @RequiresRole("ADMIN")
    public void deleteUser(Long userId) {
        // business logic only — no permission boilerplate
        userRepository.deleteById(userId);
    }
}

Write the Security Aspect

@Aspect
@Component
public class SecurityAspect {

    @Autowired
    private AuthService authService;

    @Before("@annotation(requiresRole)")
    public void checkRole(JoinPoint jp, RequiresRole requiresRole) {
        String requiredRole = requiresRole.value();
        User currentUser = authService.getCurrentUser();

        if (!currentUser.hasRole(requiredRole)) {
            throw new AccessDeniedException(
                "User " + currentUser.getId() + " needs role: " + requiredRole
            );
        }
    }
}

@annotation(requiresRole) as the pointcut matches any method carrying @RequiresRole and binds the annotation instance so you can read its value. Service classes stay clean. If role logic ever changes, you update the aspect once — one file, done.

Practical Tips: What Actually Matters in Production

AOP starts paying off around the point where you have 10–20 service methods all duplicating the same cross-cutting code. At that scale, every new method becomes a decision: repeat the boilerplate, or centralize it. Here’s what that centralization looks like in practice.

Where AOP Pays Off

  • Logging method entry, exit, and execution time
  • Permission and role checks based on custom annotations
  • Audit trails — who called what, and when
  • Retry logic for transient failures (network calls, external APIs)
  • Input sanitization at service boundaries

Where to Avoid It

  • When the cross-cutting behavior exists in only one or two places — a shared utility method is simpler and easier to trace
  • When team members are not familiar with AOP — invisible interception makes debugging confusing
  • Complex business rules that need to modify return values in non-trivial ways — use explicit service composition instead

The Self-Invocation Trap

This one catches everyone eventually. If a method inside a Spring bean calls another method in the same class, AOP will not intercept it — the call bypasses the proxy entirely.

// @RequiresRole will NOT be checked here
@Service
public class OrderService {
    public void placeOrder(Cart cart) {
        processOrder(cart); // self-invocation — no proxy, no aspect
    }

    @RequiresRole("CUSTOMER")
    public void processOrder(Cart cart) { ... }
}

Two fixes: inject the service into itself (@Autowired OrderService self) and call self.processOrder(cart), or move the annotated method to a separate service class. The second option is cleaner and avoids circular dependency headaches.

Performance

Spring AOP uses JDK dynamic proxies for interfaces, CGLIB for concrete classes. Measured overhead typically runs 2–5 microseconds per intercepted call. For logging and security checks, that’s noise. It only becomes relevant inside tight loops or endpoints processing thousands of requests per second — keep those aspects lightweight.

The Cleaned-Up Result

The difference shows immediately when you look at a service class after extracting its cross-cutting concerns. Just business logic. No logging boilerplate, no permission guards, no try-catch blocks for auditing. Each of those lives in its own aspect file, in one place.

Start with @Before for logging. Add @Around once you need execution timing. Build a custom annotation aspect for security when you need fine-grained control. Each piece works independently — and the tenth service method you write gets all of them without touching a single aspect file.

Share: