When your API breaks the world
You ship a small change to your backend. You renamed user_id to id in your response JSON because it looked cleaner. You run the tests. They pass. You deploy.
Ten minutes later, the support channel explodes. The mobile app crashes on login. A third-party integration fails to sync data. You didn't break the server. You broke the contract. The clients expected a specific shape, and you changed it without warning.
This happens to everyone. APIs are not just code. They are promises to the things that call them. When you change the promise, you break the trust. Versioning is the mechanism that lets you keep your promises while still evolving the software. It creates a parallel universe where the old contract stays alive while you build the new one.
Versioning as a contract boundary
API versioning isolates changes. You can add fields, remove fields, change types, or alter business logic in version 2 without touching version 1. Clients that depend on the old behavior keep working. Clients that want the new behavior upgrade at their own pace.
The most common approach is URL versioning. You prefix your routes with a version identifier. /api/v1/users serves the old contract. /api/v2/users serves the new one. The URL makes the version explicit. Debugging is easier because you can see the version in the address bar. Caching works naturally because the URLs are distinct.
Header versioning exists too, where you send Accept: application/vnd.myapp.v2+json. It keeps URLs clean but hides the version from casual inspection. URL versioning is the standard for public REST APIs. It is simple, transparent, and works with every HTTP tool in existence.
The routing skeleton
Rust's module system makes versioning straightforward. You create a separate module for each version. Each module defines its own handlers. The router wires them up under different scopes.
Here is the minimal structure using actix-web. The framework provides web::scope to group routes under a common prefix.
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
// V1 handlers live in their own module.
// This isolates the old contract from the new one.
mod v1 {
use actix_web::{web, HttpResponse, Responder};
/// Returns the user in the v1 format.
/// This function knows nothing about v2.
pub async fn get_user(id: web::Path<i32>) -> impl Responder {
let user_id = id.into_inner();
// V1 returns a simple string for demonstration.
// In real code, this would serialize a struct.
HttpResponse::Ok().body(format!("v1 user: {}", user_id))
}
}
// V2 handlers live in a separate module.
// You can change the logic here without touching v1.
mod v2 {
use actix_web::{web, HttpResponse, Responder};
/// Returns the user in the v2 format.
/// This can include new fields or different logic.
pub async fn get_user(id: web::Path<i32>) -> impl Responder {
let user_id = id.into_inner();
HttpResponse::Ok().body(format!("v2 user: {}", user_id))
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
// Scope v1 routes under /api/v1
.service(
web::scope("/api/v1")
.route("/users/{id}", web::get().to(v1::get_user))
)
// Scope v2 routes under /api/v2
.service(
web::scope("/api/v2")
.route("/users/{id}", web::get().to(v2::get_user))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
How the router matches
When a request arrives, the router checks the path against the registered scopes. If the path starts with /api/v1, the request enters the v1 scope. The router then looks for a route that matches the remainder of the path. If it finds /users/{id}, it calls v1::get_user.
If the path starts with /api/v2, the request enters the v2 scope. The router matches /users/{id} and calls v2::get_user.
The scopes are independent. A route defined in v1 does not exist in v2. You can have /api/v1/users and /api/v2/users with completely different handler signatures. The router handles the dispatch. Your code stays clean.
This separation prevents accidental cross-contamination. You cannot accidentally call a v2 handler from a v1 route because the modules are distinct. The compiler enforces the boundary.
Keep your handlers thin. The router should be the only place that knows about versions.
The real problem: shared logic
The skeleton above works, but it has a trap. If you copy and paste business logic into every version, you create a maintenance nightmare. Imagine you have a complex function that calculates user permissions. If you duplicate it in v1 and v2, you must update it in both places whenever the logic changes. You will miss one. You will introduce a bug.
The solution is to separate the API contract from the business logic. The handlers should only handle HTTP details: parsing inputs, calling the business logic, and formatting outputs. The business logic lives in a shared module that both versions use.
This pattern is often called the "Adapter" pattern. The v1 handler adapts the shared logic to the v1 contract. The v2 handler adapts the same shared logic to the v2 contract.
A realistic structure with traits
In Rust, traits are the perfect tool for sharing logic across versions. You define a trait that describes the business operations. You implement the trait once. Your handlers call the trait methods.
Here is how a realistic application looks. The shared module contains the business logic. The v1 and v2 modules contain the handlers.
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
// Shared business logic lives here.
// Both versions use this code.
mod shared {
use serde::{Deserialize, Serialize};
/// The internal representation of a user.
/// This struct is not exposed directly to the API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i32,
pub name: String,
pub email: String,
// Internal fields that might not be exposed in all versions
pub is_active: bool,
}
/// Trait defining the business operations.
/// Implement this once to serve all API versions.
pub trait UserService {
fn get_user(&self, id: i32) -> Option<User>;
}
/// A concrete implementation of the service.
/// In a real app, this would query a database.
pub struct AppService {
// Database connection pool would go here
}
impl UserService for AppService {
fn get_user(&self, id: i32) -> Option<User> {
// Simulate fetching from DB
if id > 0 {
Some(User {
id,
name: format!("User {}", id),
email: format!("user{}@example.com", id),
is_active: true,
})
} else {
None
}
}
}
}
// V1 handlers
mod v1 {
use actix_web::{web, HttpResponse, Responder};
use serde::Serialize;
use crate::shared::UserService;
/// V1 response format.
/// Note the field name 'user_id' instead of 'id'.
#[derive(Serialize)]
pub struct UserResponseV1 {
pub user_id: i32,
pub name: String,
// V1 does not expose email.
}
/// Handler for v1.
/// It calls the shared service and transforms the result.
pub async fn get_user(
id: web::Path<i32>,
// Inject the shared service via dependency injection
service: web::Data<crate::shared::AppService>,
) -> impl Responder {
let user_id = id.into_inner();
// Call the shared business logic
if let Some(user) = service.get_user(user_id) {
// Transform to v1 format
let response = UserResponseV1 {
user_id: user.id,
name: user.name,
};
HttpResponse::Ok().json(response)
} else {
HttpResponse::NotFound().finish()
}
}
}
// V2 handlers
mod v2 {
use actix_web::{web, HttpResponse, Responder};
use serde::Serialize;
use crate::shared::UserService;
/// V2 response format.
/// Uses 'id' and includes email.
#[derive(Serialize)]
pub struct UserResponseV2 {
pub id: i32,
pub name: String,
pub email: String,
}
/// Handler for v2.
/// It calls the same shared service but formats differently.
pub async fn get_user(
id: web::Path<i32>,
service: web::Data<crate::shared::AppService>,
) -> impl Responder {
let user_id = id.into_inner();
// Call the shared business logic
if let Some(user) = service.get_user(user_id) {
// Transform to v2 format
let response = UserResponseV2 {
id: user.id,
name: user.name,
email: user.email,
};
HttpResponse::Ok().json(response)
} else {
HttpResponse::NotFound().finish()
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Create the shared service instance
let service = crate::shared::AppService {};
HttpServer::new(move || {
App::new()
// Share the service across all handlers
.app_data(web::Data::new(service.clone()))
.service(
web::scope("/api/v1")
.route("/users/{id}", web::get().to(v1::get_user))
)
.service(
web::scope("/api/v2")
.route("/users/{id}", web::get().to(v2::get_user))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Walk through the shared logic
The shared module defines UserService. This trait has one method: get_user. The AppService struct implements this trait. The implementation contains the actual logic to fetch data.
The v1 handler takes web::Data<AppService>. Actix-web injects the shared service automatically. The handler calls service.get_user(id). It receives the internal User struct. It then creates a UserResponseV1 struct and serializes it.
The v2 handler does the same thing. It calls service.get_user(id). It receives the same internal User struct. It creates a UserResponseV2 struct and serializes it.
The business logic is written once. The API contracts are isolated. If you need to change how users are fetched, you update AppService. Both versions get the update immediately. If you need to change the v1 response format, you only touch v1::UserResponseV1. The v2 code is untouched.
This structure scales. You can add v3, v4, or v10. Each version gets its own module with its own response structs. They all call the same shared service. The complexity stays manageable.
Convention aside: The community often puts shared logic in a services or domain module, and handlers in an api or handlers module. This keeps the architecture clear. The API layer is thin. The domain layer is thick.
Pitfalls and compiler traps
Versioning introduces specific errors. The compiler will catch many of them, but you need to know what to look for.
If you try to return different types from handlers without using impl Responder or boxing, you get E0308 (mismatched types). Rust requires all branches of a function to return the same type. Using impl Responder solves this by allowing the compiler to infer the concrete type for each handler.
If you forget to implement a trait for a type, you get E0277 (trait bound not satisfied). This happens often when you try to inject a service that doesn't implement the expected trait. Check your impl blocks.
If you try to move a value out of a borrowed reference, you get E0507. This happens when you try to extract data from web::Data incorrectly. Use .clone() or reference the data properly. web::Data is thread-safe and designed to be shared.
Another trap is the "Zombie API". You add v2. You forget to remove v1. Six months later, v1 is still running, consuming resources, and confusing developers. You must have a deprecation strategy. Add a Deprecation header to v1 responses. Log warnings when v1 is called. Set a date to remove it.
Versioning isn't a feature. It's an apology to your future self. Plan the exit as you build the entry.
Decision: versioning strategies
Use URL versioning when you build public REST APIs. The version is visible in the URL. Debugging is easy. Caching works naturally. Clients can switch versions by changing the base URL.
Use Header versioning when you build internal microservices or GraphQL APIs. The URL stays clean. Clients negotiate the version via headers. This works well when you have many consumers that can dynamically switch versions.
Use module separation when you have distinct API contracts. Create a module for each version. Isolate handlers and response structs. This prevents cross-contamination and keeps the codebase organized.
Use shared traits when you have business logic that applies to multiple versions. Define a trait for the operations. Implement it once. Have handlers call the trait. This avoids duplicating logic and keeps the business rules in one place.
Use serde aliases when you need soft compatibility. If you rename a field but want to accept the old name, use #[serde(alias = "old_name")]. This handles minor changes without a full version bump.