Error handling
This document describes how failures are represented and returned in the API with Fastify, using the users module (apps/api/src/modules/users) as the concrete example.
Two kinds of failure
- Expected failures (unauthorized access, missing profile/user, invalid domain input) are returned explicitly by handlers with
reply.code(...).send(...). - Unexpected failures (thrown/rejected errors from use cases, repositories, infrastructure, or bugs) bubble to the global Fastify
setErrorHandlerinapps/api/src/index.ts.
Global Fastify error handler (index.ts)
The app defines a central app.setErrorHandler(...) that applies to uncaught route errors.
Behavior:
- Reads
error.statusCodeonly if it is numeric and>= 400; otherwise defaults to500. - Returns
{ message: "Internal server error" }for 5xx. - Returns
{ message: error.message }for 4xx. - Logs
http.request.errorwith request and tracing metadata (requestId, method, path,statusCode,err,trace_id,span_id).
This means thrown internal errors are masked from clients but remain visible in logs.
HTTP adapters (modules/*/adapters/http/handlers.ts)
Handlers use explicit control flow for known outcomes.
Common patterns in users handlers:
401when auth context is missing or incomplete ({ message: "Unauthorized" }).404when a use case returnsnull({ message: ... }).422for domain-level input rules not covered by schema alone (for example, update profile with no patchable fields).200/201for successful outcomes.
Handlers generally do not wrap use case calls in broad try/catch, so unknown failures continue to the global error handler.
Application and persistence layers
- Use cases are thin and often return
nullfor “not found”, leaving HTTP status mapping to adapters. - Repository code may use narrow catches for known infra races (for example Prisma
P2002uniqueness conflicts) and rethrow unknown errors. - Any rethrown/uncaught error eventually becomes a Fastify error-handler response.
Interaction with logging
Request logging is implemented in apps/api/src/index.ts:
http.request.erroris emitted by the global error handler.http.request.completedis emitted inonResponsefor completed requests (exceptOPTIONS), with status and duration.- Completion logs are leveled by response status (
infofor 2xx/3xx,warnfor 4xx,errorfor 5xx).
A failed request can emit both lines: one for error details and one for final response timing/status.
Summary
| Layer | Expected failure | Unexpected failure |
|---|---|---|
| Handlers | reply.code(...).send(...) | Throw/reject -> Fastify global setErrorHandler |
| Use cases | Return null or structured result | Propagate |
| Repository | Narrow catch for known cases | Re-throw/propagate |
Global app (index.ts) | N/A | Normalize status/body + log error |
When adding routes, keep this pattern: encode expected outcomes directly in handlers, keep catches narrow and intentional, and let Fastify's global error handler own fallback behavior.