7.5 KiB
Backend — agent handbook
Go 1.27 API server: stdlib net/http (Go 1.22+ method patterns +
r.PathValue), Bun ORM (SQLite file by default, Postgres via DSN), layered
so each change touches a predictable set of files. Follow this handbook
unless the user says otherwise.
Commands
| Task | Command |
|---|---|
| Full gate: fmt-check + vet + tests | make check — run before declaring any change done |
| Dev server | make run from backend/ (SQLite: no setup, make migrate-up once) |
| Tests | make test (go test ./...; no database server required) |
| Optional Postgres up / down | make db-up / make db-down (set DATABASE_URL=postgres://…) |
| Migrations | make migrate-up / make migrate-down / make migrate-status |
| New migration | make migrate-new name=create_projects |
| Binaries | make build (writes bin/) |
No linters beyond go vet and gofmt are configured — don't add config
files for other tools unless asked.
Architecture — dependencies flow one way
cmd/api (wiring only)
│
▼
internal/api ──HTTP──▶ internal/items.Service ──▶ items.Repository (interface)
(transport) (business rules) ▲
BunRepository (SQL)
internal/api— transport only: route patterns, parse input, call the service, map errors to status codes. No SQL, no business rules.internal/items/service.go— validation and business rules. Depends on theRepositoryinterface, never on*bun.DB; that is what makes the tests fake-backed.internal/items/repository.go— the only file speaking Bun/SQL.internal/config— the only file readingos.Getenv.cmd/*— wiring and process concerns (signals, exit codes). No logic.
Imports may only point rightward in the diagram. internal/api must never
import internal/database, and nothing imports internal/api except cmd.
Adding a resource (the recipe)
items is the reference. For a new resource projects:
internal/projects/model.go— struct withbun:"table:…,alias:…"tags, json tags,Repositoryinterface,ErrNotFound,ValidationError.internal/projects/repository.go—BunRepositoryimplementing it.internal/projects/service.go—Service,CreateInput, pointer-fieldUpdateInput, validation.make migrate-new name=create_projects, then fill in the generated Go migration ininternal/database/migrations/(model-driven DDL, plus a down migration).internal/api/projects.go— handler struct +failerror mapping.internal/api/server.go— register routes on the mux.- Tests:
projects/service_test.go(fake repo) + extendapi/server_test.go. make check.
HTTP conventions
- Routes are method-prefixed ServeMux patterns (
"PATCH /api/v1/projects/{id}"); read segments withr.PathValue("id"), parse via thepathIDhelper. Wrong methods get 405 +Allow— thejsonNotFoundswrapper in server.go keeps that mux behavior, so don't register catch-all routes. - Success bodies are the resource JSON directly; lists are bare arrays.
Create answers 201 +
Location; delete answers 204. - Errors are always
{"error":{"code","message"}}. Codes live ininternal/api/respond.go:invalid_json,validation_failed,not_found,invalid_id,method_not_allowed,internal_error,timeout,unavailable. Map domain errors only in the handler'sfail— sentinelErrNotFound→ 404,*ValidationError→ 400, everything else → 500 (logged, never leaked to the client). - Decode request bodies with
decodeJSON(strict: unknown fields and1 MiB bodies are 400s). List endpoints take
?limit=&offset=and resource-specific filters; defaults and caps live in the service. - Middleware chain (outermost first): logging → recover → cors → timeout → requireAuth (only when OIDC is configured). Add transport-level concerns there, not in handlers.
Auth
OIDC authorization-code + PKCE against the issuer in OIDC_ISSUER
(default https://users.k6n.net). Enabled iff OIDC_CLIENT_ID is set;
otherwise the server runs standalone (no /api/auth routes, /api/v1 open)
and logs that loudly. Pieces:
internal/auth— the OIDC client (discovery-driven endpoints, never hardcoded paths) and the cookie layer: session + login-transaction values are<base64url(json)>.<base64url(hmac-sha256)>sealed bySigner. Tokens stay server-side; the browser only ever holds the session cookie.internal/api/auth.go— the four routes:GET /api/auth/session(always 200),GET /api/auth/login?returnTo=,GET /api/auth/callback,POST /api/auth/logout. The callback path/api/auth/callbackis fixed; the redirect URI is derived per request fromX-Forwarded-Host/Proto(set by the Vite proxy) so localhost,*.preview.k6n.net, and the API origin all work with one client registration.requireAuthguards/api/v1/*with the session cookie (unknown sub-routes answer 401, not 404). It is skipped in standalone mode and inapi.Newtests that omitDeps.Auth.- Login failures redirect to
/?auth_error=<code>— the SPA reads that; details go to server logs only.returnTois sanitized to a relative path (no//, no control chars) to stay an internal redirect.
Migrations
- One Go file per migration in
internal/database/migrations/, named<YYYYMMDDHHMMSS>_<snake_name>.go, registering itself viaMigrations.MustRegister(up, down)ininit().make migrate-new name=<snake_name>scaffolds it with a fresh timestamp — never invent timestamps by hand. Apply withgo run ./cmd/migrate up(alsodown,status). - Prefer model-driven DDL (
db.NewCreateTable().Model((*pkg.Thing)(nil))): bun emits dialect-correct SQL for SQLite and Postgres, so migrations stay portable across the default file database and a real Postgres. Usedb.Execwith raw SQL only when a model can't express it. - Never edit or reorder an applied migration — add a new one. Every migration gets a down migration.
internal/database/database_test.goruns all migrations plus the items repository against in-memory SQLite — extend it when adding resources so dialect drift failsmake check, not production.
Databases
- Default:
DATABASE_URL=sqlite:dev.db— a local file (modernc pure-Go driver, no CGO, WAL + busy_timeout applied automatically unless the DSN carries its own_pragmaparams). Zero setup for iteration. - Postgres:
make db-upthenDATABASE_URL=postgres://fullstack:fullstack@localhost:5432/fullstack?sslmode=disable. The repository layer uses portable queries (?placeholders, no dialect-specific SQL) — if you must, branch ondb.Dialect().Name().
Testing
- Three layers:
internal/<pkg>/service_test.godrives business rules through an in-memory fake of the Repository interface;internal/api/server_test.godrives the real mux + middleware with the same fake behind the real service;internal/database/database_test.goruns migrations + the real BunRepository against in-memory SQLite. - Fakes are package-local and duplicated on purpose — don't build a shared testutil package until there are enough resources to justify it.
- Table-driven tests for error cases; one flow test per handler group.
Config
- Env-only;
.envin backend/ is autoloaded bycmd/api. Add a field ininternal/config/config.go, a line in.env.example, and a row in the README table. - CORS allows the Vite dev server (
http://localhost:5173) by default so the frontend can call this API during development.