Spring AOP & Cross-Cutting Concerns

Practical AOP: Performance & Security

18 min Lesson 8 of 13

Practical AOP: Performance & Security

The previous lesson applied AOP to logging and auditing — concerns that are purely observational. This lesson tackles two concerns that can actively change the outcome of a method call: performance measurement (which informs optimization decisions) and method-level security checks (which can block execution entirely). Both are canonical cross-cutting concerns that belong in aspects rather than scattered across every service method.

Timing Aspects: Measuring Method Performance

A timing aspect wraps a method call, records the elapsed wall-clock time, and either logs it or stores it for aggregation. The natural tool is @Around advice, because it is the only advice type that controls when the actual method runs and therefore can bracket the call with timestamps.

Start with a marker annotation so you can opt individual methods in without wildcards:

package com.example.aop; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Timed { /** Optional logical name shown in the log; defaults to the fully-qualified method name. */ String value() default ""; }

Now write the aspect itself:

package com.example.aop; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; import java.lang.reflect.Method; @Aspect @Component public class TimingAspect { private static final Logger log = LoggerFactory.getLogger(TimingAspect.class); @Around("@annotation(com.example.aop.Timed)") public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); Method method = sig.getMethod(); Timed timed = method.getAnnotation(Timed.class); String label = timed.value().isBlank() ? sig.getDeclaringTypeName() + "#" + sig.getName() : timed.value(); StopWatch sw = new StopWatch(label); sw.start(); try { Object result = pjp.proceed(); sw.stop(); log.info("[TIMING] {} completed in {} ms", label, sw.getLastTaskTimeMillis()); return result; } catch (Throwable ex) { sw.stop(); log.warn("[TIMING] {} failed after {} ms — {}: {}", label, sw.getLastTaskTimeMillis(), ex.getClass().getSimpleName(), ex.getMessage()); throw ex; // re-throw so normal error handling is unaffected } } }

Apply it to any service method that you want measured:

@Service public class ReportService { @Timed("report.generate") public byte[] generateMonthlyReport(int year, int month) { // potentially slow PDF-generation logic } }
Why StopWatch instead of System.nanoTime()? Spring's StopWatch is not thread-safe and should not be shared across threads, but within a single advice invocation it is perfect: it hides the raw-time arithmetic, labels the task, and formats the result cleanly. For production-grade metrics, publish to Micrometer (Timer.record(...)) instead of logging — that gives you Prometheus/Grafana dashboards for free.

Aggregating Timing Data with Micrometer

Logging individual timings is useful during development, but in production you want percentiles and histograms. Swap the log statement for a Micrometer Timer:

import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; @Aspect @Component public class MicrometerTimingAspect { private final MeterRegistry registry; public MicrometerTimingAspect(MeterRegistry registry) { this.registry = registry; } @Around("@annotation(com.example.aop.Timed)") public Object record(ProceedingJoinPoint pjp) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); Timed timed = sig.getMethod().getAnnotation(Timed.class); String name = timed.value().isBlank() ? sig.getName() : timed.value(); Timer.Sample sample = Timer.start(registry); try { return pjp.proceed(); } finally { // 'finally' ensures the timer stops even on exception sample.stop(Timer.builder(name) .description("Execution time for " + name) .register(registry)); } } }

With Spring Boot Actuator on the classpath, the metric appears automatically at /actuator/metrics/{name} and is scraped by Prometheus if you include micrometer-registry-prometheus.

Method-Level Security with AOP

Spring Security already provides @PreAuthorize, @Secured, and @RolesAllowed — all implemented as AOP advice internally. Understanding how to build your own security aspect teaches you what Spring Security does under the hood and lets you implement custom access-control logic (business rules, ownership checks, feature flags) that Spring Security's expression language cannot express cleanly.

Define a custom annotation that carries a required permission string:

package com.example.aop; import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermission { String value(); // e.g. "REPORT_EXPORT", "USER_DELETE" }

A simple security context (replace with your real principal source):

package com.example.aop; import java.util.Set; /** Thread-local holder — in a real app this wraps Spring Security's SecurityContextHolder. */ public class SecurityContext { private static final ThreadLocal<Set<String>> PERMISSIONS = new ThreadLocal<>(); public static void setPermissions(Set<String> perms) { PERMISSIONS.set(perms); } public static Set<String> getPermissions() { Set<String> p = PERMISSIONS.get(); return p != null ? p : Set.of(); } public static void clear() { PERMISSIONS.remove(); } }

Now the aspect that enforces the annotation:

package com.example.aop; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; @Aspect @Component public class SecurityAspect { @Before("@annotation(com.example.aop.RequiresPermission)") public void checkPermission(JoinPoint jp) { MethodSignature sig = (MethodSignature) jp.getSignature(); RequiresPermission ann = sig.getMethod().getAnnotation(RequiresPermission.class); String required = ann.value(); if (!SecurityContext.getPermissions().contains(required)) { throw new AccessDeniedException( "Permission '" + required + "' is required to call " + sig.getDeclaringTypeName() + "#" + sig.getName() ); } } }
Why @Before and not @Around? Security checks are a binary gate: either the caller is allowed to proceed or they are not. @Before expresses that intent directly — throw an exception to block, return normally to allow. @Around would work too but adds unnecessary boilerplate (pjp.proceed(), return value handling) for a concern that never needs to inspect the return value.

Annotate any service method that needs protection:

@Service public class AdminService { @RequiresPermission("USER_DELETE") public void deleteUser(Long userId) { // only executes when the current thread holds USER_DELETE } @Timed("admin.exportReport") @RequiresPermission("REPORT_EXPORT") public byte[] exportReport(String format) { // both aspects fire: security check first, then timing } }

Aspect Ordering When Multiple Aspects Compose

When a method is annotated with both @RequiresPermission and @Timed, two separate proxies (or one proxy with two interceptors) wrap the method. You must ensure the security check runs before the timer starts — you do not want to record timing for calls that are refused.

Use @Order on the aspect class to control precedence. Lower numbers execute in the outer position for @Before / @Around advice:

import org.springframework.core.annotation.Order; @Aspect @Component @Order(1) // runs first — outermost wrapper public class SecurityAspect { /* ... */ } @Aspect @Component @Order(2) // runs second — inside the security wrapper public class TimingAspect { /* ... */ }
Unordered aspects have undefined execution order. If you add a third aspect without @Order, Spring may run it in any position relative to the others, and the position can change between restarts. Always apply @Order to any aspect whose relative position matters — security and transaction aspects are the most common examples.

Trade-offs and Pitfalls

  • Self-invocation bypasses aspects. If deleteUser() calls another method on the same bean, that inner call goes directly to the target object — not through the proxy — so any aspects on the inner method are silently skipped. Restructure to inject the bean into itself, or use AopContext.currentProxy() (requires exposeProxy = true on @EnableAspectJAutoProxy).
  • Overhead per invocation. AOP reflection adds a small overhead per call (typically a few microseconds). For methods called thousands of times per second, prefer Micrometer's @Timed annotation (which uses a dedicated optimised path) over a custom aspect.
  • Exception transparency. Your advice must re-throw any checked exception that the target method declares. If you catch a Throwable in @Around, always re-throw it — or the caller loses the original exception type.
  • Testing aspects in isolation. Annotate the aspect class with @SpringBootTest and a minimal slice, or use AspectJ's Aspects.aspectOf() to obtain the aspect instance directly and call its advice methods in a plain unit test.

Summary

Timing aspects and security aspects are the two most impactful practical applications of AOP. A @Timed-driven @Around aspect gives you zero-noise performance instrumentation across any number of methods — plugging into Micrometer turns it into production-grade observability. A @RequiresPermission-driven @Before aspect gives you declarative, centrally-enforced access control without polluting business logic. Combine them with explicit @Order declarations and you have a composable, maintainable cross-cutting concern layer.