What a Handler Is Responsible For
A handler is an adapter between HTTP and one application operation — parse, resolve caller, call, map, return — and everything else it does belongs somewhere it can be reused and tested.
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.
What belongs inside a request handler, and what is it borrowing from layers that should own it?
POST /orders places an order: validate the basket, reserve stock, charge the card, write the order, send a confirmation, return the created resource.
Write it all in the handler. It is one function, it reads top to bottom, and every step is visible without jumping between files. Splitting it early is speculative structure.
The same operation is needed from a queue consumer replaying a failed payment, and from an admin tool. Neither has a req or a res, so the logic is copied or the job fakes an HTTP request object.
- The same operation is needed from a queue consumer replaying a failed payment, and from an admin tool. Neither has a
reqor ares, so the logic is copied or the job fakes an HTTP request object. - Testing "what happens when the payment provider times out" requires constructing an HTTP request, a response double, a router and a middleware chain, so it does not get tested.
- The transaction boundary ends up spanning the handler, which means an external payment call happens inside an open database transaction and holds a pooled connection for the length of a third-party API call (External Calls Inside a Transaction).
- Error mapping is written inline per branch, so one endpoint returns 400 for a business rule violation, another returns 422, and a third returns 500 for the same class of failure (An Error Taxonomy That Maps Cause to Response).
- The handler grows to several hundred lines and becomes the place every new requirement is added, because it is where all the context already is (Fat Controllers).
What is actually happening
- A handler sits at a boundary. On one side is HTTP: strings, headers, status codes, content types, a socket that can close. On the other is your domain: typed values, invariants, operations that either happen or do not. The handler's job is translation, and translation only.
- Five responsibilities, in order: parse transport into typed input; resolve the calling context (principal, tenant, correlation id, deadline) that middleware established (Request Context Propagation); invoke exactly one application operation; map the result — success or typed failure — to a status and a response body; return.
- What it is not responsible for: business rules, transaction boundaries, retry policy, orchestration of several operations, direct SQL, cache management, or knowing which queue a follow-up job goes on.
- The mechanism that keeps this honest is the signature of the operation you call. If it takes framework request and response objects, everything above leaks in and nothing else can call it. If it takes a typed input and a caller context and returns a result, it is callable from a job, a test, a CLI and a second transport.
- Mapping results to status codes is a policy, not a per-handler decision. A taxonomy — invalid input, not found, forbidden, conflict, dependency unavailable, unexpected — maps once to statuses, log levels and whether the client should retry (Retryability: Telling Clients What To Do Next).
- This is a shape, not a mandate for four layers. For an endpoint that maps one-to-one onto a single query, a handler calling a repository directly *is* the right structure; inserting a pass-through service adds a file and no behaviour (Alternatives to Layering).
Five jobs, and nothing else
The list is short enough to hold in your head, which is the point. When a handler is doing something not on this list, that thing has a better home — and the better home is almost always somewhere that can also be called by a job, a test or a second transport.
Note that the fifth job is the one people under-invest in. Mapping a domain outcome to a status code is a contract decision, and doing it ad hoc per handler is how an API ends up with three different responses for the same class of failure.
- 1Parse
Turns body, path and query into a typed input value; rejects malformed input with a field-level 400.
fails by Reading
req.body.xdeep inside the flow, so the shape is never actually pinned down (Transport Validation). - 2Resolve context
Reads principal, tenant, correlation id and deadline from what middleware established.
fails by Taking the tenant from the payload, which is a scope-escalation bug (Tenant Isolation).
- 3Invoke
Calls exactly one application operation with the typed input and the context.
fails by Orchestrating three calls with no transaction, no idempotency and no compensation (The Dual Write Problem).
- 4Map result
Translates success or typed failure into status, headers and body via a shared taxonomy.
fails by Inline per-case mapping, so the same failure gets different statuses on different endpoints (An Error Taxonomy That Maps Cause to Response).
- 5Return
Writes exactly one response and stops.
fails by A helper that writes a response and returns, letting the caller write a second one (The Error Boundary).
Everything not on this list — rules, transactions, retries, queue writes, cache invalidation — belongs behind the invoke step, where it is reachable by callers who do not speak HTTP.
The signature test
There is one question that settles most arguments about what belongs in a handler: can this function be called from a queue consumer? If it needs a request object, a response object, or a header, the answer is no, and the code will be duplicated the first time an operation needs to run asynchronously.
This is not an argument for layers in general. It is an argument for a single, specific boundary at the transport edge — one that costs a type definition and pays back every time the same operation acquires a second caller.
async function createOrder(req: Request, res: Response) {
const userId = req.session.userId
const items = req.body.items
// ... 200 lines: rules, tx, payment call, email, response writes
}
// the retry job needs this. it cannot call it.
// so the job builds a fake `req` — or the logic is copied.type PlaceOrderError =
| { kind: 'invalid_basket'; issues: Issue[] }
| { kind: 'stock_unavailable'; sku: string }
| { kind: 'payment_declined'; reason: string }
| { kind: 'payment_unavailable' }
async function placeOrder(
input: PlaceOrderInput,
ctx: CallerContext,
): Promise<Result<Order, PlaceOrderError>> { /* rules, tx, payment, outbox */ }
app.post('/orders', async (req, res) => {
const input = parsePlaceOrder(req.body)
if (!input.ok) return res.status(400).json(toFieldErrors(input.error))
const result = await placeOrder(input.value, req.ctx)
if (!result.ok) return res.status(statusFor(result.error)).json(toErrorBody(result.error))
res.status(201).location(`/orders/${result.value.id}`).json(toOrderResponse(result.value))
})The second placeOrder is callable from an HTTP handler, a queue consumer, an admin CLI and a test with no HTTP anywhere. Its failure cases are in the type, so a new failure mode is a compile error at every call site instead of an unhandled path — and statusFor gives the whole API one consistent answer per failure class.
Mapping outcomes to responses, once
The mapping table is the artefact worth writing down. It is the contract between your domain vocabulary and HTTP, it is what makes error responses consistent across an API, and it answers the question clients care about most: should I retry this (Retryability: Telling Clients What To Do Next)?
Two columns do the heavy lifting. Retryable tells the client whether repeating the request could succeed — which is a different property from whether the request is *safe* to repeat; that is idempotency, and it is your side of the bargain (Idempotency in Backends). Log level stops expected business outcomes from filling the error budget and the alerting channel.
| Outcome | Status | Retryable by client? | Log level | Body carries |
|---|---|---|---|---|
| Malformed or missing input | 400 | No — not without changing it | info | Field-level issues (Reporting Validation Failures) |
| Not authenticated | 401 | After obtaining a credential | info | Nothing about why |
| Authenticated, not permitted | 403 or 404 | No | warn | Nothing about the object |
| Object does not exist | 404 | No | info | A generic code |
| Business rule violated (stock, state) | 409 or 422 | Only after state changes | info | A machine-readable code the client can branch on |
| Concurrent modification | 409 | Yes, after re-reading | info | The current version (Optimistic Concurrency) |
| Rate limit or quota exceeded | 429 | Yes, after Retry-After | info | When to retry (Rate Limiting) |
| Dependency timed out or unavailable | 503 or 504 | Yes, with backoff | error | A correlation id, nothing else (Timeouts) |
| Unexpected exception | 500 | Maybe — the client cannot tell | error | A correlation id only (Not Leaking Your Internals) |
How to build it
Most important first.
- Give every operation a signature the framework cannot appear in:
placeOrder(input: PlaceOrderInput, ctx: CallerContext): Promise<Result<Order, PlaceOrderError>>. That one rule produces most of the other benefits by force. - Parse once, at the top, into typed input; never re-read
reqfurther down (Parse, Do Not Validate). - Return typed failures from the operation rather than throwing for expected outcomes; keep throwing for genuinely unexpected ones and let the error boundary handle those (The Error Boundary).
- Put the mapping from failure type to status in one shared function, so consistency is structural rather than remembered.
- Keep the transaction inside the operation, not the handler, so its boundary is decided by the work rather than by the request (Where the Transaction Boundary Goes).
- One handler, one operation. If a handler must call two operations, that is a sequencing decision with failure semantics, and it belongs in an application service where it can be made atomic, idempotent, or explicitly compensating (The Transactional Outbox).
What can go wrong
- The framework request object smuggled downward — a service that takes
req"just for the user" is no longer callable from a job. - Response writing scattered through a call chain, so an inner function returns early by writing a response and the caller continues, producing a double write ("headers already sent").
- A handler that swallows an error to return a nicer message, and in doing so removes the only record that a dependency failed (Error Boundaries: Three Translations, Not One).
- Business rules drifting into the mapper — a validation that lives in the request parser and is therefore not enforced when the same operation runs from a queue (Business Validation).
- Async work started but not awaited so the response returns quickly, and the process is terminated mid-deploy with the work half-done (Graceful Shutdown).
- A thin handler over a service that is itself a 900-line function — the layering is present and the problem simply moved (Fat Controllers).
- A handler that checks a precondition and then calls an operation that assumes it still holds has a time-of-check gap; the check belongs inside the operation, in the same transaction as the write (Backend Races).
- A client retry after a dropped connection re-enters the same handler while the first execution is still running, so the operation must be idempotent rather than the handler being careful (Idempotency in Backends).
- Work started with a fire-and-forget call outlives the response and can be cut off by shutdown, or can run concurrently with a second request doing the same thing (Unbounded Concurrency).
- Authorisation must happen where the object is known. A handler can check coarse route access; only the operation that loads the record can check ownership (Object-Level Authorization).
- The caller context must come from authenticated state, never from the request body or a path segment. A handler that reads
tenantIdfrom the payload has created a tenant-switching vulnerability (Tenant Isolation). - Binding a request body directly onto a domain entity lets a caller set fields you never exposed — role, price, tenant, id. Map explicitly from a request type (Mass Assignment and Over-Posting).
- The mapper is the last place internals can leak: an exception message, a constraint name, a stack frame or a SQL fragment in a response body is a disclosure (Not Leaking Your Internals).
- Write the audit record inside the operation, not the handler, so it exists for every caller of that operation regardless of transport (Audit Logs for Privileged Actions).
- "Thin handlers mean four layers." It means one boundary: HTTP in, domain out. How many layers live behind that boundary is a separate decision (Transport, Application, Domain, Infrastructure).
- "The service layer is always worth it." A pass-through service that forwards to a repository with no rules of its own is indirection with no behaviour — and this domain says so explicitly (When the Repository Is Just Indirection).
- "Validation in the handler is enough." Transport validation checks shape. Business rules must live where every caller crosses them, including the queue consumer (The Three Validations).
- "Returning errors instead of throwing is a style preference." For *expected* outcomes it is a design decision about whether the caller must handle them, and the type system enforces the answer.
- "The handler should log what happened." It should ensure a correlation id exists and that failures are recorded once, at the boundary. Logging the same failure at three levels of the stack is how log volume becomes a cost centre (What a Backend Should Actually Log).
Operating it
- Duration split into parse, operation and serialise. Handlers that look slow are usually operations that are slow, and occasionally serialisation of a large payload (What Serialization Costs).
- A count of responses by mapped failure type, not just by status. "400" tells you little; "invalid_basket versus stock_unavailable" tells you what the product is doing.
- A span per handler with the route template as the name, and child spans for each dependency the operation touched (Tracing From the Backend's Side).
- Alert on handlers whose 500 rate is nonzero: with typed failures mapped explicitly, a 500 should mean genuinely unexpected, which makes it actionable.
- Handler structure has no direct cost at scale; what scales is the number of endpoints and engineers. At five endpoints the discipline is invisible; at two hundred it is the difference between a codebase you can change and one you can only add to.
- The reusability payoff arrives the first time an operation must also run asynchronously — retries, backfills, admin actions — which happens to nearly every write endpoint eventually (Background Jobs).
- Under load the handler is not the bottleneck; the operation's data access and external calls are. A thin handler makes that measurable because the boundaries are real functions (Why Is My API Slow?).
- Typed input, typed failures and explicit mappers are more code than reading fields off
req. On a small service that overhead is real and buys little. - Typed result returns are more disciplined than exceptions and more verbose; teams that already have a working error taxonomy over exceptions gain less from switching than the extra ceremony costs.
- Keeping the framework out of the service layer occasionally means re-implementing a convenience the framework gave you free — content negotiation, file streaming, or a request-scoped cache.
- One operation per handler is a good default that sometimes forces an awkward application service whose only job is to sequence two calls. That service is still the right place for the sequencing.
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 five responsibilities and the "framework types stop at the handler" rule hold across languages and frameworks.
- FRAMEWORK-SPECIFICHow much the framework pushes you toward or away from this differs sharply. Spring binds a typed argument and returns a typed value, so the boundary is nearly automatic; Express hands you
(req, res)and lets you write anything anywhere; Fastify sits between, with schema-driven parsing and serialisation configured per route. The discipline required is inversely proportional to what the framework does for you. - LANGUAGE-SPECIFICTyped-failure returns are natural in Go (
(T, error)), Rust (Result) and TypeScript with a discriminated union; in languages where exceptions are the idiom, the equivalent discipline is a small exception hierarchy mapped once at the boundary rather than caught per call site.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — why an operation with a transport-free signature is the unit that can actually be tested, and what a test of a handler is genuinely worth.