--- name: backend-architecture description: Structures backend services and enforces production-grade error handling, layering, config, and operability. Use when scaffolding a service, organizing modules, handling errors, adding logging, writing Dockerfiles, or reviewing backend code structure. license: MIT metadata: author: lammanhhoang version: "1.0.0" --- # Backend Architecture Numbered rules for structuring backend services. Cite rules by number when reviewing ("violates rule #4"). This file carries the core rules under the SAME numbers as the full 42-rule pack in references/RULES.md, so numbering here has gaps — rules #5–#7, #16, #23–#24, #30–#31, and #42 live only in the full pack. Load references/RULES.md when auditing an existing codebase, when a cited rule number is missing here, or when you need a rule's rationale. Rules are language-agnostic. Examples are TypeScript; the mapping table at the bottom translates the defaults to Python/Go/Java. ## When to use - Scaffolding a new service or adding a module to an existing one. - Reviewing backend structure, error handling, logging, config, or Dockerfiles. - Deciding where a piece of code belongs (controller vs domain vs data-access). When NOT to use: frontend component organization, one-off scripts, infrastructure-as-code (Terraform/K8s manifests), database schema design in isolation. ## Rules #1–#4 — Structure by business component, never by technical layer (MUST) A folder per business capability. Technical-layer folders (`controllers/`, `services/`, `models/`) force every feature change to touch N distant folders and make module boundaries invisible. Before (layer-first — wrong): ``` src/ controllers/ user.controller.ts, order.controller.ts, payment.controller.ts services/ user.service.ts, order.service.ts, payment.service.ts models/ user.model.ts, order.model.ts, payment.model.ts utils/ (dumping ground) ``` After (component-first — right): ``` src/ user/ entry-points/ user.routes.ts, user.controller.ts domain/ user.service.ts, user.dto.ts, user.errors.ts data-access/ user.repository.ts, user.orm-entity.ts index.ts # the component's ONLY public surface order/ entry-points/ domain/ data-access/ index.ts payment/ entry-points/ domain/ data-access/ index.ts libraries/ # cross-cutting: logger/, config/, error-handling/ apps/ # composition roots: api/server.ts, worker/consumer.ts ``` Three layers inside every component, dependency direction strictly downward: | Layer | Contains | May import | |---|---|---| | `entry-points/` | routes, controllers, message consumers, cron triggers | domain | | `domain/` | business logic, DTOs, domain errors, interfaces | data-access interfaces, libraries | | `data-access/` | repositories, ORM entities, raw SQL, external API clients | libraries only | Cross-component calls go through the other component's `index.ts` (its public API) — never deep-import another component's internals (rule #4). ## Rules #8–#15 — Error doctrine **#8 (MUST)** One `AppError` class extending the platform `Error`, carrying `name`, `httpStatus`, `isOperational`. No per-module error class zoos. ```ts // libraries/error-handling/app-error.ts export class AppError extends Error { constructor( public readonly name: string, // 'order-not-found' message: string, public readonly httpStatus: number = 500, public readonly isOperational = true, // false => bug, crash public readonly cause?: unknown, ) { super(message); Error.captureStackTrace(this, this.constructor); } } ``` **#9–#10 (MUST)** Distinguish operational vs programmer errors. Operational (invalid input, timeout, 404, downstream 503): handle, respond 4xx/5xx, keep running. Programmer (undefined read, failed assertion, unknown state): log, flush, `process.exit(1)` and let the process manager (K8s, systemd, pm2) restart a clean process. A process that continues after a programmer error runs in undefined state. **#11 (MUST)** Exactly ONE centralized error handler object, called from the framework's error middleware AND from `process.on('uncaughtException')` / `process.on('unhandledRejection')` — subscribe to those two events once, in the composition root. It logs, emits metrics, decides crash-or-respond. Nothing else logs-and-rethrows. ```ts // libraries/error-handling/handler.ts export const errorHandler = { handle(error: unknown): { httpStatus: number } { const appError = normalize(error); // wrap unknowns as AppError(isOperational:false) logger.error(appError.message, { name: appError.name, stack: appError.stack }); metrics.increment('errors', { name: appError.name }); if (!appError.isOperational) { process.exitCode = 1; server.close(() => process.exit(1)); // crash on programmer errors } return { httpStatus: appError.httpStatus }; }, }; ``` **#12 (MUST NOT)** Handle errors inside controllers or middleware. Controllers may *throw* domain errors; only the centralized handler catches. A `try/catch` in a controller that logs and returns a hand-rolled 500 is a review defect. **#13 (MUST)** `async/await` with `try-catch` only where you can add context; no `.then()` chains and no error-first callbacks for control flow. Mixed styles guarantee swallowed rejections. **#14 (MUST)** Await every promise. A dangling promise turns its failure into an `unhandledRejection` with no request context. Fire-and-forget requires an explicit `.catch(errorHandler.handle)`. **#15 (MUST)** Error responses to clients are RFC 9457 `application/problem+json` — `type`, `title`, `status`, `detail`, `instance` — with `request_id` as an extension member. `AppError.name` maps to the problem `type` slug (`'order-not-found'` → `https://api.example.com/problems/order-not-found`). Never stack traces or SQL in production responses. ## Rules #17–#22 — Stateless processes & config (12-Factor III, VI, IX) **#17–#18 (MUST)** Config comes ONLY from environment variables (Factor III). No `config.prod.json` committed, no environment branching (`if (env === 'staging')`) in business code. Read all env vars in one typed, validated config module at startup — fail fast on missing/invalid values: ```ts // libraries/config/index.ts const schema = z.object({ PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), }); export const config = schema.parse(process.env); // throws at boot, not at 3am ``` **#19 (MUST NOT)** Access `process.env` anywhere outside the config module. **#20 (MUST)** Processes are stateless and share-nothing (Factor VI). No in-memory session stores, no local file uploads, no in-process caches used as source of truth. Sessions → Redis; files → object storage (S3/GCS); cache → external with TTL. Any instance must be killable at any moment. **#21 (MUST)** Graceful shutdown on SIGTERM (Factor IX): stop accepting new work → finish in-flight requests (bounded, e.g. 10s) → close DB/queue pools → exit 0. Factor IX also demands fast, single-command startup: boot work beyond config validation and pool warmup belongs in migrations/jobs, not the request process. ```ts process.on('SIGTERM', async () => { server.close(async () => { // stop accepting, drain in-flight await db.end(); await queue.close(); process.exit(0); }); setTimeout(() => process.exit(1), 10_000).unref(); // drain deadline }); ``` **#22 (MUST)** Expose two health endpoints: `/live` (process is up — restart me if failing) and `/ready` (dependencies reachable — route traffic to me). Never gate `/live` on the database: a DB outage would make the orchestrator restart-loop healthy processes. ## Rules #25–#29 — Logging **#25 (MUST)** Structured JSON logs via **pino** (escape hatch: winston if the org standardizes on it). No `console.log` in application code — it is synchronous, unstructured, and unlevellable. **#26 (MUST)** Log to stdout only. The platform (Docker, K8s, systemd) owns routing, rotation, shipping. No log files, no in-app log rotation. **#27 (MUST)** Correlate with a request-id: accept incoming `x-request-id` or generate a UUID at the edge, store in `AsyncLocalStorage`, include in every log line and in error responses. Propagate it on outbound calls. **#28 (MUST NOT)** Log secrets, tokens, or full PII payloads — configure pino `redact` paths for known-sensitive fields. **#29 (SHOULD)** Log level from config (rules #17–#18): `info` for state changes, `warn` for handled anomalies, `error` only through the centralized handler. ## Rules #32–#35 — Validation at the boundary **#32–#33 (MUST)** Validate every external input (body, params, query, headers, message payloads, webhook bodies) at the entry-point with **zod** (escape hatch: the framework's native validator, e.g. FastAPI/Pydantic, Spring Bean Validation). Parse, don't check: the output is a typed DTO. ```ts // user/entry-points/user.routes.ts const CreateUser = z.object({ email: z.string().email(), age: z.number().int().min(13).max(150), }).strict(); // reject unknown keys router.post('/users', async (req, res) => { const dto = CreateUser.parse(req.body); // ZodError -> centralized handler -> 400 const user = await createUser(dto); // domain receives trusted, typed input res.status(201).json(user); }); ``` **#34 (MUST NOT)** Re-validate shape inside the domain layer. Domain code trusts its typed inputs and enforces *business* invariants only (e.g. "order total under credit limit"). **#35 (SHOULD)** Use `.strict()` / `additionalProperties: false` semantics to reject unknown fields — mass-assignment protection for free. ## Rules #36–#41 — Docker hardening Complete ready-to-copy Dockerfiles: assets/dockerfile-node.example and assets/dockerfile-python.example (use as the starting template whenever writing a backend Dockerfile). **#36 (MUST)** Multi-stage build: build stage with compilers/dev deps, runtime stage with production deps and artifacts only. **#37 (MUST)** Pin the base image by digest (`node:22-slim@sha256:...`), not just tag. Tags are mutable; digests make builds reproducible. Get it with `docker buildx imagetools inspect node:22-slim`. **#38 (MUST)** Run as non-root: `USER node` (Node images ship user `node`), or create one (`useradd -r`) for Python/Go. Root in a container is one kernel exploit from root on the host. **#39 (MUST)** `.dockerignore` with at least: `node_modules`, `.git`, `.env*`, `*.md`, `dist`, `coverage`, `Dockerfile`. Prevents secret leakage into layers and cache-busting on every commit. **#40 (MUST)** `ENV NODE_ENV=production` (Node) / `PYTHONUNBUFFERED=1` (Python) in the runtime stage. In Node, several frameworks change behavior on it, and plain `npm ci`/`npm install` skips devDependencies when `NODE_ENV=production` (equivalent to `--omit=dev`) — Express is roughly 3x faster with view caching on. **#41 (MUST)** Exec-form CMD (`CMD ["node", "dist/server.js"]`), never shell form. Shell form makes `sh` PID 1, which swallows SIGTERM and breaks rule #21. Don't wrap in npm scripts (`npm start`) for the same reason. ## Language mapping | Concern | Node/TS (default here) | Python | Go | Java | |---|---|---|---|---| | Component layout | folders per component | packages per component | internal/ | Maven modules / packages | | AppError | class extends Error | class AppError(Exception) | custom error type + errors.Is | RuntimeException subclass | | Validation | zod | Pydantic | go-playground/validator | Bean Validation (Jakarta) | | Logger | pino | structlog | slog (stdlib) | Logback + logstash-encoder | | Config | zod-parsed env | pydantic-settings | envconfig | Spring @ConfigurationProperties | | Graceful shutdown | SIGTERM + server.close | lifespan (ASGI) | context + srv.Shutdown | SmartLifecycle | ## What NOT to do - Do not create `utils/` or `helpers/` components — cross-cutting code goes to a named library under `libraries/` (rule #6 in RULES.md). - Do not catch-log-rethrow at every layer; one error, one log line, at the centralized handler. - Do not add a message queue, microservice split, or CQRS to a service that fits in one monolith of components — components ARE the future service boundaries. - Do not put business logic in controllers ("fat controller") or in ORM entities ("smart model"). For the full 42-rule pack with per-rule justification, read references/RULES.md (load when auditing an existing codebase, writing a structure review, or when you need the rationale behind any rule cited above).