Maintaining API stability while evolving functionality challenges many development teams. I’ve found versioning essential for balancing innovation with reliability in production Rails applications. Here are seven practical techniques I implement regularly:
Header-based routing establishes clear version boundaries. This approach keeps URLs clean while allowing explicit version selection. I define routing constraints that inspect the Accept header:
# config/routes.rb
class ApiVersionConstraint
def initialize(version:, default: false)
@version = version
@default = default
end
def matches?(request)
@default || request.headers["Accept"].include?("application/vnd.myapp.v#{@version}+json")
end
end
Rails.application.routes.draw do
scope module: :v1, constraints: ApiVersionConstraint.new(version: 1, default: true) do
get "profile", to: "users#profile"
end
scope module: :v2, constraints: ApiVersionConstraint.new(version: 2) do
get "profile", to: "users#profile_details"
end
end
Controllers benefit from versioned inheritance hierarchies. I create base controllers for each API version that handle common concerns:
# app/controllers/v1/base_controller.rb
class V1::BaseController < ActionController::API
before_action :validate_content_type
rescue_from JWT::DecodeError, with: :invalid_token
private
def validate_content_type
return if request.content_type == "application/json"
render json: { error: "Unsupported media type" }, status: 415
end
def invalid_token
render json: { error: "Invalid authorization token" }, status: 401
end
end
# app/controllers/v2/users_controller.rb
class V2::UsersController < V2::BaseController
def update
user = User.find(params[:id])
# Version 2 specific update logic
render json: V2::UserSerializer.new(user).as_json
end
end
Serializers evolve through incremental inheritance. When introducing breaking changes, I extend existing serializers rather than rewriting them:
# app/serializers/v1/user_serializer.rb
class V1::UserSerializer
attributes :id, :name, :email
def name
"#{object.first_name} #{object.last_name}"
end
end
# app/serializers/v2/user_serializer.rb
class V2::UserSerializer < V1::UserSerializer
attribute :contact_preference
attribute :name, &:full_name # Changed implementation
def full_name
"#{object.last_name}, #{object.first_name}"
end
end
Deprecation warnings communicate upcoming changes effectively. I add headers indicating sunset timelines:
# app/controllers/v1/users_controller.rb
class V1::UsersController < V1::BaseController
def show
user = User.find(params[:id])
response.headers["Deprecation"] = "Sat, 01 Jan 2025 00:00:00 GMT"
response.headers["Link"] = '<https://api.example.com/v2/users>; rel="successor-version"'
render json: V1::UserSerializer.new(user).as_json
end
end
Content negotiation handles multiple response formats within versions. I extend Rails responders to support format variations:
# app/controllers/concerns/responder_extensions.rb
module ResponderExtensions
def respond_with(resource, options = {})
case request.headers["Accept"]
when "application/vnd.myapp.v2+protobuf"
render protobuf: resource.to_proto
else
super
end
end
end
# app/controllers/v2/base_controller.rb
class V2::BaseController < ActionController::API
include ResponderExtensions
end
Shared test suites verify consistent behavior across versions. I use RSpec shared examples to avoid duplication:
# spec/support/shared_examples/api_behavior.rb
RSpec.shared_examples "API authentication" do |version|
context "with invalid credentials" do
it "returns unauthorized status" do
get "/profile", headers: {
"Accept" => "application/vnd.myapp.v#{version}+json",
"Authorization" => "Invalid"
}
expect(response).to have_http_status(:unauthorized)
end
end
end
# spec/requests/v1/users_spec.rb
describe "V1 Users API" do
include_examples "API authentication", 1
end
# spec/requests/v2/users_spec.rb
describe "V2 Users API" do
include_examples "API authentication", 2
end
Monitoring adoption informs sunset decisions. I track version usage through middleware:
# lib/api_version_analytics.rb
class ApiVersionAnalytics
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
version = request.headers["Accept"][/v(\d+)/, 1] rescue "1"
Analytics.track(event: "api_request", version: version)
@app.call(env)
end
end
# config/application.rb
module MyApp
class Application < Rails::Application
config.middleware.use ApiVersionAnalytics
end
end
These approaches create sustainable versioning workflows. By isolating changes through routing namespaces and serializer inheritance, I prevent breaking existing integrations. Deprecation headers give consumers clear migration paths. Shared tests maintain behavior consistency while reducing maintenance overhead. Monitoring actual usage data helps prioritize legacy version retirement. This structured evolution process allows introducing improvements without disrupting established clients.