Skip to main content

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.
Servlet filters (Tomcat runs these, Spring MVC is not involved yet) raw 5 → accessLog 10 → apiKey 20 → visibility 25 → body 30 → fault 40 DispatcherServlet (Spring MVC finds the handler) HandlerInterceptors: A, B preHandle runs going in, postHandle and afterCompletion run coming out Aspects (Spring AOP proxies around beans) @Around on the controller (order 1), @Around on the service (order 2) HelloController#hello → GreetingService#greet Each ring runs its own code, calls the ring inside it, and runs more code after that call returns.
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 FilterHandlerInterceptor@Aspect (Spring AOP)
Defined byJakarta ServletSpring MVCSpring AOP (AspectJ annotations, proxies)
Matched byURL pattern, orderURL path patterns on handlersPointcut expression on bean method calls
Runs forEvery request Tomcat dispatches to the appRequests that reach DispatcherServlet and have a handlerCalls that go through a Spring proxy, web or not
Knows about controllersNoYesOnly as ordinary beans
Going deeper: how the pieces are wired in Boot 4

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:
HTTP 200  body=Hello, ankur
01 REQUEST  filter:raw(order 5)            before chain
02 REQUEST  filter:accessLog(order 10)     before chain GET /api/hello/ankur
03 REQUEST  servlet:DispatcherServlet      doDispatch start
04 REQUEST  interceptor:A                  preHandle  handler=HandlerMethod HelloController#hello
05 REQUEST  interceptor:B                  preHandle  handler=HandlerMethod HelloController#hello
06 REQUEST  aspect:controller              before HelloController#hello args=[ankur]
07 REQUEST  controller                     hello(ankur)
08 REQUEST  aspect:service                 before GreetingService#greet args=[ankur]
09 REQUEST  aspect:service                 after  GreetingService#greet returned=Hello, ankur
10 REQUEST  aspect:controller              after  HelloController#hello returned=Hello, ankur (String)
11 REQUEST  interceptor:B                  postHandle status=200 modelAndView=null
12 REQUEST  interceptor:A                  postHandle status=200 modelAndView=null
13 REQUEST  interceptor:B                  afterCompletion ex=null
14 REQUEST  interceptor:A                  afterCompletion ex=null
15 REQUEST  servlet:DispatcherServlet      doDispatch end
16 REQUEST  filter:accessLog(order 10)     after chain status=200 asyncStarted=false
17 REQUEST  filter:raw(order 5)            after chain
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 filter: raw (order 5) filter: accessLog (10) DispatcherServlet interceptor A interceptor B aspect: controller controller: hello() aspect: service pre post done pre post done Each bar runs from the first trace line a layer printed to its last. The numbers are the line numbers of 03-happy-path.txt. pre = preHandle, post = postHandle, done = afterCompletion. B starts after A and finishes before A.
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.
Going deeper: how the trace is built

The line for DispatcherServlet comes from a subclass that overrides doDispatch (TracingDispatcherServlet.java):

    protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        trace.log(request, "servlet:DispatcherServlet", "doDispatch start");
        try {
            super.doDispatch(request, response);
        } finally {

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:
 1  characterEncodingFilter      [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 2  formContentFilter            [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 3  requestContextFilter         [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 4  rawFilter                    [/*]                 [REQUEST]
 5  accessLog                    [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 6  apiKeyFilter                 [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 7  visibilityFilter             [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 8  bodyFilter                   [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 9  faultFilter                  [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
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:
OrderedCharacterEncodingFilter.getOrder()     = -2147483648
OrderedFormContentFilter.DEFAULT_ORDER       = -9900
OrderedRequestContextFilter.getOrder()      = -105
SecurityFilterProperties.DEFAULT_FILTER_ORDER = -100
Ordered.LOWEST_PRECEDENCE (no @Order)        = 2147483647
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:
shouldNotFilterErrorDispatch() = true
shouldNotFilterAsyncDispatch() = true
Both lines come from output/01-versions-and-jars.txt. Sections 6 and 7 show the consequence in a trace.
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

What each one can see, and what it cannot

The nesting decides what each layer has in its hands. To show it rather than claim it, the demo has a probe in each layer for one request, GET /api/visibility/42 with an X-Probe: hello header (VisibilityFilter, VisibilityInterceptor, VisibilityAspect). The lines below come from output/09-visibility.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:
FilterInterceptorAspect
Raw request and response (headers, parameters)Yes (read X-Probe=hello)YesOnly 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 themNo
Which controller method will runNo before the chain; the attribute is set afterYes: HandlerMethod, its annotations and parameter typesNot the HandlerMethod; the join point’s signature and target
Method arguments, convertedNoNo (only the raw path strings)Yes: 42 as a Long
Return valueNoNo (modelAndView=null for @ResponseBody)Yes: Item[id=42]
Runs for calls that are not HTTP requestsNoNoYes (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.
  • Spring Framework reference: interceptors

Matching jobs to layers: authentication, authorisation, timing, logging

Authentication in a filter

Anything that must stop a request before Spring MVC is involved belongs in a filter. ApiKeyFilter protects /api/secure/** with an X-Api-Key header:
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        if (!"demo-key".equals(request.getHeader("X-Api-Key"))) {
            trace.log(request, "filter:apiKey(order 20)", "REJECT 401, chain stops here");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"missing or wrong X-Api-Key\"}");
            return;
        }
        trace.log(request, "filter:apiKey(order 20)", "key ok, continue");
        chain.doFilter(request, response);
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):
    @Around("execution(* com.ankurm.filteraop.service..*(..))")
    public Object aroundService(ProceedingJoinPoint pjp) throws Throwable {
        String method = pjp.getSignature().getDeclaringType().getSimpleName() + "#" + pjp.getSignature().getName();
        trace.log("aspect:service", "before " + method + " args=" + Summaries.of(pjp.getArgs()));
        long start = System.nanoTime();
        try {
            Object result = pjp.proceed();
            trace.log("aspect:service", "after  " + method + " returned=" + Summaries.of(result));
            return result;
        } catch (Throwable t) {
Going deeper: more on each job

When something goes wrong: what still runs

This is the section that explains most “my interceptor did not run” reports. Each row below is one request in output/04-exceptions.txt, output/05-static-404-error-dispatch.txt, output/06-short-circuits.txt or output/12-no-handler.txt, and every cell is something the tests assert. “ERROR dispatch” means Tomcat made a second trip into the app to render /error after sendError or an unhandled exception.
RequestpreHandlepostHandleafterCompletionAdviceFilters’ “after chain” sawERROR dispatch
Controller throws, @ExceptionHandler handles itA, BskippedB, A with ex=nullranstatus 500no
Controller throws, nobody handles itA, BskippedB, A with ex=UnsupportedOperationExceptionnostatus 200yes: interceptors run for BasicErrorController, filters do not
A filter throwsnone in the first tripnonenonenostatus 200yes: exception=IllegalStateException
Static file exists (/hello.txt)A, B, with ResourceHttpRequestHandlerranrann/astatus 200no
No controller, no file (/nope)A, B, with ResourceHttpRequestHandlerskippedB, A with ex=nulln/astatus 404yes
Wrong method (POST /api/ping)nonenonenonen/astatus 405yes
add-mappings=false, any unknown URLnonenonenonen/astatus 404yes
preHandle of gate returns falseA, gatenoneA onlyn/astatus 403, or 200 with no bodyno
filters: raw, accessLog “before chain” ran DispatcherServlet doDispatch start ran interceptors A, B: preHandle ran aspect: controller “before” ran controller: boom() throws IllegalStateException ran aspect: controller “THREW”, rethrows ran @RestControllerAdvice @ExceptionHandler writes the 500 body ran interceptors B, A: postHandle SKIPPED interceptors B, A: afterCompletion, ex=null ran filter accessLog “after chain”, status=500 ran The controller threw, yet afterCompletion reports ex=null: the advice already resolved the exception. postHandle never runs.
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:
08 REQUEST  aspect:controller              after  HelloController#unhandled THREW UnsupportedOperationException
09 REQUEST  interceptor:B                  afterCompletion ex=UnsupportedOperationException
10 REQUEST  interceptor:A                  afterCompletion ex=UnsupportedOperationException
11 REQUEST  servlet:DispatcherServlet      doDispatch end
12 REQUEST  filter:accessLog(order 10)     after chain status=200 asyncStarted=false
13 REQUEST  filter:raw(order 5)            after chain
14 ERROR    servlet:DispatcherServlet      doDispatch start
15 ERROR    interceptor:A                  preHandle  handler=HandlerMethod BasicErrorController#error
16 ERROR    interceptor:A                    error attributes: status=500 exception=ServletException
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.
  • Boot’s global error handling in more depth: Global exception handling with ProblemDetail (RFC 9457) in Spring Boot 4.

Async: preHandle runs twice, the filters run once

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):
01 REQUEST  filter:raw(order 5)            before chain  <tomcat-1>
02 REQUEST  filter:accessLog(order 10)     before chain GET /api/async-callable  <tomcat-1>
03 REQUEST  servlet:DispatcherServlet      doDispatch start  <tomcat-1>
04 REQUEST  interceptor:A                  preHandle  handler=HandlerMethod HelloController#asyncCallable  <tomcat-1>
05 REQUEST  interceptor:B                  preHandle  handler=HandlerMethod HelloController#asyncCallable  <tomcat-1>
06 REQUEST  aspect:controller              before HelloController#asyncCallable args=[]  <tomcat-1>
07 REQUEST  controller                     asyncCallable() returns a Callable  <tomcat-1>
08 REQUEST  aspect:controller              after  HelloController#asyncCallable returned=a lambda (HelloController$$Lambda)  <tomcat-1>
09 REQUEST  interceptor:B                  afterConcurrentHandlingStarted (async began, no postHandle/afterCompletion yet)  <tomcat-1>
10 REQUEST  interceptor:A                  afterConcurrentHandlingStarted (async began, no postHandle/afterCompletion yet)  <tomcat-1>
11 REQUEST  servlet:DispatcherServlet      doDispatch end  <tomcat-1>
12 REQUEST  filter:accessLog(order 10)     after chain status=200 asyncStarted=true  <tomcat-1>
13 REQUEST  filter:raw(order 5)            after chain  <tomcat-1>
14 REQUEST  callable                       finished after 100 ms, returning  <mvc-task-1>
15 ASYNC    servlet:DispatcherServlet      doDispatch start  <tomcat-2>
16 ASYNC    interceptor:A                  preHandle  handler=HandlerMethod HelloController#asyncCallable  <tomcat-2>
17 ASYNC    interceptor:B                  preHandle  handler=HandlerMethod HelloController#asyncCallable  <tomcat-2>
18 ASYNC    interceptor:B                  postHandle status=200 modelAndView=null  <tomcat-2>
19 ASYNC    interceptor:A                  postHandle status=200 modelAndView=null  <tomcat-2>
20 ASYNC    interceptor:B                  afterCompletion ex=null  <tomcat-2>
21 ASYNC    interceptor:A                  afterCompletion ex=null  <tomcat-2>
22 ASYNC    servlet:DispatcherServlet      doDispatch end  <tomcat-2>
Tomcat thread 1 (REQUEST dispatch) MVC task thread (the Callable) Tomcat thread 2 (ASYNC dispatch) filters before chain, DispatcherServlet start, preHandle A, B (1st) controller returns a Callable; the aspect sees a lambda, not the result afterConcurrentHandlingStarted on B, then A filters “after chain”: status=200 asyncStarted=true; the thread is released sleeps 100 ms, returns “callable-done” DispatcherServlet start, preHandle A, B (2nd), postHandle B, A, afterCompletion B, A filters and aspects do not run again on the ASYNC dispatch (see the transcript) One request, two trips through the interceptors: preHandle runs twice, postHandle and afterCompletion once.
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

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-ModeWhat the filter doesResult
(none)nothing200 echo:hello body
consumereads request.getInputStream(), passes the same request on400, controller never runs
consume-wrappedwraps in ContentCachingRequestWrapper but reads through it before the chain400, controller never runs
cachewraps in ContentCachingRequestWrapper and passes it on without reading200; the body is in the wrapper after the chain
replayreads the bytes up front into a wrapper that can be read again200
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
15 REQUEST  filter:body(order 30)          CCRW after chain: getContentAsByteArray().length=10 -> "hello body"
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):
    public CachedBodyRequest(HttpServletRequest request) throws IOException {
        super(request);
        this.body = request.getInputStream().readAllBytes();
    }
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:
HTTP 200  (empty body)  Content-Length=0
01 REQUEST  filter:raw(order 5)            before chain
02 REQUEST  filter:accessLog(order 10)     before chain GET /api/hello/ankur
03 REQUEST  servlet:DispatcherServlet      doDispatch start
04 REQUEST  interceptor:A                  preHandle  handler=HandlerMethod HelloController#hello
05 REQUEST  interceptor:B                  preHandle  handler=HandlerMethod HelloController#hello
06 REQUEST  aspect:controller              before HelloController#hello args=[ankur]
07 REQUEST  controller                     hello(ankur)
08 REQUEST  aspect:service                 before GreetingService#greet args=[ankur]
09 REQUEST  aspect:service                 after  GreetingService#greet returned=Hello, ankur
10 REQUEST  aspect:controller              after  HelloController#hello returned=Hello, ankur (String)
11 REQUEST  interceptor:B                  postHandle status=200 modelAndView=null
12 REQUEST  interceptor:A                  postHandle status=200 modelAndView=null
13 REQUEST  interceptor:B                  afterCompletion ex=null
14 REQUEST  interceptor:A                  afterCompletion ex=null
15 REQUEST  servlet:DispatcherServlet      doDispatch end
16 REQUEST  filter:body(order 30)          response wrapper holds 12 bytes
17 REQUEST  filter:body(order 30)          copyBodyToResponse() NOT called
Source: output/08-request-body.txt
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.
  • See the ETag post for another filter that wraps the response: Mastering Cache Control with ETag.

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):
/api/hello/ankur         200  [/api/**, /api/hello/*, /api/hello/**, /api/hello/{name}, /**] ERROR dispatch: []
/api/hello/ankur/        404  [/api/**, /api/hello/**, /**]                                  ERROR dispatch: [/**, /error]
/api/hello               404  [/api/*, /api/**, /api/hello, /api/hello/**, /**]              ERROR dispatch: [/**, /error]
/api/hello/              404  [/api/**, /api/hello/*, /api/hello/**, /**]                    ERROR dispatch: [/**, /error]
/api/ping                200  [/api/*, /api/**, /**]                                         ERROR dispatch: []
/api/ping/               404  [/api/**, /**]                                                 ERROR dispatch: [/**, /error]
/API/hello/ankur         404  [/**]                                                          ERROR dispatch: [/**, /error]
/api/hello/a/b           404  [/api/**, /api/hello/**, /**]                                  ERROR dispatch: [/**, /error]
/api/hello/ankur.json    200  [/api/**, /api/hello/*, /api/hello/**, /api/hello/{name}, /**] ERROR dispatch: []
/api/hello/ankur;x=1     200  [/api/**, /api/hello/*, /api/hello/**, /api/hello/{name}, /**] ERROR dispatch: []
/nope                    404  [/**]                                                          ERROR dispatch: [/**, /error]
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
setPathMatcher                     @Deprecated(since="7.0", forRemoval=true)
setPatternParser                   -
setUrlPathHelper                   @Deprecated(since="7.0", forRemoval=true)
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"}
06 REQUEST  interceptor:B                  afterCompletion ex=ClassCastException
07 REQUEST  interceptor:A                  afterCompletion ex=ClassCastException
08 REQUEST  servlet:DispatcherServlet      doDispatch end
09 REQUEST  filter:accessLog(order 10)     after chain status=200 asyncStarted=false
10 REQUEST  filter:raw(order 5)            after chain
11 ERROR    servlet:DispatcherServlet      doDispatch start
12 ERROR    interceptor:A                  preHandle  handler=HandlerMethod BasicErrorController#error
13 ERROR    interceptor:A                    error attributes: status=500 exception=ServletException
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.
  • Spring Framework reference: interceptors

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)
Source: output/15-aop-traps.txt
### GET /api/private/ankur  (callsPrivate calls a private method)
HTTP 200  body=secret-ankur
06 REQUEST  aspect:controller              before HelloController#privateCall args=[ankur]
07 REQUEST  aspect:service                 before GreetingService#callsPrivate args=[ankur]
08 REQUEST  aspect:service                 after  GreetingService#callsPrivate returned=secret-ankur
09 REQUEST  aspect:controller              after  HelloController#privateCall returned=secret-ankur (String)
Source: output/15-aop-traps.txt
### 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.txt greetTwice 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.
  • Pointcut designators and proxy types in depth: Spring AOP Explained.

Spring Security sits inside the servlet chain

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:
 1  characterEncodingFilter      [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 2  formContentFilter            [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 3  earlyFilter                  [/*]                 [REQUEST]
 4  requestContextFilter         [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 5  springSecurityFilterChain    [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 6  rawFilter                    [/*]                 [REQUEST]
 7  accessLog                    [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 8  apiKeyFilter                 [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
 9  visibilityFilter             [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
10  bodyFilter                   [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
11  faultFilter                  [/*]                 [FORWARD, INCLUDE, REQUEST, ERROR, ASYNC]
12  twiceFilter                  [/*]                 [REQUEST]
13  Tomcat WebSocket (JSR356) Filter [/*]                 [FORWARD, REQUEST]
characterEncodingFilter order Integer.MIN_VALUE formContentFilter order -9900 earlyFilter (demo, FilterRegistrationBean) order -200 requestContextFilter order -105 springSecurityFilterChain order -100 rawFilter order 5 accessLog order 10 apiKeyFilter, visibilityFilter, bodyFilter, faultFilter order 20, 25, 30, 40 twiceFilter (no @Order) order LOWEST A request that Spring Security rejects never reaches the dashed group. Only a filter ordered before -100, like earlyFilter, sees it (traced: early before chain, early after chain).
Inside that one filter, FilterChainProxy runs twelve filters of its own:
 1  DisableEncodeUrlFilter
 2  WebAsyncManagerIntegrationFilter
 3  SecurityContextHolderFilter
 4  HeaderWriterFilter
 5  LogoutFilter
 6  TwiceFilter
 7  BasicAuthenticationFilter
 8  RequestCacheAwareFilter
 9  SecurityContextHolderAwareRequestFilter
10  AnonymousAuthenticationFilter
11  ExceptionTranslationFilter
12  AuthorizationFilter
Source: output/16-security-order.txt The consequence is about order. A request with no credentials:
### 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:
        http.authorizeHttpRequests(a -> a.requestMatchers("/diag/**").permitAll().anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .csrf(c -> c.disable())
                .addFilterBefore(twiceFilter, BasicAuthenticationFilter.class);
        return http.build();
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).
    FilterRegistrationBean<TwiceFilter> twiceFilterRegistration(TwiceFilter twiceFilter) {
        FilterRegistrationBean<TwiceFilter> reg = new FilterRegistrationBean<>(twiceFilter);
        reg.setEnabled(false);
        return reg;
With it, output/18-security-double-registration-fixed.txt ends with:
filter:twice ran 1 time(s) for ONE request
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.
Going deeper: Security and the rest

MockMvc is not Tomcat

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:
RequestMockMvcReal Tomcat
GET /api/hello/ankur15 trace lines; no DispatcherServlet lines17 trace lines
GET /nope (404)8 lines, no ERROR dispatch19 lines: the ERROR dispatch runs the interceptors for /error
GET /api/unhandledperform() throws ServletException (cause UnsupportedOperationException)HTTP 500 with Boot’s JSON error body
GET /api/async-callableperform() returns with asyncStarted=true; the ASYNC trip happens only if you call asyncDispatchthe 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:
  OrderedCharacterEncodingFilter
  OrderedFormContentFilter
  OrderedRequestContextFilter
  RawFilter
  AccessLogFilter
  ApiKeyFilter
  VisibilityFilter
  BodyFilter
  FaultFilter
And the async case, after asyncDispatch, shows the ASYNC trip with no filter lines in it, the same as the real server:
01 ASYNC    interceptor:A                  preHandle  handler=HandlerMethod HelloController#asyncCallable
02 ASYNC    interceptor:B                  preHandle  handler=HandlerMethod HelloController#asyncCallable
03 ASYNC    interceptor:B                  postHandle status=200 modelAndView=null
04 ASYNC    interceptor:A                  postHandle status=200 modelAndView=null
05 ASYNC    interceptor:B                  afterCompletion ex=null
06 ASYNC    interceptor:A                  afterCompletion ex=null
Source: output/19-mockmvc-vs-server.txt
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…UseWhy (and where it was shown)
Reject a request before Spring MVC, or before authenticationFilterIt can end the chain; nothing inside runs (section 5)
Log every request including 401s and unhandled exceptionsFilter, ordered before the auth filterSees rejections; but read the status in the error attributes, not response.getStatus() (sections 6, 11)
Read or rewrite the body, or compress/transform the responseFilter with a wrapperOnly a filter can swap the request or response objects (section 8)
Check an annotation on the controller method before it runsInterceptorIt is handed the HandlerMethod (sections 4, 5)
Add something to every MVC response headers-wiseInterceptor preHandle, not postHandleThe response is already committed by postHandle for @ResponseBody (see the box below)
Audit business calls with arguments and resultsAspectThe only one that sees converted arguments and return values (section 4)
Cover code that is not reached by HTTP (jobs, listeners)AspectThe only one that also works on a direct call (section 10)
Per-request work that must run exactly once with async controllersInterceptor with a dispatcher-type check, or a filterInterceptor 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:
09 REQUEST  interceptor:lateHeader         postHandle response.isCommitted()=true
X-Added-In-PreHandle  = yes
X-Added-In-PostHandle = null
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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.