API Versioning Guide
Status: Guideline Date: 2026-01-06 Context: Reference domain implementation, header-based API versioning
Overview
This guide describes the header-based API versioning strategy used across all new schema domains. This approach provides clean, domain-focused URLs with version information in request headers.
Philosophy
Clean, domain-focused URLs with optional header-based versioning - Modern REST approach.
Key Principles
- URLs reflect domain model -
/reference/countriesnot/api/v2/reference/countries - Version in headers -
Accept: application/vnd.kulcare.v2+json - Simple namespace -
Reference::CountriesControllernotApi::V2::Reference::CountriesController - Easy evolution - Add new versions without changing URLs
- Industry standard - Used by GitHub, Stripe, AWS
Directory Structure
domains/
└── reference/
├── controllers/
│ ├── base_controller.rb # Reference::BaseController
│ ├── countries_controller.rb # Reference::CountriesController
│ ├── states_controller.rb # Reference::StatesController
│ ├── cities_controller.rb # Reference::CitiesController
│ ├── languages_controller.rb # Reference::LanguagesController
│ ├── currencies_controller.rb # Reference::CurrenciesController
│ ├── specialties_controller.rb # Reference::SpecialtiesController
│ ├── facility_types_controller.rb # Reference::FacilityTypesController
│ ├── identifier_systems_controller.rb # Reference::IdentifierSystemsController
│ ├── reference_data_controller.rb # Reference::ReferenceDataController
│ └── v2/ # Optional: version-specific overrides
│ └── ...
├── models/
│ ├── country.rb
│ ├── state.rb
│ └── ...
├── blueprints/
│ ├── country_blueprint.rb # Default blueprint
│ ├── v2/
│ │ └── country_blueprint.rb # V2-specific blueprint
│ └── v3/ # Future
│ └── country_blueprint.rb # V3-specific blueprint
└── routes.rbZeitwerk Configuration
File: config/application.rb (lines 105-110)
# Load controllers directory with Reference namespace
controllers_path = File.join(domain_dir, 'controllers')
if File.directory?(controllers_path)
loader.push_dir(controllers_path, namespace: namespace)
# namespace = Reference, so:
# controllers/countries_controller.rb → Reference::CountriesController
endHow It Works:
File: domains/reference/controllers/countries_controller.rb
↓
Directory structure defines namespace
↓
Reference::CountriesControllerRoutes
File: domains/reference/routes.rb
Rails.application.routes.draw do
# Clean, domain-focused routes
scope :reference do
# Bulk data endpoints
get 'all', to: 'reference_data#all'
get 'geography', to: 'reference_data#geography'
# Geographic data
resources :countries, only: [:index, :show] do
member do
get :states
end
end
resources :states, only: [:index, :show] do
member do
get :cities
end
end
resources :cities, only: [:index, :show]
# Localization data
resources :languages, only: [:index, :show]
resources :currencies, only: [:index, :show]
# Healthcare data
resources :specialties, only: [:index, :show] do
collection do
get :tree
end
member do
get :children
end
end
resources :facility_types, only: [:index, :show]
# Identifier systems
resources :identifier_systems, only: [:index, :show] do
collection do
get :available
end
end
end
endVersion Detection Middleware
File: app/middleware/api_version_detection.rb
# frozen_string_literal: true
# Middleware to detect API version from request headers
class ApiVersionDetection
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
version = detect_version(request)
env['api.version'] = version if version
@app.call(env)
end
private
def detect_version(request)
# Strategy 1: Accept header (preferred)
accept_header = request.headers['Accept']
return "v#{$1}" if accept_header&.match(/application\/vnd\.kulcare\.v(\d+)\+json/)
# Strategy 2: Custom header
custom_header = request.headers['X-API-Version']
return custom_header if custom_header&.match(/^v\d+$/)
# Strategy 3: Query parameter (testing)
query_param = request.params['api_version']
return query_param if query_param&.match(/^v\d+$/)
nil # No version specified, use default
end
endRegister Middleware: config/application.rb
config.middleware.use ApiVersionDetectionController Concern
File: domains/shared/concerns/api_versioning.rb
# frozen_string_literal: true
module Shared
module Concerns
module ApiVersioning
extend ActiveSupport::Concern
included do
before_action :set_api_version
end
private
def set_api_version
@api_version = request.env['api.version'] || 'v2' # Default to v2
end
def current_api_version
@api_version
end
def api_version?(version)
@api_version == version.to_s
end
end
end
endInclude in Base Controller: domains/shared/base_controller.rb
class BaseController < ApiController
include Shared::Concerns::ApiVersioning
# ...
endExample Controller
File: domains/reference/controllers/countries_controller.rb
# frozen_string_literal: true
module Reference
class CountriesController < BaseController
# GET /reference/countries
def index
countries = ::Reference::Country.alphabetical
countries = apply_active_filter(countries)
countries = apply_search_filter(countries)
# Version-specific blueprint
blueprint = blueprint_for_version(:country)
render_blueprint_collection(countries, blueprint)
end
# GET /reference/countries/:id
def show
country = ::Reference::Country.find(params[:id])
render_blueprint(country, ::Reference::CountryBlueprint, view: :detailed)
end
# GET /reference/countries/:id/states
def states
country = ::Reference::Country.find(params[:id])
states = country.states.active.alphabetical
render_blueprint_collection(states, ::Reference::StateBlueprint)
end
private
# Select blueprint based on API version
def blueprint_for_version(resource_name)
version = current_api_version # 'v2', 'v3', etc.
# Try to load version-specific blueprint
blueprint_class = "::Reference::#{version.camelize}::#{resource_name.camelize}Blueprint".safe_constantize
# Fall back to default blueprint
blueprint_class || "::Reference::#{resource_name.camelize}Blueprint".constantize
end
end
endBase Controller
File: domains/reference/controllers/base_controller.rb
# frozen_string_literal: true
module Reference
class BaseController < Shared::BaseController
# Skip authentication for reference endpoints - data is public
skip_before_action :authenticate_user, raise: false
# Skip tenant context for reference endpoints - data is global
skip_before_action :set_tenant_from_jwt, raise: false
skip_before_action :set_rls_context, raise: false
skip_before_action :clear_rls_context, raise: false
protected
# ... filter methods (apply_active_filter, apply_search_filter, etc.)
end
endAPI Endpoints
All versions use the same URLs, version specified in headers:
# V2 with Accept header (preferred)
GET /reference/countries
Accept: application/vnd.kulcare.v2+json
# V2 with custom header
GET /reference/countries
X-API-Version: v2
# V2 with query parameter (testing only)
GET /reference/countries?api_version=v2
# No version specified (uses default: v2)
GET /reference/countriesAvailable Endpoints:
GET /reference/countries
GET /reference/countries/:id
GET /reference/countries/:id/states
GET /reference/states
GET /reference/states/:id
GET /reference/states/:id/cities
GET /reference/cities
GET /reference/languages
GET /reference/currencies
GET /reference/specialties
GET /reference/specialties/tree
GET /reference/facility_types
GET /reference/identifier_systems
GET /reference/all
GET /reference/geographyVersion-Specific Blueprints
When V3 requires different response format:
domains/reference/blueprints/
├── country_blueprint.rb # Default (V2)
├── v2/
│ └── country_blueprint.rb # Explicit V2 (same as default)
└── v3/
└── country_blueprint.rb # V3 with different fieldsV2 Blueprint:
module Reference
class CountryBlueprint < Blueprinter::Base
identifier :id
fields :name, :iso_code, :phone_code
end
endV3 Blueprint:
module Reference
module V3
class CountryBlueprint < Blueprinter::Base
identifier :id
fields :name, :iso_code, :phone_code, :continent, :currency_code
end
end
endVersioning Strategy
Detection Priority
- Accept header (preferred) -
application/vnd.kulcare.v2+json - Custom header -
X-API-Version: v2 - Query parameter (testing) -
?api_version=v2 - Default -
v2if no version specified
Version Format
- Pattern:
v{number}where number is 2, 3, 4, etc. - Examples:
v2,v3,v4 - Invalid:
v2.1,2.0,latest
Default Version
- Current default:
v2 - Changed by updating
ApiVersionDetectionmiddleware - Announce default version changes in API changelog
Creating New Versions
Step 1: Create Blueprint
# Create version-specific blueprint directory
mkdir -p domains/reference/blueprints/v3
# Create V3 blueprint
touch domains/reference/blueprints/v3/country_blueprint.rbStep 2: Implement Changes
# domains/reference/blueprints/v3/country_blueprint.rb
module Reference
module V3
class CountryBlueprint < Blueprinter::Base
identifier :id
# Add/remove fields as needed
fields :name, :iso_code, :phone_code, :continent
end
end
endStep 3: Update Documentation
- Document breaking changes
- Provide migration guide
- Set deprecation timeline for old version
Step 4: Update Default (Optional)
When ready to make V3 default:
# app/middleware/api_version_detection.rb
def set_api_version
@api_version = request.env['api.version'] || 'v3' # Change default
endBenefits
✅ Clean URLs: /reference/countries instead of /api/v2/reference/countries
✅ Domain-Focused: URL structure reflects domain model
✅ Simple Namespace: Reference::CountriesController instead of Api::V2::Reference::CountriesController
✅ Easy to Evolve: Add versions without URL changes
✅ Industry Standard: Used by GitHub, Stripe, AWS
✅ Flexible: Can mix versioning strategies
✅ Stable URLs: URLs don't change between versions
✅ Backwards Compatible: Old clients continue working
Drawbacks
❌ Version Hidden: Version not obvious from URL ❌ Header Required: Clients must send version header ❌ Harder to Test: Can't just use browser (need header extensions) ❌ Documentation Required: Must document versioning strategy ❌ Less Familiar: Some developers find it confusing initially
Best Practices
1. Version Incrementing
- Major changes: Increment version number (v2 → v3)
- Breaking changes: Always increment version
- Non-breaking additions: Keep same version
- Bug fixes: Keep same version
2. Deprecation
- Announce deprecation at least 6 months in advance
- Add deprecation warnings to old versions
- Document migration path clearly
- Provide sunset date for old versions
3. Documentation
- Document all version differences
- Provide request/response examples for each version
- Include migration guides
- Maintain changelog
4. Testing
- Test all supported versions
- Include version headers in integration tests
- Test backwards compatibility
- Test default version behavior
References
Examples
Request with Version Header
curl -X GET http://localhost:5000/reference/countries \
-H "Accept: application/vnd.kulcare.v2+json"Response (V2)
{
"data": [
{
"id": "019ba2c4-606b-7c80-8ecb-5564fcb61816",
"name": "India",
"iso_code": "IN",
"phone_code": "+91"
}
],
"meta": {
"pagination": {
"total_entries": 1,
"current_page": 1,
"per_page": 50
}
}
}Request with V3 Header (Future)
curl -X GET http://localhost:5000/reference/countries \
-H "Accept: application/vnd.kulcare.v3+json"Response (V3 - with additional fields)
{
"data": [
{
"id": "019ba2c4-606b-7c80-8ecb-5564fcb61816",
"name": "India",
"iso_code": "IN",
"phone_code": "+91",
"continent": "Asia",
"currency_code": "INR"
}
],
"meta": {
"pagination": {
"total_entries": 1,
"current_page": 1,
"per_page": 50
}
}
}Summary: This header-based versioning approach provides clean URLs, simple namespaces, and easy evolution while following industry best practices from GitHub, Stripe, and AWS.