skip to content

Why can't @AfterReturning modify the value returned to the caller, and when should you reach for @Around instead?

level: seniorimportance: should knowfreq 45%

answer

  1. Advice's own return value is discarded
  2. Fires after target already produced result
  3. Replacement → @Around only
  4. Mutation of returned object → possible, smell
  5. Forget proceed() → target skipped

basics

~20 s

@AfterReturning is an observer: Spring gives it the return value but provides no way to substitute a different one — its own return type is ignored by the framework. To transform or replace the result you need @Around, which controls what proceed() returns.

solid answer

~50 s

@AfterReturning receives the return value purely for inspection. The advice method's own return value is discarded by the AOP framework — there is no API to hand a replacement back into the call chain, so the caller always gets the original value. This is by design: after-returning advice sits in the interceptor chain after the target has already produced its result. If you need to transform, wrap, sanitize, or conditionally replace the result, use @Around: you call ProceedingJoinPoint.proceed(), capture the result, and return whatever you want (the original, a modified copy, or a new object). One subtlety: while @AfterReturning can't replace the reference, it CAN mutate a returned mutable object's fields, and the caller sees those mutations because it holds the same reference. That is a side effect, not substitution, and is usually a code smell.

code

java · 13 lines
java
// @AfterReturning: cannot replace, only observe (and — smell — mutate)
@AfterReturning(pointcut = "execution(* com.app.UserService.load(..))", returning = "user")
public void mask(User user) {
    // returning a value here would be ignored by Spring
    user.setSsn("***");   // caller sees this ONLY because it's the same reference (mutation)
}

// @Around: the correct way to actually transform/replace the result
@Around("execution(* com.app.UserService.load(..))")
public Object aroundLoad(ProceedingJoinPoint pjp) throws Throwable {
    User original = (User) pjp.proceed();          // run the target
    return new SafeUserView(original);             // caller receives THIS instead
}

go deeper

for a junior

Knows it can't change the result and @Around can.

for a middle

Explains the observer role and points to @Around for transformation.

for a senior

Articulates interceptor-chain mechanism and mutation-vs-replacement precisely.

for a principal

Weighs intent/safety trade-offs and treats result mutation as a design smell.

## The mechanism Spring AOP builds an **interceptor chain** (via `MethodInterceptor`s) around the target method. `@Around` maps to an interceptor that wraps the actual invocation and must call `ProceedingJoinPoint.proceed()` to run the next link (eventually the target). Because `@Around` *is* the wrapper, whatever it returns becomes the result the caller sees. `@AfterReturning` is different: it maps to an `AfterReturningAdvice`-style hook that fires **after** the target has already returned. The framework passes the return value in for observation but **ignores the advice method's own return value** — there is no channel to feed a replacement back up the chain. Hence it is structurally impossible for `@AfterReturning` to change what the caller receives. ## Replacement vs mutation There are two different notions: - **Replacement** (swapping the reference/value): only `@Around` can do this. - **Mutation** (changing fields of the returned object): any advice holding the reference can do this, including `@AfterReturning`. Since Java passes references by value, the caller and the advice point at the **same** object, so setter calls are visible to the caller. Example: masking a field on a returned DTO. This works but couples the aspect to the object's mutability and is easy to get wrong; prefer `@Around` returning a sanitized copy when you truly need transformation. ## When to use which - **@AfterReturning**: read-only reactions to success — logging, auditing, metrics, cache population, publishing domain/success events, triggering async follow-up. - **@Around**: transforming/wrapping results, retries, timing that must span the whole call, short-circuiting (returning without proceeding), caching that both reads and writes, exception translation, or anything needing full control over inputs, output, and control flow. ## Cost/complexity note `@Around` is the most powerful but also the easiest to misuse — forgetting to call `proceed()` silently skips the target; you must handle the returned `Object` and re-throw `Throwable`. If you only need to observe a successful result, `@AfterReturning` expresses intent more clearly and is safer. ## Gotcha: don't rely on @AfterReturning to enforce invariants on the returned value Because it can't reject or replace the value, it can't act as a guard. If you need to, say, forbid returning `null` or strip secrets, that must be `@Around` (or handled in the target).

  • If @AfterReturning can mutate a returned object, is that a valid way to sanitize responses?
    It works only for mutable objects and creates hidden coupling and side effects; it's fragile and considered a smell. Prefer @Around returning a sanitized copy/view so the transformation is explicit and works for immutable types too.
  • What breaks if an @Around advice forgets to call proceed()?
    The target method never executes; the caller receives whatever the advice returns (often null), silently short-circuiting the real logic — a common and dangerous @Around bug.

saying these in an interview costs you the question

  • Claiming @AfterReturning can replace the return value by returning a value
  • Not distinguishing mutation of the returned object from replacing it
  • Using @Around when a read-only @AfterReturning would suffice (over-powered advice)

context