Skip to content

The ten-minute tour

The pitch says you can understand this codebase in ten minutes. Here is that claim, made falsifiable: one request, followed from the browser to the database and back. Everything else in the kit is a variation on what you are about to read.

The request is POST /api/orgs/{orgID}/invitations — an admin inviting a teammate. It is the most representative one we have: it crosses sessions, CSRF, tenancy, RBAC, validation, a domain service, generated queries and email, and every one of those crossings is the same in every other route.

1. The route (internal/http/server.go)

Routes are assembled in one file, and the tree is the documentation:

go
api.Route("/orgs", func(or chi.Router) {
    or.Use(authMW.RequireAuth)
    // ...
    or.Route("/{orgID}", func(one chi.Router) {
        one.Use(orgMW.Context)
        // ...
        one.Group(func(adm chi.Router) {
            adm.Use(middleware.RequireOrgRole(org.RoleAdmin))
            adm.Get("/invitations", orgsH.Invitations)
            adm.Post("/invitations", orgsH.Invite)
            adm.Delete("/invitations/{invID}", orgsH.RevokeInvitation)
        })
    })
})

Read it bottom-up and you have the security model: to reach Invite you must hold a session (RequireAuth), belong to this organization (orgMW.Context) and be at least an admin in it (RequireOrgRole). There is no annotation system and no route registry — chi middleware composition, nothing else.

Before any of that ran, two global layers already did their work: the session (server-side, alexedwards/scs, loaded from the gvk_session cookie) and CSRF (gorilla/csrf — every mutating request carries a token the SPA fetched from /api/auth/csrf).

2. Tenancy (internal/http/middleware/org.go)

orgMW.Context is the row-level multi-tenancy anchor. It resolves the {orgID} route param against the caller's memberships, not against the organizations table:

go
m, err := o.Q.GetOrgMember(r.Context(), sqlcgen.GetOrgMemberParams{
    OrganizationID: orgID, UserID: u.ID,
})
if errors.Is(err, sql.ErrNoRows) {
    writeError(w, http.StatusNotFound, "not found")
    return
}

A non-member gets a 404 — indistinguishable from an organization that does not exist, so tenant IDs never leak. What lands in the request context is the organization and the caller's role in it; every handler downstream scopes its queries with oc.Org.ID and never trusts a client-supplied organization ID for anything.

3. The handler (internal/http/handlers/orgs.go)

Handlers are deliberately boring: decode, validate, delegate, map errors to statuses.

go
func (h *Orgs) Invite(w http.ResponseWriter, r *http.Request) {
    oc, _ := middleware.OrgFrom(r.Context())
    u, _ := middleware.UserFrom(r.Context())
    var req struct {
        Email string `json:"email" validate:"required,email,max=254"`
        Role  string `json:"role" validate:"required,oneof=admin member"`
    }
    if !h.decodeValid(w, r, &req) {
        return
    }
    if req.Role == org.RoleAdmin && oc.Role != org.RoleOwner {
        Error(w, http.StatusForbidden, "only owners can invite admins")
        return
    }
    err := h.Svc.Invite(r.Context(), oc.Org, u.ID, u.Locale, req.Email, req.Role)
    // ... errors.Is → 409 / 403 / 500
}

Two things worth noticing. Validation is declarative (go-playground/validator) and oneof=admin member means the owner role cannot be requested at all — granting ownership is a separate, deliberate action. And the rule "inviting an admin takes an owner" lives here, in plain if, where you can read it — not in a policy engine.

4. The domain service (internal/org/org.go)

Business rules live in a plain struct with a sqlc handle, a mailer and a logger — no interfaces until a second implementation exists:

go
func (s *Service) Invite(ctx context.Context, o sqlcgen.Organization, inviterID, inviterLocale, emailAddr, role string) error {
    emailAddr = auth.NormalizeEmail(emailAddr)
    // Already a member? Already invited? → ErrAlreadyMember / ErrAlreadyInvited
    raw, hash := auth.NewToken()
    _, err := s.Q.CreateOrgInvitation(ctx, sqlcgen.CreateOrgInvitationParams{
        ID: auth.NewID(), OrganizationID: o.ID, Email: emailAddr, Role: role,
        TokenHash: hash, InvitedBy: inviterID,
        ExpiresAt: now().Add(auth.InviteTTL), CreatedAt: now(),
    })
    // ...
}

The token pattern repeats across the whole kit: the email carries raw, the database stores only its SHA-256. A database leak exposes no usable link, and lookups by hash are timing-safe by construction. Timestamps are computed in Go (now() is UTC, truncated) — never now() in SQL, which the two engines spell differently.

5. The query (internal/db/queries/orgs.sql)

sql
-- name: CreateOrgInvitation :one
INSERT INTO org_invitations (id, organization_id, email, role, token_hash, invited_by, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;

sqlc turns this into the typed Go you saw above — once, with $N placeholders. The same generated code runs on PostgreSQL and SQLite (modernc's driver binds $N natively), which is why there is exactly one migration set and one query set for two first-class engines. make sqlc regenerates; nothing is written by hand.

6. The email (internal/org/org.go → internal/email)

go
subject, body, err := email.Render("org_invitation", inviterLocale, map[string]any{
    "OrgName": o.Name,
    "URL":     s.BaseURL + "/invite?token=" + raw,
})

Templates are plain text, per locale (templates/fr/org_invitation.txt, templates/en/…), rendered in the invitee's likely language. In production the message goes through the durable job queue — a redeploy cannot swallow it — and a parity test fails the build if a locale is missing a template.

7. Back out: what the invitee does

The emailed link lands on /invite?token=…. Accepting consumes the invitation and creates the membership in one transaction — a rejected acceptance (already a member, expired) rolls back and the invitation survives. If the token never reaches the browser (account created straight from /signup, another machine), the dashboard offers the invitation to the verified address instead. Both paths, and the cross-tenant 404s, are integration-tested against a real database.

That's the whole architecture

Every feature follows the shape you just read:

LayerWhereRule
Routesinternal/http/server.gomiddleware composition is the security model
Tenancymiddleware/org.goresolve through membership; outsiders get 404
Handlersinternal/http/handlers/decode, validate, delegate, map errors
Domaininternal/{auth,org,user,billing,admin}plain structs, sentinel errors
Queriesinternal/db/queries/*.sqlportable SQL, generated once, both engines
Emailinternal/emailtext templates × locales, durable queue

Where to go next:

  • Billing (internal/billing) adds one idea: webhook events and their business writes commit in the same transaction, keyed on the event ID — replays are no-ops. The Stripe guide covers operating it.
  • Outbound HTTP (internal/safehttp) is the one client allowed to fetch configurable URLs: SSRF-hardened, private ranges blocked.
  • Adding your own resource: Make it yours walks the same chain in build order.

Commercial license — you buy it once, the code is yours.