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:

  1. Config.Versioning — a *core.VersioningConfig on your app config that picks the strategy.
  2. 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
  • ANY on a path in one version mixed with a specific method on the same path in another
  • Static() / 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