api-service-scaffold
Scaffold a new backend service that is shaped correctly from commit one: clear layers, one error model, validation at the edge, config from the environment, and a health/readiness surface an orchestrator can probe. The goal is a skeleton a team can build on for years, not a demo that has to be re-architected the first time it meets production.
When to use
- Standing up a brand-new service.
- Adding a new bounded context / module to an existing monorepo (reuse the repo's conventions; this skill fills the gaps).
- Rescuing a service that grew without layers — apply the target structure below incrementally.
Non-negotiables (hold these on every scaffold)
- One error model. Every failure leaves the domain as a typed error and is mapped to a transport status in exactly one place (the handler/middleware boundary). No
throw new Error("bad") escaping to the client.
- Validation at the edge. Untrusted input is parsed into a typed value object at the boundary; the core never sees a raw request body.
- Config is injected, never read deep in the tree. One config module reads the environment once, validates it, and is passed down. No
process.env.X / os.environ[...] in business logic.
- No I/O in the domain layer. Handlers orchestrate; services hold logic; repositories own I/O. The domain is testable without a network or a database.
- Health ≠ readiness.
/healthz = "process is up"; /readyz = "dependencies reachable". Orchestrators need both distinct.
Target layout
src/
config/ # env parsing + validation, typed config object
http/ # transport: routes, request/response mapping, middleware
domain/ # entities, value objects, domain errors — no I/O
services/ # use-cases; orchestrate domain + repositories
repositories/ # data access; the ONLY layer that talks to DB/queue/HTTP
observability/ # logger, metrics, tracing setup (see backend-observability)
app.ts|main.py # composition root: build config → wire deps → start server
test/
smoke/ # one test that boots the app and hits /healthz + one real route
Dependencies point inward only: http → services → domain, services → repositories → domain. domain imports nothing outward. If an import arrow points the wrong way, the layering is broken — fix it before moving on.
Procedure
- Read the repo first. If this is a monorepo, match its lint config, package manager, test runner, and error conventions. Do not introduce a second way of doing something the repo already does.
- Config module. Parse the environment once into a validated, typed object. Fail fast at boot on a missing/malformed required var — a service that starts with bad config and 500s on first request is worse than one that refuses to start.
- Error model. Define a small hierarchy of domain errors (
NotFoundError, ValidationError, ConflictError, UnauthorizedError, …), each carrying a stable machine-readable code. Add one mapper at the transport boundary: domain error → status + JSON body { code, message, details? }. Never leak stack traces or internal messages to clients.
- Boundary validation. Pick one schema library (zod / pydantic / io-ts) and parse every inbound body, query, and param into a typed value at the handler. On failure, raise
ValidationError → 422 with field-level details.
- One vertical slice. Wire a single real route end to end (handler → service → repository, with an in-memory or real repo) so the skeleton is runnable, not just directories.
- Health surface.
/healthz returns 200 unconditionally once the event loop is live. /readyz pings each critical dependency with a short timeout and returns 200 only if all are reachable; 503 otherwise, with a per-dependency status body.
- Smoke test. One test boots the app, asserts
/healthz is 200 and the vertical-slice route returns its happy-path shape. This is the gate that keeps the skeleton alive as it grows.
- Graceful shutdown. On SIGTERM: stop accepting new connections, drain in-flight requests with a bounded deadline, close DB/queue pools, then exit. Orchestrators send SIGTERM before SIGKILL — use the window.
Error → status mapping (the one table)
| Domain error |
HTTP |
Notes |
ValidationError |
422 |
Include details[] with field + reason. |
UnauthorizedError |
401 |
Authentication missing/invalid. |
ForbiddenError |
403 |
Authenticated but not allowed. |
NotFoundError |
404 |
Never distinguish "doesn't exist" from "not yours" — that leaks. |
ConflictError |
409 |
Optimistic-lock / uniqueness violations. |
RateLimitedError |
429 |
Set Retry-After. |
| unmapped / unknown |
500 |
Log with correlation id; return an opaque body. Never echo internals. |
Node / TypeScript recipe
- Runtime web layer: Fastify (schema-first, fast) or Express if the repo already uses it.
- Validation:
zod, inferred types shared between transport and services.
- Config: a single
loadConfig() that zod-parses process.env and is called once in the composition root.
- Test:
vitest or jest; the smoke test uses the framework's inject/supertest to hit routes without opening a port.
Python / FastAPI recipe
- FastAPI +
pydantic v2 models as the boundary schema (validation is built in).
- Config:
pydantic-settings BaseSettings subclass, instantiated once.
- Dependency wiring via FastAPI
Depends; keep use-cases as plain functions/classes so they're unit-testable without the framework.
- Test:
pytest + httpx.AsyncClient against the ASGI app in-process.
Definition of done
lint and typecheck clean.
test/smoke passes: app boots, /healthz 200, one real route returns its documented shape.
- No
process.env / os.environ outside config/; no raw Error reaching a client; no I/O import inside domain/.
- README documents required env vars and the run/test commands.
1---2name: api-service-scaffold3description: Scaffold a production-grade backend HTTP service (REST or GraphQL) with the layering, error model, validation, config and health surface already wired. Use when starting a new service or adding a new bounded context to a monorepo. Produces a runnable skeleton that passes lint + a smoke test, not a toy. Language-agnostic playbook with concrete Node/TypeScript and Python/FastAPI recipes.4---56# api-service-scaffold78Scaffold a new backend service that is shaped correctly from commit one: clear layers, one error model, validation at the edge, config from the environment, and a health/readiness surface an orchestrator can probe. The goal is a skeleton a team can build on for years, not a demo that has to be re-architected the first time it meets production.910## When to use11- Standing up a brand-new service.12- Adding a new bounded context / module to an existing monorepo (reuse the repo's conventions; this skill fills the gaps).13- Rescuing a service that grew without layers — apply the target structure below incrementally.1415## Non-negotiables (hold these on every scaffold)16- **One error model.** Every failure leaves the domain as a typed error and is mapped to a transport status in exactly one place (the handler/middleware boundary). No `throw new Error("bad")` escaping to the client.17- **Validation at the edge.** Untrusted input is parsed into a typed value object at the boundary; the core never sees a raw request body.18- **Config is injected, never read deep in the tree.** One config module reads the environment once, validates it, and is passed down. No `process.env.X` / `os.environ[...]` in business logic.19- **No I/O in the domain layer.** Handlers orchestrate; services hold logic; repositories own I/O. The domain is testable without a network or a database.20- **Health ≠ readiness.** `/healthz` = "process is up"; `/readyz` = "dependencies reachable". Orchestrators need both distinct.2122## Target layout23```24src/25 config/ # env parsing + validation, typed config object26 http/ # transport: routes, request/response mapping, middleware27 domain/ # entities, value objects, domain errors — no I/O28 services/ # use-cases; orchestrate domain + repositories29 repositories/ # data access; the ONLY layer that talks to DB/queue/HTTP30 observability/ # logger, metrics, tracing setup (see backend-observability)31 app.ts|main.py # composition root: build config → wire deps → start server32test/33 smoke/ # one test that boots the app and hits /healthz + one real route34```35Dependencies point inward only: `http → services → domain`, `services → repositories → domain`. `domain` imports nothing outward. If an import arrow points the wrong way, the layering is broken — fix it before moving on.3637## Procedure381. **Read the repo first.** If this is a monorepo, match its lint config, package manager, test runner, and error conventions. Do not introduce a second way of doing something the repo already does.392. **Config module.** Parse the environment once into a validated, typed object. Fail fast at boot on a missing/malformed required var — a service that starts with bad config and 500s on first request is worse than one that refuses to start.403. **Error model.** Define a small hierarchy of domain errors (`NotFoundError`, `ValidationError`, `ConflictError`, `UnauthorizedError`, …), each carrying a stable machine-readable `code`. Add one mapper at the transport boundary: domain error → status + JSON body `{ code, message, details? }`. Never leak stack traces or internal messages to clients.414. **Boundary validation.** Pick one schema library (zod / pydantic / io-ts) and parse every inbound body, query, and param into a typed value at the handler. On failure, raise `ValidationError` → 422 with field-level `details`.425. **One vertical slice.** Wire a single real route end to end (handler → service → repository, with an in-memory or real repo) so the skeleton is *runnable*, not just directories.436. **Health surface.** `/healthz` returns 200 unconditionally once the event loop is live. `/readyz` pings each critical dependency with a short timeout and returns 200 only if all are reachable; 503 otherwise, with a per-dependency status body.447. **Smoke test.** One test boots the app, asserts `/healthz` is 200 and the vertical-slice route returns its happy-path shape. This is the gate that keeps the skeleton alive as it grows.458. **Graceful shutdown.** On SIGTERM: stop accepting new connections, drain in-flight requests with a bounded deadline, close DB/queue pools, then exit. Orchestrators send SIGTERM before SIGKILL — use the window.4647## Error → status mapping (the one table)48| Domain error | HTTP | Notes |49| --- | --- | --- |50| `ValidationError` | 422 | Include `details[]` with `field` + `reason`. |51| `UnauthorizedError` | 401 | Authentication missing/invalid. |52| `ForbiddenError` | 403 | Authenticated but not allowed. |53| `NotFoundError` | 404 | Never distinguish "doesn't exist" from "not yours" — that leaks. |54| `ConflictError` | 409 | Optimistic-lock / uniqueness violations. |55| `RateLimitedError` | 429 | Set `Retry-After`. |56| unmapped / unknown | 500 | Log with correlation id; return an opaque body. Never echo internals. |5758## Node / TypeScript recipe59- Runtime web layer: Fastify (schema-first, fast) or Express if the repo already uses it.60- Validation: `zod`, inferred types shared between transport and services.61- Config: a single `loadConfig()` that `zod`-parses `process.env` and is called once in the composition root.62- Test: `vitest` or `jest`; the smoke test uses the framework's `inject`/`supertest` to hit routes without opening a port.6364## Python / FastAPI recipe65- FastAPI + `pydantic` v2 models as the boundary schema (validation is built in).66- Config: `pydantic-settings` `BaseSettings` subclass, instantiated once.67- Dependency wiring via FastAPI `Depends`; keep use-cases as plain functions/classes so they're unit-testable without the framework.68- Test: `pytest` + `httpx.AsyncClient` against the ASGI app in-process.6970## Definition of done71- `lint` and `typecheck` clean.72- `test/smoke` passes: app boots, `/healthz` 200, one real route returns its documented shape.73- No `process.env` / `os.environ` outside `config/`; no raw `Error` reaching a client; no I/O import inside `domain/`.74- README documents required env vars and the run/test commands.