What Belongs in the Pipeline
A concern belongs in middleware if it is uniform, transport-level, needed even when no handler runs, and cheap — which rules out several of the things most often put there.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Which concerns belong in the middleware pipeline, and which ones only look like they do?
Logging, authentication, tenant resolution, authorisation, validation, caching, feature flags, transactions and audit all apply to many endpoints. Somebody has to decide which of them are pipeline entries.
Middleware is where shared behaviour goes. If more than one route needs it, put it in the pipeline — that is what the pipeline is for, and it keeps handlers short.
Authorisation in middleware can only see the route, not the object. GET /orders/:id passes a route-level check and still returns someone else's order, and the middleware makes it *look* handled (Object-Level Authorization).
- Authorisation in middleware can only see the route, not the object.
GET /orders/:idpasses a route-level check and still returns someone else's order, and the middleware makes it *look* handled (Object-Level Authorization). - A middleware that loads the current user's organisation runs on every request, including health checks, CORS preflights, static assets and 404s — a database round trip per request for a value most of them never read (Health Checks: Startup, Readiness, Liveness).
- A transaction opened in middleware and committed on response spans the whole request, so an external payment call happens inside an open transaction and holds a pooled connection for its duration (External Calls Inside a Transaction).
- A feature-flag middleware that evaluates every flag per request adds a network call to the critical path of every endpoint (Feature Flags: Rollout, Kill Switches and Debt).
- Business validation in middleware runs for HTTP callers and not for the queue consumer performing the same operation, so the rule is enforced on one path and not the other (Business Validation).
- Handlers become untestable in isolation: exercising one now requires assembling a pipeline, because half of what it needs was attached by middleware.
What is actually happening
- Four tests decide it. Uniform: does it apply identically to every request that reaches this point, without needing to know the resource? Transport-level: is it about the request as an HTTP message rather than about the domain? Needed without a handler: must it happen for 404s, preflights and rejected requests? Cheap: is its per-request cost justified on requests that never use its result?
- Anything that needs the object fails the uniformity test by construction. The pipeline runs before the handler, and the handler is what loads the object — so any decision requiring the object is not a pipeline decision (Where the Check Belongs).
- Anything that must apply to every caller of an operation — not every HTTP request — belongs in the operation, not the pipeline. A queue consumer, an admin CLI and a scheduled job all bypass the pipeline entirely (What a Handler Is Responsible For).
- A concern can be split: resolve in the pipeline, enforce in the layer that has the data. Resolve the tenant from the authenticated principal in middleware; enforce the tenant filter in the repository where the query is built (The Repository Layer).
- The pipeline's real cost is invisibility. Every entry is behaviour a reader of the handler cannot see. Uniform enforcement is worth that cost; convenience is not, which is why a short, named, deliberately chosen chain beats a long organic one.
- Per-route middleware is a middle ground and a trap: it gives locality and reintroduces the "someone forgets one" failure that global registration exists to prevent. Use it for genuinely route-shaped concerns like schema validation, not for security controls.
The four tests, applied
The table below is the lesson. Read the last column first — the reason is always about what the concern needs to know and who else calls the same operation, never about how many routes use it.
Note how many rows are split rather than in or out. Resolve in the pipeline, enforce where the data is: that pattern covers tenant, permissions and feature context, and it is what makes the enforcement survive a caller that never went through HTTP.
| Concern | Pipeline? | Why |
|---|---|---|
| Correlation id | Yes | Uniform, transport-level, needed for requests that never reach a handler |
| Access logging + metrics | Yes | Must record rejected and unmatched requests too — the ones a handler never sees |
| CORS | Yes | Must answer preflights with no handler at all, and must attach headers to error responses |
| Body size limit | Yes | Bounds reading itself; too late anywhere else (Request Bodies and Streaming) |
| Authentication | Yes | Uniform and transport-level: identity comes from headers, not from the resource |
| Coarse rate limit | Yes | Cheap, uniform, and its whole value is running before expensive work (Authenticate First, or Rate-Limit First?) |
| Body parsing | Per route | Shape and limits differ per route; webhook routes need the raw bytes (Webhook Signature Verification) |
| Schema validation | Per route | Needs that route's schema; a global validator cannot know it (Transport Validation) |
| Tenant resolution | Split | Resolve from the principal in the pipeline; enforce the filter in the data layer, failing closed (Tenant Isolation) |
| Object-level authorisation | No | Needs the object, which only the handler loads (Object-Level Authorization) |
| Business validation | No | Must apply to every caller of the operation, including queue consumers (Business Validation) |
| Transactions | No | Boundary must match the work, not the request (Where the Transaction Boundary Goes) |
| Retries | No | The body is consumed and side effects may have happened; retry belongs at the call that failed (Retries) |
| Response caching | Depends | Fine as a pipeline concern for uniform public GETs; a correctness hazard for anything user-specific (Caching in Backends) |
Concerns that look cross-cutting and are not
Authorisation is the case worth spending time on, because the middleware version is not merely incomplete — it is actively harmful. It creates the impression that access control is handled, and it satisfies a reviewer who sees requireRole('user') on the route.
The fix is not "add more middleware". It is to move the decision to the layer that has the object, and to make that layer's API impossible to use without answering the question.
router.get('/orders/:id',
requireAuth,
requireRole('customer'), // can only see the route
async (req, res) => {
const order = await repo.findById(req.params.id)
res.json(order) // any customer can read any order
})// pipeline: authenticate, put the principal on the context, coarse route check
router.get('/orders/:id', requireAuth, requireRole('customer'), async (req, res) => {
const order = await orders.getForPrincipal(parseOrderId(req.params.id), req.ctx.principal)
if (!order.ok) return res.status(statusFor(order.error)).json(toErrorBody(order.error))
res.json(toOrderResponse(order.value))
})
// repository: the tenant/owner filter is part of the query, not a check after it
async function getForPrincipal(id: OrderId, p: Principal) {
return db.query(
'select * from orders where id = $1 and tenant_id = $2',
[id, p.tenantId], // absent tenant throws, never widens
)
}The route-level check still has value as defence in depth — it keeps non-customers out cheaply — but it cannot be the control, because it runs before the object exists. Pushing the ownership predicate into the query means the check cannot be skipped, applies to every caller of getForPrincipal including queue consumers, and fails closed when the tenant is missing rather than returning everything.
The invisibility cost is real, so keep the chain short
Every pipeline entry is behaviour that a reader of the handler cannot see. That is the deal: you trade local reasoning for uniform enforcement. It is a good trade for a small number of concerns that genuinely must apply everywhere, and a bad one for anything else.
The practical consequence is a discipline, not a rule: keep the chain short enough to hold in your head, name every entry for what it provides, and let the four tests decide additions. A chain of eight entries you can recite is worth more than fifteen that individually made sense.
- Name entries by what they provide, not what they do —
providesPrincipalbeatsauthMiddlewarewhen you are reading an ordering. - One file, one order, one comment per line stating requires and provides (Middleware Ordering Is a Correctness Decision).
- A test that snapshots the chain, so growth is a reviewable event rather than a gradual accumulation.
- An explicit exclusion list for cheap paths, so health checks and static assets do not pay for the whole pipeline.
- A budget: know what the chain costs per request, and treat a new entry that does I/O as a design decision rather than an addition.
- Anything that must apply to non-HTTP callers goes in the operation, not the chain — that is the single question that resolves most placement arguments (What a Handler Is Responsible For).
How to build it
Most important first.
- Apply the four tests explicitly when adding an entry, and write the answer in the comment next to it.
- Keep security controls global with an explicit allowlist of exceptions, so a missing control is a visible diff rather than an omission (Middleware Ordering Is a Correctness Decision).
- Split resolve-from-enforce for tenant, permissions and feature context: middleware puts facts on the context; the layer that acts on them enforces them (Request Context Propagation).
- Do per-route schema validation with the route's own schema, not a global validator that cannot know the shape (Transport Validation).
- Keep transactions inside the operation, where their boundary can match the work rather than the request (Where the Transaction Boundary Goes).
- Exclude cheap, high-volume paths — health checks, metrics endpoints, static assets — from expensive middleware explicitly, and make that exclusion visible.
- Budget the pipeline: know roughly what it costs per request, because it is paid on every request whether or not the result is used.
What can go wrong
- A control that appears complete because middleware exists for it, but is enforced at the wrong granularity — route instead of object.
- A middleware doing I/O that becomes a hard dependency for every request, including the health check, so its outage is a total outage (Cascading Failure).
- Middleware that mutates the request or response in ways later code cannot see, producing behaviour with no visible cause.
- Per-route middleware arrays drifting between routes.
- A chain that grew to twenty entries, where nobody can say what any given request has already been through.
- Handler tests that must construct the entire pipeline, so they stop being written.
- A middleware that caches a per-tenant or per-user value in process memory shares it across concurrent requests, and a concurrent permission change makes the cached value stale for the whole TTL (Cache Invalidation).
- Lazy initialisation of a middleware dependency on first request can run several times concurrently at startup (Initialization Races).
- A transaction opened in middleware and committed after the response leaves a window where a concurrent request sees uncommitted-then-committed state at a moment unrelated to the work (Transactions from Application Code).
- Authentication is a good pipeline concern: uniform, transport-level, needed before anything else. Authorisation is usually not, because it needs the object (Authentication vs Authorization).
- Coarse route-level checks in middleware are useful defence in depth and must never be the only check (Defence in Depth).
- Anything that bypasses the pipeline bypasses everything in it: static handlers, mounted sub-apps, framework-provided endpoints, and non-HTTP callers of the same operations.
- Audit records written in middleware capture HTTP requests, not domain actions. If the same operation can happen from a job, the audit belongs in the operation (Audit Logs for Privileged Actions).
- Tenant resolution in middleware plus tenant enforcement in the data layer is the combination that survives a lost context, because the enforcing layer can fail closed (Tenant Isolation).
- "Cross-cutting means it goes in middleware." Cross-cutting means many places need it. Where it goes depends on what it needs to know and who else calls the same operation.
- "Authorisation is cross-cutting." Authentication is. Authorisation needs the object and the domain rules, and route-level checks alone are the classic broken-access-control shape (Broken Access Control (IDOR / BOLA)).
- "Transactions per request are simpler." They hold a pooled connection for the whole request including external calls, and they make the transaction boundary a property of the transport (Where the Transaction Boundary Goes).
- "Middleware keeps handlers thin, so more middleware is better." It moves code out of sight. Past a certain point, nobody can predict what a request has already been through.
- "Validation belongs in middleware." Transport validation, per-route, yes. Business rules belong where every caller crosses them (The Three Validations).
- "If it is in middleware, every request goes through it." Only requests that reach it. Static handlers, earlier routes and non-HTTP callers do not (Route Precedence).
Operating it
- Time spent in the pipeline versus in the handler. A pipeline that is a significant share of a fast endpoint's latency is doing too much.
- A count of requests where an expensive middleware ran but its result was never used — the clearest evidence that it belongs somewhere else.
- Dependency call counts attributable to middleware. A per-request user lookup shows up as query volume proportional to *all* traffic, not to traffic that needs a user (The Metrics a Backend Must Emit).
- The assembled chain, printed and diffed in CI, so growth is a reviewable event.
- Health-check latency as a canary: if it rises with general load, the health endpoint is going through middleware it should not (Health Checks: Startup, Readiness, Liveness).
- Pipeline cost multiplies by total request count, and total includes the requests you never think about. At 100x, a 2 ms lookup in middleware is 2 ms on every preflight and every 404.
- A middleware that calls a dependency makes that dependency's availability an upper bound on yours, and the coupling is invisible in a service diagram (Failure Propagation).
- Moving genuinely uniform, cheap concerns to the edge — CORS, coarse rate limits, static asset serving — removes them from your process entirely (Load Balancing, From the Backend's Side).
- As the endpoint count grows, the value of global-with-allowlist rises sharply: the failure mode it prevents is "one of two hundred routes forgot".
- Global enforcement guarantees coverage and makes handlers incomplete to read. That invisibility is permanent and is the price of uniformity.
- Splitting resolve from enforce is more moving parts than doing both in one place, and it is what lets non-HTTP callers get the same enforcement.
- Excluding cheap paths from expensive middleware is correct and adds a second, easily-forgotten configuration list.
- Keeping authorisation out of the pipeline means it must be written in every operation, and the mitigation — enforcing it in the layer that loads objects — is a design constraint, not a middleware entry.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe four tests — uniform, transport-level, needed without a handler, cheap — apply to any pipeline architecture including gateway policies and mesh filters.
- FRAMEWORK-SPECIFICHow much the framework encourages the wrong placement varies. Rails
before_actionand Django middleware make per-controller filters idiomatic, which is convenient and makes route-level authorisation feel sufficient; Fastify's hook encapsulation scopes middleware to a plugin subtree, which reduces drift; ASP.NET Core separates authentication and authorisation into distinct middleware with an explicit required order and a policy system that can still only see the route unless you use resource-based authorisation. - SCALE-SPECIFICOn a small service with a handful of endpoints and one team, per-route middleware arrays are perfectly manageable. The global-with-allowlist argument earns its cost when the number of routes exceeds what one person can hold in their head, which is where the "someone forgot one" failure begins.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.