Skip to main content

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 AppError to 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​

KindWhereLoaded by
Secrets (keys, passwords)SSM Parameter Store, injected as env vars by the task definitionconfig/env.ts, Zod-validated at start-up
Settings with defaultsA typed settings object per moduleconfig/settings.ts, env overrides allowed
Feature flagsRemote configRead 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 AppError codes; one central mapping.
  • Outside-service errors map to 502 and are logged.
  • Start-up validates every required secret.