Middleware Pipeline
HTTP middleware chain where each link processes the request, optionally short-circuits, or delegates to the next. Builder assembles the pipeline fluently. Template Method defines the middleware skeleton.
Key Abstractions
Immutable data classes carrying method, path, headers, and body through the pipeline
Abstract base class using Template Method — defines handle() skeleton with pre_process and post_process hooks
Concrete middleware implementations that override pre_process and post_process hooks
Wraps an HttpResponse to add headers or compress the body without modifying the original
Fluent builder that assembles middleware in explicit order via .use(auth).use(cors).use(compress).build()
Wraps a plain function or third-party handler to conform to the Middleware interface
Class Diagram
How It Works
A middleware pipeline is a chain of processors that sit between the raw HTTP request and your application logic. Each middleware gets the request, does some work, and either passes it to the next middleware or short-circuits the chain by returning a response immediately.
The Template Method pattern defines the skeleton. The abstract Middleware class has a handle() method that calls pre_process, then delegates to the next handler, then calls post_process. Concrete middleware only override the hooks they care about. AuthMiddleware overrides pre_process to check tokens. CompressionMiddleware overrides post_process to wrap the response body. Neither one touches the chain-linking logic.
Chain of Responsibility connects the middlewares. Each one holds a reference to the next handler. When pre_process returns an early response (auth failure), the chain stops. No downstream middleware runs. When it returns None, the request flows to the next link.
The Builder assembles the chain in a specific order. Calling .use(logging).use(auth).use(cors).use(compression) means logging runs first on the way in and last on the way out. The builder makes this ordering explicit and prevents accidental reordering.
Response Decorators wrap the response object without mutation. CompressedResponse wraps the original, replacing the body with a compressed version and adding a Content-Encoding header. HeaderInjectedResponse adds CORS headers. These decorators compose: you can compress a CORS-decorated response without either decorator knowing about the other.
Requirements
Functional
- Define a middleware interface with pre-processing and post-processing hooks
- Support short-circuiting: a middleware can reject a request and stop the chain
- Provide a fluent builder for assembling middleware in a specific order
- Support adapting plain functions or third-party handlers to the middleware interface
- Decorate responses (compression, header injection) without modifying the original object
Non-Functional
- Pipeline is built once at startup and reused for all requests
- Middleware order is deterministic and explicit
- Each middleware is a single-responsibility class, easy to add, remove, or reorder
- No shared mutable state between concurrent requests (frozen request objects)
Design Decisions
Couldn't you just loop over middleware in a list?
A for-loop iterating over middleware gives you pre-processing, but post-processing becomes awkward. You would need a second reverse loop after the handler runs. Chain of Responsibility gives you a natural call stack. pre_process runs on the way in, next.handle() recurses deeper, and post_process runs on the way out. It mirrors a normal function call and return. Short-circuiting is trivial: just return early without calling next.
Does middleware order really need a Builder?
Middleware order is not alphabetical; it's a deliberate design choice. Auth must run before business logic. Logging should wrap everything so you capture the full duration. Compression should be the innermost response transformation so it compresses the final body. A Builder with .use() calls makes this sequence readable and explicit. A config file or auto-discovery would hide the ordering and lead to subtle bugs.
What if every middleware just implemented a handle() method directly?
A pure interface forces every middleware to implement the full handle(request, next) logic, including the call to next. Forgetting to call next silently drops the request. Template Method puts the chain logic in the base class. Subclasses only override pre_process and post_process. The base class guarantees next gets called unless there's an explicit short-circuit.
How do you integrate third-party middleware that doesn't match your interface?
Third-party libraries and legacy code won't conform to your Middleware abstract class. A metrics library might expose a (req, callback) -> resp function. MiddlewareAdapter wraps that function so the pipeline treats it like any other middleware. You get integration without modifying the third-party code or breaking your pipeline's type safety.
Interview Follow-ups
- "How would you add async middleware?" Change the handler signature to return a
Future<HttpResponse>or useasync/await. The Template Method skeleton becomesawait pre_process,await next.handle,await post_process. The chain structure stays the same; only the execution model changes. - "How would you add error-handling middleware?" Insert an error-handling middleware early in the chain that wraps the
next.handle()call in a try/catch. If any downstream middleware or the application throws, the error handler catches it, logs it, and returns a 500 response. This is exactly how Express.js error middleware works. - "How would you measure per-middleware latency?" Wrap each middleware with a
MetricsMiddlewaredecorator that records the time before and after calling the inner middleware'shandle(). Emit the timing to a metrics system with the middleware's class name as a label. This gives you a flamechart-style breakdown of where time is spent in the pipeline. - "How does this compare to Express.js vs Spring interceptors?" Express.js uses a flat
(req, res, next)signature: middleware mutatesreqandresdirectly and callsnext()to continue. Spring'sHandlerInterceptorseparatespreHandle,postHandle, andafterCompletion: closer to Template Method but with three hooks instead of two. Both are variations of Chain of Responsibility; the difference is how much structure the framework imposes.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from typing import Callable, Optional
5 import gzip
6 import time
7
8
9 # ─── Data Classes ───────────────────────────────────────────────
10
11 @dataclass(frozen=True)
12 class HttpRequest:
13 method: str
14 path: str
15 headers: dict = field(default_factory=dict)
16 body: str = ""
17
18
19 @dataclass
20 class HttpResponse:
21 status_code: int
22 body: str
23 headers: dict = field(default_factory=dict)
24
25
26 # ─── Handler Type ───────────────────────────────────────────────
27
28 Handler = Callable[[HttpRequest], HttpResponse]
29
30
31 # ─── Response Decorators (Decorator Pattern) ────────────────────
32
33 class ResponseDecorator(HttpResponse):
34 """Base decorator that wraps an existing HttpResponse."""
35 def __init__(self, wrapped: HttpResponse):
36 self._wrapped = wrapped
37 self.status_code = wrapped.status_code
38 self.body = wrapped.body
39 self.headers = dict(wrapped.headers)
40
41
42 class CompressedResponse(ResponseDecorator):
43 """Simulates gzip compression on the response body."""
44 def __init__(self, wrapped: HttpResponse):
45 super().__init__(wrapped)
46 original_len = len(self.body)
47 compressed = gzip.compress(self.body.encode())
48 self.body = f"[gzip:{len(compressed)}B from {original_len}B]"
49 self.headers["Content-Encoding"] = "gzip"
50
51
52 class HeaderInjectedResponse(ResponseDecorator):
53 """Injects extra headers into the response."""
54 def __init__(self, wrapped: HttpResponse, extra_headers: dict):
55 super().__init__(wrapped)
56 self.headers.update(extra_headers)
57
58
59 # ─── Middleware ABC : Template Method Pattern ───────────────────
60
61 class Middleware(ABC):
62 """
63 Template Method: handle() defines the skeleton.
64 Subclasses override pre_process / post_process hooks only.
65 """
66
67 def handle(self, request: HttpRequest, next_handler: Handler) -> HttpResponse:
68 # Step 1: pre-process : may short-circuit by returning a response
69 processed_request, early_response = self.pre_process(request)
70 if early_response is not None:
71 return early_response
72
73 # Step 2: delegate to next middleware in chain
74 response = next_handler(processed_request)
75
76 # Step 3: post-process the response
77 return self.post_process(response)
78
79 def pre_process(
80 self, request: HttpRequest
81 ) -> tuple[HttpRequest, Optional[HttpResponse]]:
82 """Override to inspect/modify request or short-circuit."""
83 return request, None
84
85 def post_process(self, response: HttpResponse) -> HttpResponse:
86 """Override to inspect/modify response."""
87 return response
88
89
90 # ─── Concrete Middleware ────────────────────────────────────────
91
92 class AuthMiddleware(Middleware):
93 """Checks for a valid Bearer token. Short-circuits on failure."""
94
95 def __init__(self, valid_tokens: set[str] | None = None):
96 self.valid_tokens = valid_tokens or {"token-abc", "token-xyz"}
97
98 def pre_process(self, request):
99 auth = request.headers.get("Authorization", "")
100 token = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
101 if token not in self.valid_tokens:
102 print(f" [AUTH] REJECTED : invalid token '{token}'")
103 return request, HttpResponse(401, "Unauthorized")
104 print(f" [AUTH] OK : token '{token}' accepted")
105 return request, None
106
107
108 class LoggingMiddleware(Middleware):
109 """Logs incoming request and outgoing response."""
110
111 def pre_process(self, request):
112 print(f" [LOG] >>> {request.method} {request.path}")
113 self._start = time.time()
114 return request, None
115
116 def post_process(self, response):
117 elapsed_ms = (time.time() - self._start) * 1000
118 print(f" [LOG] <<< {response.status_code} ({elapsed_ms:.1f}ms)")
119 return response
120
121
122 class CorsMiddleware(Middleware):
123 """Adds CORS headers to every response."""
124
125 def __init__(self, allowed_origins: str = "*"):
126 self.allowed_origins = allowed_origins
127
128 def post_process(self, response):
129 decorated = HeaderInjectedResponse(response, {
130 "Access-Control-Allow-Origin": self.allowed_origins,
131 "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
132 "Access-Control-Allow-Headers": "Authorization, Content-Type",
133 })
134 print(f" [CORS] Headers injected: Allow-Origin={self.allowed_origins}")
135 return decorated
136
137
138 class CompressionMiddleware(Middleware):
139 """Wraps response body with gzip compression decorator."""
140
141 def post_process(self, response):
142 decorated = CompressedResponse(response)
143 print(f" [COMPRESS] {decorated.body}")
144 return decorated
145
146
147 # ─── Middleware Adapter (Adapter Pattern) ───────────────────────
148
149 class MiddlewareAdapter(Middleware):
150 """
151 Wraps a plain function (req, next) -> resp to conform
152 to the Middleware interface. Useful for third-party or
153 legacy handlers.
154 """
155
156 def __init__(self, fn: Callable[[HttpRequest, Handler], HttpResponse], name: str = "adapter"):
157 self._fn = fn
158 self._name = name
159
160 def handle(self, request: HttpRequest, next_handler: Handler) -> HttpResponse:
161 print(f" [ADAPTER:{self._name}] delegating to wrapped function")
162 return self._fn(request, next_handler)
163
164
165 # ─── Pipeline Builder (Builder Pattern) ─────────────────────────
166
167 class PipelineBuilder:
168 """Fluent builder that assembles middleware into a handler chain."""
169
170 def __init__(self):
171 self._middlewares: list[Middleware] = []
172
173 def use(self, middleware: Middleware) -> PipelineBuilder:
174 self._middlewares.append(middleware)
175 return self
176
177 def build(self, final_handler: Handler) -> Handler:
178 """
179 Wraps final_handler with middleware in reverse order so that
180 the first .use() call runs first in the chain.
181 """
182 handler = final_handler
183 for mw in reversed(self._middlewares):
184 handler = self._wrap(mw, handler)
185 return handler
186
187 @staticmethod
188 def _wrap(mw: Middleware, next_handler: Handler) -> Handler:
189 def wrapped(req: HttpRequest) -> HttpResponse:
190 return mw.handle(req, next_handler)
191 return wrapped
192
193
194 # ─── Application Handler (simulates actual endpoint) ────────────
195
196 def application_handler(request: HttpRequest) -> HttpResponse:
197 """Simulates a real endpoint returning JSON."""
198 body = f'{{"message": "Hello from {request.path}", "method": "{request.method}"}}'
199 return HttpResponse(200, body, {"Content-Type": "application/json"})
200
201
202 # ─── Demo ───────────────────────────────────────────────────────
203
204 if __name__ == "__main__":
205 # A simple third-party timing function adapted to Middleware
206 def timing_fn(req: HttpRequest, nxt: Handler) -> HttpResponse:
207 start = time.time()
208 resp = nxt(req)
209 elapsed = (time.time() - start) * 1000
210 resp.headers["X-Timing-Ms"] = f"{elapsed:.2f}"
211 return resp
212
213 timing_adapter = MiddlewareAdapter(timing_fn, name="timing")
214
215 # Build the pipeline: logging -> auth -> CORS -> compression -> timing
216 pipeline = (
217 PipelineBuilder()
218 .use(LoggingMiddleware())
219 .use(AuthMiddleware(valid_tokens={"token-abc", "token-xyz"}))
220 .use(CorsMiddleware(allowed_origins="https://example.com"))
221 .use(CompressionMiddleware())
222 .use(timing_adapter)
223 .build(application_handler)
224 )
225
226 print("=" * 60)
227 print("TEST 1: Valid request : full chain executes")
228 print("=" * 60)
229 req1 = HttpRequest(
230 method="GET",
231 path="/api/users",
232 headers={"Authorization": "Bearer token-abc"},
233 )
234 resp1 = pipeline(req1)
235 print(f" Status : {resp1.status_code}")
236 print(f" Body : {resp1.body}")
237 print(f" Headers: {resp1.headers}\n")
238
239 print("=" * 60)
240 print("TEST 2: Invalid token : auth short-circuits the chain")
241 print("=" * 60)
242 req2 = HttpRequest(
243 method="POST",
244 path="/api/orders",
245 headers={"Authorization": "Bearer bad-token"},
246 )
247 resp2 = pipeline(req2)
248 print(f" Status : {resp2.status_code}")
249 print(f" Body : {resp2.body}")
250 print(f" Headers: {resp2.headers}\n")
251
252 print("=" * 60)
253 print("TEST 3: Missing Authorization header : auth short-circuits")
254 print("=" * 60)
255 req3 = HttpRequest(method="GET", path="/api/health")
256 resp3 = pipeline(req3)
257 print(f" Status : {resp3.status_code}")
258 print(f" Body : {resp3.body}")
259 print(f" Headers: {resp3.headers}\n")
260
261 print("=" * 60)
262 print("TEST 4: Another valid request to different path")
263 print("=" * 60)
264 req4 = HttpRequest(
265 method="DELETE",
266 path="/api/sessions/42",
267 headers={"Authorization": "Bearer token-xyz"},
268 )
269 resp4 = pipeline(req4)
270 print(f" Status : {resp4.status_code}")
271 print(f" Body : {resp4.body}")
272 print(f" Headers: {resp4.headers}\n")
273
274 print("Pipeline built once, reused for all four requests.")
275 print("Notice auth short-circuit skips CORS, compression, and timing.")Common Mistakes
- ✗Wrong middleware order: compression before auth means you compress error responses too, wasting CPU on 401s.
- ✗Not calling next in the chain: silently drops the request and returns None or an empty response.
- ✗Mutating the shared request object: race conditions with concurrent requests when headers or body are modified in place.
- ✗Building the pipeline at runtime on every request instead of once at startup: unnecessary allocation and ordering bugs.
Key Points
- ✓Chain of Responsibility: each middleware decides to process and forward, or short-circuit (e.g., auth rejects with 401 and stops the chain).
- ✓Builder: pipeline construction order matters. Auth before logging before compression. Builder makes this explicit and fluent.
- ✓Template Method: Middleware base class defines handle() skeleton: pre_process, then next.handle, then post_process. Subclasses only override hooks.
- ✓Decorator on responses: compression and header injection are response wrappers that compose without altering the original response object.