๐ง The big idea in one line
Filters, interceptors, and aspects are three places to run code around a request โ at three different depths of the stack.
- Every web app has work that isn't the "real" job of any single endpoint: logging, timing, auth checks, adding headers, collecting metrics.
- Copying that work into every controller method is a mess. You repeat it everywhere, and you forget it somewhere.
- A cross-cutting concern is a job that applies to many requests or many methods โ it cuts across your normal code instead of belonging to one spot.
- Spring gives you three tools to handle these in one place. They differ by where in the request's journey they sit, and how much they can see.
- You meet this the first time you need "run this before every request" โ and immediately hit the real question: which of the three?
๐ฃ๏ธ First, the journey of one request
Before comparing the tools, you need to see the path a request takes. It does not land on your controller directly.
-
Servlet container (Tomcat, Jetty): the web server that speaks HTTP and hands your app a raw
HttpServletRequest. - DispatcherServlet: Spring's single front-door servlet. It reads the URL, picks the right controller method, calls it, and turns the return value into a response.
- Handler: the specific controller method chosen to serve this request.
Here is where each tool sits, from outside in:
HTTP request
โ
โผ
โ Servlet Filter โ outermost ยท raw HTTP ยท no idea which controller
โ
โผ
โ Handler Interceptor โ inside Spring MVC ยท knows the handler
โ
โผ
โ Controller method
โ (calls a service bean)
โผ
โ AOP Aspect โ wraps any bean method ยท fires for non-web calls too
- The order is the whole insight: filters sit outermost, interceptors sit inside the DispatcherServlet, aspects sit deepest โ around your bean methods.
- As you move inward, each layer sees more Spring detail and less raw HTTP.
๐งฑ Layer 1 โ Servlet Filters (the outer wall)
- A Filter is part of the Servlet spec โ plain Jakarta/Java EE, not Spring. It wraps the request before Spring even runs.
- It sees the raw
HttpServletRequestandHttpServletResponse, and nothing about which controller will run.
@Component
public class TimingFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
long start = System.currentTimeMillis();
chain.doFilter(req, res); // hand off to the next filter, then the app
long took = System.currentTimeMillis() - start;
System.out.println("Request took " + took + "ms");
}
}
-
chain.doFilter(...)is the pivot. Everything before it runs on the way in; everything after it runs on the way out. - If you never call
chain.doFilter, the request stops right here. That is how a filter blocks a request entirely โ for example, rejecting a missing API key before any Spring code runs. - It holds both the request and the response, so it can even wrap or replace the response body.
Filters are good at:
- Work that must happen for every HTTP request โ controller-bound or not (static files, error paths).
- Reading or modifying the raw request/response (compression, CORS headers, caching the request body).
- Security gates that should run before Spring. Spring Security itself is built as one big filter.
๐งญ Layer 2 โ HandlerInterceptors (inside Spring MVC)
- A HandlerInterceptor runs inside the DispatcherServlet, after Spring has already decided which handler will serve the request.
- So it knows the handler โ a filter never does.
It gives you three hooks around the controller call:
| Hook | When it runs | Typical use |
|---|---|---|
preHandle |
before the controller method | auth check; start a timer; return false to block |
postHandle |
after the controller, before the view renders | tweak the model or response |
afterCompletion |
after everything, even when the handler threw | cleanup; stop a timer; log the exception |
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
if (req.getHeader("X-User") == null) {
res.setStatus(401);
return false; // stop: the controller never runs
}
return true; // continue to the controller
}
}
- Returning
falsefrompreHandlestops the request โ the same idea as a filter that skipschain.doFilter, except you are already inside Spring. - You register it against URL patterns, and the
handlerargument lets you read the target method's annotations:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/api/**");
}
}
Interceptors are good at:
- Web concerns that need to know the controller or endpoint (per-route auth, request logging that names the method).
- Anything tied to the MVC lifecycle, like touching the model before the view renders.
๐ฏ Layer 3 โ AOP / Aspects (around any bean method)
The first two tools only see web requests. But cross-cutting work often lives deeper โ inside services that are called from web endpoints and from scheduled jobs and from message listeners.
- AOP (Aspect-Oriented Programming) lets you run code around any Spring bean method call, web or not.
- How does it reach inside a method call? Spring wraps your bean in a proxy โ a stand-in object that looks identical to your bean but runs extra code before and after the real method.
caller โโโถ [ Proxy ] โโโถ your real bean method
โ
โโ runs "advice" before & after the real call
Two terms fall out of that picture:
- Advice: the extra code the aspect runs (before, after, or around the method).
- Pointcut: the rule that picks which methods to wrap โ e.g. "every method in the service package".
@Aspect
@Component
public class LoggingAspect {
@Around("execution(* com.example.service..*(..))")
public Object logTime(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed(); // call the real method
long took = System.currentTimeMillis() - start;
System.out.println(pjp.getSignature() + " took " + took + "ms");
return result;
}
}
-
pjp.proceed()is the AOP version ofchain.doFilterโ it runs the real method. Same in-and-out shape as a filter, but wrapped around a plain Java method instead of an HTTP request. - The pointcut
execution(* com.example.service..*(..))targets every method under the service package.
Aspects are good at:
- Business-level cross-cutting:
@Transactional,@Cacheable, retries, method-level security, a custom@Auditedannotation. - Work that is not about HTTP at all โ it fires no matter who called the method.
โ ๏ธ Easy to confuse โ the proxy gotcha (self-invocation)
- Because AOP works through a proxy, calling one method of a bean from another method of the same bean skips the proxy โ the advice does not run.
- The reason:
this.otherMethod()goes straight to the real object and never touches the wrapper. - This is the classic "
@Transactionaldid nothing" bug: an internal call bypassed the proxy that would have opened the transaction.
๐งฉ Bad vs good: picking the wrong layer
โ Putting per-endpoint auth in a Filter
- A filter cannot see which controller method will run, so you end up re-parsing the URL by hand to decide the rule. Fragile, and it duplicates Spring's own routing.
โ Use an interceptor (or method security) instead
- It already knows the handler and its annotations, so the rule lives next to where the endpoint is defined.
โ Using AOP to set an HTTP response header
- Your service method has no
HttpServletResponseโ you would have to smuggle one in. Wrong layer.
โ Use a filter or interceptor
- They hold the raw response and can set headers directly.
โ ๏ธ Easy to confuse โ quick separations
- Filter vs Interceptor: both wrap web requests. A filter is at the servlet level, has no idea which controller runs, and fires even for non-Spring requests. An interceptor is inside Spring MVC, knows the handler, and only fires for requests the DispatcherServlet routes.
- Interceptor vs Aspect: an interceptor is web-only and lifecycle-based (pre/post/after). An aspect targets any method by pointcut and fires for non-web calls too.
-
The "continue" call in each:
chain.doFilter(),return true, andpjp.proceed()all mean "go on to the next thing." Skip any of them and the flow stops there.
๐ Quick summary
| Filter | Interceptor | Aspect (AOP) | |
|---|---|---|---|
| Level | Servlet container | Spring MVC (DispatcherServlet) | Any Spring bean method |
| Spec | Servlet (Jakarta) | Spring MVC | Spring AOP |
| Sees | raw request/response | handler + request/response | method args + return value |
| Knows the controller? | โ no | โ yes | it is a bean method |
| Fires for non-web calls? | โ no | โ no | โ yes |
| "Continue" call | chain.doFilter() |
return true |
pjp.proceed() |
| Typical use | CORS, compression, security gate | per-endpoint auth, MVC logging |
@Transactional, caching, audit |
๐ฏ Decision rule
- Need it for every HTTP request โ even errors and static files โ or must it run before Spring? โ Filter.
- Need to know which controller/endpoint runs, or hook the MVC lifecycle? โ Interceptor.
- Need it around service/business methods, or for non-web calls too? โ Aspect (AOP).
- Rough guide: outer = HTTP plumbing, inner = business logic. Pick the outermost layer that still has everything you need.
๐ก Remember this
- Three layers, outer to inner: Filter โ Interceptor โ Aspect. Each sees less raw HTTP and more Spring/business detail.
- The "continue" step is the heart of all three:
chain.doFilter/return true/proceed(). Skip it to stop the flow. - Only the filter runs without Spring MVC; only the aspect fires for non-web method calls.
- AOP works through a proxy, so a self-invocation inside the same bean silently skips the advice.
Top comments (0)