Skip to content

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.

PackageOwnsMay import
internal/configenvironment variables, defaultsnothing
internal/serverroutes, middleware, JSON, rate limitingauth
internal/auththe domain: hashing, tokens, session rulesneither HTTP nor SQL
internal/storePostgreSQL queries and migrationsauth (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:

internal/auth/service.go
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.

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.

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 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.