Stage 7 · Master
Building the HTTP API with Gin
Gin Router & Route Groups
net/http's ServeMux can't express nested resource scope or per-group middleware without hand-rolled wrapping. That gap is why Gin gets introduced here, not earlier.
Why Gin, Now
Every service so far has been built on stdlib net/http, on purpose — a single health check and a couple of flat routes never needed anything more. That stops being true starting this module. Fieldwork's resource model is a real containment chain (tenant → workspace → project → task), and the API needs routes that reflect it: path parameters nested several levels deep, and middleware that should only run for a specific branch of that tree, not globally. net/http's ServeMux (even the pattern-matching one introduced in Go 1.22) has no concept of route groups — every 'only run this middleware for /tenants/:id/*' rule would have to be hand-rolled as nested closures wrapping handlers, which gets unreadable fast once there are more than two or three levels of scope.
That's the concrete gap Gin closes: named path parameters, route groups that share a prefix and a middleware chain, and a request context (*gin.Context) that carries bound values down to the handler without extra plumbing. It's a router library, not a framework replacing net/http underneath — Gin still runs on top of net/http's http.Handler interface, so nothing about how the process starts (Module 2) changes.
cmd/api/main.go still does the same job: build a handler, call http.ListenAndServe. The only thing changing is what internal/httpserver hands back — a *gin.Engine instead of a stdlib *http.ServeMux, both of which satisfy http.Handler.
The Migration
go.modmodifyResponsibility: Adds the github.com/gin-gonic/gin dependency.
Why now: First and only reason a router library was added: nested route groups and shared middleware chains, which the stdlib mux from Module 2 can't express cleanly.
internal/httpserver/server.gomodifyResponsibility: Now constructs a *gin.Engine instead of a stdlib *http.ServeMux, but keeps the same New(...) http.Handler contract.
Why now: Only the routing mechanism changes — everything upstream (main.go's ListenAndServe call) keeps working unmodified because gin.Engine implements http.Handler.
Connects to: Replaces the http.NewServeMux() call introduced in Module 2's cmd/internal lesson.
internal/httpserver/routes.gocreateResponsibility: Owns the full route tree: every group, every path, every middleware attachment point.
Why now: The route tree is big enough now (four nesting levels, per-group middleware) that it deserves its own file instead of living inline in server.go.
Connects to: Called from server.go's New(...) to populate the *gin.Engine before it's returned.
mw.Authenticate, mw.RequireTenantRole, and the rest of the auth-shaped middleware in this lesson's route tree are named here to show where they attach in the route tree. Module 5 (Authentication & Authorization) is where they're actually built, with real JWT verification and permission checks. Until then, treat them as placeholders that document intent — that's a deliberate sequencing choice, not an accident.
Routing Is Where Resource Shape Becomes Real
A router tree is not just URL decoration. It is one of the first places your data model either stays coherent or gets blurred. Fieldwork's domain has a clear containment chain: tenants own workspaces, workspaces own projects, projects own tasks. If the route structure ignores that, middleware loses the natural points where scope becomes known and authorization has to happen. I wanted the URL design to reflect the actual ownership model instead of flattening everything into task endpoints with optional query parameters.
The rejected design was a mostly flat API such as /tasks, /projects, and /workspaces with tenant context hidden in headers or inferred late. That may look shorter, but it moves important scoping information out of the route tree and into conventions every handler must remember. In Fieldwork, the route shape is allowed to be a little verbose because the verbosity buys clarity about where tenant and workspace boundaries enter the request lifecycle.
If the tenant boundary only appears as an implementation detail, you will end up attaching authorization too low or too inconsistently. Route groups make the request lifecycle legible.
The Route Tree Fieldwork Actually Wants
Fieldwork's API starts with /v1 and then groups resources by the containment chain clients already reason about. The main shape is tenant-first because tenant is the top-level isolation boundary. Under that come workspaces, then projects, then tasks. There are still some convenience endpoints such as 'my tasks,' but the canonical write paths stay nested enough that scope is explicit.
This is not the only plausible route tree, but it matches the system's data and permission model without cleverness. A workspace membership check makes sense only after workspaceID is known. A tenant-wide admin permission check makes sense as soon as tenantID is known. The router should help enforce that ordering instead of leaving it as handler trivia.
Where Middleware Attaches
Middleware in Gin is most useful when each layer adds one kind of context. Fieldwork uses global middleware for operational concerns: request IDs, panic recovery, structured request logging, and authentication. Tenant group middleware binds tenant scope and loads the tenant if needed. Workspace group middleware verifies the caller is actually a member of that workspace. Resource-specific write middleware applies role checks. Each layer attaches after the route parameters it needs are present and before the handler would otherwise have to rediscover them.
| Middleware | Attached at | Why there |
|---|---|---|
| RequestLogger | /v1 | All API requests need correlation and response logging |
| Authenticate | /v1 | No tenant logic matters until the caller is known |
| BindTenantScope | /tenants/:tenantID | tenantID becomes available at this point |
| RequireWorkspaceMembership | /workspaces/:workspaceID | workspaceID is now available for membership lookup |
| RequireWorkspaceRole | write endpoints only | Read and write permissions differ |
That layering also keeps failures honest. If a request dies in authentication, it never pretends tenant scope existed. If it fails membership, the handler never runs. This sounds obvious, but it is easy to get wrong when teams attach middleware by habit instead of by information flow. In a multi-tenant API, lifecycle order matters because later checks depend on earlier context being trustworthy.
Keeping Handlers Boring
A good router setup makes handlers pleasantly boring. By the time CreateTask runs, the caller is authenticated, tenant scope is bound, workspace membership is verified, and request logging is already enriched. The handler can focus on binding a DTO, validating it, and calling the task service. That is exactly how I want transport code to feel: narrow, repetitive, and not very smart.
I am intentionally not putting business logic in middleware. Middleware establishes context and guards. Services decide domain outcomes. That boundary keeps authorization readable without turning the request pipeline into a maze of hidden behavior. If a task write needs additional rules such as 'completed tasks cannot move back to todo without admin override,' that belongs in the task service, not in a random middleware closure.
Once middleware starts loading full projects, tasks, and membership graphs for convenience, it becomes a second application layer with worse visibility. Keep it focused on context binding and access checks.
The Decision
- Use a tenant-first route tree because tenant is the top-level isolation boundary.
- Attach middleware at the point where route parameters make the next check possible.
- Keep handlers thin by doing auth, scope binding, and membership checks upstream.
- Do not flatten resource ownership into vague endpoints that hide scope in conventions.
Fieldwork structures Gin route groups around actual resource containment because that keeps tenant scope, workspace membership, and write authorization attached at the right points in the request lifecycle. The rejected alternative was a flatter API with more implicit context. That would save some path segments and cost much more in correctness and readability once the system's multi-tenant behavior becomes real.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.