The Problem with Imperative Handlers
Most server routes follow the same broad sequence:
- validate input
- build a query or command
- call an external system or database
- transform the result
- format a response
In imperative code, that often becomes one long function with mixed concerns and scattered error handling. The more endpoints a project accumulates, the more that repetition turns into drift.
Functional Pipelines
Functional composition offers a cleaner model: each step performs one transformation, and the route becomes a declaration of flow.
export const pipe = (...fns) => (x) =>
fns.reduce((value, fn) => fn(value), x)
export const pipeAsync = (...fns) => (x) =>
fns.reduce(async (value, fn) => fn(await value), x)
The route handler stops being a miniature script and starts becoming a pipeline.
await pipeAsync(
buildQuery,
runQuery,
shapeResult,
sendResponse
)(input)
That may look small, but it changes how responsibilities are distributed.
Why It Works
Each pipeline stage owns one concern:
- query construction
- execution
- shaping
- transport formatting
This creates three benefits immediately:
- testability: each stage can be tested in isolation
- reusability: common stages can be shared across routes
- readability: the route expresses intent instead of mechanics
This is especially effective when many endpoints differ only in the first one or two transformation stages.
Query Builders as a Separate Layer
One useful convention is to treat query builders as pure functions that return query objects but do not execute them.
That means:
- business rules decide what should be queried
- execution helpers decide how to run the query
- transport helpers decide how to send the result
This separation makes it much easier to test query behavior without mocking the entire runtime stack.
Error Handling Should Stay Outside the Pipeline
One of the biggest advantages of this approach is that individual stages do not need to own try/catch behavior.
Instead:
- stages throw when something is wrong
- route wrappers catch async errors
- centralized middleware decides how errors are logged and serialized
That keeps the happy path readable and avoids repeated local error boilerplate in every stage.
Limits of the Pattern
Functional composition is not automatically better in every case.
It can become awkward when:
- a flow has too many branches
- stage naming is vague
- the pipeline is so long that intent gets buried
- debugging requires inspecting many tiny layers for one simple issue
The pattern works best when pipelines stay short and stage boundaries are explicit.
Design Lessons
- Pipelines are most valuable when many routes share the same broad shape.
- Query builders should construct data, not perform I/O.
- Centralized error handling keeps pipeline stages simple.
- Composition improves readability only if stages are well named and narrowly scoped.
- If a pipeline gets too long, the route probably needs a different decomposition.