Go API (chi + sqlc)
Project context
This is a Go HTTP service. We follow the Go community's "standard library first" instinct: chi for routing because it's idiomatic and tiny, sqlc for type-safe queries against Postgres, and as little framework as possible. Concurrency is structured; errors are values; context propagates through every call.
Stack
- Go 1.22+
- chi v5 for HTTP routing
- sqlc for SQL → Go type generation
- Postgres 15+ via pgx/v5
- slog (stdlib structured logging)
- testify or stdlib testing
- golangci-lint
- migrate for DB migrations
Folder structure
.
├── cmd/
│ └── api/
│ └── main.go — entrypoint
├── internal/
│ ├── config/ — env config
│ ├── http/
│ │ ├── handlers/ — HTTP handlers
│ │ ├── middleware/
│ │ └── router.go
│ ├── service/ — business logic
│ ├── repo/ — data access (wraps sqlc)
│ ├── domain/ — pure types and interfaces
│ └── platform/ — DB, cache, queue clients
├── db/
│ ├── migrations/
│ ├── queries/ — .sql files for sqlc
│ └── sqlc/ — generated code (committed)
├── go.mod
├── go.sum
└── sqlc.yaml
internal/ keeps these packages from being imported by external Go code. cmd/<name> per binary.
Idioms
- Error wrapping:
fmt.Errorf("doing X: %w", err)always; neverfmt.Errorf("doing X: %s", err) errors.Is/errors.Asto inspect wrapped errors; never string compare- Sentinel errors in the package that defines the operation:
var ErrNotFound = errors.New("not found") - Context first:
func (s *Service) GetPost(ctx context.Context, id string) (*Post, error) - Accept interfaces, return structs. Define the interface in the consumer; the producer returns its concrete type.
- Zero-value usable when sensible. A struct with no init function reduces ceremony.
sqlc workflow
- Write the
CREATE TABLEmigration indb/migrations/ - Write the SQL query in
db/queries/<resource>.sqlwith-- name: GetPost :oneannotations - Run
sqlc generate— produces type-safe Go indb/sqlc/ - Wrap generated code in a repo type that takes
context.Contextand returns domain types
-- name: GetPostByID :one
SELECT id, title, body, created_at FROM posts WHERE id = $1;
-- name: ListPosts :many
SELECT id, title, body, created_at FROM posts ORDER BY created_at DESC LIMIT $1;
type PostRepo struct{ q *sqlc.Queries }
func (r *PostRepo) GetByID(ctx context.Context, id string) (*domain.Post, error) {
row, err := r.q.GetPostByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound }
return nil, fmt.Errorf("get post: %w", err)
}
return toDomain(row), nil
}
HTTP handlers
func (h *Handler) ListPosts(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
posts, err := h.posts.List(ctx, 20)
if err != nil {
h.error(w, r, err)
return
}
writeJSON(w, http.StatusOK, posts)
}
- Always thread
r.Context()into service / repo calls - Use one
error(w, r, err)helper that maps domain errors → HTTP status - Helpers like
decodeJSON(r, &v)andwriteJSON(w, status, v)keep handlers small
Concurrency
- Use
errgroup.Groupfor "fan out, wait, propagate first error" - Use
sync.WaitGrouponly when you genuinely don't want errors to short-circuit - Always derive a child context with
context.WithTimeoutfor outbound calls - Never spawn a goroutine from a handler without a way to wait for or cancel it
Configuration
- Read env once in
internal/config, return a typed struct - Validate at startup; fail fast on missing or malformed values
- Never
os.Getenvinside business logic
Logging
slogwith a structured handler; one logger at the root, child loggers per request- Include request ID via middleware; pass through context
- Don't log secrets, tokens, or PII
Migrations
migrate create -ext sql -dir db/migrations -seq <name>- Always include
up.sqlanddown.sql - Never modify a committed migration; create a new one
- Run in CI/CD:
migrate -path db/migrations -database $DATABASE_URL up
Patterns to avoid
- Naked returns in long functions
panicfor normal errors — return theminit()for non-trivial setup — call frommain- Global state — pass dependencies explicitly
- Empty interface (
any/interface{}) as a parameter type — be specific - Long functions — extract once they grow past ~50 lines, but don't over-extract trivial logic
Testing
- Stdlib
testing; testify only for assertions when stdlib is verbose - Table-driven tests for any function with multiple inputs:
for _, tc := range []struct{ name string; in, want X }{ ... } { t.Run(tc.name, func(t *testing.T) { ... }) } - Use
httptest.NewRecorderfor handler tests - For DB tests, use a real Postgres in Docker; truncate between tests
Tooling
go run ./cmd/apigo build ./...go test ./... -racegolangci-lint runsqlc generateafter editing queriesmigrate up/migrate down
AI behavioral rules
- Always wrap errors with
%w; never%s - Always thread
context.Contextas the first parameter - Use sentinel errors +
errors.Isfor typed checks; never string-compare - After editing a
.sqlquery file, runsqlc generate— don't hand-edit generated code - Never modify a committed migration; create a new one
- Don't introduce a framework when the standard library suffices
- Run
go test ./... -raceandgolangci-lint runbefore declaring a task done