# API Service Scaffold

> 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.

- Skill: `omonuj/api-service-scaffold` (Agent Skill)
- Install (CLI): `npx skillmds@latest add omonuj/api-service-scaffold`
- Raw SKILL.md: https://api.skillmd.com/api/skills/omonuj/api-service-scaffold/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: omonuj (https://skillmd.com/u/omonuj)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/omonuj/api-service-scaffold

---


# 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
1. **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.
2. **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.
3. **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.
4. **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`.
5. **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.
6. **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.
7. **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.
8. **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.

