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.

Read the full series →

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 →

LayerDoesMust not
RoutesMatch path, add middlewareHold logic
ControllerRead req, call one service, pick statusTouch the DB, hold rules
ServiceBusiness rules, transactionsImport express, see req
RepositoryQueries, rows to objectsDecide business
  • Dependencies point down only. req never 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()) from server.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 →

OperationRequestSuccess
ListGET /orders?status=paid&limit=20200
CreatePOST /orders201 + Location
ReplacePUT /orders/7200 or 204
PartialPATCH /orders/7200 or 204
DeleteDELETE /orders/7204
Async workPOST /reports202 + Location
  • Nouns for resources, methods for actions, status codes for results. Never 200 for 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 /v1 by default. Prefer additive changes and warn before retiring.
  • Idempotency-Key makes POST safe to retry. ETag plus If-Match stops 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-Match condition 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 →

WhereTrusted?
ClientNo: user experience only
Server edgeYes: reject bad input
Business layerYes: rules that need data
DatabaseFinal net: NOT NULL, UNIQUE, CHECK, FK
  • Parse, don't validate: a schema turns input into a typed object (z.infer gives the type).
  • Query and path params arrive as strings: coerce them.
  • Mass assignment: one strict schema per endpoint. Fields like isAdmin or role are set only by the server.
  • Validation error (400/422, field paths) is not a business error (409/422, domain code).
  • Limit body size, parameterise SQL, validate config at startup, share schemas with the frontend.

Errors and shutdownLesson 4 →

KindOperationalProgrammer
WhatWorld misbehavesA bug
ExamplesBad input, timeout, no rowundefined property
DoHandle, map to status, warnLog fatal, 500, restart
  • Typed errors with status, stable code and cause. Throw domain errors, map them in one central handler.
  • Express 5 forwards rejected async handlers. Express 4 needs a wrapper.
  • Never catch uncaughtException and 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 →

PillarAnswers
LogsWhat happened, in detail
MetricsHow much, how often, how fast
TracesWhere 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: AsyncLocalStorage carries 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).
ModelIdea
RBACRoles hold permissions; role explosion risk
ABACRules on user, resource and context
ReBACAccess from relationships (owner, member)
Ownershipresource.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/123 becomes /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 nodeMeaning
Seq ScanWhole table; red flag on big ones
Index ScanWalk index, then fetch rows
Index Only ScanAnswered from the index alone
Sort (Disk)Index gave no order; spilled to disk
Nested Loop, big loopsN+1 inside the database

Tip: Run EXPLAIN (ANALYZE, BUFFERS). Compare estimated and actual rows.

Transactions and racesLesson 7 →

PostgreSQL levelBehaviour
Read Committed (default)Each statement sees committed data; lost updates possible
Repeatable ReadOne snapshot; may fail, retry
SerializableStops write skew; always retry
UPDATE products
SET stock = stock - 1
WHERE id = $1 AND stock >= 1;
-- 1 row = reserved, 0 = sold out

Overselling 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 if in app code. Use UNIQUE for "only one" rules.
  • Deadlock: lock in a consistent order, retry the loser.

Prisma: N+1 and poolsLesson 8 →

include / select
Eager load; select trims 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 findUnique calls automatically.
  • One PrismaClient per process (the globalThis trick 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 →

AspectRESTGraphQL
ShapeServer decidesClient picks fields
CachingHTTP, simpleHard: one POST URL
FetchingOver/under-fetchExact data
RiskEndpoint sprawlCost, 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 200 with an errors array: 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/await is not parallel.
OptionUse for
cluster / PM2All cores for an I/O-bound API
worker_threadsCPU-bound work
child_processOther programs (ffmpeg)
More containersThe usual cloud answer

TestingLesson 11 →

LevelTestsShare
UnitPure logic, policiesMany
IntegrationHTTP to a real databaseThick layer
ContractFrontend and backend agreeA few
E2ELogin, checkoutVery 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-Key stored 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.