Filters, Interceptors and AOP: Order and Practice
Set aside the names Filter, Interceptor and AOP for a moment. All you need to remember is this: an HTTP request is a passenger walking from the terminal entrance to the gate, and it passes several completely different checkpoints along the way — each able to see a wildly different amount of information.

the check-in desk is a Filter. It sits in the Servlet container (Tomcat, the server that actually accepts network connections) and inspects only your ID card and your luggage — it knows "a person", but has no idea which flight you are on or which gate. The security checkpoint is an Interceptor, owned by Spring MVC, and it holds the flight manifest: it can see exactly which method of which class you are about to call (an object called HandlerMethod), so it can say "you are not on this flight". The gate re-check is AOP (aspect-oriented programming — the technique of weaving "the chores every method must do" uniformly around a method call), and it watches only the act of boarding; it has no say in how you walked through the terminal.
what is afterCompletion (the callback that always fires when the request wraps up)? It is settling the bill at departure. Every earlier stage may go wrong — a delay, a rebooking, a refused boarding — but the cashier does not care how the play went; at departure they settle up and clear the table. That is why "measure elapsed time" and "clear the ThreadLocal (a private locker tied to the thread)" always belong in afterCompletion, never in postHandle, which only fires for passengers who made it.
After this article you should be able to answer three questions:
- A Filter, an Interceptor and an aspect all look like they can "intercept" a request. Which one can see the method name, and why does that single fact decide everything?
- The controller throws an exception. Which hooks still run, which are skipped, and where does the exception actually go?
- I need a login check that returns a uniform JSON error body and can whitelist
/login. Which layer, and why is the obvious alternative worse?
"Should the login check live in a Filter or an Interceptor?" "Should request timing use an interceptor or AOP?" Almost every Spring newcomer hits the same fork: all three mechanisms seem able to "intercept" a request before the business code runs, so you pick one at random — until the day you discover "the interceptor can't see the method name" or "the aspect can't catch static resources" and go back to study.
Define the roles first; the choice then follows naturally:
| Mechanism | Spec / container | Granularity | What it can see | Typical use |
|---|---|---|---|---|
| Filter | Servlet spec (Tomcat) | One HTTP request / response | ServletRequest / ServletResponse; it does not know which controller method will run | CORS, charset, request logging, gzip, XSS filtering |
| Interceptor | Spring MVC | Handler level (around the controller) | The HandlerMethod (which method of which bean), ModelAndView | Login checks, authorization, auditing, i18n |
| AOP | Spring container (method level) | Any method of any Spring bean | Signature, arguments, return value, thrown exception | Transactions, caching, timing, idempotency, rate limiting |
the real watershed is whether it knows the business method. A Filter lives in the Servlet world and only sees HttpServletRequest; it has no idea which controller will run. An Interceptor is owned by Spring MVC and can access the HandlerMethod. AOP goes even deeper, wrapping an individual bean method. Want the logic "closer to business"? Use Interceptor / AOP. Want it "closer to the container, applying to every request"? That is what Filter is for.

Flatten one normal request and the order is:
Request arrives └─ Filter chain (code before chain.doFilter, forward order) └─ DispatcherServlet.doDispatch └─ Interceptor.preHandle (forward order) └─ Controller method ── (wrapped inside by AOP @Around: before → target → after) └─ Interceptor.postHandle (reverse order, only on success) └─ View rendering / response writing └─ Interceptor.afterCompletion (reverse order, finally semantics, always runs) └─ Filter chain (code after chain.doFilter, reverse order) └─ Response back to the client- Front hooks run forward, back hooks run in reverse — like nested dolls, unwinding in the opposite order
postHandleruns only when the controller returns successfully; it holds theModelAndView, so it can adjust the view — rarely needed in RESTafterCompletionhas finally semantics: as long as itspreHandleonce returnedtrue, it runs whether the request succeeded or failed — ideal for clearing ThreadLocals and closing resources

What if the controller throws? The exception path is: Filter before-code → preHandle → controller throws → postHandle is skipped → afterCompletion (reverse order, exception passed as the argument) → the exception is handed to HandlerExceptionResolver → response is written → Filter after-code. Remember one line: postHandle is for success, afterCompletion is for cleanup — one may be skipped, the other always runs.
That line can only be read once. The diagram below unfolds it into nine stations you can click through — when learning an order, what matters is not memorising it but knowing who each station is waiting for:
Filters suit chores that are business-agnostic and apply to every request. Two classics: CORS and access logs.
A CORS filter must run first, or preflight (OPTIONS) requests get blocked by later logic:
@Component@Order(Ordered.HIGHEST_PRECEDENCE) // CORS must run firstpublic class CorsFilter extends OncePerRequestFilter { // guarantees a single run per request @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); response.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "*"); if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); // finish preflight, skip business return; } chain.doFilter(request, response); // pass to the next filter }}- Extend
OncePerRequestFilterrather than implementFilterdirectly, so forward/include do not run it twice @Order(HIGHEST_PRECEDENCE)puts it before every other filter — CORS headers must be written firstchain.doFilter(request, response)is the dividing line: before it is "before", after it is "after"- OPTIONS preflight has no body; returning 200 directly avoids the business code
A request-timing log demonstrates the "after" code:
@Component@Order(1)public class AccessLogFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class); @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { chain.doFilter(req, res); // "before": the clock starts } finally { long cost = System.currentTimeMillis() - start; // "after": written in finally log.info("{} {} status={} cost={}ms", req.getMethod(), req.getRequestURI(), res.getStatus(), cost); } }}- "Start" goes before
chain.doFilter, "end" after it — the Filter's before/after model in one picture - Putting the log in
finallyrecords timing and status even when business code throws res.getStatus()is already final here, because the after-code runs before the response is committed
A @Component Filter is auto-registered by Spring Boot, but its order is hard to control. For precise ordering use FilterRegistrationBean:
@Configurationpublic class FilterConfig { @Bean public FilterRegistrationBean<AccessLogFilter> accessLog() { FilterRegistrationBean<AccessLogFilter> reg = new FilterRegistrationBean<>(new AccessLogFilter()); reg.addUrlPatterns("/*"); // intercept every path reg.setOrder(1); // smaller number runs earlier reg.setName("accessLogFilter"); return reg; }}The annotation route exists too, but the app must scan it:
@WebFilter(urlPatterns = "/*", filterName = "accessLogFilter")public class AccessLogFilter extends OncePerRequestFilter { /* ... */ }// Add this to the main class, or @WebFilter silently does nothing@ServletComponentScan@SpringBootApplicationpublic class DemoApplication { }| Registration | Order control | Dependency injection | Best for |
|---|---|---|---|
FilterRegistrationBean | setOrder, precise | Natural | When order and injected beans matter (recommended) |
@WebFilter + @ServletComponentScan | @Order, easy to muddle | Via container callbacks | Simple cases, native Servlet habits |
Plain @Component | @Order | Supported | Global default, but ordering is opaque |
the classic @WebFilter trap is forgetting @ServletComponentScan. The annotation is there, the class is there, but the filter never runs — because it is not picked up by Spring component scanning, and Servlet component scanning must be enabled explicitly.
The login check is the Interceptor's home turf: it knows which method will run and can be whitelisted precisely with excludePathPatterns.
@Componentpublic class LoginInterceptor implements HandlerInterceptor { private final TokenService tokenService; // injected; note it is a singleton public LoginInterceptor(TokenService tokenService) { this.tokenService = tokenService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; // let CORS preflight through } String token = request.getHeader("Authorization"); LoginUser user = tokenService.parse(token); // null when invalid / expired if (user == null) { throw new UnauthorizedException("Please sign in"); // throw, let the global handler respond } UserContext.set(user); // stash in a ThreadLocal for later business code return true; // true continues the chain } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView mv) { // Runs only when the controller returns successfully; usually a no-op in REST projects } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); // ALWAYS runs: clear the ThreadLocal to avoid leakage }}preHandlereturningtruepasses,falseaborts — the only semantics to remember- Throw
UnauthorizedException(...)instead of returningfalse, so the global@RestControllerAdvicereturns a consistent error body (see the table in Section 5) - The
Exception exargument ofafterCompletion: null means it finished normally, non-null means the controller threw — it is your last chance to tell whether the request succeeded afterCompletionis always for cleanup: ThreadLocals, counters, request-scoped caches
Register the interceptor and configure include/exclude patterns:
@Configurationpublic class WebMvcConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebMvcConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor = loginInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") // only business APIs .excludePathPatterns( // whitelist; first match wins "/api/auth/**", // login, register "/actuator/**", // health checks "/error", // error page "/favicon.ico" ); }}addPathPatterns/excludePathPatternsuse Ant-style paths:/**matches multiple levels,/*matches one- Registration order equals execution order (
preHandleforward); the back hooks run in reverse - The interceptor is a singleton bean and can inject
TokenServicenormally — but must never store request data in fields (see Section 9)
This is the table to remember — it decides whether the client gets a proper error at all:
| Dimension | preHandle returns false | preHandle throws |
|---|---|---|
| Controller invoked | No | No |
| Response behaviour | You must write the response yourself, or the client gets an empty / ambiguous 200 | Propagates to DispatcherServlet, handed to HandlerExceptionResolver |
postHandle runs | No | No |
afterCompletion runs | Not for this interceptor (only earlier ones that already passed do) | Yes for all interceptors that passed; the exception is passed in |
| Consistent error body | Hand-written; easily diverges from the global one | Reuses @RestControllerAdvice, consistent by default |
| Best for | When you already produced the response (redirect, JSON / HTML) | When you want a standard error body and shared global handling |
when preHandle returns false, that interceptor's own afterCompletion will not run. DispatcherServlet aborts during applyPreHandle, triggering afterCompletion only for interceptors that already returned true. If you set a ThreadLocal in preHandle and clear it in afterCompletion, one false return leaves residue — decide exactly where cleanup happens.
when you can produce a standard error, throw; returning false is only for "I already wrote the response". This echoes the DispatcherServlet conclusion in Article 22: an interceptor guards the door, and a failed guard should give a clear signal, not leave the client staring at an empty response.
The two paths differ by one line of source but by an order of magnitude in outcome. Step through the debugger and watch exactly what gets skipped — pay attention to step 2 (the short circuit) and step 4 (why postHandle never gets a second chance):
boolean interest = mappedHandler.applyPreHandle(request, response);if (!interest) { return; } // short circuit: nothing below happensmv = ha.handle(request, response, mappedHandler.getHandler);mappedHandler.applyPostHandle(request, response, mv);} catch (Exception ex) { dispatchException = ex; }processDispatchResult(request, response, mappedHandler, mv, dispatchException);mappedHandler.triggerAfterCompletion(request, response, ex);| interceptors | 2 |
| currently running | AuthInterceptor.preHandle |
| passed counter | 0 |
DispatcherServlet.doDispatchHandlerExecutionChain.applyPreHandle
Frame ⑤ of that animation is the expensive one: the interceptor that returned false never gets its own afterCompletion. Metrics, ThreadLocal cleanup and auditing written in its completion hook silently miss every request it rejects — which is precisely what the "swap the registration order" exercise in Tier 2 of Section 18 makes you observe.
Many people wonder, on first meeting AOP: "If an aspect can wrap methods, can it intercept the whole request?" The answer is no, and the reason lives in the word "proxy".
- AOP works by creating a proxy for a bean that the Spring container manages; a call must go through the injected proxy for the aspect to fire
- The Servlet container (Tomcat) instantiates and calls Filters directly, bypassing the Spring proxy entirely — no aspect can attach
- Spring MVC calls the registered interceptor object directly;
preHandleis not a proxied bean method either, so aspects miss it too - More fundamentally: AOP wraps a method call, so the request must already be inside the method body. The whole point of
preHandleis to reject a request before the method is invoked — AOP simply cannot do that; by the time it steps in, the business method is already running
AOP's real home is inside methods: transactions, caching, timing, idempotency, rate limiting. It has boundaries too, worth knowing before use:
- It applies only to Spring-managed beans; objects you
newyourself have no proxy - Self-invocation inside the same class (
this.otherMethod()) bypasses the proxy, so the aspect is skipped — inject the bean into itself or useAopContext.currentProxy() private/finalmethods cannot be overridden by CGLIB, so the aspect is skipped as well
in one line — Filter decides "can it get in", Interceptor decides "should it be passed to this handler", AOP decides "what happens before/after this method call". They relay rather than replace each other.
All three can be ordered, but differently; mixing them up is easy. Rather than memorising a table, play a round: the left column is the code you write, the right column is the rule that actually applies — a wrong pick explains itself immediately.
all three agree that "smaller comes first", but the Interceptor ignores the number and uses only registration order — the easiest discrepancy to trip over. To reorder interceptors, change the addInterceptor call sequence; @Order has no effect on them.
Real tracing needs "one traceId per request", carried from the outermost layer into the logs. This is where the three cooperate: the Filter creates and binds it, and the Interceptor / AOP / business just reuse it.
@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class TraceIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id")) .orElseGet(() -> UUID.randomUUID().toString().replace("-", "")); MDC.put("traceId", traceId); // bind to the current thread; logs pick it up res.setHeader("X-Trace-Id", traceId); // echo it back for cross-checking try { chain.doFilter(req, res); } finally { MDC.remove("traceId"); // the thread pool reuses threads; always clean up } }}- Generate the traceId in the Filter: it runs first, guaranteeing a value for everything downstream
MDC(SLF4J's mapped diagnostic context) binds the traceId to the current thread so the logging framework prints it automatically- Remove it in
finally: Tomcat's thread pool reuses threads, and leftover state leaks one request's traceId into the next
Reference %X{traceId} in the log pattern to print it automatically:
<!-- logback-spring.xml --><pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>%X{traceId}reads the current thread's traceId from the MDC- Interceptors, aspects and business code can just call
MDC.get("traceId")— no parameter threading - This is the classic "Filter generates → MDC shares → everyone reuses" combination
That relay — generate once, reuse everywhere, clean up at the end — deserves its own walkthrough, because the classic beginner failure here is doing the first two steps and forgetting the third. When traceIds in production logs belong to the wrong request, eight times out of ten the remove call in finally was skipped:

the MDC is backed by a ThreadLocal, so async threads (@Async, thread pools, CompletableFuture) cannot see the parent thread's traceId. Crossing threads requires wrapping the task manually (MDC.getCopyOfContextMap() before submitting, setContextMap before running), or async logs will miss or mix up traceIds.
an interceptor is a singleton bean — never store request-private state in its fields. There is exactly one LoginInterceptor instance, shared across concurrent requests. If you write private LoginUser currentUser; and assign it in preHandle, request B overwrites request A's user, causing a privilege leak — a sporadic, nearly unreproducible bug. Store it in a ThreadLocal (cleared afterwards) or request.setAttribute, never in a field.
an interceptor only affects requests that enter DispatcherServlet; static resources often slip through. When static resources are served by the container's default Servlet, they never reach DispatcherServlet and the interceptor does not apply; when they are served by Spring MVC's ResourceHttpRequestHandler, a / interceptor catches them and blocks JS / CSS requests behind a login check. Conclusion: always exclude static resource paths (e.g. /static/, /favicon.ico) with excludePathPatterns; do not count on "it does nothing by default".
The demo below turns the DispatcherServlet dispatch flow into switchable paths. Switch between /users/42, /users and /nope, and watch the order of argument resolution and controller execution — preHandle happens before both, which is exactly why an interceptor can reject a request early.
The first ten sections were talking. This one is running. The demo below turns the five things people argue about most in this chapter into switchable parameters — work through order → pre → ex → cors → scope in that order, and each cell maps onto a real ticket.

The same need, placed on the wrong layer, simply loses its powers — which is exactly what that diagram catalogues:
| You want to… | Only this layer can do it | Why the others cannot |
|---|---|---|
| Add CORS headers, gzip or a traceId to every response | Filter | An interceptor sits inside the MVC layer and an aspect cannot reach static resources or the container's own filters at all |
Reject a request by reading @LoginRequired on the method | Interceptor | A Filter has no HandlerMethod, so it can only match URL strings; an aspect fires after the method has already been invoked |
| Time a service call, or make it idempotent | AOP | A Filter cannot see a service bean; an interceptor only wraps controllers, so @Service internals escape it |
| Answer 401 before the controller runs | Interceptor (preHandle) | An aspect runs around the invocation, so the method is already on the stack; a Filter can abort too, but has no method-level whitelist |
| Clear a ThreadLocal no matter how the request ended | Interceptor (afterCompletion) | AOP advice runs only if you catch the exception yourself; a Filter's finally works but cannot reach per-handler state |
What each of the five parameters is supposed to show you:
- order: the complete line — container filters (forward) → DispatcherServlet →
preHandle(forward) → argument resolution and validation → controller →postHandle(reverse) → exception resolution →afterCompletion(reverse) → filter after-code (reverse). Note thatafterCompletionruns earlier than the body is actually flushed out - pre: the moment
preHandlereturnsfalse,applyPreHandleaborts — later interceptors, argument resolution, the controller andpostHandleare all skipped, and only interceptors that already passed receiveafterCompletion. That is the number one reason counters silently under-report - ex: when the controller throws,
applyPostHandleis skipped wholesale — even if@RestControllerAdvicelater turns the exception into a tidy 400,postHandleis never replayed, whileafterCompletionalways runs and carries the original exception - cors: which of the three CORS placements (Filter /
addCorsMappings/@CrossOrigin) survives. A preflight carries no credentials, so authorisation ordered ahead of CORS judges a legitimate OPTIONS request as 401 - scope: the hard boundary of what each checkpoint can see — the diagram above, in motion
In the traceId combination of Section 8 you notice a clash as soon as the project pulls in Spring Security: "your own interceptor" and "Security's filters" are competing for the same job. Look at the structure of its chain first — the Security chain is itself a dozen filters, bridged by a DelegatingFilterProxy (a container filter that delegates to Spring beans) into a FilterChainProxy that picks the concrete chain.
this is two parallel security lanes at the airport. Your LoginInterceptor is the extra re-check the airline adds at the gate; the Security chain is the airport's own screening, and every passenger walks through it. permitAll only means "the last officer nods at you" — the metal detector still happens. So marking a public endpoint permitAll saves no time, it only removes "must you be signed in".
Three conclusions to take away from that chain:
- Authentication first, authorisation last:
AuthorizationFilterat the tail (formerlyFilterSecurityInterceptor) is the only step that decides whether the request passes - 401 versus 403 depends purely on "who are you": no identity at all →
AuthenticationEntryPointanswers 401; identity without the permission →AccessDeniedHandleranswers 403 - When you need custom authorisation, extend that chain instead of stacking a second
LoginInterceptoron top — otherwise one request is judged twice, and when it goes wrong you cannot tell who blocked it
Section 13 was about who blocked it; this one is about when. The same URL can fail in three different places — handler found but nobody can execute it, parameters that cannot be filled, an exception nobody takes — and each produces a completely different answer. First pin the interceptor onto the dispatch line:
Now match the three paths against what preHandle actually sees:
| Request | What happens on the dispatch line | The interceptor's point of view |
|---|---|---|
/users/42 | hits @GetMapping("/users/{id}") → resolves "42" into Long → reflective call → writes JSON | handler is a HandlerMethod, so the annotation on the method is readable |
/users | hits the list method, no path variable needed | Same, but a different HandlerMethod — this is why annotation-based whitelisting works at all |
/nope | getHandler returns null → noHandlerFound → 404 | The interceptor never runs: no handler means no interceptor chain |
Then look at where exceptions land. An exception from preHandle, from argument resolution, or from the controller all travel one and the same resolver chain, but they are picked up by different resolvers and end in different status codes:
- Key point: if you want a uniform JSON for "not signed in", the exception has to enter this chain — which is exactly why Section 5 recommends throwing over returning
false: areturn falserequest never gets here - The last parameter,
none, is the cell beginners least expect: when all three resolvers return null the exception escapes, Tomcat's error-page mechanism catches it, and the client receives a Whitelabel page instead of your error body
You can also get the interception right and still lose on the shape of the return value: the same return "redirect:/login" is a view name in an @Controller and a JSON string in a @RestController. Click all four cases, missing in particular — that one is the real body of "the browser lands on a blank page after being rejected":
The labs watch the kernel run; the console is you driving it. This is a real container and every line of output is computed by the Java kernel in your browser — use beans to confirm the filters and interceptors were actually registered, then lab to replay each scene above:
If beans does not list your LoginInterceptor, the reason is usually not a missing @Component: the registration method was never called. A WebMvcConfigurer living outside the main class's package means addInterceptors is silently skipped. Commands 3 and 4 are the Section 5 fork intype-it-yourself form: same handler, order gives you all nine stations, pre gives you "everything from here disappears".
The four columns below are fixed: error fragment / real cause / 30-second fix / where to dig. Copy the error text verbatim into a search engine — engines match fully qualified class names and whole English sentences, not your description of them.
| Error text (fragment) | Real cause | 30-second fix | Dig deeper |
|---|---|---|---|
| Endpoint returns HTTP 200 with an empty body, the front end gets no message at all | preHandle returned false, so doDispatch stops there: no controller, no HandlerExceptionResolver, and the status header stays at its default 200 | Throw and let @RestControllerAdvice answer; if you really must return false, write the body yourself with response.getWriter().write(...) and flush it | Section 5 |
Browser console: Access to fetch at '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. | The response is missing Access-Control-Allow-Origin: CORS was configured on an interceptor or with @CrossOrigin, while the request had already been rejected by an earlier auth filter, so the header-adding code never ran | Move CORS into the earliest filter (Ordered.HIGHEST_PRECEDENCE), answer OPTIONS before anything else, and rule out "the interceptor blocked the preflight" | Section 12 (cors) |
Startup fails outright: IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" | allowCredentials=true (cookies) combined with a wildcard origin — the spec forbids it, because Access-Control-Allow-Origin cannot be both * and credentialed | List the origins explicitly, or use allowedOriginPatterns("*"); never leave both unset in production just to silence the message | Section 12 (cors) |
In preHandle you want to check "does this method carry @NeedLogin" and the cast fails, or the annotation is always null | The handler parameter is not always a HandlerMethod: static resources give ResourceHttpRequestHandler, the error dispatch gives ParameterizableViewController | Open with if (!(handler instanceof HandlerMethod hm)) return true; to pass non-method handlers through, then read the annotation | Section 9, plus the trace below |
A filter logs the request body, and the controller's @RequestBody is then null or fails with HttpMessageNotReadableException: Required request body is missing (or Stream closed) | The body is a one-shot byte stream: once request.getInputStream() is drained there is nothing left for Spring to deserialise | Wrap the request with ContentCachingRequestWrapper or your own HttpServletRequestWrapper so it can be re-read; do it in the filter layer, never in an interceptor | Section 12 (scope) |
A filter annotated with @Component "just never runs" | It is actually @WebFilter without @ServletComponentScan; or it is registered but its urlPatterns do not match the real path | Register it with FilterRegistrationBean and an explicit addUrlPatterns("/*"), and log on the first line of doFilter to prove it was called | Section 3.1 |
| You added an interceptor, and now the login page plus all CSS / JS answer 401 | The interceptor pattern is /**, which also captures static resources and the error dispatch; intercepting /error turns a real exception into a blank page | excludePathPatterns("/static/", "/favicon.ico", "/error", "/actuator/"), explicitly | Section 9 |
Asynchronous endpoints (@Async, CompletableFuture) have no traceId in their logs, or someone else's | MDC is a ThreadLocal and pooled threads are reused; the async task is not on the original thread, so it cannot see the parent context | MDC.getCopyOfContextMap() before submitting, setContextMap(...) before running, or wrap it once with a TaskDecorator | Section 8 |
rows two and three are two different failures — the second is the browser refusing at runtime, the third is Spring refusing at startup. Check whether the application can start before you open the browser console; doing it the other way round wastes an afternoon.
The fourth row — fine in testing, a 500 as soon as somebody opens the homepage — is the best stack to practise reading. It is long, but only one line is guilty:
Your login interceptor passes every API test. Then somebody opens the homepage in a browser: the page turns into a 500 and the log fills with a long stack, although you did not change a single line of code.
search only the first segment after the colon (for example cannot be cast to). A full sentence matches worse, because the class names at the end vary between Spring versions.
Half of the failures above are caused by not being able to see the scene: a static resource caught by the chain, a body read empty, a 404 that never reaches an interceptor. Generate the triage-time configuration instead of typing it out by hand:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
One thing to check in the generated output: logging.level.org.springframework.web=debug prints the type of the matched handler, which is where the static-resource and error-page handlers from Sections 4 and 15 finally show themselves. Turn it back off before production.
This sandbox computes nothing; it is a job matching exercise. Pick a layer on the left and a requirement on the right, and the output tells you whether the three things that decide it are available: can it get the HandlerMethod (which class, which method), can it get the response body (before it is written), is the transaction already open.
HandlerMethod ✓ response body ✓ (you must write it) transaction ✗if (!(handler instanceof HandlerMethod hm)) return true; // pass static resources first#It can read annotations and pass precisely via excludePathPatterns: this is its home turf
the right way to play this sandbox is to ask first "what does this logic need to see", then pick the layer — not "the Filter is earliest, so let us put it there". The three layers are a relay: the Filter decides whether you get in, the interceptor decides whether you are passed to this handler, AOP decides what happens before and after the method call.
Goal: run "one Filter + two Interceptors + one Advice" end to end and watch three shapes with your own eyes: the ordering, the short circuit, the exception path.
package com.example.lab.chain;import jakarta.servlet.*;import jakarta.servlet.http.*;import org.slf4j.*;import org.springframework.context.annotation.*;import org.springframework.core.Ordered;import org.springframework.stereotype.*;import org.springframework.web.filter.OncePerRequestFilter;import org.springframework.web.servlet.*;import org.springframework.web.servlet.config.annotation.*;import java.io.IOException;import java.util.UUID;@Component@Order(Ordered.HIGHEST_PRECEDENCE)class TimingFilter extends OncePerRequestFilter { // OncePerRequestFilter: exactly one run per request private static final Logger log = LoggerFactory.getLogger(TimingFilter.class); @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { long start = System.currentTimeMillis(); req.setAttribute("traceId", UUID.randomUUID().toString().substring(0, 8)); try { chain.doFilter(req, res); // <- only after this line does DispatcherServlet get a turn } finally { log.info("[FILTER] {} {} status={} cost={}ms trace={}", req.getMethod(), req.getRequestURI(), res.getStatus(), System.currentTimeMillis() - start, req.getAttribute("traceId")); } }}@Componentclass AuthInterceptor implements HandlerInterceptor { // registered before the metrics interceptor @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception { if (!(handler instanceof HandlerMethod hm)) { return true; // static resources / error pages are not method handlers } if (hm.hasMethodAnnotation(PublicApi.class)) { return true; // annotation-based passing needs a HandlerMethod } if (req.getHeader("Authorization") == null) { throw new IllegalStateException("NOT_LOGIN"); // throw -> uniform error body; false -> empty 200 } return true; }}@Componentclass MetricsInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(MetricsInterceptor.class); @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { req.setAttribute("t0", System.currentTimeMillis()); return true; } @Override public void postHandle(HttpServletRequest req, HttpServletResponse res, Object handler, ModelAndView mv) { log.info("[METRICS] postHandle ran for {}", handler); // a failing request never prints this line } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { long cost = System.currentTimeMillis() - (long) req.getAttribute("t0"); log.info("[METRICS] {} cost={}ms ex={}", handler, cost, ex == null ? "none" : ex.getClass().getSimpleName()); }}@RestController@RequestMapping("/api")class LabController { @GetMapping("/public") @PublicApi public String open() { return "ok"; } @GetMapping("/me") public String me(@RequestHeader("Authorization") String token) { return "hello " + token; } @GetMapping("/boom") @PublicApi public String boom() { throw new RuntimeException("boom"); }}@Configurationclass MvcConfig implements WebMvcConfigurer { private final AuthInterceptor auth; private final MetricsInterceptor metrics; MvcConfig(AuthInterceptor a, MetricsInterceptor m) { this.auth = a; this.metrics = m; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(auth).addPathPatterns("/api/**") // 1st registered = 1st preHandle .excludePathPatterns("/api/public", "/static/**", "/error"); registry.addInterceptor(metrics).addPathPatterns("/api/**"); // 2nd registered }}Verify each case with curl after startup — every request takes a different path through the chain:
# 1) public endpoint: whitelist hit, both interceptors pass it throughcurl -i localhost:8080/api/public# 2) no Authorization header: AuthInterceptor throws -> does postHandle appear?curl -i localhost:8080/api/me# 3) a normal request with a tokencurl -i -H "Authorization: abc123" localhost:8080/api/me# 4) the controller throws on its owncurl -i localhost:8080/api/boomExpected log order for request 3:
[METRICS] postHandle ran for ... # only a successful request prints this line[METRICS] ...LabController#me... cost=4ms ex=none[FILTER] GET /api/me status=200 cost=9ms trace=8f3c1a02For requests 2 and 4 the expectation is: no postHandle ran line at all, while [METRICS] ... ex=... and [FILTER] still print. That is "postHandle is for success, afterCompletion always runs, the Filter wraps everything" proven on your own console.
Three edits, each one or two lines long; the point is to watch the error text and the response body change:
- Replace the
throwinAuthInterceptor.preHandlewithreturn false, then run request 2. You will see the status is still 200 with an empty body, and the[METRICS]line disappears too — because when the first interceptor short-circuits, the second never even gets apreHandle. This is theinterceptor|authcell of the sandbox in Section 17. - Swap the two
addInterceptorcalls so metrics registers first, then run request 2 again. Now[METRICS] ... ex=IllegalStateException: NOT_LOGINappears: the metrics interceptor was entered first, so it receives the completion callback. Conclusion: register the metrics interceptor before the auth one, or every rejected request is missing from your counters. - Add
req.getInputStream().readAllBytes();beforechain.doFilterinTimingFilter, then POST to an endpoint with@RequestBody. You will getHttpMessageNotReadableException: Required request body is missing, and wrapping the request in aContentCachingRequestWrapperfixes it immediately.
Build a "gatekeeper + bookkeeper" pair: every write operation (POST / PUT / DELETE) must be signed in and carry X-Request-Id, and the app must be able to answer "who was blocked, at which layer".
Requirements:
- A
CorsFilteratHIGHEST_PRECEDENCE: answerOPTIONSwith 200 without entering the business code; when cookies are involved useallowedOriginPatterns, never* - An
AuditInterceptor: inpreHandletesthandler instanceof HandlerMethod, then put the class-level@RequestMappingprefix plus the method name into the MDC; inafterCompletionlog the elapsed time and flag requests whereex != null - A
RepeatableBodyFilter: cache the body with anHttpServletRequestWrapperso one JSON payload can be read by the filter and still be read by@RequestBody - A
@RestControllerAdvice: turnIllegalStateException("NOT_LOGIN")into{"code":40100,"msg":"please sign in"}with the HTTP status still 401
Acceptance checklist:
- [ ]
curl -i -X OPTIONS -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST" localhost:8080/api/ordersreturns 200 and carriesAccess-Control-Allow-Origin— proof that CORS runs ahead of auth - [ ]
/api/orderswithoutAuthorizationanswers 401 plus the uniform JSON error body — not an empty 200, not a Whitelabel page - [ ] The logs contain "Class#method" (proof the interceptor really received the
HandlerMethod; move the same logic intoCorsFilterand it immediately cannot) - [ ] POST a valid JSON: the controller receives the object and the filter log prints the raw body (proof the wrapper works)
- [ ] Make the controller throw on purpose and confirm the audit line is still written (proof the bookkeeping lives in
afterCompletion, notpostHandle)
without looking back, can you recite every station a request passes through and say which steps run in reverse? (Filter before-code forward → DispatcherServlet → preHandle forward → argument resolution and validation → controller, with AOP wrapping the call → postHandle reverse → exception resolution → afterCompletion reverse → message converter writes the body → Filter after-code reverse)
after preHandle returns false, which hooks still run and which do not? (Later interceptors' preHandle, argument resolution, the controller and postHandle are all skipped; only the earlier interceptors that already returned true get afterCompletion; the one that returned false does not get it for itself)
why does elapsed-time measurement belong in afterCompletion rather than postHandle? (postHandle is skipped entirely when an exception is in flight — precisely the samples you most want to see; afterCompletion has finally semantics, always runs, and carries the original exception)
what are the three possible runtime types of the interceptor's handler parameter, and why must you test instanceof HandlerMethod first? (HandlerMethod, ResourceHttpRequestHandler for static resources, ParameterizableViewController for the error page; cast without testing and /favicon.ico or /error throws ClassCastException)
why does adding @EnableWebMvc make pages go blank and static resources 404? (It switches off Spring Boot's MVC auto-configuration, including the default static resource mapping; for custom configuration implement WebMvcConfigurer instead of annotating with @EnableWebMvc)
the check-in desk does not know your flight, the checkpoint does — need "which method"? the interceptor. Need raw bytes? the Filter. Need before and after a call? AOP. postHandle hands out candy only to those who succeeded; afterCompletion is the cashier who settles every bill at departure.
keep one sequence line plus four insights. The line: Filter before-code → DispatcherServlet → Interceptor.preHandle → Controller (with AOP inside it) → reverse hooks: postHandle → view rendering → afterCompletion → Filter after-code → client. The four insights: ① before-hooks run forward, after-hooks run reverse; ② postHandle runs only on success, afterCompletion always runs; ③ AOP cannot catch Filter / Interceptor and cannot "block a request", because it happens after the method call; ④ the three relay rather than replace — Filter controls entry, Interceptor controls passing, AOP controls before and after a method call. Burn the line and the four insights in, and "where should the interception go" will never puzzle you again.