Versioning
Declare a version once with #[controller(version = "1")]; pick how callers select one — URI, header or media type — as deployment config.
A long-lived API outlives its first wire shape. URI versioning lets a
controller mount under a /v<N> prefix without touching its path = "…"
— one attribute, one prefix, every consumer of the route table sees the
same thing.
Mount one controller under a version
Section titled “Mount one controller under a version”use nest_rs::http::{controller, routes};
#[controller(path = "/posts", version = "1")]pub struct PostsV1Controller { #[inject] svc: Arc<PostsService>,}
#[routes]impl PostsV1Controller { #[get("/")] #[authorize(Read, posts::Entity)] async fn list(&self) -> Result<Json<Vec<Post>>> { Ok(Json(self.svc.feed().await?)) }}Routes mount under /v1/posts. The boot log, the OpenAPI document and the
served path all route through the same version_path helper, so under the
default strategy they cannot drift:
$ nestrs run dev apiINFO nest_rs::routes: mounted route controller="PostsV1Controller" method="GET" path="/v1/posts" handler="list"The version string is opaque — "1", "2", "beta" all work; the
prefix is built as format!("/v{version}"). Stick to integers unless
you have a reason not to.
Serve one controller under several versions
Section titled “Serve one controller under several versions”Most of a v2 is v1. version = ["1", "2"] mounts the whole controller at both
prefixes, and the routes that actually differ say so themselves:
#[controller(path = "/posts", version = ["1", "2"])]pub struct PostsController { #[inject] svc: Arc<PostsService>,}
#[routes]impl PostsController { #[get("/")] #[authorize(Read, posts::Entity)] async fn list(&self) -> Result<Json<Vec<Post>>> { Ok(Json(self.svc.feed().await?)) }
#[post("/:id/publish")] #[version("2")] #[authorize(Publish, posts::Entity)] async fn publish(&self, Path(id): Path<Uuid>) -> Result<Json<Post>> { Ok(Json(self.svc.publish(id).await?)) }}list answers at /v1/posts and /v2/posts; publish only at
/v2/posts/:id/publish. POST /v1/posts/:id/publish is a 405 — the path
exists in v1, that verb does not — which is the honest answer, and one a
404 would have hidden.
A #[version] naming something #[controller(version = […])] never declared
does not compile. It would otherwise mount nowhere: a handler that builds,
registers, appears in the document and answers nothing.
Run two controllers side by side
Section titled “Run two controllers side by side”When the two versions share nothing but a URL, a controller each is clearer:
use nest_rs::core::module;
use super::controller::{PostsV1Controller, PostsV2Controller};use crate::authz::AuthzModule;use crate::posts::PostsModule;
#[module( imports = [PostsModule, AuthzModule], providers = [PostsV1Controller, PostsV2Controller],)]pub struct PostsHttpModule;Both controllers live in the feature’s http/controller.rs — a version is a
wire concern, not a second feature, and the app crate holds no controllers at
all.
#[controller(path = "/posts", version = "1")]pub struct PostsV1Controller { /* … */ }
#[controller(path = "/posts", version = "2")]pub struct PostsV2Controller { /* … */ }Two controllers, two mount paths (/v1/posts, /v2/posts), two
OpenAPI tags by default (the controller struct name groups routes), zero
runtime overhead — the version is decided at boot, not per request.
A common pattern: V2 delegates to the same PostsService when the change is
purely on the wire (rename a field, add an optional field), and only
the controller’s Json<T> shape differs. The service stays one.
Let the caller state the version instead
Section titled “Let the caller state the version instead”The URI is the default, not the only strategy. NESTRS_HTTP__VERSIONING
switches how a caller selects a version — and nothing about the controllers
changes, because #[controller(version = "2")] remains the one place a version
is declared:
NESTRS_HTTP__VERSIONING | The caller writes |
|---|---|
uri (default) | GET /v2/posts |
header | GET /posts + X-API-Version: 2 |
media_type | GET /posts + Accept: application/json; version=2 |
NESTRS_HTTP__VERSIONING=header \NESTRS_HTTP__VERSION_HEADER=X-API-Version \NESTRS_HTTP__DEFAULT_VERSION=1 \nestrs run dev apiUnder the last two the version is resolved per request, in front of routing, and folded into the path the controller already mounts at — so one route table serves every strategy. The boot log follows the caller, not the mount: it prints the address a client uses and carries the version as its own field.
$ NESTRS_HTTP__VERSIONING=header nestrs run dev apiINFO nest_rs::routes: mounted route controller="PostsV1Controller" method="GET" path="/posts" version=1 handler="list"Five behaviours worth knowing before you switch:
- An address no versioned route answers at is served whatever version is
stated. Header versioning is meant to be configured once in the client, so an
unversioned controller must stay reachable from a client that always sends the
header. There is exactly one shape at
/status, so no other version could have been meant — this is neutrality, not a fallback, and the difference is the next bullet. It is decided per route, not per prefix: an unversioned#[controller(path = "/posts/drafts")]beside a versioned/postskeeps answering, and a versioned controller atpath = "/"still selects. - A stated version beats an unversioned route at the same address; a default
yields to it. An explicit request is the strongest signal you can send, so it
wins — otherwise a versioned controller would be unreachable wherever an
unversioned one shares its address.
NESTRS_HTTP__DEFAULT_VERSIONis the weakest: the caller asked for nothing, so nothing moves under them. Self-mounted endpoints (/graphql,/mcp,/api-json, a WebSocket gateway) are neutral against both — each owns its path outright. - An unknown version on a path that has versions is a
404, never a fallback. Quietly servingv1to a client that asked forv9is how a client talks to the wrong API for a month.NESTRS_HTTP__DEFAULT_VERSIONanswers a caller who states none — a default among versions, never a rewrite of everything the app mounts, so every self-mounted endpoint (/graphql,/mcp,/api-json,/health) is served as written too. - The URI form stops being a second address. With
headerormedia_typein force,GET /v2/postsis a404: there is exactly one way to ask. - A malformed version is a
400. The token is spliced into a path, so it is validated first — alphanumerics,.and-, nothing longer than 32 bytes. - The OpenAPI document follows the caller as well. It names
/posts, not/v1/posts, and each versioned operation gains the version as anin: headerparameter — yourNESTRS_HTTP__VERSION_HEADER, orAcceptwith itsversion=parameter — enumerating the versions that serve that path. The parameter isrequiredunlessNESTRS_HTTP__DEFAULT_VERSIONanswers for a caller who states none.
Because OpenAPI 3.1 keys operations by path, two versions of /posts cannot both
be described in one document — so the document is published per version:
$ curl -s localhost:3000/api-json/v2 | jq '.paths."/posts".get.parameters[0]'{ "name": "x-api-version", "in": "header", "required": true, "description": "Selects the API version this operation is served under.", "schema": { "type": "string", "enum": ["2"] }}GET /api-json serves the default version (every version, when the deployment
names none), and Swagger UI at /api reads that one. A
NESTRS_HTTP__DEFAULT_VERSION naming a version no controller declares fails
the boot, listing the versions that do exist — an empty document is not a
thing to publish quietly.
Pick by what your consumers are: URL versioning is cacheable and greppable in a
log; header and media-type versioning keep one URL per resource for its whole
life. NESTRS_HTTP__VERSIONING means that is a deployment decision, not a
rewrite.
Which transports have a version
Section titled “Which transports have a version”Versioning is addressing, so a transport can carry it only if it has an address a client picks. Two do:
| Decorator | version = "…" | The address |
|---|---|---|
#[controller] | full — three strategies, several versions, per route | URL, header, Accept |
#[gateway] | yes — /v1/ws | the socket URL |
The rest do not, and writing version on one is a compile error naming that
transport’s own answer rather than silence:
#[resolver]— a GraphQL schema is not versioned. One schema, one introspection, one generated client: evolve the field and deprecate the old one. This is the ecosystem’s position, not ours.#[mcp]— the endpoint is addressed by its whole path, so write#[mcp(path = "/mcp/v1")]. On this edge the word already meansserverInfo.version, and that belongs to the app rather than to any one host.#[processor]— a queue is addressed by its name, and versioning that name splits the consumer group: a deployment decision, not a declaration. What a job usually needs is payload evolution.#[scheduled],#[listeners]— the clock and an in-process event have no caller and no wire.
The rule is one sentence: you never have to wonder whether version works
here. It compiles and means what it means everywhere else, or the compiler
tells you what this transport does instead.
Limits
Section titled “Limits”- The version string is opaque to
#[controller]but not to the wire: a token a caller sends is validated (alphanumerics,.,-, ≤ 32 bytes) before it reaches a path. A version you declare is checked against the same set at compile time, because it is spliced into a path just the same. - With no default version,
/api-jsonaggregates every version and resolves a contested path to the highest one. Point a client at/api-json/v{n}when it needs a specific version’s shapes; the aggregate is a directory, not a contract.
Going further
Section titled “Going further”- Controllers & routes — back to the route table.
- OpenAPI — the generated spec includes the version prefix on every operation.
- Configuration —
HttpTransport::global_prefixfor the orthogonal “everything under/api” case (versioning stacks on top:/api/v1/posts).