Filter vs HandlerInterceptor vs AOP in Spring Boot 4: Which Runs When
A servlet Filter, a HandlerInterceptor and an @Aspect all run code around a request, but not at the same point. A traced Spring Boot 4.1 app on Tomcat shows the real order, what each can see, and where each one silently fails.
Every Spring Boot project ends up needing code that runs on every request: log it, check an API key, time it, add a header, record who called what. Spring gives you three places to put that code — a servlet Filter, a Spring MVC HandlerInterceptor and an @Aspect — and they look interchangeable right up to the day one of them does not fire, fires twice, or finds the request body empty.
This post runs all three in one small application and prints, line by line, the order they really execute in on an embedded Tomcat. Then it breaks things on purpose: a controller that throws, a 404, a static file, an async return value, a request body read twice, Spring Security in front of everything, and a service method an aspect never sees. The same requests also run under MockMvc, because the two do not behave identically. Every output block below is copied from a file that the test suite writes.
Versions this was built and run against (checked 2026-10-30). Spring Boot 4.1.2, the newest GA on Maven Central (4.2.0-RC1 also exists, but it is a release candidate); Spring Framework 7.0.10; Spring Security 7.1.2; embedded Tomcat 11.0.27; AspectJ weaver 1.9.25.1; JDK 25.0.5 (Temurin). The AOP starter is spring-boot-starter-aspectj; spring-boot-starter-aop has no 4.x GA release on Maven Central. The Framework 6.2 comparison uses 6.2.19, the newest 6.2.x GA. All of it comes from output/01-versions-and-jars.txt and output/20-central-versions.txt.
Companion code: the filter-interceptor-aop module of spring-boot-demo. Every console block below is copied from a file in its output/ folder. The transcripts are written by the tests themselves, so a number quoted here is an assertion that fails the build when it stops being true. Nothing runs on Docker or a network service; one JVM, one Tomcat, one thread pool.
Three hooks, three layers: the map before the details
A request arrives at Tomcat. Tomcat first pushes it through a chain of servlet filters. Those are part of the Servlet specification, so they work for any web framework and know nothing about controllers. At the end of the chain sits one servlet, Spring’s DispatcherServlet. It looks up which controller method should handle the request (Spring calls that method the handler) and runs the interceptors around it. The controller and service objects are wrapped in proxies, and an aspect lives in those proxies, so it runs around individual method calls rather than around the request.
The picture is the whole idea. Each ring does some work, calls the ring inside it, and does more work after that call returns. The differences between the three tools are only where in the nesting they sit and what they have been handed at that point. A filter gets the raw servlet request and response and decides whether to continue the chain. An interceptor gets the request, the response and the handler Spring picked. An aspect gets the method call itself: its arguments, its return value and any exception.
Servlet Filter
HandlerInterceptor
@Aspect (Spring AOP)
Defined by
Jakarta Servlet
Spring MVC
Spring AOP (AspectJ annotations, proxies)
Matched by
URL pattern, order
URL path patterns on handlers
Pointcut expression on bean method calls
Runs for
Every request Tomcat dispatches to the app
Requests that reach DispatcherServlet and have a handler
Calls that go through a Spring proxy, web or not
Knows about controllers
No
Yes
Only as ordinary beans
Going deeper: how the pieces are wired in Boot 4
The servlet filters are registered with Tomcat by Boot: a Filter bean, or a FilterRegistrationBean, becomes a mapping in the order shown in section 3. Boot reference: servlets, filters and listeners
Interceptors are registered through WebMvcConfigurer.addInterceptors (WebConfig.java).
A deeper look at pointcut designators and why an aspect may not fire: Spring AOP Explained.
One request, every layer, in order
To stop arguing from memory, every component in the demo writes one line into a small Trace object, and the controller is HelloController. Two filters, two interceptors and two aspects are wired in; the first request is GET /api/hello/ankur, sent with RestClient to a real Tomcat on a random port. Here is what ran, copied from output/03-happy-path.txt:
Read the numbers against the bars. The two filters bracket everything (lines 1 and 2 going in, 16 and 17 coming out). DispatcherServlet is line 3 and line 15, so by the time any interceptor runs, the filter chain has already finished its “before” half. Interceptor A and B run preHandle in registration order (lines 4 and 5), but postHandle (11, 12) and afterCompletion (13, 14) run in reverse order, B then A. The controller aspect (6 to 10) is inside both interceptors, and the service aspect (8, 9) is inside the controller method, because the controller calls the service bean through its proxy.
Outside in, then inside out. Filter, then servlet, then interceptors, then aspects, then your method; on the way back it unwinds in exactly the opposite order. If you remember one thing from this post, remember that the three tools are three concentric rings, not three alternatives at the same level.
It replaces Boot’s servlet bean (ServletConfig.java). Boot’s own bean backs off for a bean named dispatcherServlet and applies four WebMvcProperties settings, which I read with javap from DispatcherServletAutoConfiguration and copied (output/22-boot-bytecode-facts.txt). The middle column of each transcript line is the dispatcher type, REQUEST for the first trip through the servlet; sections 6 and 7 show the other two values, ERROR and ASYNC.
The trace is a global, unbounded list. It is a diagnostic: delete it, and every call to it, before shipping anything like this. The same goes for the diagnostic endpoint in section 3.
Filters: who goes first, and what @Order really does
Filters are the outermost ring, so their order matters most. The demo has one plain Filter ordered with @Order(5) (RawFilter.java), one OncePerRequestFilter registered through a FilterRegistrationBean with order 10 (FilterConfig.java), and four more OncePerRequestFilter components ordered with @Order 20, 25, 30 and 40. Rather than trust the order, ask Tomcat. DiagController reads the real chain from the embedded Tomcat context, and output/02-filter-chain-order.txt holds the result:
Three things to take from that list. First, @Order on a component and setOrder on a registration bean share one scale: the registration at 10 landed between the 5 and the 20. Second, Boot’s own filters come first because their orders are very low, and the constants printed in output/01-versions-and-jars.txt show how low:
Third, look at the right-hand column. The OncePerRequestFilter beans are mapped to five dispatcher types, including ERROR and ASYNC, but the plain rawFilter is mapped to REQUEST only. Boot chooses that default by testing whether the filter is an OncePerRequestFilter; I read that from the bytecode of AbstractFilterRegistrationBean (output/22-boot-bytecode-facts.txt: an instanceof check, then EnumSet.allOf, otherwise DispatcherType.REQUEST). The mapping alone does not tell you what runs, though: OncePerRequestFilter skips error and async dispatches by default, which the demo confirms by asking a subclass:
A filter with no order runs last. The demo’s twiceFilter has no @Order, and in the Spring Security transcript (section 11) it sits after faultFilter (order 40), at the end of the chain. Ordered.LOWEST_PRECEDENCE is 2147483647 (printed above). If your filter needs to run before authentication or after it, give it an explicit order.
Going deeper: Boot 4 moved some of these classes
In Boot 4 the filter constants live in the new modular jars: OrderedFilter is in spring-boot-servlet, FilterRegistrationBean is still in spring-boot, and the Spring Security filter order is SecurityFilterProperties.DEFAULT_FILTER_ORDER (-100) in spring-boot-security. I first looked for that constant on SecurityProperties and the compiler did not find it. The jar locations are in output/01-versions-and-jars.txt.
01 REQUEST filter:raw(order 5) before chain
05 REQUEST interceptor:A preHandle handler=HandlerMethod HelloController#visibility
08 REQUEST interceptor:visibility preHandle: the converted argument and the return value are not available here
10 REQUEST aspect:visibility before arg[0]=42 (Long, already converted from the path string)
12 REQUEST aspect:visibility request reachable only through RequestContextHolder: GET /api/visibility/42
14 REQUEST aspect:visibility after returned=Item[id=42] (Item)
20 REQUEST interceptor:A afterCompletion ex=null
pathVars(raw strings)={id=42} is the interceptor’s view: the path variable as text, not yet converted. The aspect line below it shows arg[0]=42 (Long, ...): by the time the proxy is called, Spring has converted it. And the filter, before the chain ran, saw handler attribute=null; the handler attribute is only filled in once DispatcherServlet has looked it up. Summarised:
Filter
Interceptor
Aspect
Raw request and response (headers, parameters)
Yes (read X-Probe=hello)
Yes
Only through RequestContextHolder (the probe reads GET /api/visibility/42 that way)
Replace the request or response object (wrappers)
Yes (section 8)
No: it is handed the objects, it cannot swap them
No
Which controller method will run
No before the chain; the attribute is set after
Yes: HandlerMethod, its annotations and parameter types
Not the HandlerMethod; the join point’s signature and target
Method arguments, converted
No
No (only the raw path strings)
Yes: 42 as a Long
Return value
No
No (modelAndView=null for @ResponseBody)
Yes: Item[id=42]
Runs for calls that are not HTTP requests
No
No
Yes (section 10: a direct call on the proxy)
An interceptor sees the method, not the call. It can read @RequiresRole off the method before the method runs, which neither of the others can do cleanly. It cannot see what the method was called with or what it returned. If you need arguments or the return value (an audit log, say), you want an aspect.
Going deeper: reading the handler from a filter
The filter’s “after chain” line in the transcript shows handler attribute=HandlerMethod visibility pattern=/api/visibility/{id} status=200. That attribute (HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE) is the supported way for a filter to learn which controller ran, but only after the chain returns.
The aspect’s “this” is a CGLIB subclass (HelloController$$...) while the “target” is the real HelloController; the transcript prints both.
Without the key, the filter writes the 401 itself and never calls chain.doFilter. From output/06-short-circuits.txt:
### GET /api/secure/data without a key: the FILTER answers
HTTP 401 body={"error":"missing or wrong X-Api-Key"}
01 REQUEST filter:raw(order 5) before chain
02 REQUEST filter:accessLog(order 10) before chain GET /api/secure/data
03 REQUEST filter:apiKey(order 20) REJECT 401, chain stops here
04 REQUEST filter:accessLog(order 10) after chain status=401 asyncStarted=false
05 REQUEST filter:raw(order 5) after chain
No DispatcherServlet, no interceptor, no aspect, no controller: none of them ever learns the request existed. The outer filters still see the 401 on the way out, which is why an access-log filter ordered before the auth filter can log rejected requests. (This filter is a teaching example. For real authentication use Spring Security; section 11 shows where its chain sits.)
Authorisation in an interceptor
Authorisation that depends on which method is being called suits an interceptor, because only it can read an annotation off the handler before the handler runs. RoleInterceptor reads a custom @RequiresRole:
if (!(handler instanceof HandlerMethod hm)) {
return true;
}
RequiresRole required = hm.getMethodAnnotation(RequiresRole.class);
if (required == null) {
return true;
}
String role = request.getHeader("X-Role");
boolean ok = required.value().equals(role);
trace.log(request, "interceptor:role", "@RequiresRole(\"" + required.value() + "\") on "
+ hm.getMethod().getName() + ", X-Role=" + role + " -> " + (ok ? "allow" : "deny 403"));
if (!ok) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "role " + required.value() + " required");
}
return ok;
Calling the admin endpoint with X-Role: user gives:
### GET /api/admin/report as a plain user: the INTERCEPTOR denies
HTTP 403 body={"timestamp":"...","status":403,"error":"Forbidden","path":"/api/admin/report"}
01 REQUEST filter:raw(order 5) before chain
02 REQUEST filter:accessLog(order 10) before chain GET /api/admin/report
03 REQUEST servlet:DispatcherServlet doDispatch start
04 REQUEST interceptor:A preHandle handler=HandlerMethod HelloController#admin
05 REQUEST interceptor:B preHandle handler=HandlerMethod HelloController#admin
06 REQUEST interceptor:role @RequiresRole("admin") on admin, X-Role=user -> deny 403
07 REQUEST interceptor:B afterCompletion ex=null
08 REQUEST interceptor:A afterCompletion ex=null
09 REQUEST servlet:DispatcherServlet doDispatch end
10 REQUEST filter:accessLog(order 10) after chain status=403 asyncStarted=false
11 REQUEST filter:raw(order 5) after chain
Source: output/06-short-circuits.txt
The aspect and the controller are absent: preHandle returned false, so the handler was never invoked. Note the check handler instanceof HandlerMethod at the top; section 9 shows what happens without it.
Timing: the same request measured four ways
A request that takes 80 ms because the service sleeps is measured by the filter, the interceptor, the controller aspect and the service aspect (output/10-timing.txt):
filter (accessLog) 87 ms
interceptor (timing) 86 ms
aspect around controller 80 ms
aspect around service 80 ms
Each number is a little larger than the one below it, because each ring includes the ring inside it. The test asserts that ordering (filter ≥ interceptor ≥ controller aspect ≥ service aspect ≥ 80), not the exact milliseconds, which change every run. So “how long did the request take” depends on where you ask: the filter number includes the servlet and every interceptor, the service-aspect number only the business call. Pick the ring that matches the question, and remember section 7: for an async return value the filter and aspect numbers stop meaning what they appear to.
Logging
Request-level logging (method, URL, status, duration) is a filter’s job, and AccessLogFilter is the shape: record in finally, after chain.doFilter. Business-level logging (which service method, with which arguments, returning what) is an aspect’s job. The service aspect in the happy-path transcript, before GreetingService#greet args=[ankur] and after ... returned=Hello, ankur, comes from this advice (ServiceAspect.java):
Filter-level logging of request bodies needs a wrapper; section 8 shows why and how.
Spring Security’s own method-level authorisation (@PreAuthorize) is an aspect-style proxy too, with the same proxy traps: Method Security in Spring Security 7.
yes: interceptors run for BasicErrorController, filters do not
A filter throws
none in the first trip
none
none
no
status 200
yes: exception=IllegalStateException
Static file exists (/hello.txt)
A, B, with ResourceHttpRequestHandler
ran
ran
n/a
status 200
no
No controller, no file (/nope)
A, B, with ResourceHttpRequestHandler
skipped
B, A with ex=null
n/a
status 404
yes
Wrong method (POST /api/ping)
none
none
none
n/a
status 405
yes
add-mappings=false, any unknown URL
none
none
none
n/a
status 404
yes
preHandle of gate returns false
A, gate
none
A only
n/a
status 403, or 200 with no body
no
The diagram is the first row: the controller threw, an aspect saw it and rethrew it, the advice turned it into a 500 body, and the exception was resolved. After a resolved exception DispatcherServlet skips postHandle and calls afterCompletion with no exception. Here is the unhandled case, where nothing resolves it, from output/04-exceptions.txt:
Line 12 of that transcript, the access-log filter’s “after chain”, reports status=200 although the client received a 500: the exception was still unwinding through the filter when it logged, and Tomcat sets the 500 afterwards. The 500 only appears on the ERROR dispatch (line 16, status=500 exception=ServletException). Then Tomcat made a second trip, and notice that the filters are silent during it while both interceptors run again, this time for BasicErrorController#error.
afterCompletion cannot see an exception that @ControllerAdvice handled. In the handled case the controller threw an IllegalStateException, yet both afterCompletion lines print ex=null (output/04-exceptions.txt). If you use afterCompletion to count failures, you will miss every exception your advice handles. Count in the advice, or look at the response status.
A filter’s exception never reaches @ControllerAdvice. The filter that throws is outside DispatcherServlet, so the advice line is absent from that transcript; the error is rendered by /error instead, with the original exception in the error attributes. The same structure explains why FaultFilter shows no DispatcherServlet line in the first trip.
Now preHandle returning false. The gate interceptor sits between A and B, and with ?mode=silent it returns false without writing anything:
### GET /api/gated/hello?mode=silent: gate returns false and writes nothing
HTTP 200 (empty body)
01 REQUEST filter:raw(order 5) before chain
02 REQUEST filter:accessLog(order 10) before chain GET /api/gated/hello
03 REQUEST servlet:DispatcherServlet doDispatch start
04 REQUEST interceptor:A preHandle handler=HandlerMethod HelloController#gated
05 REQUEST interceptor:gate preHandle -> FALSE (mode=silent)
06 REQUEST interceptor:A afterCompletion ex=null
07 REQUEST servlet:DispatcherServlet doDispatch end
08 REQUEST filter:accessLog(order 10) after chain status=200 asyncStarted=false
09 REQUEST filter:raw(order 5) after chain
Source: output/06-short-circuits.txt
Only interceptor A, whose preHandle had returned true, gets afterCompletion; B never started, and there is no postHandle at all. And because nothing wrote a response, the client gets HTTP 200 with an empty body. If an interceptor says no, it has to say it itself: set the status and write the body, or call sendError, as the ?mode=write variant of the transcript does.
Going deeper: why filters skip the error trip but interceptors do not
The filters are OncePerRequestFilters, which return true from shouldNotFilterErrorDispatch() by default (section 3). That is why the ERROR half of every transcript has no filter: lines, even though the registration maps them to ERROR.
Interceptors have no such switch. A pattern like /** also matches /error, so the interceptor runs again for the error page; the pattern matrix in section 9 shows the /error pattern firing on the ERROR dispatch. Exclude it with excludePathPatterns("/error") if you do not want that. I did not run that exclusion, so treat it as the documented API rather than a result.
When a controller returns a Callable or a DeferredResult, the container thread is released and the request is dispatched again later, with dispatcher type ASYNC, when the value is ready. That second dispatch walks the interceptors again. The first of the two traces in output/07-async.txt, for a controller that returns a Callable, with the thread each line ran on (relabelled tomcat-1, mvc-task-1 so the file is reproducible):
Count the interceptor:A lines. preHandle appears twice (lines 04 and 16), afterConcurrentHandlingStarted once, and postHandle and afterCompletion only once, on the ASYNC dispatch. That is what the AsyncHandlerInterceptor interface is for. The ASYNC trip also has no filter lines and no aspect lines: OncePerRequestFilter skips async dispatches by default, and the aspect already returned when it handed back the Callable. The DeferredResult endpoint produces the same shape, which the test asserts for both.
A filter that measures time or status around an async controller is measuring the wrong thing. Line 12: after chain status=200 asyncStarted=true printed while the Callable had not even finished. The real status and the real duration only exist after the ASYNC dispatch, where the filter does not run. The aspect has the same problem: line 08 says returned=a lambda, the Callable itself, not callable-done.
If a preHandle must run once per request, branch on request.getDispatcherType(); that is exactly the value the trace prints in its second column. In this run the Callable ran on mvc-task-1 and the ASYNC dispatch on a different Tomcat thread, tomcat-2; Tomcat may reuse a thread, so do not rely on either.
Going deeper: async references
Virtual threads, @Async and structured concurrency change how context propagates; see Spring Security Context Propagation. This module did not run any of them.
The request body can be read once
An HTTP request body arrives as a stream, and a stream can be read once. If a filter reads it to log or validate it, the controller’s @RequestBody finds nothing left. There are two ways to get this wrong and two that work, for different jobs. All four, plus a baseline, are selectable with an X-Body-Mode header on BodyFilter, and the endpoint is a plain @RequestBody String. The transcript is output/08-request-body.txt:
X-Body-Mode
What the filter does
Result
(none)
nothing
200 echo:hello body
consume
reads request.getInputStream(), passes the same request on
400, controller never runs
consume-wrapped
wraps in ContentCachingRequestWrapper but reads through it before the chain
400, controller never runs
cache
wraps in ContentCachingRequestWrapper and passes it on without reading
200; the body is in the wrapper after the chain
replay
reads the bytes up front into a wrapper that can be read again
200
The failing cases carry the real message. From the consume run:
07 REQUEST advice:@ExceptionHandler handling HttpMessageNotReadableException: Required request body is missing: public java.lang.String com.ankurm.filteraop.web.HelloController.echo(java.lang.String)
Source: output/08-request-body.txt
Look at what is missing from that run: there is no aspect:controller line. The failure happens while Spring resolves the controller’s arguments, which is before the proxy-wrapped method is called, so an aspect never sees it. For the cache mode the filter logs the wrapper twice, and the numbers show what ContentCachingRequestWrapper actually does:
03 REQUEST filter:body(order 30) CCRW before chain: getContentAsByteArray().length=0
ContentCachingRequestWrapper caches only what is read through it, later. Before the chain its cache is empty (0 bytes); after the chain it holds the 10 bytes the controller consumed. So it is the right tool for logging the body after the request, and the wrong tool for inspecting it before. To look at the body first and still let the controller read it, read the bytes up front into a wrapper that can replay them (CachedBodyRequest.java):
An interceptor cannot do this at all: preHandle receives the request as a parameter and has no way to hand a different one onward. Body inspection is a filter job.
The response has the mirror-image trap. ContentCachingResponseWrapper holds the body until you call copyBodyToResponse(). With X-Resp-Mode: forget the filter wraps the response and never copies it back:
A forgotten copyBodyToResponse() looks like a healthy empty 200. The wrapper held 12 bytes (response wrapper holds 12 bytes in the same transcript), the controller returned them, and the client received Content-Length=0 with status 200. Nothing is logged as an error. The copy variant of the same request returns Hello, ankur with Content-Length=12.
Going deeper: body wrappers in practice
Spring’s ContentCachingRequestWrapper takes a cache limit in its constructor (the demo passes 1024); this module did not test bodies above the limit, form posts through the wrapper, or multipart.
The replaying wrapper reads the whole body into memory. For large uploads that is a decision, not a default.
Interceptor paths in Framework 7: which URLs match
addPathPatterns takes path patterns, and the details decide whether your interceptor runs. The demo registers one tiny interceptor per pattern (PatternProbeConfig.java) and fires eleven URLs at them. Each line lists the patterns whose interceptor ran on the first trip, and then on the ERROR dispatch (output/14-path-patterns.txt):
Reading the rows: a trailing slash does not match (/api/hello/ankur/ is a 404 and /api/ping/ too); matching is case-sensitive (/API/hello/ankur reaches only /**); a ;x=1 matrix parameter is stripped before matching; /api/hello/** matches /api/hello with zero extra segments, but /api/hello/* does not; and a .json suffix is not stripped: /api/hello/ankur.json still matched /api/hello/{name} and returned 200, with the whole last segment as the match. Also look at the 404 rows: the interceptors for /** and /api/**did run for URLs that no controller handles, because they were matched to the static-resource handler, and in the ERROR column the /error pattern fires.
What changed from Framework 6.2 to 7? I ran javap on the same classes in both versions (output/21-framework-6.2-vs-7.0.txt) and had the demo ask the 7.0.10 jar by reflection (output/01-versions-and-jars.txt):
setters = [setCaseSensitive, setPathOptions]
caseSensitive = true
has setMatchOptionalTrailingSeparator = false
has PathMatchConfigurer.setUseTrailingSlashMatch = false
In 6.2.19, PathMatchConfigurer still had setUseTrailingSlashMatch, setUseSuffixPatternMatch and setUseRegisteredSuffixPatternMatch, and PathPatternParser had setMatchOptionalTrailingSeparator. In 7.0.10 none of them exists, and setPathMatcher and setUrlPathHelper are deprecated for removal since 7.0. On the interceptor side, InterceptorRegistration in 7.0.10 has includeHttpMethods and excludeHttpMethods, which 6.2.19 does not, and its pathMatcher(...) method is marked deprecated. In practice: write your patterns as path patterns, and do not expect a trailing slash to match.
The last pattern trap is the one behind section 5’s instanceof check. Interceptors registered for /** also run for static files, and the handler there is not a HandlerMethod. With the naive cast in NaiveCastInterceptor.java enabled, output/13-handler-cast.txt shows:
### GET /api/hello/ankur with the naive (HandlerMethod) cast
HTTP 200 body=Hello, ankur
### GET /hello.txt with the naive cast
HTTP 500 body={"timestamp":"...","status":500,"error":"Internal Server Error","path":"/hello.txt"}
Always check handler instanceof HandlerMethod. The API request is fine and the static file is a 500, and the access-log filter (line 09 of that transcript) still says status=200. The same check protects you from the handlers of /error, actuator endpoints and anything else registered with a pattern wide enough to include it.
Going deeper: patterns
The probe interceptors cover eight patterns; ** in the middle of a pattern, regular-expression segments and a servlet context path were not tried.
Compare what an HTTP method filter can now do: includeHttpMethods exists in the 7.0.10 jar, but this module did not exercise it.
Where AOP stops: self-invocation, private methods and new
An aspect is not magic bytecode: in Spring AOP it is a proxy that Spring puts in front of a bean. Every limit follows from that. First the dependency. In Boot 4 the starter is spring-boot-starter-aspectj:
<!-- Boot 4 name. spring-boot-starter-aop has no 4.x release (last: 3.5.16). -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
This is from pom.xml. The old name is gone: output/20-central-versions.txt reads Maven Central’s metadata, which lists no GA 4.x version of spring-boot-starter-aop (only 4.0.0 milestones; the last release is 3.5.16) and 4.1.2 for the new one, and the starter’s published pom depends on exactly aspectjweaver, spring-aop and spring-boot-starter (output/01-versions-and-jars.txt). The auto-configuration, AopAutoConfiguration, still lives in spring-boot-autoconfigure.
spring-boot-starter-aspectj latest GA 4.1.2 newest listed 4.2.0-RC1
spring-boot-starter-aop latest GA none newest listed 4.0.0-M2
spring-boot-starter-aop latest GA 3.5.16 newest listed 3.5.16
The proxy class is visible: the container injected GreetingService$$SpringCGLIB$$N, not GreetingService (output/15-aop-traps.txt). Now three service methods (GreetingService.java) that an aspect with a pointcut for the whole service package never sees:
/** Calls greet() twice through "this": the aspect sees greetTwice and never the two greet() calls. */
public String greetTwice(String name) {
return greet(name) + " / " + greet(name);
}
/** Calls a private method: the pointcut cannot match it, and a proxy cannot intercept it. */
public String callsPrivate(String name) {
return secret(name);
}
private String secret(String name) {
return "secret-" + name;
}
/** Uses an object made with new: no proxy, no advice, even though the signature matches the pointcut. */
public String usesHelper(String name) {
return new Helper().shout(name);
The aspect saw:
### GET /api/twice/ankur (self-invocation: greetTwice calls this.greet() twice)
HTTP 200 body=Hello, ankur / Hello, ankur
06 REQUEST aspect:controller before HelloController#twice args=[ankur]
07 REQUEST aspect:service before GreetingService#greetTwice args=[ankur]
08 REQUEST aspect:service after GreetingService#greetTwice returned=Hello, ankur / Hello, ankur
09 REQUEST aspect:controller after HelloController#twice returned=Hello, ankur / Hello, ankur (String)
### GET /api/helper/ankur (usesHelper calls new Helper().shout(...))
HTTP 200 body=ANKUR!
06 REQUEST aspect:controller before HelloController#helper args=[ankur]
07 REQUEST aspect:service before GreetingService#usesHelper args=[ankur]
08 REQUEST aspect:service after GreetingService#usesHelper returned=ANKUR!
09 REQUEST aspect:controller after HelloController#helper returned=ANKUR! (String)
Source: output/15-aop-traps.txtgreetTwice appears in the trace; the two greet calls inside it do not, because this.greet(...) calls the real object, not the proxy. callsPrivate appears, but the private secret it calls does not (a proxy only intercepts calls made to it, and a private method is never one). usesHelper appears, but Helper#shout does not, although its signature matches the pointcut: a Helper made with new is not a Spring bean and has no proxy.
Self-invocation is the trap behind most “my aspect does not fire” reports. The same mechanism makes @Cacheable silently do nothing when a method calls another method on the same class (measured in the cache post), and any other annotation implemented by a proxy follows the same rule. It is the subject of The Spring Cache Abstraction and the Self-Invocation Trap.
One fix, tested here: inject a reference to the proxy itself and call through it (FixedGreetingService.java takes a @Lazy reference to its own type in the constructor). The same two calls are then advised:
### GET /api/twice-fixed/ankur (the fix: FixedGreetingService calls greet() through an injected @Lazy reference to itself)
HTTP 200 body=Hello, ankur / Hello, ankur
06 REQUEST aspect:controller before HelloController#twiceFixed args=[ankur]
07 REQUEST aspect:service before FixedGreetingService#greetTwice args=[ankur]
08 REQUEST aspect:service before FixedGreetingService#greet args=[ankur]
09 REQUEST aspect:service after FixedGreetingService#greet returned=Hello, ankur
10 REQUEST aspect:service before FixedGreetingService#greet args=[ankur]
11 REQUEST aspect:service after FixedGreetingService#greet returned=Hello, ankur
12 REQUEST aspect:service after FixedGreetingService#greetTwice returned=Hello, ankur / Hello, ankur
13 REQUEST aspect:controller after HelloController#twiceFixed returned=Hello, ankur / Hello, ankur (String)
Going deeper: other fixes and limits
Moving the called method to a different bean also works because the call then crosses a proxy; I did not run that variant, and neither AopContext.currentProxy() (which needs exposeProxy) nor AspectJ load-time or compile-time weaving.
The direct call service.greetTwice("direct") outside any request is advised too: output/15-aop-traps.txt ends with two aspect:service lines whose dispatcher column is -, because there is no request.
With Spring Security 7.1.2 on the classpath and the demo’s security profile on (SecurityDemoConfig.java), Spring Security is not a separate system: it is one more servlet filter, springSecurityFilterChain, ordered at -100, which in turn contains its own list of filters. The real chain, from output/16-security-order.txt:
### GET /api/hello/ankur with no credentials
HTTP 401 (empty body)
01 REQUEST filter:early(order -200) before chain
02 REQUEST filter:twice doFilter
03 REQUEST filter:early(order -200) after chain
04 ERROR filter:twice doFilter
Source: output/16-security-order.txt
Only the filter ordered at -200 saw it. The access-log filter at order 10 never ran, and neither did the interceptors or the controller, because Spring Security answered 401 inside its own filter. If you want rejected requests in your access log, the logging filter has to be ordered before -100. The 401 also arrives with an empty body: the ERROR dispatch reached the security filter (filter:twice on line 04) and went no further, so Boot’s error page never rendered. That is the traced behaviour for this configuration, where anyRequest().authenticated() covers /error too.
And now the bug that this layout makes easy. A plain Filter declared as a bean and also added to the security chain with addFilterBefore:
is registered twice: once by Spring Security, once by Boot, which registers every Filter bean with Tomcat. One request, from output/17-security-double-registration.txt:
02 REQUEST filter:twice doFilter
03 REQUEST filter:raw(order 5) before chain
04 REQUEST filter:accessLog(order 10) before chain GET /api/hello/ankur
05 REQUEST filter:twice doFilter
filter:twice ran 2 times for ONE request
The fix is to stop Boot registering it: a FilterRegistrationBean for the same filter with setEnabled(false).
Only a plain Filter was tested here. A OncePerRequestFilter has its own guard against running twice per request, but I did not run that case, so I do not claim it. The safest habit is the same for both: a filter that belongs in the security chain should not also be a container-registered bean.
Most teams test this layer with MockMvc, and it is fast and good for controller logic. But it does not run the same path. The same four requests go through both in output/19-mockmvc-vs-server.txt, and the differences are real:
Request
MockMvc
Real Tomcat
GET /api/hello/ankur
15 trace lines; no DispatcherServlet lines
17 trace lines
GET /nope (404)
8 lines, no ERROR dispatch
19 lines: the ERROR dispatch runs the interceptors for /error
perform() returns with asyncStarted=true; the ASYNC trip happens only if you call asyncDispatch
the container re-dispatches on its own
The first row is a testing artefact worth knowing about: MockMvc uses its own TestDispatcherServlet (the class is in the spring-test jar, output/22-boot-bytecode-facts.txt), so the demo’s TracingDispatcherServlet never runs. The set of filters is the same, in the same order, minus Tomcat’s WebSocket filter:
Use MockMvc for what the controller returns; use a real server for what runs when. Anything about error pages, the ERROR dispatch, filters that behave differently on error or async dispatches, or the real container filter order needs @SpringBootTest(webEnvironment = RANDOM_PORT) and an HTTP client. Everything in sections 6 to 8 would have passed or failed differently under MockMvc alone.
Which one should you use?
You want to…
Use
Why (and where it was shown)
Reject a request before Spring MVC, or before authentication
Filter
It can end the chain; nothing inside runs (section 5)
Log every request including 401s and unhandled exceptions
Filter, ordered before the auth filter
Sees rejections; but read the status in the error attributes, not response.getStatus() (sections 6, 11)
Read or rewrite the body, or compress/transform the response
Filter with a wrapper
Only a filter can swap the request or response objects (section 8)
Check an annotation on the controller method before it runs
Interceptor
It is handed the HandlerMethod (sections 4, 5)
Add something to every MVC response headers-wise
Interceptor preHandle, not postHandle
The response is already committed by postHandle for @ResponseBody (see the box below)
Audit business calls with arguments and results
Aspect
The only one that sees converted arguments and return values (section 4)
Cover code that is not reached by HTTP (jobs, listeners)
Aspect
The only one that also works on a direct call (section 10)
Per-request work that must run exactly once with async controllers
Interceptor with a dispatcher-type check, or a filter
Interceptor preHandle runs twice, filters once (section 7)
The “headers” row rests on one more result. A header set in preHandle arrives; the same header set in postHandle is silently lost for a @ResponseBody method, because the response is already committed by then. From output/11-late-header.txt:
Should you even write one of these? Often not. Authentication belongs in Spring Security, not in a hand-rolled filter. Request metrics and tracing are better served by Micrometer observations than by a timing interceptor. An aspect is the heaviest of the three: an advised bean is replaced by a proxy (a CGLIB subclass of GreetingService in this run), and the self-invocation trap above is easy to hit. Reach for a filter for container-level concerns, an interceptor for method-annotation checks, an aspect when you need arguments or results, and write none of them if a framework feature already does it. What this post did not test: WebFlux, Jetty or Undertow, virtual threads, @Async methods, AspectJ weaving, form and multipart bodies, a Boot 3.5 run of the same suite (only Framework 6.2.19 was compared, by javap), HTTP/2, and any real load.
No Comments yet!