The Request Lifecycle
Everything a request passes through, in the order it actually happens. Most of the confusing bugs in a Go API — a CORS header that never appears, a WAF rule that blocks the wrong thing, a CSRF rejection on a mobile client — are ordering problems. This page is the map.
The order
From internal/routes/routes.go, top to bottom. Each layer wraps the ones below it, so a request goes down the list and the response comes back up.
┌─ request in│1 Maintenance 503 for everyone but allowlisted IPs2 SecurityHeaders CSP, HSTS, X-Frame-Options, nosniff3 MaxBodySize 10 MB cap — before anything reads the body4 RequestID X-Request-ID, threaded through every log line5 Logger method, path, status, latency, request id6 Recovery turns a panic into a 500 instead of a dead process7 CORS preflight + Access-Control-* headers8 Gzip response compression9 AutoCSRF enforces ONLY on cookie-authenticated mutations10 Idempotency replays the cached 2xx when Idempotency-Key repeats11 Sentinel WAF, rate limits, AuthShield, anomaly + geo (if enabled)12 Pulse tracing, N+1 detection, runtime metrics (if enabled)│├─ route group middleware│ protected: Auth → ActivityLogger│ admin: Auth → RequireRole("ADMIN")│└─ your handler
The global layers
Order here is deliberate, and a few positions are load-bearing:
- Maintenance is first so a maintenance window costs nothing — no body read, no DB, no auth.
- MaxBodySize precedes anything that reads the body. A cap applied after parsing is not a cap.
- Recovery sits after Logger, not before. Gin runs middleware in registration order, so Logger wraps Recovery and a panicking request still gets logged with its status. Flip them and panics vanish from your logs.
- CORS runs before auth, because a browser preflight (
OPTIONS) carries no credentials. Put CORS behind auth and every cross-origin call fails preflight with a 401 that never reaches your handler — the single most common “my frontend can't call my API” cause.
CSRF only applies to cookie sessions
AutoCSRF enforces the double-submit token only when a request is a state-changing method and authenticated by cookie. It deliberately skips:
- safe methods (it issues the token there instead);
- requests carrying
Authorization: Bearer— those authenticate explicitly and can't be forged cross-site; - bootstrap endpoints (
login,register,refresh, password reset, TOTP verify) which have no token yet; - the SAML assertion consumer — the identity provider posts it from its own origin and will never have a token.
This is why a mobile or desktop client never sends a CSRF header and still works: those flows are bearer-authenticated. If you add an endpoint that must be callable cross-origin without a session, it needs an explicit exemption — see internal/middleware/csrf.go.
Group middleware
Below the global stack, routes hang off groups that add their own layers:
v1 := r.Group("/api/" + APIVersion)// Public: auth endpoints, SSO, public form submissions.auth := v1.Group("/auth")// Authenticated: everything a signed-in user can reach.protected := v1.Group("")protected.Use(middleware.Auth(db, authService))protected.Use(middleware.ActivityLogger(db))// Admin-only.admin := v1.Group("")admin.Use(middleware.Auth(db, authService))admin.Use(middleware.RequireRole("ADMIN"))
Auth populates the gin context with user_id, user_email, user_role and user_grants, so everything after it — including your handler — can read the caller without another query.
Where does my logic go?
| You want to… | Put it… |
|---|---|
| Reject a request before any work happens | Global middleware, high in the list |
| Require a permission for one route | middleware.RequireRole("perm:invoices.delete") on that route |
| Require it for a whole section | .Use() on a route group |
| Check the caller owns the record | In the handler, via authz.MustOwn — it needs the record |
| Business rules | The service layer. Handlers stay thin; see Services |
| An invariant every writer must obey | A GORM hook or callback, returning respond.Rule(...) — see below |
| Record that something happened | services.LogActivity from the handler, after it succeeds |
Invariants, and telling the caller why
Most business rules belong in the service layer. Some do not: a rule that must hold no matter who writes the row belongs on the model, because the service layer is not the only thing holding the database handle. GORM Studio ships mounted at /studio and edits tables directly, the CSV importer writes through the same gorm.DB, and so does every handler nobody has written yet. A ledger that balances only when saved through one service method does not balance.
Put those in a hook or a callback and return respond.Rule, which marks an error as one the caller is meant to read:
func (e *JournalEntry) BeforeCreate(tx *gorm.DB) error {debits, credits := sum(e.Lines)if debits != credits {return respond.Rule("does not balance: debits %s, credits %s", debits, credits)}return nil}
The generated handlers pass whatever a write returns to respond.WriteError, which turns a Rule into 422 carrying that sentence, a missing row into 404, and anything else into an opaque 500 with the error logged.
Rule is how your code says which errors are the exception. An error you did not mark keeps the generic 500 it always had.Adding your own middleware
A Grit middleware is an ordinary Gin one. Register it in routes.go at the position its job implies — cheap rejections high, anything needing an authenticated user below Auth:
func RequireTenant() gin.HandlerFunc {return func(c *gin.Context) {tenant := c.GetHeader("X-Tenant")if tenant == "" {respond.BadRequest(c, "X-Tenant header is required")c.Abort() // Abort, not return — return alone continues the chainreturn}c.Set("tenant", tenant)c.Next()}}
c.Abort() is not optional. Writing a response and returning does not stop the chain — the handler still runs, and you get a rejected request that also did the work. Any middleware that denies a request must call c.Abort().
