Spring AOP & Cross-Cutting Concerns

@Around Advice & ProceedingJoinPoint

18 min Lesson 6 of 13

@Around Advice & ProceedingJoinPoint

@Around is the most powerful advice type in Spring AOP. Unlike @Before or @After, which observe method execution from the outside, @Around wraps the entire call. Your advice code runs before the target method, decides whether to invoke it at all, and then runs again after it returns — with full access to the return value and any thrown exception. That control is expressed through a single object: ProceedingJoinPoint.

The Method Signature

An @Around method must declare a ProceedingJoinPoint as its first parameter and must return Object (the advised method's return value, possibly modified).

import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Aspect @Component public class TimingAspect { @Around("execution(* com.example.service.*.*(..))") public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); // invoke the real method long elapsed = System.currentTimeMillis() - start; System.out.printf("[TIMING] %s.%s took %d ms%n", pjp.getTarget().getClass().getSimpleName(), pjp.getSignature().getName(), elapsed); return result; // hand the return value back to the caller } }
You must call pjp.proceed() and return its result. If you forget either, the original method never executes (or its return value is silently discarded), which is almost always a bug. The compiler will not warn you — this is a runtime responsibility.

How proceed() Works

pjp.proceed() delegates to the next advice in the chain (or to the real target method if no more advice applies). It propagates any exception thrown by the target, which is why the method signature must declare throws Throwable. You can catch specific exceptions, handle or rethrow them, or swallow them entirely — but swallowing is almost always wrong outside very specific retry patterns.

Inspecting the Join Point

ProceedingJoinPoint exposes rich context about the intercepted call:

  • pjp.getSignature() — method name, declaring type, return type.
  • pjp.getArgs() — the actual argument values at the time of the call.
  • pjp.getTarget() — the target bean (the object whose method is being called).
  • pjp.getThis() — the proxy itself (rarely needed).

Altering Arguments Before Proceeding

You can replace the argument array before calling proceed(Object[]). The overloaded variant accepts a new argument array, which Spring passes to the target method instead of the originals. This is useful for input sanitisation, normalisation, or injecting audit metadata.

@Around("execution(* com.example.service.UserService.createUser(..))") public Object sanitiseInput(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); // Trim whitespace from every String argument for (int i = 0; i < args.length; i++) { if (args[i] instanceof String s) { args[i] = s.strip(); } } return pjp.proceed(args); // pass modified args to the real method }
Prefer immutable argument replacement. Clone the array before modifying it (Object[] args = pjp.getArgs().clone()) so that the original array is not mutated in case other aspects inspect it.

Altering the Return Value

Whatever pjp.proceed() returns is just an Object. You can inspect, replace, or wrap it before returning it to the caller. A common use case is caching: check a cache before calling proceed(), and store the result afterward.

@Around("@annotation(com.example.annotation.Cacheable)") public Object cacheResult(ProceedingJoinPoint pjp) throws Throwable { String key = buildKey(pjp); Object cached = localCache.get(key); if (cached != null) { return cached; // short-circuit — target method is NOT called } Object result = pjp.proceed(); localCache.put(key, result); return result; }

Notice that when the cached value exists, proceed() is never called. That is intentional and entirely valid — but document it clearly, because it is invisible to anyone reading only the target method.

Exception Handling Inside @Around

You can catch exceptions thrown by the target and decide what to do:

@Around("execution(* com.example.service.PaymentService.*(..))") public Object resilient(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (TransientPaymentException ex) { log.warn("Transient payment error, returning fallback: {}", ex.getMessage()); return PaymentResult.PENDING; // swallow and substitute a safe default } // All other exceptions propagate normally }
Be careful when swallowing exceptions. If the caller expects a checked exception to signal a failure, silently replacing it with a default value hides the problem. Reserve this pattern for well-understood transient faults where a fallback is genuinely safe.

Practical Timing Aspect: Full Example

Here is a production-grade timing aspect that uses SLF4J, skips fast methods below a threshold, and logs a warning for slow ones:

import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @Aspect @Component public class SlowMethodAspect { private static final Logger log = LoggerFactory.getLogger(SlowMethodAspect.class); private static final long WARN_THRESHOLD_MS = 200; @Around("within(com.example.service..*)") public Object track(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); try { return pjp.proceed(); } finally { long elapsedMs = (System.nanoTime() - start) / 1_000_000; if (elapsedMs > WARN_THRESHOLD_MS) { log.warn("SLOW: {}.{}() took {} ms", pjp.getTarget().getClass().getSimpleName(), pjp.getSignature().getName(), elapsedMs); } } } }

The finally block guarantees timing is recorded whether the method returns normally or throws. Using System.nanoTime() instead of System.currentTimeMillis() avoids wall-clock jumps caused by NTP adjustments.

Trade-offs and When to Use @Around

  • Use @Around when you need to measure elapsed time, conditionally skip the method, modify arguments or the return value, or implement retry / fallback logic.
  • Prefer @Before / @After for simpler cross-cutting concerns such as logging entry/exit, because they cannot accidentally swallow exceptions or forget to call the target.
  • Performance: @Around adds one extra method invocation per matched join point. The overhead is typically a few microseconds — negligible except in tight inner loops. Narrow your pointcut to avoid matching hot code paths unnecessarily.

Summary

@Around gives you a complete wrapper around the intercepted method. ProceedingJoinPoint.proceed() hands control to the target; its overload proceed(Object[]) lets you swap arguments first; and the Object return value can be replaced before it reaches the caller. Used correctly — with finally for cleanup, and a clear mental model of what happens when proceed() is not called — @Around is the foundation of logging, timing, caching, retry, and security cross-cutting concerns in production Spring applications.