StackPractices
intermediate By Mathias Paulenko

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

flowchart diagram: Client

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

VariantMechanismUse Case
Linear chainEach filter calls the nextStandard web middleware
DecoratorsObject wrappingFunctional composition, single handler
Event-drivenFilters subscribe to lifecycle eventsHighly decoupled systems
DAG pipelineDirected acyclic graph of stagesComplex data processing (ETL, ML)
Intercepting FilterChain of ResponsibilityDecorator
GoalPre/postprocess around one targetFind one handler for the requestAdd behavior to one object
Who processes?All filters + one targetExactly one handler (the first that accepts)The wrapped object
Response pathUnwinds through all filtersNone — the handler ends the chainUnwinds through wrappers
Typical useWeb middleware, servlet filtersValidation, event routing, command dispatchI/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 else in 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

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.