API Versioning in Go: URI, Header, and Media Type Strategies
NestGo supports API versioning in Go through three strategies — URI prefix, request header, and media type — configured once and applied automatically to any controller that declares a version. This mirrors NestJS’s versioning model in idiomatic Golang.
How API versioning works in NestGo
Two pieces cooperate:
Config.Versioning— a*core.VersioningConfigon your app config that picks the strategy.VersionedController— an optional interface a controller implements to declare which version it serves.
type VersioningConfig struct {
Strategy VersioningStrategy
// Header is the header name for HeaderVersioning. Default: "Accept-Version".
Header string
}
type VersionedController interface {
Version() string
}
During controller registration, the DI container checks each controller: if it implements VersionedController and Config.Versioning is set, the version is applied — as a route prefix for URI versioning, or via a per-path version dispatch route for header and media type versioning. Controllers that don’t implement Version() are registered unversioned, so you can mix versioned and unversioned controllers freely.
A minimal versioned controller:
type UserControllerV1 struct{}
func NewUserControllerV1() *UserControllerV1 { return &UserControllerV1{} }
func (c *UserControllerV1) Version() string { return "1" }
func (c *UserControllerV1) Prefix() string { return "/users" }
func (c *UserControllerV1) RegisterRoutes(r core.Router) {
r.GET("/", core.Handle1(core.Q("search", ""), c.List))
}
The version wrapper is applied before the Prefix() group, so with URI versioning the routes above live at /v1/users (and under GlobalPrefix, e.g. /api/v1/users).
Strategy 1: URI versioning (/v1/users)
The version becomes a path prefix: /v{version}. This is the most common and most visible strategy.
config := core.DefaultConfig()
config.Versioning = &core.VersioningConfig{Strategy: core.URIVersioning}
Register two controllers with different Version() values and NestGo groups them under separate prefixes:
func (c *UserControllerV1) Version() string { return "1" } // → /v1/users
func (c *UserControllerV2) Version() string { return "2" } // → /v2/users
Request and response:
curl http://localhost:8080/v1/users
# → 200, handled by UserControllerV1
curl http://localhost:8080/v2/users
# → 200, handled by UserControllerV2
curl http://localhost:8080/v3/users
# → 404 (no controller registered for v3, route does not exist)
No guard is involved — the router’s normal prefix matching does all the work.
Strategy 2: Header versioning (Accept-Version: 1)
The URL stays clean; the client picks a version via a request header. The default header is Accept-Version, configurable via VersioningConfig.Header.
config.Versioning = &core.VersioningConfig{
Strategy: core.HeaderVersioning,
// Header: "X-API-Version", // optional override; default is "Accept-Version"
}
Behind the scenes, NestGo collects the routes of all versioned controllers and mounts exactly one dispatch route per method + path. Its handler compares the header value to each registered Version() and invokes the matching version’s handler chain; a version no controller declares fails with a 404:
curl -H "Accept-Version: 1" http://localhost:8080/users
# → 200, handled by the Version() == "1" controller
curl -H "Accept-Version: 3" http://localhost:8080/users
# → 404 {"error": {"code": 404, "message": "version '3' not found"}}
curl http://localhost:8080/users
# → 404 (no version header → matches no versioned controller)
Note that with header (and media type) versioning, v1 and v2 controllers share the same URL paths — the dispatch route decides which one answers, so registering the same path in several versions is safe on both adapters.
Strategy 3: Media type versioning (Accept: application/vnd.api.v1+json)
The version is embedded in the Accept header’s media type — the “content negotiation” style used by APIs like GitHub’s.
config.Versioning = &core.VersioningConfig{Strategy: core.MediaTypeVersioning}
The dispatch route checks that the Accept header contains the token v{version} (e.g. v1 for a controller whose Version() is "1"). Matching is exact-token via core.MediaTypeAcceptsVersion(accept, version) — v1 does not match v10, v12, or v1.2:
curl -H "Accept: application/vnd.api.v1+json" http://localhost:8080/users
# → 200, handled by the v1 controller
curl -H "Accept: application/json" http://localhost:8080/users
# → 404 {"error": {"code": 404, "message": "unsupported media type version"}}
VersionGuard: manual versioning for hand-registered routes
core.VersionGuard(config, version) returns a standard NestGo Guard applying the same matching rules as the dispatch route. The DI auto-registration no longer needs it — it mounts the single dispatch route described above — but the guard remains useful when you register routes yourself, outside the DI auto-registration:
guard := core.VersionGuard(*config.Versioning, "2")
v2 := r.Group("", core.UseGuards(guard))
v2.GET("/users", listUsersV2)
Its behavior per strategy:
| Strategy | Check | On mismatch |
|---|---|---|
HeaderVersioning | Accept-Version header (or VersioningConfig.Header) equals the version string | 404 — version '<v>' not found |
MediaTypeVersioning | Accept header contains the exact token v<version> (core.MediaTypeAcceptsVersion) | 404 — unsupported media type version |
URIVersioning | No check — the guard passes; the URI prefix does the routing | — |
Fail-fast startup checks
With header and media type versioning, route misconfigurations surface at startup as clear errors (failing app.Run()) — never as router panics or silent 404s:
- The same method + path registered twice within one version
ANYon a path in one version mixed with a specific method on the same path in anotherStatic()/StaticFile()on a versioned controller (file routes cannot be dispatched by version)- A versioned dispatch route colliding with a non-versioned route on the same path
A complete versioned app
func main() {
config := core.DefaultConfig()
config.Addr = ":8080"
config.GlobalPrefix = "/api"
config.Versioning = &core.VersioningConfig{Strategy: core.URIVersioning}
app := di.NewApp(config, ginadapter.New,
fx.Module("users",
fx.Provide(di.AsController(NewUserControllerV1)), // /api/v1/users
fx.Provide(di.AsController(NewUserControllerV2)), // /api/v2/users
),
)
app.Run()
}
Which API versioning strategy should you choose?
| URI | Header | Media type | |
|---|---|---|---|
| Visibility | Version is in every URL — obvious, easy to debug | Hidden from the URL | Hidden from the URL |
| Browser / curl friendliness | Excellent — just change the path | Needs an explicit header | Needs an explicit header |
| Caching & CDNs | Works out of the box (distinct URLs) | Requires Vary handling | Requires Vary handling |
| REST purity | URLs change between versions | Same resource, same URL | Same resource, same URL; standard content negotiation |
| Unversioned requests | Hit nothing under /vN (404) | 404 unless an unversioned controller matches | 404 unless an unversioned controller matches |
| Typical fit | Public APIs, most teams | Internal APIs with disciplined clients | Hypermedia / content-negotiation-heavy APIs |
Practical default: start with URIVersioning. It is the easiest to test, cache, and document. Reach for header or media type versioning when your API design requires stable URLs across versions.
Next steps
- Validation & Error Handling — the
404responses above come from NestGo’s HTTP error system - Server-Sent Events — add real-time streaming endpoints
- Browse the full source on GitHub