Intercepting Filter: Pluggable Request Pipelines
Compose cross-cutting concerns into a chain of pluggable filters that intercept requests and responses, enabling reusable preprocessing and postprocessing logic.
Overview
If you’ve ever written app.use(authenticate) in Express, registered a servlet Filter in web.xml, or added middleware to an ASP.NET Core pipeline, you’ve already used this pattern. The Intercepting Filter Pattern composes cross-cutting concerns — authentication, logging, compression, validation — into a chain of pluggable filters that intercept requests before they reach the handler, and responses on the way back out.
Each filter does one job, then delegates to the next via a chain object. The final target (a servlet, controller, or handler) runs the actual business logic. Because filters are registered declaratively, you can add, remove, or reorder them without touching the handler — which is exactly why every mainstream web framework adopted this pattern under the name “middleware”.
When to Use
Use the Intercepting Filter Pattern when:
- Cross-cutting concerns (auth, logging, caching) should be reused across several handlers
- Request/response processing needs preprocessing or postprocessing stages
- You need a configurable pipeline where filters can be added or reordered
- Several handlers share the same set of cross-cutting concerns
For simpler cases — a single handler needing one layer of wrapping — the Decorator Pattern is enough.
When NOT to Use
- A single handler with unique one-off concerns (a plain decorator suffices)
- Latency-critical paths where per-filter overhead is unacceptable
- When ordering dependencies between filters get complex enough that reasoning about the chain becomes its own problem
- Small scripts where a few direct calls are clearer than a pipeline
Solution
Python
from abc import ABC, abstractmethod
from typing import Dict, Any
from dataclasses import dataclass, field
@dataclass
class HttpRequest:
path: str
headers: Dict[str, str]
body: Any = None
user: Any = None
authenticated: bool = False
@dataclass
class HttpResponse:
status: int = 200
headers: Dict[str, str] = field(default_factory=dict)
body: Any = None
class Filter(ABC):
"""Base filter that can chain to the next filter."""
def __init__(self, next_filter: 'Filter' = None):
self.next_filter = next_filter
@abstractmethod
def do_filter(self, request: HttpRequest, response: HttpResponse):
pass
def _invoke_next(self, request: HttpRequest, response: HttpResponse):
if self.next_filter:
self.next_filter.do_filter(request, response)
class AuthenticationFilter(Filter):
"""Checks if the request has a valid token."""
def do_filter(self, request: HttpRequest, response: HttpResponse):
token = request.headers.get("Authorization")
if token and token.startswith("Bearer "):
request.user = "authenticated_user"
request.authenticated = True
self._invoke_next(request, response)
else:
# Short-circuit: never calls _invoke_next
response.status = 401
response.body = {"error": "Unauthorized"}
class LoggingFilter(Filter):
"""Logs request details before and after processing."""
def do_filter(self, request: HttpRequest, response: HttpResponse):
print(f"[LOG] Request to {request.path}")
self._invoke_next(request, response)
print(f"[LOG] Response status: {response.status}")
class CompressionFilter(Filter):
"""Compresses the response body if the client accepts it."""
def do_filter(self, request: HttpRequest, response: HttpResponse):
self._invoke_next(request, response)
if "gzip" in request.headers.get("Accept-Encoding", ""):
response.headers["Content-Encoding"] = "gzip"
print("[COMPRESS] Response compressed")
class TargetHandler(Filter):
"""The final handler that processes the core request."""
def __init__(self):
super().__init__(None)
def do_filter(self, request: HttpRequest, response: HttpResponse):
if response.status == 200:
response.body = {"message": f"Hello, {request.user or 'guest'}!"}
class FilterChain:
"""Builds and executes the filter pipeline."""
def __init__(self):
self.filters: list[type[Filter]] = []
def add_filter(self, filter_cls):
self.filters.append(filter_cls)
return self
def execute(self, request: HttpRequest) -> HttpResponse:
# Build the chain from tail to head
target = TargetHandler()
current = target
for filter_cls in reversed(self.filters):
new_filter = filter_cls()
new_filter.next_filter = current
current = new_filter
response = HttpResponse()
current.do_filter(request, response)
return response
# Usage
chain = FilterChain()
chain.add_filter(AuthenticationFilter) \
.add_filter(LoggingFilter) \
.add_filter(CompressionFilter)
request = HttpRequest(
path="/api/hello",
headers={"Authorization": "Bearer abc123", "Accept-Encoding": "gzip"}
)
response = chain.execute(request)
print(f"Result: {response.status} - {response.body}")
Java
import java.util.*;
class HttpRequest {
private final String path;
private final Map<String, String> headers;
private String user;
private boolean authenticated;
public HttpRequest(String path, Map<String, String> headers) {
this.path = path; this.headers = headers;
}
public String getPath() { return path; }
public Map<String, String> getHeaders() { return headers; }
public String getUser() { return user; }
public void setUser(String user) { this.user = user; }
public boolean isAuthenticated() { return authenticated; }
public void setAuthenticated(boolean auth) { this.authenticated = auth; }
}
class HttpResponse {
private int status = 200;
private final Map<String, String> headers = new HashMap<>();
private Object body;
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public Map<String, String> getHeaders() { return headers; }
public Object getBody() { return body; }
public void setBody(Object body) { this.body = body; }
}
interface Filter {
void doFilter(HttpRequest request, HttpResponse response, FilterChain chain);
}
class FilterChain {
private final List<Filter> filters = new ArrayList<>();
private int currentIndex = 0;
public void addFilter(Filter filter) { filters.add(filter); }
public void doFilter(HttpRequest request, HttpResponse response) {
if (currentIndex < filters.size()) {
Filter filter = filters.get(currentIndex++);
filter.doFilter(request, response, this);
}
}
// Entry point: reset the cursor so the chain can serve the next request.
public void execute(HttpRequest request, HttpResponse response) {
currentIndex = 0;
doFilter(request, response);
}
}
class AuthenticationFilter implements Filter {
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
String token = request.getHeaders().get("Authorization");
if (token != null && token.startsWith("Bearer ")) {
request.setUser("authenticated_user");
request.setAuthenticated(true);
chain.doFilter(request, response);
} else {
response.setStatus(401);
response.setBody(Map.of("error", "Unauthorized"));
}
}
}
class LoggingFilter implements Filter {
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
System.out.println("[LOG] Request to " + request.getPath());
chain.doFilter(request, response);
System.out.println("[LOG] Response status: " + response.getStatus());
}
}
class CompressionFilter implements Filter {
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
chain.doFilter(request, response);
String encoding = request.getHeaders().getOrDefault("Accept-Encoding", "");
if (encoding.contains("gzip")) {
response.getHeaders().put("Content-Encoding", "gzip");
System.out.println("[COMPRESS] Response compressed");
}
}
}
class TargetHandler implements Filter {
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) {
if (response.getStatus() == 200) {
response.setBody("Hello, " + (request.getUser() != null ? request.getUser() : "guest") + "!");
}
}
}
// Usage
HttpRequest request = new HttpRequest("/api/hello", Map.of(
"Authorization", "Bearer abc123",
"Accept-Encoding", "gzip"
));
HttpResponse response = new HttpResponse();
FilterChain chain = new FilterChain();
chain.addFilter(new AuthenticationFilter());
chain.addFilter(new LoggingFilter());
chain.addFilter(new CompressionFilter());
chain.addFilter(new TargetHandler());
chain.execute(request, response);
System.out.println("Result: " + response.getStatus() + " - " + response.getBody());
JavaScript
class HttpRequest {
constructor(path, headers) {
this.path = path;
this.headers = headers;
this.user = null;
this.authenticated = false;
}
}
class HttpResponse {
constructor() {
this.status = 200;
this.headers = {};
this.body = null;
}
}
class FilterChain {
constructor() {
this.filters = [];
this.index = 0;
}
addFilter(filter) {
this.filters.push(filter);
return this;
}
doFilter(request, response) {
if (this.index < this.filters.length) {
const filter = this.filters[this.index++];
filter.doFilter(request, response, this);
}
}
// Entry point: reset the cursor so the chain can be reused.
execute(request, response) {
this.index = 0;
this.doFilter(request, response);
}
}
class AuthenticationFilter {
doFilter(request, response, chain) {
const token = request.headers['Authorization'];
if (token && token.startsWith('Bearer ')) {
request.user = 'authenticated_user';
request.authenticated = true;
chain.doFilter(request, response);
} else {
response.status = 401;
response.body = { error: 'Unauthorized' };
}
}
}
class LoggingFilter {
doFilter(request, response, chain) {
console.log(`[LOG] Request to ${request.path}`);
chain.doFilter(request, response);
console.log(`[LOG] Response status: ${response.status}`);
}
}
class CompressionFilter {
doFilter(request, response, chain) {
chain.doFilter(request, response);
const encoding = request.headers['Accept-Encoding'] || '';
if (encoding.includes('gzip')) {
response.headers['Content-Encoding'] = 'gzip';
console.log('[COMPRESS] Response compressed');
}
}
}
class TargetHandler {
doFilter(request, response, chain) {
if (response.status === 200) {
response.body = { message: `Hello, ${request.user || 'guest'}!` };
}
}
}
// Usage
const request = new HttpRequest('/api/hello', {
Authorization: 'Bearer abc123',
'Accept-Encoding': 'gzip',
});
const response = new HttpResponse();
const chain = new FilterChain();
chain.addFilter(new AuthenticationFilter())
.addFilter(new LoggingFilter())
.addFilter(new CompressionFilter())
.addFilter(new TargetHandler());
chain.execute(request, response);
console.log('Result:', response.status, response.body);
How It Works
The request enters at the first filter. Each filter gets three chances to act: before delegating (preprocessing), by choosing not to delegate (short-circuiting), and after the delegate returns (postprocessing). Notice how LoggingFilter logs on both legs and CompressionFilter only acts on the way back — the call stack unwinding through the chain is what makes response filters free.
Ordering is a contract, not a preference. Authentication must run before authorization, which must run before caching — a cache placed ahead of auth will happily serve unauthorized responses from memory. Declare the order in one place (registration code, config file) and treat changes to it like API changes: they can silently break behavior.
Error handling needs a strategy up front. If a filter throws, does the chain propagate the exception, convert it to an error response, or log and continue? Frameworks usually install a dedicated error-handling stage first in the chain so it catches everything downstream. Rolling your own, wrap the whole chain.execute() in one try/catch that maps exceptions to error responses — anything else leaks stack frames into client output.
Async changes the rules. The unwind-on-return trick works because calls are synchronous. In async pipelines (Express next(), ASP.NET await _next(context)), the “after” code runs inside a continuation — forget the await/next() and your response filters never fire, or worse, fire after the response was already sent. This is one of the most common production bugs in middleware pipelines.
Every filter is overhead. Each stage adds a dispatch, and in hot paths that adds up. Ten small filters cost more than two consolidated ones doing the same work — merge tightly related concerns (e.g., one ObservabilityFilter instead of separate logging + metrics + tracing filters) once the chain gets long.
Variants
| Variant | Mechanism | Use Case |
|---|---|---|
| Linear chain | Each filter calls the next | Standard web middleware |
| Decorators | Object wrapping | Functional composition, single handler |
| Event-driven | Filters subscribe to lifecycle events | Highly decoupled systems |
| DAG pipeline | Directed acyclic graph of stages | Complex data processing (ETL, ML) |
Intercepting Filter vs. Related Patterns
| Intercepting Filter | Chain of Responsibility | Decorator | |
|---|---|---|---|
| Goal | Pre/postprocess around one target | Find one handler for the request | Add behavior to one object |
| Who processes? | All filters + one target | Exactly one handler (the first that accepts) | The wrapped object |
| Response path | Unwinds through all filters | None — the handler ends the chain | Unwinds through wrappers |
| Typical use | Web middleware, servlet filters | Validation, event routing, command dispatch | I/O streams, UI wrapping |
Rule of thumb: if every stage should see the request and a fixed target does the work, it’s an Intercepting Filter. If stages compete to become the handler, it’s a Chain of Responsibility.
What Works
- Order filters deliberately. Auth before authorization before caching; document the dependency if it isn’t obvious.
- Keep filters stateless. One instance serves all threads — instance fields are race conditions waiting to happen.
- Short-circuit on failure. An auth failure stops the chain; it doesn’t annotate-and-continue.
- Use both legs. Postprocessing on the unwind path is the pattern’s free lunch — compression, headers, timing.
- Name the target in the chain. Register the handler as the last stage, not as a hidden
elsein the chain builder.
Companion code: The intercepting-filter-pattern companion repo has a runnable filter chain in Python and JavaScript plus an Express middleware example with tests.
Common Mistakes
- Caching before auth. The cache serves unauthorized responses from memory — check order first when a security bug appears.
- Instance state in filters. One filter object serves every concurrent request; per-request state belongs on the request/context object, not
this. - Swallowing exceptions. An exception caught and ignored mid-chain produces a half-processed response that looks successful.
- Filter sprawl. Twenty tiny filters add dispatch overhead and make ordering unmanageable — consolidate related concerns.
- Forgetting
next()/await. In async pipelines the post-processing code silently never runs, or runs too late.
Real-World Examples
Servlet Filters (Java EE)
Java’s javax.servlet.Filter interface defines doFilter(request, response, chain). Filters are configured in web.xml or via @WebFilter annotations, and the container guarantees ordering by registration.
Express.js Middleware
Express middleware functions are Intercepting Filters: app.use((req, res, next) => { ... next() }). The next() call is the chain delegation — and forgetting to call it produces the classic Express bug where requests hang forever. See the Express middleware patterns recipe for composition, error handling, and route-scoped chains.
ASP.NET Core Middleware
ASP.NET Core builds the pipeline with app.Use() and app.Map(), where each middleware receives a RequestDelegate for the next stage. app.Run() registers a terminal middleware — the “target” of the chain.
Front Controller companion
Intercepting Filter usually sits in front of a Front Controller: filters handle the cross-cutting work, then the front controller dispatches to the right handler. Java EE described them as a pair for exactly this reason.
Further Reading
- Core J2EE Patterns — Intercepting Filter — the original pattern catalog entry (Oracle).
- Express — Writing middleware — the middleware model this pattern became.
- ASP.NET Core Middleware — Microsoft’s pipeline guide.
- For the J2EE companion pattern, see Business Delegate Pattern and Context Object Pattern.
Frequently Asked Questions
What is the difference between Intercepting Filter and Chain of Responsibility?
Chain of Responsibility hands the request to a series of candidate handlers until one accepts it — the goal is finding the processor. Intercepting Filter has exactly one target; every filter only pre/postprocesses around it. If all stages see the request, it's a filter chain; if stages compete to handle it, it's CoR.
How do I handle exceptions in a filter chain?
Install an error-handling filter at the head of the chain so its try/catch wraps everything downstream, or wrap the whole chain.execute() in a single try/catch that produces an error response. Never let a filter silently swallow an exception and continue — you get half-processed responses that look successful.
Can filters modify the response on the way back?
Yes — that's the pattern's built-in advantage. Code after chain.doFilter() runs while the call stack unwinds, which is how compression, response headers, and timing filters work. In async pipelines this "after" code lives in a continuation; missing an await means it never runs or runs too late.
In what order should I register filters?
Security first (auth → authorization), then request shaping (validation, rate limiting), then caching, then observability, then the target. The general rule: a filter should never need something a later filter produces. If two filters fight over order, one of them is probably doing two jobs.
Is Express middleware the same as this pattern?
Yes — app.use((req, res, next) => { ...; next() }) is an Intercepting Filter with next() as the chain delegation. Express adds route-scoped chains (app.use('/admin', auth)) and error-handling middleware (four-parameter functions), but the mechanism is identical.
Related Resources
Chain of Responsibility Pattern
Pass requests along a chain of handlers until one handles it. A behavioral design pattern for decoupling senders and receivers.
PatternDecorator Pattern
Add new functionality to objects dynamically by wrapping them. A structural design pattern for flexible behavior extension.
PatternProxy Pattern
Provide a surrogate or placeholder for another object to control access to it. A structural design pattern for access control, lazy loading, and logging.
PatternBusiness Delegate Pattern
Reduce coupling between presentation and business tiers by introducing an intermediary that handles lookup, creation, and invocation of business services.
PatternContext Object Pattern: Examples
Learn the Context Object Pattern to reduce parameter bloat. Practical examples in Python, Java, and JavaScript for request contexts and DI containers.
PatternFront Controller Pattern
Route all incoming requests through a single handler that dispatches to the appropriate page command, centralizing request processing and security.