Middleware pattern
Request pipelines: auth → logging → validation → handler.
Middleware is "Chain of Responsibility" wearing a web framework's clothes: a request walks through a stack of small functions, each one free to log, authenticate, validate, short-circuit, or pass along. Every backend framework is built on it.
The big idea
A request comes in, a response goes out, and in between there is a pipeline of small functions, each able to do something before the handler and something after.
The "onion" model
Each middleware can run code before and after the handler. The shape is an onion — you go inwards through the layers, the handler sits at the centre, then you walk back out.
type Middleware = (
req: Request,
next: () => Promise<Response>,
) => Promise<Response>;
const logger: Middleware = async (req, next) => {
const start = Date.now();
const res = await next(); // walks inward
console.log(`${req.method} ${req.url} -> ${res.status} (${Date.now() - start}ms)`);
return res; // …and back out
};
const auth: Middleware = async (req, next) => {
if (!req.headers.get('authorization')) {
return new Response('unauthorized', { status: 401 }); // short-circuit
}
return next();
};from time import monotonic
from typing import Awaitable, Callable
Middleware = Callable[[Request, Callable[[], Awaitable[Response]]], Awaitable[Response]]
async def logger(req: Request, next_):
start = monotonic()
res = await next_() # walks inward
print(f"{req.method} {req.url} -> {res.status} ({int((monotonic() - start) * 1000)}ms)")
return res # …and back out
async def auth(req: Request, next_):
if not req.headers.get("authorization"):
return Response("unauthorized", status=401) # short-circuit
return await next_()Calling next() walks one layer deeper. Not calling it short-circuits the pipeline —
useful for auth failures and rate limits.
Composing the pipeline
A tiny runner is all you need:
async function compose(
middlewares: Middleware[],
handler: (req: Request) => Promise<Response>,
req: Request,
) {
let i = -1;
const dispatch = async (idx: number): Promise<Response> => {
if (idx <= i) throw new Error('next() called twice');
i = idx;
if (idx === middlewares.length) return handler(req);
return middlewares[idx](req, () => dispatch(idx + 1));
};
return dispatch(0);
}
await compose([logger, auth, rateLimit, validate], finalHandler, request);async def compose(
middlewares: list[Middleware],
handler: Callable[[Request], Awaitable[Response]],
req: Request,
) -> Response:
last = -1
async def dispatch(idx: int) -> Response:
nonlocal last
if idx <= last:
raise RuntimeError("next() called twice")
last = idx
if idx == len(middlewares):
return await handler(req)
return await middlewares[idx](req, lambda: dispatch(idx + 1))
return await dispatch(0)
await compose([logger, auth, rate_limit, validate], final_handler, request)This is essentially what Koa's source looks like.
Where each concern lives
Cross-cutting concerns are exactly what middleware is for. A typical production stack:
Request ID + tracing
Stamp every request with an ID; thread it into logs and downstream calls.
Structured logging
Log incoming + outgoing pair with status, latency, user.
Authentication
Resolve the caller. Fail fast with 401.
Authorization
Decide whether this caller can do this action. Fail with 403.
Rate limiting
Reject excessive traffic with 429.
Body parsing + validation
Parse JSON, validate against schema, return 400 on failure.
Business handler
The actual route — no boilerplate left, just domain logic.
Error handler (outermost)
Catch everything that escaped; map to status + body.
Beyond HTTP
The pattern shows up everywhere there's a request-shaped pipeline:
- gRPC interceptors
- Same shape, different protocol.
- Message bus consumers
- Each handler decorates the next; great for tracing + retries.
- Database hooks
- Pre/post-insert handlers (ORMs).
- Redux middleware
- Even client-side state updates.
In practice
The first instinct of new code is to stuff cross-cutting concerns into the route handler ("just log it here for now"). Resist. Build the middleware stack early. Every concern you extract becomes one less thing your handlers have to remember to do.
Key takeaways
- Middleware is Chain of Responsibility for request pipelines.
- Each middleware can act before and after the handler — the onion model.
- Calling `next()` continues; not calling it short-circuits.
- Order matters: logging outermost, error handler outermost, rate limit before auth.
- Every cross-cutting concern that ends up in a handler is a middleware that wasn't.
Checkpoint questions
Use these to test whether the lesson is clear enough to explain without rereading.
- 1What happens before and after the handler in an onion-style middleware stack?
- 2Why does middleware order matter for auth, logging, validation, and error handling?
- 3Which concerns belong in middleware instead of the route handler?
- 4Where else do middleware-like pipelines appear outside HTTP frameworks?
References
External resources for going deeper after the lesson above.