Part 17: Wrap-up: the checklist, what we skipped, and where to go next
What we built, a reusable production-readiness checklist, an honest list of what's still missing, and how to adapt all this to your own project.
Series: Build a production-grade REST API in Go · Part 17 of 17 Where we are: the finished API from Part 16
We started with an in-memory map and a few hundred lines. We end with roughly 3,800 lines of Go, about 5,800 with tests, and nearly seventy test functions, a database with versioned migrations, authentication and authorization, layered rate limiting, TLS, and an observability stack you can open in a browser. More importantly, each addition came with the reason it exists and the failure it prevents.
The whole journey in one table
| Part | We added | The failure it prevents |
|---|---|---|
| 1 | handlers, routing, store | (nothing yet; a baseline) |
| 2 | database, dependency injection | data lost on restart; untestable globals |
| 3 | validation, errors.Is, context, middleware, timeouts, graceful shutdown |
garbage data, slow-client exhaustion, dropped requests on deploy |
| 4 | env config, fail-fast validation | magic numbers; mis-configured prod |
| 5 | shared base, per-resource structure, FK + cascade | copy-paste growth; orphan rows; check-then-act races |
| 6 | PostgreSQL, pooling, migrations, real-DB tests | single-machine limits; schema drift; mock-hidden SQL bugs |
| 7 | logs + metrics + traces + health | "it's slow" with no way to find out why |
| 8 | Docker, compose, monitoring stack | "works on my machine"; unobserved deploys |
| 9 | keyset pagination, indexes, gzip, statement timeout | unbounded responses; O(n) page cost; runaway queries |
| 10 | token-bucket limiter, safe client identification | one client starving the rest; header-spoofing bypass |
| 11 | bcrypt, JWT, rotating refresh tokens | credential theft impact; replayed sessions |
| 12 | roles, ownership scoped in SQL | IDOR / broken object-level authorization |
| 13 | layered limits, failure-based login throttle | brute force, credential stuffing, account enumeration |
| 14 | TLS, hot-reload certs, verify-full DB |
sniffing, man-in-the-middle, expiry outages |
| 15 | idempotency keys, claimed in the business transaction | duplicate orders from retries and double-clicks |
| 16 | OpenAPI spec, Swagger UI under a strict CSP, contract tests | docs that rot; clients guessing the API; spec and code drifting apart |
Seven ideas that did the most work
If you only remember a handful of things from the series, make it these. They recurred in almost every post:
- Layers with one-way dependencies, and interfaces owned by the consumer. It let us swap SQLite for Postgres without touching a handler, and test each layer alone.
- Make the safe thing the only thing you can write. Required
ownerIDparameters, request types without privileged fields, secure-by-default routes. Compilers and structure beat vigilance. - Let the database enforce integrity. Foreign keys,
CHECKs, unique indexes, atomic statements. Application code is one path; the database is every path. - Context everywhere. One
ctxcarries cancellation, deadlines, trace ids and the authenticated user from the socket to the SQL. - Fail fast and loud. Validate config at startup; load certificates at startup; ping the database at startup.
- Bound everything. Body size, page size, pool size, map size, query time, request time, shutdown time. Unbounded is how outages start.
- Test the attacker's path. Wrong user, expired token, forged header, spoofed IP, replayed refresh token. Happy-path tests don't find security bugs.
The production-readiness checklist
Copy this for your own services. Each line points back to where we did it.
Correctness and input
- Strict JSON decoding, body size limit, field validation, separate request types (3, 12)
- Database constraints mirror business rules (5, 6)
- Errors: sentinel +
errors.Is, one mapping to status codes, no internals leaked (3) - Pagination on every list; index matches the query (9)
Resilience
- Server timeouts; per-request context deadline; DB statement timeout (3, 4, 9)
- Connection pool sized against the server's limit (6)
- Graceful shutdown (readiness off → drain → close) (3, 7, 8)
- Rate limits at several layers, with bounded memory (10, 13)
- Retried creates are idempotent, atomically with the write (15)
- A machine-readable API contract that tests keep honest (16)
- Migrations run as a separate step; compatible with the previous app version (6, 8)
Security
- Passwords hashed with a slow salted hash; length-bounded (11)
- Short-lived access tokens; revocable rotating refresh tokens (11)
- Authorization enforced per object, in the query (12)
- No account enumeration (message and timing) (11, 13)
- TLS 1.2+, HSTS, verified DB TLS, certificate expiry monitored (14)
- Secrets only from the environment/secret store; never logged; non-root, read-only container (4, 8, 11)
Operability
- Structured logs with request id, trace id, user id (7, 11)
- RED metrics with bounded label cardinality; pool and runtime metrics (7)
- Traces through HTTP and SQL (7)
- Liveness ≠ readiness; admin endpoints on a private port (7)
- Alerts on symptoms, not causes (8, 13, 14)
- One command to run everything locally (8)
Adding a new resource: a recipe
You'll do this constantly, so here is the sequence we followed with orders. For a resource called widgets:
- Migration: the next numbered file, e.g.
00006_create_widgets.sql(identity key, constraints, foreign keys, index on every FK and every filter column). - Store:
storage/widgets.gowithctx-first methods,RETURNING, sentinel errors, keysetList(ctx, ownerScope, afterID, limit). If the data belongs to a user, make the owner scope a required parameter. - Handler:
handlers/widgets.goembeddingbase, with a consumer-declaredWidgetStoreinterface and a request type that has no id, owner or role fields. - Routes: one function in
routes.go, each routeprotected(...)unless you can justify otherwise. - Wiring: construct store and handler in
main. - Tests: the happy path, validation failures, the other user's attempt, no-token, pagination boundary.
- Observability: nothing to do, which is the point. Logs, metrics and traces apply automatically via middleware; add a business counter only if a decision depends on it.
If a step above feels like more than a few dozen lines, your structure is leaking; revisit Part 5.
What we deliberately skipped (and what to do about it)
Be wary of any tutorial that claims to be "complete". This is what remains between this project and a system you'd run a business on:
| Gap | Why it matters | Where to start |
|---|---|---|
| CI/CD | tests, linting, vulnerability scans and image builds should run on every push | GitHub Actions: go vet, go test -race with a Postgres service container, govulncheck, Trivy image scan |
| Breaking-change detection | renaming a field can pass every test yet break clients | oasdiff in CI, comparing the new spec to the last released one |
| Alert delivery | our alerts fire but nobody is paged | Prometheus Alertmanager → Slack / PagerDuty |
| Real certificates and secrets management | the dev CA and .env file are for learning |
Let's Encrypt/cert-manager; Vault or your cloud's secret store |
| Backups and restore drills | "a backup you've never restored is not a backup" | pg_dump / PITR on a managed Postgres; schedule a restore test |
| High availability | one database, one of each tool | managed Postgres with replicas; run ≥2 API instances; shared Redis for global rate limits |
| Email flows | verification, password reset, "new sign-in" alerts, MFA | a transactional email provider + one-time tokens (hashed, single-use, short-lived) |
| Caching | read-heavy endpoints | ETags/conditional GETs; short-lived caches |
| Background work | emails, webhooks, long tasks | a job queue; the transactional-outbox pattern for reliable events |
| Audit logging | who changed what | append-only audit table written in the same transaction |
| Load testing | our limits are guesses until measured | k6 or vegeta; tune pool size and rate limits from data |
Where to go next
- Run the stack, break it on purpose (pause Postgres, send 10,000 requests, replay a refresh token, expire a certificate) and watch what the dashboards show. That teaches more than reading.
- Replace
orderswith your own domain and apply the recipe above. - Add the CI workflow. It is the highest-value next step and takes an hour.
- Read the Go standard library source for
net/http'sServer.Shutdownandhttputil: it is unusually readable.
A final word
None of the techniques here are exotic. A production-grade service is mostly a lot of small, boring decisions made deliberately: a timeout here, a constraint there, a test for the attacker's path. The skill is knowing which decisions exist, and that is what this series tried to put on your radar.
If you built along, you now have the vocabulary and a working reference. Take the parts that fit your situation, drop the parts that don't, and write down why either way. Future you will be grateful.
Thanks for reading. If this was useful, share it with someone who is about to ship their first Go API.
Comments
Join the discussionNo comments yet. Be the first to comment.
Build a production-grade REST API in Go
A hands-on series: start with under 200 lines of in-memory CRUD and end with Postgres, JWT auth, rate limiting, TLS and a full observability stack.