Status: work in progress. This README describes the target state; most of it is not built yet. Today the repository contains the project skeleton: a
net/httpserver withGET /health, plusmake run,build,testandlint.
A task-management API (users, projects, tasks) built with Go, Huma, PostgreSQL and sqlc. It is a sibling of the FastAPI implementation and the full-stack Nuxt and Next.js takes: the same domain, different stacks.
A user owns projects; a project contains tasks. You can only see and change your own data.
| Concern | Choice |
|---|---|
| Language / runtime | Go 1.26+, single static binary |
| HTTP | net/http (Go 1.22+ routing) + Huma v2 |
| Database | PostgreSQL 17, pgx v5 pool |
| Queries | sqlc — type-safe Go generated from SQL |
| Migrations | goose (plain SQL, embedded) |
| Auth | Bearer JWT access + revocable, rotating refresh tokens |
| Passwords | Argon2id (golang.org/x/crypto/argon2) |
| Background jobs | River (Postgres-backed queue) |
| Cache / rate limit | Redis (go-redis v9) |
| Logging | log/slog (JSON) |
| Config | Environment variables (caarlos0/env) |
| Tooling | golangci-lint, go test + testcontainers-go, Make |
- Registration, login,
me, token refresh (with rotation) and logout. - Email verification and password reset via short-lived signed tokens.
- CRUD for projects and tasks nested under a project.
- Ownership authorization on every resource (
403for others',404if absent). - Pagination, case-insensitive search, status filtering and whitelisted sorting.
- Per-endpoint rate limiting on authentication.
- One error envelope for every failure.
- Background email dispatch, Redis caching, JSON logs and a DB-aware health check.
- OpenAPI 3.1 generated from Go types, interactive docs and an exported
openapi.json. - Graceful shutdown: in-flight requests and jobs finish before the process exits.
- Huma on
net/http, not Fiber or Gin. Huma is the closest Go analogue of FastAPI: handlers take and return typed structs, and validation plus the OpenAPI schema are derived from those types. It sits on the standardnet/httprouter, so all ecosystem middleware works. Fiber is fast but built onfasthttp, which is not compatible withnet/http. For a JSON API backed by Postgres the database dominates latency, so that speed buys nothing here. - sqlc instead of an ORM. Queries are written as plain SQL and sqlc
generates typed Go functions from them at build time. You get compile-time
checking with no reflection or query builder at runtime, and the SQL you read
is the SQL that runs. The cost is that dynamic queries such as optional
filters and sorting take some care. They are handled with a fixed whitelist
and
CASE/COALESCEpatterns rather than string building. - Package by feature, not by layer.
internal/auth,internal/projectandinternal/taskeach hold their own handler, service and storage code. This is the idiomatic Go layout: a package is a unit of meaning, andinternal/keeps everything private to this module. - Explicit dependency injection in
main. There is no DI container.mainbuilds the pool, services and handlers and passes them in as arguments. Go favours wiring you can read top to bottom over magic. - Interfaces at the consumer. A service declares the small interface it
needs (for example
Mailer) instead of depending on a concrete type. Tests then substitute fakes without a mocking framework. - JWT access + revocable refresh tokens (hybrid). Access tokens are stateless and short-lived (15 min), so authorizing a request never touches the database. Refresh tokens are stored hashed (SHA-256) and are revocable. Each refresh rotates them, so reuse is detectable, and logout and password reset revoke them. This matches the sibling implementations.
- River over a Redis queue. River keeps jobs in Postgres. A job can be enqueued in the same transaction as the data change, so "user created but welcome email lost" cannot happen. Redis is still used for the cache and rate limiting, where losing data is harmless.
- Errors are values. Every layer returns
error. Domain errors (ErrNotFound,ErrForbiddenand so on) are mapped to the HTTP error envelope in one place, and unexpected errors become500without leaking internals. - Real Postgres in tests. Integration tests start PostgreSQL with testcontainers-go instead of mocking the database, because sqlc queries are only proven by running them.
- Go 1.26+
- Docker (for PostgreSQL and Redis)
make(optional; every target is a plain command)
Only
make run,build,testandlintexist today. The other targets below (tools,migrate,seed,worker,openapi,check) are planned.
cp .env.example .env
docker compose up -d db redis # PostgreSQL on :5434, Redis on :6379
make tools # install sqlc, goose, golangci-lint
make migrate # apply migrations
make seed # optional: load demo data
make run # go run ./cmd/apiThe API is then at http://localhost:8080 and the docs at http://localhost:8080/docs.
Run the background worker in a second terminal:
make worker # go run ./cmd/workerDemo user (after seeding): demo@example.com / password123.
JWT_SECRET_KEY=$(openssl rand -base64 48) \
docker compose -f docker-compose.prod.yml up --buildThis starts PostgreSQL, Redis, the migration job, the API, the worker and
nginx. The API is served at http://localhost:8081.
All endpoints are versioned under /api/v1 except /health. Protected routes
require Authorization: Bearer <access_token>.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/register |
Create an account |
| POST | /api/v1/auth/login |
Exchange credentials for tokens |
| GET | /api/v1/auth/me |
Current user |
| POST | /api/v1/auth/refresh |
Rotate the token pair |
| POST | /api/v1/auth/logout |
Revoke refresh token(s) |
| POST | /api/v1/auth/verify-email/request |
Send a verification email |
| POST | /api/v1/auth/verify-email/confirm |
Confirm an email address |
| POST | /api/v1/auth/password-reset/request |
Send a reset email |
| POST | /api/v1/auth/password-reset/confirm |
Set a new password |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects |
List your projects (paged) |
| POST | /api/v1/projects |
Create a project |
| GET | /api/v1/projects/{id} |
Get a project |
| PATCH | /api/v1/projects/{id} |
Update a project |
| DELETE | /api/v1/projects/{id} |
Delete a project |
Query parameters: page, size, q (search by name), sort
(created_at, updated_at, name; prefix - for descending).
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/projects/{project_id}/tasks |
List tasks (paged) |
| POST | /api/v1/projects/{project_id}/tasks |
Create a task |
| GET | /api/v1/projects/{project_id}/tasks/{id} |
Get a task |
| PATCH | /api/v1/projects/{project_id}/tasks/{id} |
Update a task |
| DELETE | /api/v1/projects/{project_id}/tasks/{id} |
Delete a task |
Query parameters: page, size, q (search by title), status
(todo, in_progress, done), sort
(created_at, updated_at, title, status).
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/stats |
Counts of your projects and tasks |
| GET | /health |
Liveness + database readiness |
Every failure has the same shape:
{ "error": { "code": "not_found", "message": "Project not found", "details": [] } }Codes: bad_request, unauthorized, forbidden, not_found, conflict,
validation_error, rate_limit_exceeded, internal_error.
# Register and log in
curl -s -X POST localhost:8080/api/v1/auth/register \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","name":"Ada","password":"s3cret-pass"}'
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"s3cret-pass"}' | jq -r .access_token)
# Create a project, then a task in it
PROJECT_ID=$(curl -s -X POST localhost:8080/api/v1/projects \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Website redesign"}' | jq -r .id)
curl -s -X POST localhost:8080/api/v1/projects/$PROJECT_ID/tasks \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"title":"Draft the new layout"}'- Background jobs: emails are dispatched through River and processed by
cmd/worker. - Scheduled cleanup: a periodic River job purges expired and revoked refresh tokens every night.
- Cache:
/api/v1/statsuses Redis cache-aside with a short TTL. - Logging:
slogwrites one JSON access line per request with method, path, status, duration and a correlatedX-Request-ID. - Health:
/healthpings the database and returns503if it is down. - Shutdown: on
SIGINT/SIGTERMthe server stops accepting connections and waits up toSHUTDOWN_TIMEOUTfor in-flight requests to finish.
- CORS is denied by default. Set
CORS_ORIGINS(comma-separated) to allow browser clients. - Rate limiting keys on the client IP. Behind the bundled nginx, set
TRUST_FORWARDED_FOR=trueso the real client IP is used instead of the proxy's. - Secrets: in
productionthe app refuses to start with the default or a weak (< 32char)JWT_SECRET_KEY.
Huma serves the schema and interactive docs:
- Docs UI:
/docs - Raw schema:
/openapi.json,/openapi.yaml
A committed copy will live at openapi.json (planned). Regenerate it without
running the server:
make openapimake check # go vet, golangci-lint, go test -race with coverageCI runs the same gate on every push and pull request. It applies the
migrations, checks that sqlc generate leaves no diff and enforces a 70%
minimum coverage.
cmd/
api/ # HTTP server entrypoint: wiring + graceful shutdown
worker/ # River worker entrypoint
seed/ # demo data loader
internal/
config/ # env config + validation
platform/ # infrastructure: postgres pool, redis, logger
httpx/ # server, middleware (request id, logging, recover, CORS), error envelope
auth/ # registration, login, JWT, refresh tokens, password hashing
project/ # project handlers, service, ownership checks
task/ # task handlers, service
stats/ # stats endpoint + cache
jobs/ # River job definitions (emails, cleanup)
db/ # sqlc-generated code (do not edit)
pagination/ # page/size/sort parsing shared by list endpoints
db/
migrations/ # goose SQL migrations (embedded into the binary)
queries/ # SQL that sqlc turns into Go
deploy/ # nginx configuration