Architecture
labapp is one binary, one database, and four packages. The shape is deliberately boring; the only structural opinion it holds is where the authentication rules are allowed to live.
The packages
Section titled “The packages”| Package | Owns | May import |
|---|---|---|
internal/config | environment variables, defaults | nothing |
internal/server | routes, middleware, JSON, rate limiting | auth |
internal/auth | the domain: hashing, tokens, session rules | neither HTTP nor SQL |
internal/store | PostgreSQL queries and migrations | auth (for its types) |
The rule that does the work is the one on internal/auth: it imports neither
net/http nor a database driver. Password comparison, token generation, and the
decision that a login has failed are all reachable without a server or a
database, which is why that package’s tests are exhaustive and run in
milliseconds. The seams are two small interfaces the domain declares and the
store implements:
type UserStore interface { CreateUser(ctx context.Context, email, passwordHash string) (User, error) GetUserByEmail(ctx context.Context, email string) (User, error) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error}Handlers depend on the domain, the domain depends on interfaces, and the database implements them. Nothing points inward at HTTP.
A request, end to end
Section titled “A request, end to end”An authenticated call carries a token that has to be turned back into a user before the handler runs:
sequenceDiagram
participant C as Client
participant I as Ingress (TLS)
participant M as Middleware
participant H as Handler
participant A as auth.Service
participant D as PostgreSQL
C->>I: GET /api/v1/me (cookie or bearer)
I->>M: HTTP, request ID assigned
M->>A: Authenticate(token)
A->>D: lookup by sha256(token), unexpired
D-->>A: session + user, or nothing
A-->>M: user, or ErrSessionNotFound
alt resolved
M->>H: user on request context
H-->>C: 200 {"user": …}
else not resolved
M-->>C: 401 {"error":"unauthenticated"}
end
The middleware never sees SQL and the handler never sees a token — by the time
handleMe runs, the only thing on the context is a User. Errors travel as
domain sentinels (ErrSessionNotFound, ErrEmailTaken, ErrInvalidCredentials)
and are mapped to status codes in exactly one place, which is what keeps the
failure responses consistent.
Deployment
Section titled “Deployment”Two replicas behind an ingress that terminates TLS, sharing one PostgreSQL instance:
flowchart LR
U[Client] -->|HTTPS| T[Ingress]
T -->|HTTP :8080| P1[labapp pod]
T -->|HTTP :8080| P2[labapp pod]
P1 --> DB[(PostgreSQL 17)]
P2 --> DB
Traffic inside the cluster is plaintext. That is a lab-scale decision, not a recommendation: a service mesh with mutual TLS is the real answer, and is out of scope here.
Two details of running more than one replica are worth naming:
Migrations race. Both pods start at once and both try to migrate. Each takes a PostgreSQL advisory lock first, so one applies the schema and the other waits and finds nothing to do. Startup migrations are only safe because of that lock.
Rate limits are per-pod. The login limiter is in-memory, so two replicas mean an attacker gets twice the configured budget. This is a deliberate simplification — see authentication for the arithmetic and the condition that would force a shared limiter.
Configuration and secrets
Section titled “Configuration and secrets”Configuration is environment variables only, with development-friendly defaults for everything except the database URL, which is required and has no default. The database password is generated once into a git-ignored file and mirrored into a Kubernetes secret; it is never in the chart, the image, or the repository. Session tokens need no signing key at all — a small dividend of choosing opaque tokens over JWTs.