API handler pattern
API handler pattern and cross-cutting concerns
When writing an HTTP API, the code around the handler body (auth, validation, logging, error translation, rate limiting) often outweighs the body itself. Where and how this cross-cutting code lives decides consistency and maintenance cost.
1. Per-framework mechanisms
The mechanisms for expressing cross-cutting concerns vary in name across frameworks.
| Framework | Mechanism | Form |
|---|---|---|
| Express (Node) | middleware | (req, res, next) => ... |
| Koa | middleware | async (ctx, next) => ... |
| Spring MVC | HandlerInterceptor · Filter |
Class + annotation |
| FastAPI | Depends · middleware |
Function dependency + ASGI middleware |
| NestJS | Interceptor · Guard · Pipe | Decorator + class |
| Next.js Route Handlers | Wrapper function | withAuth(handler) style composition |
| Hono | middleware | app.use(...) |
One standard response format is RFC 7807 — Problem Details for HTTP APIs (2016, later revised as RFC 9457). It defines the application/problem+json content type and the type · title · status · detail · instance fields.
2. Middleware chain (Express model)
A request passes through middlewares in the order they were registered, and each calls next() to advance. Sending a response directly or throwing an error breaks the chain there.
app.use(requestId)
app.use(logger)
app.use(authenticate)
app.use('/admin', authorize('admin'))
app.use(routes)
app.use(errorHandler) // (err, req, res, next) — error-only
Koa re-expressed the same model in async/await. It is called the "onion model" because the same middleware can wake up after the response to do final processing.
3. Spring's Interceptor and Filter
A Servlet Filter sits at the outermost layer; HandlerInterceptor sits closer to the controller. @RestControllerAdvice translates exceptions in one place.
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ResponseEntity<ProblemDetail> notFound(NotFoundException e) {
var p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
return ResponseEntity.of(p).build();
}
}
From Spring 6, the ProblemDetail class produces RFC 7807 responses in a standardized way.
4. FastAPI's Depends + middleware
Depends is evaluated right before the handler enters and carries auth, DB session, and validation. app.add_middleware carries ASGI-level global processing. Beyond HTTPException, FastAPI can translate user-defined exceptions via app.exception_handler.
5. Next.js wrapper
Next.js App Router's route.ts is a function. Cross-cutting concerns are added by composition.
export const GET = withAuth(withLogging(async (req) => {
return NextResponse.json({ ok: true })
}))
Function composition does not use decorators or classes — it stays light, but order and types must be managed by hand.
6. RFC 7807 response
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc"
}
The strength is that an API client can assume standard fields when parsing the response. Adding custom fields like errors[] is also allowed by the spec.
7. Function composition vs class decorators
- Function composition (Express · Hono · Next wrapper) — the flow reads top-to-bottom. Dependencies are explicit. Indentation grows as composition deepens.
- Class decorators (NestJS · Spring) — declared via metadata. Handlers stay short. Annotation order and priority can be implicit.
- Dependency injection (FastAPI
Depends) — dependencies are written in the signature. A middle ground between composition and decorators.
Whichever form, the goal is the same — cross-cutting concerns gather in one place and the handler focuses on the body.
8. ApiError standard response class
Having a custom ApiError class is a common pattern.
class ApiError extends Error {
constructor(public status: number, public code: string, public detail?: string) { super(detail) }
}
When thrown, a global handler translates it into problem+json. Mapping RFC 7807's type to a domain code lets clients branch on it.
9. Middleware order
The following order is generally safe.
① Request ID assignment (observability)
② Security headers (Helmet-style)
③ CORS
④ Logging
⑤ Body parsing
⑥ Authentication
⑦ Authorization (per route)
⑧ Validation (schema)
⑨ Route handler
⑩ Error handler (last)
Rate limiting belongs at the middleware layer. Redis-backed token bucket and sliding window are common (express-rate-limit · @upstash/ratelimit · Spring's Bucket4j).
10. Common pitfalls
Error middleware not catching async throws — Express 4 does not auto-propagate rejections from async handlers. A helper like express-async-errors or a try/catch wrapper is needed. Express 5 improved this.
Sending the response twice — if one middleware sends a response and another sends again, ERR_HTTP_HEADERS_SENT is raised. Tighten the timing of next() calls and returns.
Client IP and proxies — when behind Caddy or nginx, missing X-Forwarded-For trust configuration causes rate limiting to lump everything into the proxy IP.
Information leaks in error messages — make sure stack traces and DB messages are not put into detail directly, and mask in production.
Closing thoughts
Where cross-cutting concerns become half of the code, the value of bundling via wrappers or middleware shows up clearly. Starting with direct try/catch and moving to abstractions when patterns repeat is the safest flow.
Next
- jobs-apscheduler
- typeorm-readonly
See RFC 7807 · RFC 9457 (revision) · Express middleware · Spring RestControllerAdvice · FastAPI Dependencies · NestJS Interceptors · Hono Middleware.