10 KiB
Architecture
This document explains how the pieces of go-simple-api fit together, and
why they're structured this way - useful both as a reference and as a
guide if you extend the project.
High-level request flow
Every incoming HTTP request passes through the same pipeline, built in
internal/router/router.go:
request
│
▼
chimw.RequestID -- tags the request with a unique ID
│
▼
middleware.RequestLogger -- records start time, wraps the response writer
│
▼
chimw.Recoverer -- catches panics, converts them to a 500
│
▼
chimw.Timeout(60s) -- cancels the request context if it runs too long
│
▼
cors.Handler -- validates cross-origin requests (browser only)
│
▼
httprate.LimitByIP(100/min) -- global rate limit
│
▼
sessions.LoadAndSave -- loads session data from Redis into context
│
▼
[ per-route middleware, e.g. httprate strict limit, or requireAuth ]
│
▼
handler -- e.g. handlers.Login, handlers.Me
│
▼
response written
│
▼
(back through the stack) middleware.RequestLogger logs the final status/duration
Each middleware is a function shaped func(http.Handler) http.Handler: it
wraps the next thing in the chain, does something before calling
next.ServeHTTP(w, r), and optionally does something after. This is why
ordering matters - RequestLogger wraps everything registered after it,
so it can measure the full duration including all of those inner layers.
Dependency construction (cmd/api/main.go)
main.go is intentionally the only place that constructs the "big"
shared resources - the logger, the database pool, the session manager -
and it constructs each of them exactly once, then passes them down as
explicit function arguments (router.New(logger, db, sessions, cfg)).
This is a form of dependency injection: nothing deep in the call stack
reaches for a global variable to get a database connection or a logger.
Every package that needs one receives it explicitly, either as a
constructor argument (NewUserRepository(db)) or a struct field
(AuthHandler.userRepo). The benefit: you can trace exactly what any given
piece of code depends on just by reading its constructor signature, and
(if you add tests later) you can substitute a fake/mock dependency without
any global state to fight with.
Package responsibilities
| Package | Responsibility | Should NOT contain |
|---|---|---|
config |
Read env vars into a typed struct | Any logic beyond defaults/parsing |
logging |
Build the shared *slog.Logger |
Per-request logging logic (that's middleware) |
database |
Open the MySQL pool, run migrations | Table-specific queries (that's models) |
models |
Domain structs + repositories (all SQL) | HTTP concerns (status codes, JSON) |
session |
Build the *scs.SessionManager |
Route-specific session key names beyond session.UserIDKey |
oauth |
Build provider *oauth2.Config values |
Handling the actual HTTP callback (that's handlers) |
handlers |
Parse requests, call into models/session, write responses | Raw SQL, direct Redis calls |
middleware |
Cross-cutting HTTP behavior (logging, auth) | Business logic specific to one route |
router |
Wire dependencies + register routes | Any actual request handling logic |
If you're ever unsure where a new piece of code belongs, this table is the first place to check.
The repository pattern (internal/models)
UserRepository is the only place in the entire codebase that writes
SQL for the users table. Handlers call methods like FindByEmail or
Create - they never see a raw *sql.DB or write a query themselves.
Why this matters in practice:
- If you swap MySQL for PostgreSQL later, you change
user_repository.goonly - no handler code changes. - SQL injection risk is contained to one file, and that file consistently
uses parameterized queries (
?placeholders), never string concatenation. - Errors are translated at the boundary:
sql.ErrNoRows(a database/sql-specific sentinel) becomesmodels.ErrUserNotFound(an application-specific sentinel), so callers reason about "not found" as a concept, not a SQL implementation detail.
Sessions: how "server-side" actually works
session.New(cfg)builds a*scs.SessionManagerwhose.Storeis Redis-backed (internal/session/session.go).sessions.LoadAndSave(applied as middleware inrouter.go) runs on every request: it reads thesession_idcookie, loads the corresponding session data from Redis into the request'scontext.Context, lets the handler run, then - after the handler returns - saves any changes back to Redis and sets/refreshes the cookie on the response.- Handlers never touch cookies or Redis directly. They call
sessions.Put(ctx, key, value)/sessions.GetInt(ctx, key)/sessions.Destroy(ctx), and the manager handles the rest via the context it already loaded in step 2. - Only the user's numeric ID is stored in the session
(
session.UserIDKey) - never the full user object. This keeps the session tiny and guarantees/meandmiddleware.RequireAuthalways see fresh data from the database, never a stale cached copy.
Authentication middleware and context.Context
middleware.RequireAuth (internal/middleware/require_auth.go) is the
single place that decides "is this request authenticated?" It:
- Reads
session.UserIDKeyfrom the session. - Looks the user up in the database via
UserRepository.FindByID. - On success, stores the
*models.Userin the request'scontext.Contextunder a private key, and callsnext.ServeHTTPwith the new request (contexts and requests are immutable -context.WithValueandr.WithContextboth return new values rather than mutating in place). - On any failure, responds 401 immediately and
next.ServeHTTPis never called - the wrapped handler doesn't run at all.
Handlers that need the current user call middleware.CurrentUser(r),
which does the type assertion back out of the context. They never see or
touch the context key itself, which is intentionally unexported.
To protect a new route, add it inside the r.Group(func(r chi.Router) { r.Use(requireAuth); ... }) block in router.go.
Google OAuth2 flow in detail
Browser This API Google
│ │ │
│ GET /auth/google/login │ │
├───────────────────────────►│ │
│ │ generate random `state`, │
│ │ store it in session │
│ 302 redirect to Google │ │
│◄───────────────────────────┤ │
│ │
│ user logs in / approves, entirely on Google's own site │
│────────────────────────────────────────────────────────────►
│ │
│ 302 redirect back with ?state=...&code=... │
│◄────────────────────────────────────────────────────────────
│ │ │
│ GET /auth/google/callback │ │
├───────────────────────────►│ │
│ │ verify state matches │
│ │ POST code -> exchange for token │
│ ├──────────────────────────────►│
│ │◄──────────────────────────────┤
│ │ GET userinfo with token │
│ ├──────────────────────────────►│
│ │◄──────────────────────────────┤
│ │ find-or-create local user, │
│ │ renew session token, │
│ │ store user ID in session │
│ 200 OK { id, email } │ │
│◄───────────────────────────┤ │
The state parameter exists purely as CSRF protection for the login flow
itself - without it, an attacker could craft a callback URL using their
own Google account and trick a victim's browser into using it.
Docker networking
Inside docker-compose.yml, each service's name becomes its hostname on
the internal Docker network Compose creates automatically. That's why the
app service is configured with DB_HOST: mysql and REDIS_ADDR: redis:6379 instead of 127.0.0.1 - Compose's built-in DNS resolves
mysql and redis to the correct container IPs. This is also exactly why
internal/config reads these values from environment variables instead of
hardcoding them: the same compiled binary works unchanged whether it's
running on your laptop directly or inside this Compose network - only the
environment variables differ.
Logging shape (for Grafana Loki / Alloy)
Every log line the app writes is a single JSON object to stdout, e.g.:
{"time":"2026-07-15T10:00:05Z","level":"INFO","msg":"http_request","request_id":"...","method":"GET","path":"/health","status":200,"bytes":16,"duration_ms":123000,"remote_addr":"127.0.0.1:54321"}
This shape is deliberately Alloy/Loki-friendly: consistent JSON keys mean
Alloy can scrape container stdout and ship structured log lines without
custom parsing rules, and you can filter/query in Loki on fields like
status, path, or request_id directly.