Cheat sheet · Backend in Depth: Designing an API You'd Put in Production
Backend in Depth cheat sheet
A Node and Express backend in one page: clean code, layers, REST rules, validation, errors, logs, auth, databases, Prisma, GraphQL, performance and tests. Keep rules easy to find, test and trust.
blogs · jp-blogs.vercel.app/cheatsheets/series/backend
Clean backend codeLesson 0 →
- Name by intent. Small functions, one job, guard clauses instead of nesting.
- Pure logic in the middle, I/O at the edges. Pass in the clock, repositories and gateways instead of importing them.
- Dependency injection needs no framework: a function that takes its dependencies as arguments.
- DRY is about knowledge, not text. Abstract on the third repeat. Delete layers that give nothing back.
- Automate standards: strict types, lint, CI, architecture checks. Refactor a 600-line controller behind tests, in small steps.
- S
- One reason to change per module
- O
- Add behaviour without editing old code
- L
- Subtypes work wherever the base type does
- I
- Small interfaces, no unused methods
- D
- Depend on abstractions, inject the real thing
Layers and foldersLesson 1 →
| Layer | Does | Must not |
|---|---|---|
| Routes | Match path, add middleware | Hold logic |
| Controller | Read req, call one service, pick status | Touch the DB, hold rules |
| Service | Business rules, transactions | Import express, see req |
| Repository | Queries, rows to objects | Decide business |
- Dependencies point down only.
reqnever enters a service, so a CLI or queue worker can reuse it. - Group by feature, with layers inside. Each module exposes a small public surface.
- Split
app.ts(buildApp()) fromserver.ts(listen()) so tests can import the app. - Input shape: middleware. Business rules and ownership: service. Auth: middleware. Env config: one file checked at startup.
REST API rulesLesson 2 →
| Operation | Request | Success |
|---|---|---|
| List | GET /orders?status=paid&limit=20 | 200 |
| Create | POST /orders | 201 + Location |
| Replace | PUT /orders/7 | 200 or 204 |
| Partial | PATCH /orders/7 | 200 or 204 |
| Delete | DELETE /orders/7 | 204 |
| Async work | POST /reports | 202 + Location |
- Nouns for resources, methods for actions, status codes for results. Never
200for errors. - Always paginate, cap page size, allow only known filters and sorts.
- Dates ISO 8601 UTC, money as integer minor units, ids as strings.
- Version with
/v1by default. Prefer additive changes and warn before retiring. Idempotency-Keymakes POST safe to retry.ETagplusIf-Matchstops lost updates (412).
Status codes to knowLesson 2 →
- 400 / 422
- Malformed / valid format, bad values
- 401 / 403
- Not logged in / not allowed
- 404
- No such resource (or hidden)
- 409
- Clash with state: duplicate, old version
- 412
If-Matchcondition false- 415
- Body format not accepted
- 429
- Rate limited
- 207
- Per-item results of a batch
Tip: One error format (problem details): stable code, message, field paths and a request id.
Validate at the edgeLesson 3 →
| Where | Trusted? |
|---|---|
| Client | No: user experience only |
| Server edge | Yes: reject bad input |
| Business layer | Yes: rules that need data |
| Database | Final net: NOT NULL, UNIQUE, CHECK, FK |
- Parse, don't validate: a schema turns input into a typed object (
z.infergives the type). - Query and path params arrive as strings: coerce them.
- Mass assignment: one strict schema per endpoint. Fields like
isAdminorroleare set only by the server. - Validation error (
400/422, field paths) is not a business error (409/422, domaincode). - Limit body size, parameterise SQL, validate config at startup, share schemas with the frontend.
Errors and shutdownLesson 4 →
| Kind | Operational | Programmer |
|---|---|---|
| What | World misbehaves | A bug |
| Examples | Bad input, timeout, no row | undefined property |
| Do | Handle, map to status, warn | Log fatal, 500, restart |
- Typed errors with
status, stablecodeandcause. Throw domain errors, map them in one central handler. - Express 5 forwards rejected async handlers. Express 4 needs a wrapper.
- Never catch
uncaughtExceptionand carry on. Log, exit, let a supervisor restart. - Log the error once. Return a request id, not a stack trace.
process.on("SIGTERM", () => {
server.close(() =>
prisma.$disconnect()
.finally(() => process.exit(0)));
setTimeout(() => process.exit(0),
10_000).unref();
});Retry only idempotent, short failures, with backoff and jitter. Set timeouts.
Logs, metrics, tracesLesson 5 →
| Pillar | Answers |
|---|---|
| Logs | What happened, in detail |
| Metrics | How much, how often, how fast |
| Traces | Where the time went across services |
- Structured JSON logs to stdout with pino, with secret redaction. Levels:
fatal error warn info debug trace. - A request id on every line:
AsyncLocalStoragecarries it, return it, pass it to other services. - Fixed message, variables in fields. Never log passwords, tokens or personal data.
- RED: rate, errors, duration (p50/p95/p99, not averages). USE for resources. Add event-loop lag.
- Log the route pattern
/orders/:id, not the raw path. Alert on symptoms, add a runbook.
Passwords and sessionsLesson 6 →
- Hash with Argon2id, then scrypt, then bcrypt (cost 10 or more, 72-byte limit). Never SHA-256 or MD5.
- Salt per password is built in. Pick settings costing tens of milliseconds and rehash on login when they change.
- Use the async API (hashing blocks the loop). Rate-limit login, give one generic error to stop user enumeration.
- Session ids: high entropy, renew on login (stops session fixation).
| Model | Idea |
|---|---|
| RBAC | Roles hold permissions; role explosion risk |
| ABAC | Rules on user, resource and context |
| ReBAC | Access from relationships (owner, member) |
| Ownership | resource.ownerId === user.id |
AuthorizationLesson 6 →
// ownership lives in the query
const order = await db.order
.findFirst({
where: { id, userId: user.id },
});
if (!order) throw new NotFound();Not yours returns 404, so you do not reveal that it exists.
- Most severe bugs are missing object-level checks:
/orders/123becomes/orders/124. - Limit every query by owner or tenant. Keep rules in policy functions. Default deny.
- Take roles and ids from the session, never from the request body.
- Test three cases: another user, no user, a lower role.
Indexes and EXPLAINLesson 7 →
- An index trades slower writes for faster reads. Design it from real queries.
- Composite
(user_id, status, created_at)works for the leftmost prefix.WHERE status = ...alone does not use it. - Index foreign keys yourself in PostgreSQL.
- No help: tiny tables, low selectivity, functions on the column, leading wildcard
LIKE.
| Plan node | Meaning |
|---|---|
| Seq Scan | Whole table; red flag on big ones |
| Index Scan | Walk index, then fetch rows |
| Index Only Scan | Answered from the index alone |
| Sort (Disk) | Index gave no order; spilled to disk |
| Nested Loop, big loops | N+1 inside the database |
Tip: Run EXPLAIN (ANALYZE, BUFFERS). Compare estimated and actual rows.
Transactions and racesLesson 7 →
| PostgreSQL level | Behaviour |
|---|---|
| Read Committed (default) | Each statement sees committed data; lost updates possible |
| Repeatable Read | One snapshot; may fail, retry |
| Serializable | Stops write skew; always retry |
UPDATE products
SET stock = stock - 1
WHERE id = $1 AND stock >= 1;
-- 1 row = reserved, 0 = sold outOverselling fix. Others: SELECT ... FOR UPDATE, a version column, CHECK (stock >= 0).
- ACID: atomic, consistent, isolated, durable (write-ahead log). Keep transactions short.
- Fix races in the database, not with
ifin app code. UseUNIQUEfor "only one" rules. - Deadlock: lock in a consistent order, retry the loser.
Prisma: N+1 and poolsLesson 8 →
- include / select
- Eager load;
selecttrims the payload - `in` query
- Batch by hand: one query for all ids
- DataLoader
- Batch per tick; new loader per request
- _count
- Count in the DB, not by loading rows
- createMany
- Batch writes instead of loops
- N+1: one query for the list, then one per row. Find it by logging query counts.
- Prisma batches same-tick
findUniquecalls automatically. - One
PrismaClientper process (theglobalThistrick for hot reload). - Serverless: a pooler (PgBouncer) plus a small pool per instance (1 to 5).
- Migrations must be backward compatible. Interactive transactions hold a connection.
REST, GraphQL, HasuraLesson 9 →
| Aspect | REST | GraphQL |
|---|---|---|
| Shape | Server decides | Client picks fields |
| Caching | HTTP, simple | Hard: one POST URL |
| Fetching | Over/under-fetch | Exact data |
| Risk | Endpoint sprawl | Cost, N+1, authz |
- GraphQL needs limits on depth and cost, page-size caps, persisted queries, per-field authorization and a dataloader per request.
- Errors come back as
200with anerrorsarray: monitor that. - Evolve by adding fields and deprecating. Hasura gives instant GraphQL on Postgres, but logic lives in metadata.
- Choose by how different your clients are and your caching needs, not fashion.
Node performanceLesson 10 →
- Node runs JS on one thread: a slow sync task stalls every request. Watch event-loop lag.
- Blockers: big
JSON.parse, sync fs, heavy regex, CPU loops, sync crypto. - The libuv pool (default 4) serves fs, DNS lookup, some crypto and zlib.
- Stream big data with
pipeline(backpressure), or use presigned URLs.async/awaitis not parallel.
| Option | Use for |
|---|---|
| cluster / PM2 | All cores for an I/O-bound API |
| worker_threads | CPU-bound work |
| child_process | Other programs (ffmpeg) |
| More containers | The usual cloud answer |
TestingLesson 11 →
| Level | Tests | Share |
|---|---|---|
| Unit | Pure logic, policies | Many |
| Integration | HTTP to a real database | Thick layer |
| Contract | Frontend and backend agree | A few |
| E2E | Login, checkout | Very few |
- Mock the edges, not your own code. A mocked DB hides bad SQL and constraints.
- Cover unhappy paths: validation, auth, other users, conflicts, dependency failures.
- Concurrency rule? Fire parallel requests at the last item in stock.
- Keep tests independent, deterministic and fast. Flakiness is a bug. Coverage is not the goal.
Interview one-linersLesson 11 →
- Controller vs service
- HTTP in one, business rules in the other
- Safe POST retry
Idempotency-Keystored in the DB- Stop mass assignment
- Strict schema per endpoint
- Operational vs bug
- Handle it vs log and restart
- Intermittent 500
- Request id, structured logs, traces
- Store passwords
- Argon2id, salted, slow, async
- IDOR on /orders/124
- Filter the query by owner
- Overselling
- Atomic conditional
UPDATE - p99 spikes
- Something blocks the event loop
A cheat sheet is a reminder, not a teacher. Each card links to the lesson that explains it step by step.