Errors, validation and config
Where input is checked, how failures travel from a use-case to an HTTP status, and how settings and secrets are loaded. Read it when you add an endpoint or a setting.
How it works
How to build it
Validation at the edge
- Every route and resolver parses its input with a Zod schema before calling the
use-case. That includes ids:
z.string().uuid(), not "any string". - Output is also shaped by a schema, so a new database column is never leaked by accident.
- Use-cases trust their typed input and check business rules (is this order the caller's, is it still cancellable).
One error type, one mapping
// shared/errors/app-error.ts
export type AppErrorCode =
| 'invalid_request' | 'unauthorised' | 'forbidden' | 'not_found'
| 'conflict' | 'rate_limited' | 'service_unavailable' | 'upstream_error';
const STATUS: Record<AppErrorCode, number> = {
invalid_request: 400, unauthorised: 401, forbidden: 403, not_found: 404,
conflict: 409, rate_limited: 429, service_unavailable: 503, upstream_error: 502,
};
- One central handler maps
AppErrorto a status and the body{ code, message, details }. - Unexpected errors become a 500 with a generic message. The real error goes to logs and error tracking, never to the client.
- Errors from an outside service map to 502
upstream_error, logged at warn with the provider's message. Our own misconfiguration (a rejected key) maps to 503. - Pick one envelope per company and use it in every API. We had four.
Config and secrets
| Kind | Where | Loaded by |
|---|---|---|
| Secrets (keys, passwords) | SSM Parameter Store, injected as env vars by the task definition | config/env.ts, Zod-validated at start-up |
| Settings with defaults | A typed settings object per module | config/settings.ts, env overrides allowed |
| Feature flags | Remote config | Read at runtime, cached |
Start-up fails if a required secret is missing or malformed. A service that starts without its payment key fails later, in front of a customer.
Rules
Rule
Validate every id at the edge. Our logs once filled with database errors (invalid UUID)
counted as server errors, because GraphQL ID! arguments went straight into queries.
Rule
Never return an internal error message, stack trace or SQL to the client.
Mistakes we made
- Junk ids reached Postgres and were reported as 500s, hiding real errors in the noise.
- A provider's 400 was returned as the caller's
invalid_request, with no log, so it looked like a client bug for a day. - Validation differed by module: some used Zod schemas, others only GraphQL types and hand-written checks.
Checklist
- Zod schema for input, including id formats, on every route.
- Output shaped by a schema.
- Failures are
AppErrorcodes; one central mapping. - Outside-service errors map to 502 and are logged.
- Start-up validates every required secret.