Go Microservice
Overview
A Go service is six things: config, an HTTP/RPC surface, state (DB +
cache), async work, outbound calls, and operability. Get those right and
the rest is business logic. Go's defaults are safe for CLIs and unsafe for servers —
most of this skill is about the settings nobody sets until an incident forces them to.
Verified against Go 1.26 (current, Feb 2026) as of 2026-08. Library versions
throughout come from proxy.golang.org, not from blog posts — check
go list -m -u <module> for anything newer.
When to Use
- Starting a new Go service, or adding an endpoint/consumer to an existing one
- Choosing a router, database layer, cache client, or queue library
- Reviewing Go service code for production-readiness
- Diagnosing: goroutine leaks, OOMKills, CPU throttling, dropped requests on deploy,
connection-pool exhaustion, "slow under load but fine locally"
- Containerising a Go service, or tuning it for Kubernetes
Not for: CLI tools (much of this is irrelevant), library design, or non-service Go.
Step 0: Choose the HTTP layer
| If… |
Use |
| Default, no strong reason otherwise |
stdlib net/http + ServeMux (1.22+ routing is genuinely enough) |
| You want groups/sub-routers, still stdlib-shaped |
chi |
| You want built-in binding/validation/rendering and accept a non-stdlib handler signature |
gin or echo |
Browser clients, or HTTP/1.1-only infrastructure, from .proto |
ConnectRPC |
| Service-to-service, typed contracts, streaming |
gRPC (+ buf) |
| Raw throughput on a narrow service, accepting ecosystem loss |
fiber — read the warning in references/frameworks.md first |
Since Go 1.22 the stdlib does method + wildcard routing. Start there and add a
router only when you feel the absence. Full comparison, and fiber's net/http
incompatibility, in references/frameworks.md.
Quick Reference
| Task |
Reach for |
Detail |
| Router |
stdlib ServeMux, chi |
references/frameworks.md |
| Postgres |
pgx v5, + sqlc for typed queries |
references/persistence.md |
| Migrations |
golang-migrate, goose, atlas |
references/persistence.md |
| Redis |
rueidis (or go-redis/v9) |
references/caching-redis.md |
| Kafka |
franz-go |
references/messaging.md |
| Job queue |
River (Postgres, transactional enqueue) |
references/messaging.md |
| Outbound HTTP |
http.Client with every timeout set |
references/http-clients.md |
| Logging |
log/slog, JSON to stdout |
references/observability.md |
| Metrics/tracing |
prometheus/client_golang, OTel + otelhttp |
references/observability.md |
| Config |
envconfig/koanf → validate at boot |
references/packaging-deploy.md |
| DI |
manual wiring; wire if it gets big; fx for lifecycle |
references/frameworks.md |
| Tests |
table-driven, httptest, testcontainers-go, testing/synctest |
references/testing.md |
| Lint |
golangci-lint + errcheck, bodyclose, sqlclosecheck, noctx |
references/frameworks.md |
| Container |
multi-stage → distroless/static:nonroot |
references/packaging-deploy.md |
Bootstrap a new service
myservice/
cmd/myservice/main.go # entry point: parse config, wire, run. Keep it thin.
internal/
config/ # typed config, validated at boot
http/ # handlers, middleware, router
service/ # business logic — no HTTP or SQL types here
store/ # DB access (sqlc output or repositories)
client/ # outbound HTTP/gRPC clients
migrations/
Dockerfile
Makefile
go mod init <module> — set the go directive to 1.25+ so you get
container-aware GOMAXPROCS.
- Config struct +
LoadConfig() that fails fast on missing/invalid values.
run() error called from main() — not logic in main, because os.Exit skips
deferred cleanup.
http.Server with all timeouts set (ReadHeaderTimeout at minimum).
/healthz (liveness, process-local) and /readyz (readiness, checks dependencies).
- Middleware: request ID → structured logging → metrics → panic recovery.
- Graceful shutdown with the correct drain order — copy it from
references/packaging-deploy.md, don't improvise.
GOMEMLIMIT ≈ 90% of the container memory limit.
The non-negotiables
These cause incidents when skipped. Everything else is preference.
- Every
http.Server timeout set. &http.Server{Handler: mux} with no timeouts
is vulnerable to slowloris by default. ReadHeaderTimeout is the bare minimum.
- Every outbound call has a timeout.
http.DefaultClient has none. A hung
dependency with no timeout is how one service's slowness becomes your outage.
context.Context first parameter on anything that blocks, propagated, never
stored in a struct. Every WithTimeout gets defer cancel().
- Graceful shutdown, in order: fail readiness → wait for LB propagation → stop
accepting → drain in-flight → stop consumers → close resources.
GOMEMLIMIT set on every containerised service. GOGC is ratio-based and knows
nothing about your cgroup limit — this is the cheapest OOMKill prevention available.
- Structured logs to stdout as JSON, with a request ID. Never a formatted
sentence; never secrets.
recover() in every long-lived goroutine. An unrecovered panic in any
goroutine kills the whole process.
- Bounded fan-out.
errgroup.SetLimit, or a semaphore. Unbounded concurrency
against a dependency is a self-inflicted DoS.
- Consumers are idempotent. At-least-once delivery is the only kind you get.
go test -race in CI. A detected race is a bug, not a flake.
Reference Map
| File |
Read when |
| references/language-runtime.md |
Version features, GC, GOMAXPROCS/GOMEMLIMIT, goroutine leaks, errors, PGO |
| references/frameworks.md |
Choosing a router/RPC/library; project layout; linting |
| references/persistence.md |
SQL driver, sqlc/ORM choice, pool tuning, transactions, migrations |
| references/caching-redis.md |
Cache client, patterns, TTLs, distributed locks |
| references/messaging.md |
Queue/stream consumers, idempotency, retries, DLQs |
| references/http-clients.md |
Outbound calls: timeouts, retries, breakers, pooling |
| references/observability.md |
slog, metrics + cardinality, OTel, health endpoints, pprof |
| references/testing.md |
Table-driven tests, testcontainers, synctest, goleak |
| references/packaging-deploy.md |
Graceful shutdown, Dockerfile, config, Kubernetes |
Common Mistakes
| Mistake |
Why it hurts |
Fix |
http.Get / http.DefaultClient |
No timeout — waits forever |
Construct a client with Timeout + tuned transport |
&http.Server{Handler: mux} |
No timeouts; slowloris-vulnerable |
Set all five timeout fields |
Not closing resp.Body |
Leaks the connection permanently |
defer resp.Body.Close(); enable bodyclose |
MaxIdleConnsPerHost left at 2 |
New TCP+TLS per call under load |
Raise to expected concurrency |
uber-go/automaxprocs on Go ≥1.25 |
Redundant — the runtime does this now |
Delete it |
No GOMEMLIMIT |
GC ignores the cgroup limit → OOMKill |
Set ≈90% of the memory limit |
Only requests.cpu, no limit |
Go 1.25 reads the limit, not requests |
Set CPU limits (avoid fractional for latency-sensitive) |
err = err / ignored errors |
Silently swallowed failures — a real bug found in production framework code |
Enable errcheck; return or log, never both |
| Raw path in a metric label |
Unbounded cardinality kills Prometheus |
Use r.Pattern (the route template) |
| Goroutine with no lifecycle owner |
Leak: holds its stack forever |
Every go f() answers "who waits, how does it stop?" |
time.After in a hot loop |
Timer uncollectable until it fires |
time.NewTimer + defer Stop(), or context |
strings.Contains(err.Error(), …) |
API contract made of sand |
errors.Is / errors.As |
Two libraries both calling a global setter (e.g. SetGlobalTracer) |
Last one silently wins; the other's data vanishes |
One tracing/metrics stack per process |
pkg/ by habit |
Ceremony; internal/ is compiler-enforced |
Use internal/ unless exporting to other repos |
Wrapping ResponseWriter without forwarding Flush/Hijack |
Silently breaks SSE/websockets |
Implement Unwrap(); forward optional interfaces |
1---2name: go-microservice3description: Use when building, scaffolding, or reviewing a Go backend service or HTTP/gRPC API — choosing between stdlib net/http, chi, gin, echo, fiber, ConnectRPC or gRPC; wiring Postgres/MySQL with pgx, sqlc, GORM or ent; Redis caching and distributed locks; SQS/Kafka consumers; graceful shutdown; GOMAXPROCS/GOMEMLIMIT container tuning; goroutine leaks; log/slog structured logging; OpenTelemetry; Dockerfiles for Go; project layout with cmd/ and internal/. Also for "start a Go service", "add an endpoint", "why is my Go service leaking goroutines", "Go service is getting OOMKilled".4---56# Go Microservice78## Overview910A Go service is six things: **config**, an **HTTP/RPC surface**, **state** (DB +11cache), **async work**, **outbound calls**, and **operability**. Get those right and12the rest is business logic. Go's defaults are safe for CLIs and unsafe for servers —13most of this skill is about the settings nobody sets until an incident forces them to.1415Verified against Go **1.26** (current, Feb 2026) as of 2026-08. Library versions16throughout come from `proxy.golang.org`, not from blog posts — check17`go list -m -u <module>` for anything newer.1819## When to Use2021- Starting a new Go service, or adding an endpoint/consumer to an existing one22- Choosing a router, database layer, cache client, or queue library23- Reviewing Go service code for production-readiness24- Diagnosing: goroutine leaks, OOMKills, CPU throttling, dropped requests on deploy,25 connection-pool exhaustion, "slow under load but fine locally"26- Containerising a Go service, or tuning it for Kubernetes2728**Not for:** CLI tools (much of this is irrelevant), library design, or non-service Go.2930## Step 0: Choose the HTTP layer3132| If… | Use |33|---|---|34| Default, no strong reason otherwise | **stdlib `net/http` + `ServeMux`** (1.22+ routing is genuinely enough) |35| You want groups/sub-routers, still stdlib-shaped | **chi** |36| You want built-in binding/validation/rendering and accept a non-stdlib handler signature | **gin** or **echo** |37| Browser clients, or HTTP/1.1-only infrastructure, from `.proto` | **ConnectRPC** |38| Service-to-service, typed contracts, streaming | **gRPC** (+ buf) |39| Raw throughput on a narrow service, accepting ecosystem loss | **fiber** — read the warning in references/frameworks.md first |4041Since Go 1.22 the stdlib does method + wildcard routing. **Start there** and add a42router only when you feel the absence. Full comparison, and fiber's `net/http`43incompatibility, in references/frameworks.md.4445## Quick Reference4647| Task | Reach for | Detail |48|---|---|---|49| Router | stdlib `ServeMux`, chi | references/frameworks.md |50| Postgres | **pgx v5**, + **sqlc** for typed queries | references/persistence.md |51| Migrations | golang-migrate, goose, atlas | references/persistence.md |52| Redis | **rueidis** (or go-redis/v9) | references/caching-redis.md |53| Kafka | **franz-go** | references/messaging.md |54| Job queue | **River** (Postgres, transactional enqueue) | references/messaging.md |55| Outbound HTTP | `http.Client` with **every** timeout set | references/http-clients.md |56| Logging | **`log/slog`**, JSON to stdout | references/observability.md |57| Metrics/tracing | prometheus/client_golang, OTel + `otelhttp` | references/observability.md |58| Config | envconfig/koanf → validate at boot | references/packaging-deploy.md |59| DI | **manual wiring**; wire if it gets big; fx for lifecycle | references/frameworks.md |60| Tests | table-driven, `httptest`, testcontainers-go, `testing/synctest` | references/testing.md |61| Lint | golangci-lint + errcheck, bodyclose, sqlclosecheck, noctx | references/frameworks.md |62| Container | multi-stage → `distroless/static:nonroot` | references/packaging-deploy.md |6364## Bootstrap a new service6566```67myservice/68 cmd/myservice/main.go # entry point: parse config, wire, run. Keep it thin.69 internal/70 config/ # typed config, validated at boot71 http/ # handlers, middleware, router72 service/ # business logic — no HTTP or SQL types here73 store/ # DB access (sqlc output or repositories)74 client/ # outbound HTTP/gRPC clients75 migrations/76 Dockerfile77 Makefile78```79801. `go mod init <module>` — set the `go` directive to **1.25+** so you get81 container-aware `GOMAXPROCS`.822. **Config struct + `LoadConfig()` that fails fast** on missing/invalid values.833. `run() error` called from `main()` — *not* logic in `main`, because `os.Exit` skips84 deferred cleanup.854. `http.Server` with **all** timeouts set (`ReadHeaderTimeout` at minimum).865. `/healthz` (liveness, process-local) and `/readyz` (readiness, checks dependencies).876. Middleware: request ID → structured logging → metrics → panic recovery.887. **Graceful shutdown** with the correct drain order — copy it from89 references/packaging-deploy.md, don't improvise.908. `GOMEMLIMIT` ≈ 90% of the container memory limit.9192## The non-negotiables9394These cause incidents when skipped. Everything else is preference.95961. **Every `http.Server` timeout set.** `&http.Server{Handler: mux}` with no timeouts97 is vulnerable to slowloris by default. `ReadHeaderTimeout` is the bare minimum.982. **Every outbound call has a timeout.** `http.DefaultClient` has none. A hung99 dependency with no timeout is how one service's slowness becomes your outage.1003. **`context.Context` first parameter on anything that blocks**, propagated, never101 stored in a struct. Every `WithTimeout` gets `defer cancel()`.1024. **Graceful shutdown, in order**: fail readiness → wait for LB propagation → stop103 accepting → drain in-flight → stop consumers → close resources.1045. **`GOMEMLIMIT` set** on every containerised service. `GOGC` is ratio-based and knows105 nothing about your cgroup limit — this is the cheapest OOMKill prevention available.1066. **Structured logs to stdout as JSON, with a request ID.** Never a formatted107 sentence; never secrets.1087. **`recover()` in every long-lived goroutine.** An unrecovered panic in *any*109 goroutine kills the whole process.1108. **Bounded fan-out.** `errgroup.SetLimit`, or a semaphore. Unbounded concurrency111 against a dependency is a self-inflicted DoS.1129. **Consumers are idempotent.** At-least-once delivery is the only kind you get.11310. **`go test -race` in CI.** A detected race is a bug, not a flake.114115## Reference Map116117| File | Read when |118|---|---|119| references/language-runtime.md | Version features, GC, GOMAXPROCS/GOMEMLIMIT, goroutine leaks, errors, PGO |120| references/frameworks.md | Choosing a router/RPC/library; project layout; linting |121| references/persistence.md | SQL driver, sqlc/ORM choice, pool tuning, transactions, migrations |122| references/caching-redis.md | Cache client, patterns, TTLs, distributed locks |123| references/messaging.md | Queue/stream consumers, idempotency, retries, DLQs |124| references/http-clients.md | Outbound calls: timeouts, retries, breakers, pooling |125| references/observability.md | slog, metrics + cardinality, OTel, health endpoints, pprof |126| references/testing.md | Table-driven tests, testcontainers, synctest, goleak |127| references/packaging-deploy.md | Graceful shutdown, Dockerfile, config, Kubernetes |128129## Common Mistakes130131| Mistake | Why it hurts | Fix |132|---|---|---|133| `http.Get` / `http.DefaultClient` | No timeout — waits forever | Construct a client with `Timeout` + tuned transport |134| `&http.Server{Handler: mux}` | No timeouts; slowloris-vulnerable | Set all five timeout fields |135| Not closing `resp.Body` | Leaks the connection permanently | `defer resp.Body.Close()`; enable `bodyclose` |136| `MaxIdleConnsPerHost` left at 2 | New TCP+TLS per call under load | Raise to expected concurrency |137| `uber-go/automaxprocs` on Go ≥1.25 | Redundant — the runtime does this now | Delete it |138| No `GOMEMLIMIT` | GC ignores the cgroup limit → OOMKill | Set ≈90% of the memory limit |139| Only `requests.cpu`, no limit | Go 1.25 reads the **limit**, not requests | Set CPU limits (avoid fractional for latency-sensitive) |140| `err = err` / ignored errors | Silently swallowed failures — a real bug found in production framework code | Enable `errcheck`; return or log, never both |141| Raw path in a metric label | Unbounded cardinality kills Prometheus | Use `r.Pattern` (the route template) |142| Goroutine with no lifecycle owner | Leak: holds its stack forever | Every `go f()` answers "who waits, how does it stop?" |143| `time.After` in a hot loop | Timer uncollectable until it fires | `time.NewTimer` + `defer Stop()`, or context |144| `strings.Contains(err.Error(), …)` | API contract made of sand | `errors.Is` / `errors.As` |145| Two libraries both calling a global setter (e.g. `SetGlobalTracer`) | Last one silently wins; the other's data vanishes | One tracing/metrics stack per process |146| `pkg/` by habit | Ceremony; `internal/` is compiler-enforced | Use `internal/` unless exporting to other repos |147| Wrapping `ResponseWriter` without forwarding `Flush`/`Hijack` | Silently breaks SSE/websockets | Implement `Unwrap()`; forward optional interfaces |