Design Patterns, Explained Simply · Modern Patterns and Choosing Well
Middleware and Pipelines
Process a request through a chain of small, reusable steps (logging, auth, rate limiting) where each step can act before and after the next one, or stop the request.
The problem
Every API endpoint starts with the same lines: log the request, check the auth token, check the rate limit, parse JSON, catch errors and format them. Forty endpoints repeat them with small differences, and one forgot the auth check.
You want these cross-cutting steps written once, applied consistently, and easy to reorder or skip per route.
Real-world analogy
Airport security. Every passenger passes through a sequence of checkpoints: ticket check, ID check, bag scan, body scan. Each checkpoint can stop you, and each does one job. The gate (the handler) only sees passengers who passed every check, and new checkpoints can be added without redesigning the gate.
Without the pattern
def get_orders(request):
log(request)
if not valid_token(request.headers.get("Authorization")):
return 401
if rate_limited(request.ip):
return 429
... # the actual endpoint, after copy-pasted checks
The pattern
- Handler: the final step that does the real work (
handler(request) -> response). - Middleware: a function or object that receives the request and a
nextfunction. It can:- do work before calling
next(request)(log, authenticate), - stop the request by returning a response without calling
next(401, 429), - do work after
nextreturns (add headers, measure time).
- do work before calling
- Pipeline: composes the middleware list around the handler, so each one wraps the rest.
This combines Chain of Responsibility (each step can stop the request) with Decorator (each step wraps the next with before/after behaviour).
The code
def logging_mw(request, next_):
print(f"-> {request['path']}")
response = next_(request) # before ... after
print(f"<- {response['status']}")
return response
def auth_mw(request, next_):
if request.get("token") != "secret":
return {"status": 401, "body": "unauthorized"} # stop here
request["user"] = "ada"
return next_(request)
def handler(request):
return {"status": 200, "body": f"orders for {request['user']}"}
def pipeline(middlewares, final):
def build(index):
if index == len(middlewares):
return final
return lambda req: middlewares[index](req, build(index + 1))
return build(0)
app = pipeline([logging_mw, auth_mw], handler)
print(app({"path": "/orders", "token": "secret"})["body"])
print(app({"path": "/orders", "token": "wrong"})["body"])
Output
-> /orders <- 200 orders for ada -> /orders <- 401 unauthorized
type Req = { path: string; token?: string; user?: string };
type Res = { status: number; body: string };
type Handler = (req: Req) => Res;
type Middleware = (req: Req, next: Handler) => Res;
const loggingMw: Middleware = (req, next) => {
console.log(`-> ${req.path}`);
const res = next(req); // before ... after
console.log(`<- ${res.status}`);
return res;
};
const authMw: Middleware = (req, next) => {
if (req.token !== "secret") return { status: 401, body: "unauthorized" }; // stop here
return next({ ...req, user: "ada" });
};
const handler: Handler = (req) => ({ status: 200, body: `orders for ${req.user}` });
function pipeline(middlewares: Middleware[], final: Handler): Handler {
return middlewares.reduceRight<Handler>((next, mw) => (req) => mw(req, next), final);
}
const app = pipeline([loggingMw, authMw], handler);
console.log(app({ path: "/orders", token: "secret" }).body);
console.log(app({ path: "/orders", token: "wrong" }).body);
Output
-> /orders <- 200 orders for ada -> /orders <- 401 unauthorized
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class Main {
interface Middleware { Res apply(Map<String, String> req, Function<Map<String, String>, Res> next); }
record Res(int status, String body) {}
static Function<Map<String, String>, Res> pipeline(List<Middleware> mws, Function<Map<String, String>, Res> handler) {
Function<Map<String, String>, Res> next = handler;
for (int i = mws.size() - 1; i >= 0; i--) { // wrap from the inside out
Middleware mw = mws.get(i);
Function<Map<String, String>, Res> inner = next;
next = req -> mw.apply(req, inner);
}
return next;
}
public static void main(String[] args) {
Middleware logging = (req, next) -> {
System.out.println("-> " + req.get("path"));
Res res = next.apply(req); // before ... after
System.out.println("<- " + res.status());
return res;
};
Middleware auth = (req, next) -> {
if (!"secret".equals(req.get("token"))) return new Res(401, "unauthorized"); // stop here
Map<String, String> withUser = new HashMap<>(req);
withUser.put("user", "ada");
return next.apply(withUser);
};
Function<Map<String, String>, Res> handler = req -> new Res(200, "orders for " + req.get("user"));
var app = pipeline(List.of(logging, auth), handler);
System.out.println(app.apply(Map.of("path", "/orders", "token", "secret")).body());
System.out.println(app.apply(Map.of("path", "/orders", "token", "wrong")).body());
}
}
Output
-> /orders <- 200 orders for ada -> /orders <- 401 unauthorized
Run this on your computer — see “Get Started” for setup.
<?php
$logging = function (array $req, callable $next): array {
echo "-> {$req['path']}\n";
$res = $next($req); // before ... after
echo "<- {$res['status']}\n";
return $res;
};
$auth = function (array $req, callable $next): array {
if (($req["token"] ?? null) !== "secret") return ["status" => 401, "body" => "unauthorized"]; // stop here
$req["user"] = "ada";
return $next($req);
};
$handler = fn(array $req): array => ["status" => 200, "body" => "orders for {$req['user']}"];
function pipeline(array $middlewares, callable $final): callable {
return array_reduce(array_reverse($middlewares),
fn(callable $next, callable $mw) => fn(array $req) => $mw($req, $next), $final);
}
$app = pipeline([$logging, $auth], $handler);
$first = $app(["path" => "/orders", "token" => "secret"]);
echo $first["body"], "\n";
$second = $app(["path" => "/orders", "token" => "wrong"]);
echo $second["body"], "\n";
Output
-> /orders <- 200 orders for ada -> /orders <- 401 unauthorized
The handler never checks auth, and logging wraps both outcomes. A rate limiter would be one more function in the list.
When to use it
- Requests or messages need the same cross-cutting steps: logging, auth, CORS, rate limiting, error handling, compression, transactions, tracing.
- Steps should be reusable and reorderable, and different routes or queues need different combinations.
- You process messages or jobs through stages (a queue consumer pipeline, a data-processing pipeline).
When not to use it
- Logic that belongs to one endpoint. Middleware is for things that apply to many.
- Middleware that depends on each other's side effects in hidden ways ("auth must run after session, which must run after cookies"). Document the order or make dependencies explicit.
- Too many layers make a request's path hard to trace. Keep each middleware small and well named.
You've already used it
- Express / Koa / Fastify (
app.use((req, res, next) => ...)), Laravel middleware (handle($request, Closure $next)), Django middleware, ASP.NET Core pipeline, Starlette/FastAPI middleware. - Redux middleware (
store => next => action => ...). - Laravel's
Pipelineclass and Java servlet filters. - Message pipelines: Celery and Sidekiq middleware, and gRPC interceptors.
Key points
- Middleware wraps a handler with before/after behaviour and can stop a request.
- A pipeline composes middleware so each wraps the next, combining Chain of Responsibility and Decorator.
- Use it for cross-cutting concerns that apply to many routes or messages.
- Further reading: Laravel middleware and Express: using middleware.
Practice questions
How does middleware combine Chain of Responsibility and Decorator?
Answer
Like Chain of Responsibility, each middleware can handle the request itself and stop the chain (return a 401). Like Decorator, each middleware wraps the next with behaviour before and after it (logging, timing, adding headers).
Why does the order of middleware matter?
Answer
Each middleware wraps the ones after it. Error handling placed first catches errors from everything inside; logging placed before auth logs rejected requests too; a body parser must run before code that reads the body.
What kinds of logic belong in middleware?
Answer
Cross-cutting concerns applied to many routes or messages: logging, tracing, authentication, CORS, rate limiting, error formatting, compression and transactions. Endpoint-specific business logic does not.
Open in the app to mark lessons complete, take notes and track your progress.