Multi-tenant SaaS means different tenants integrate at different times and upgrade at different speeds. Tenant A built their integration six months ago against your current response format. Tenant B signed last week. When you change { "total": 29.99 } to { "total": { "amount": 29.99, "currency": "USD" } }, Tenant A’s checkout page breaks in production. Their customers see errors. Their revenue stops.
This is not a hypothetical. Every SaaS company that skips API versioning ships a breaking change within the first year. The result is always the same: an emergency rollback, a hasty compatibility shim, and a tech debt trail that never gets cleaned up.
The fix is explicit API versioning from day one, with a thin translation layer between versions and a shared business logic core that never knows what version the caller is using.
Versioning Strategies
There are three common approaches. Each has real trade-offs.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v1/orders | Explicit, cacheable, easy to route, obvious in logs | URL changes, some REST purists object |
| Header | Accept: application/vnd.shelfwise.v2+json | Clean URLs, content negotiation | Invisible in browser, easy to forget, harder to debug |
| Query param | /api/orders?version=2 | Simple to add | Pollutes query string, caching issues, not semantic |
ShelfWise uses URL path versioning. The version is visible in every log line, every monitoring dashboard, and every curl command a tenant pastes into a support ticket. When a tenant says “my /api/v1/orders call is failing,” there is zero ambiguity about which contract they expect.
Router-Per-Version Architecture
The key insight: versions are thin translation layers. Business logic is version-agnostic. If your v2 router contains business logic that differs from v1, you do not have versioning — you have two applications.
Each version router has one responsibility: translate between the version-specific API contract and the version-agnostic service interface.
from fastapi import APIRouter, FastAPI
from src.api.v1 import router as v1_routerfrom src.api.v2 import router as v2_router
def mount_versioned_routes(app: FastAPI) -> None: """Mount all API versions under /api/vN prefixes.""" app.include_router(v1_router.router, prefix="/api/v1") app.include_router(v2_router.router, prefix="/api/v2")from fastapi import APIRouter
router = APIRouter(tags=["v1"])
# Import route modules to register endpointsfrom src.api.v1 import orders # noqa: F401, E402from src.api.v1 import catalog # noqa: F401, E402The Service Layer Stays Version-Free
The OrderService returns domain objects. It has no knowledge of JSON shapes, response formats, or API versions. This is the pattern from Part 4 paying dividends — the service layer boundary is the stable contract.
from dataclasses import dataclassfrom decimal import Decimalfrom uuid import UUID
@dataclass(frozen=True, slots=True)class OrderTotal: """Version-agnostic order total from the service layer.""" amount: Decimal currency: str
@dataclass(frozen=True, slots=True)class OrderSummary: """Version-agnostic order summary returned by the service.""" id: UUID tenant_id: UUID items: list["OrderItem"] total: OrderTotal status: str
class OrderService: """Business logic for orders. Knows nothing about API versions."""
async def get_order(self, order_id: UUID) -> OrderSummary: order = await self._repo.get(order_id) if order is None: raise OrderNotFoundError(order_id) return self._to_summary(order)
def _to_summary(self, order: Order) -> OrderSummary: total = sum(item.price * item.quantity for item in order.items) return OrderSummary( id=order.id, tenant_id=order.tenant_id, items=order.items, total=OrderTotal(amount=total, currency=order.currency), status=order.status, )Version-Specific Response Schemas
Each version defines its own Pydantic response models. The v1 router translates the domain OrderSummary into v1’s flat format. The v2 router translates it into v2’s structured format. The translation is explicit, testable, and contained.
from decimal import Decimalfrom uuid import UUID
from pydantic import BaseModel, ConfigDict
class OrderResponseV1(BaseModel): """v1 contract: total is a flat number (USD assumed).""" model_config = ConfigDict(frozen=True)
id: UUID total: Decimal status: strfrom decimal import Decimalfrom uuid import UUID
from pydantic import BaseModel, ConfigDict
class MoneyV2(BaseModel): """v2 contract: total is a structured money object.""" model_config = ConfigDict(frozen=True)
amount: Decimal currency: str
class OrderResponseV2(BaseModel): """v2 contract: explicit currency support.""" model_config = ConfigDict(frozen=True)
id: UUID total: MoneyV2 status: strTranslation Layer: Where Versions Diverge
The router is the translation boundary. Each version endpoint takes the same service result and maps it to its own schema.
from uuid import UUID
from fastapi import APIRouter, Depends
from src.api.deps import get_order_servicefrom src.api.v1.schemas.orders import OrderResponseV1from src.services.order_service import OrderService, OrderSummary
router = APIRouter()
def _to_v1(summary: OrderSummary) -> OrderResponseV1: """Translate domain object to v1 response (flat total).""" return OrderResponseV1( id=summary.id, total=summary.total.amount, # Drop currency — v1 assumes USD status=summary.status, )
@router.get( "/orders/{order_id}", response_model=OrderResponseV1, deprecated=True, # Visible in OpenAPI docs)async def get_order_v1( order_id: UUID, service: OrderService = Depends(get_order_service),) -> OrderResponseV1: summary = await service.get_order(order_id) return _to_v1(summary)from uuid import UUID
from fastapi import APIRouter, Depends
from src.api.deps import get_order_servicefrom src.api.v2.schemas.orders import MoneyV2, OrderResponseV2from src.services.order_service import OrderService, OrderSummary
router = APIRouter()
def _to_v2(summary: OrderSummary) -> OrderResponseV2: """Translate domain object to v2 response (structured money).""" return OrderResponseV2( id=summary.id, total=MoneyV2( amount=summary.total.amount, currency=summary.total.currency, ), status=summary.status, )
@router.get("/orders/{order_id}", response_model=OrderResponseV2)async def get_order_v2( order_id: UUID, service: OrderService = Depends(get_order_service),) -> OrderResponseV2: summary = await service.get_order(order_id) return _to_v2(summary)Safe vs Breaking Changes
Not every API change requires a new version. The distinction matters because unnecessary versions multiply your maintenance burden.
| Change Type | Examples | New Version Required? |
|---|---|---|
| Safe: additive | New optional field in response, new endpoint, new enum value in response | No — existing clients ignore unknown fields |
| Safe: relaxing | Making a required request field optional, widening an accepted type | No — existing requests still valid |
| Breaking: removal | Removing a field, removing an endpoint | Yes — clients depending on it will fail |
| Breaking: rename | Renaming a field (price → unit_price) | Yes — same as removal + addition |
| Breaking: type change | String to object, number to string | Yes — deserialization breaks |
| Breaking: tightening | Adding a required request field, narrowing validation | Yes — existing requests rejected |
Deprecation Workflow
Deprecation is a process, not an event. Tenants need time to migrate, and you need data to know when it is safe to remove the old version.
Step 1: Mark deprecated in OpenAPI. Set deprecated=True on the endpoint. Automated API docs show the strikethrough. Tenants generating clients from your OpenAPI spec get compiler warnings.
Step 2: Emit deprecation headers. Every response from a deprecated endpoint includes a Deprecation header with the sunset date, per RFC 8594.
from datetime import date
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpointfrom starlette.requests import Requestfrom starlette.responses import Response
class DeprecationMiddleware(BaseHTTPMiddleware): """Add Deprecation and Sunset headers to deprecated API versions."""
SUNSET_DATES: dict[str, date] = { "/api/v1": date(2026, 10, 10), # 6-month sunset window }
async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: response = await call_next(request) for prefix, sunset in self.SUNSET_DATES.items(): if request.url.path.startswith(prefix): response.headers["Deprecation"] = "true" response.headers["Sunset"] = sunset.isoformat() response.headers["Link"] = ( f'</api/v2{request.url.path.removeprefix(prefix)}>; ' f'rel="successor-version"' ) return responseStep 3: Monitor usage. Track request counts per version per tenant. You cannot sunset v1 until you know who is still using it.
from prometheus_client import Counter
api_version_requests = Counter( "api_version_requests_total", "Requests by API version and tenant", ["version", "tenant_id", "endpoint"],)Step 4: Notify and sunset. Email tenants still on v1 at 90, 60, and 30 days before sunset. On sunset day, return 410 Gone for v1 endpoints with a response body pointing to the v2 equivalent.
Per-Tenant API Version Pinning
Some tenants cannot migrate on your timeline. Enterprise contracts sometimes guarantee API stability for 12-18 months. Per-tenant version pinning solves this without holding back your entire API.
from dataclasses import dataclassfrom datetime import datefrom uuid import UUID
@dataclass(frozen=True, slots=True)class Tenant: """Tenant context with API version preference.""" id: UUID slug: str plan: str pinned_api_version: int | None = None # None = use latest version_pin_expires: date | None = Nonefrom starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpointfrom starlette.requests import Requestfrom starlette.responses import JSONResponse, Response
from src.core.context import get_current_tenant
class VersionRoutingMiddleware(BaseHTTPMiddleware): """Warn tenants using an API version beyond their pin expiry."""
async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: tenant = get_current_tenant() if tenant.pinned_api_version and tenant.version_pin_expires: requested = _extract_version(request.url.path) if ( requested and requested > tenant.pinned_api_version ): # Tenant is proactively migrating — let them through pass # Log that tenant is still on pinned version for tracking
return await call_next(request)
def _extract_version(path: str) -> int | None: """Extract version number from /api/vN/ path prefix.""" parts = path.split("/") for part in parts: if part.startswith("v") and part[1:].isdigit(): return int(part[1:]) return NoneContract Testing with Schemathesis
Manual testing catches the bugs you think of. Contract testing catches the ones you do not. schemathesis generates test cases from your OpenAPI schema and verifies that every endpoint conforms to its declared contract.
import schemathesisfrom schemathesis import Case
schema = schemathesis.from_url( "http://localhost:8000/openapi.json", base_url="http://localhost:8000",)
@schema.parametrize()def test_api_contract(case: Case) -> None: """Every endpoint must conform to its OpenAPI schema.
Schemathesis generates random valid inputs and verifies: - Response status codes match declared codes - Response bodies match declared schemas - No 500 errors on valid input """ response = case.call() case.validate_response(response)# Run against both versions simultaneouslyschemathesis run http://localhost:8000/openapi.json \ --hypothesis-max-examples=200 \ --checks all \ --stateful=linksThe ShelfWise Versioning Story
Here is the concrete scenario. ShelfWise v1 has been live for six months. The order endpoint returns:
{ "id": "550e8400-e29b-41d4-a716-446655440000", "total": 29.99, "status": "shipped"}Tenant “Powell’s Books” has integrated this into their POS system. Tenant “Foyles” is building their integration now. The product team needs multi-currency support for a new UK distributor tenant.
The migration plan:
- Week 1: Ship v2 with the structured
totalfield. Both v1 and v2 are live. Foyles integrates against v2 from day one. - Week 2: Add
deprecated=Trueto all v1 endpoints. EmitDeprecationandSunsetheaders. Sunset date: 6 months from now. - Month 2, 4, 5: Email Powell’s with migration guide and v2 endpoint examples. Offer engineering support for their integration update.
- Month 6: Check usage metrics. If Powell’s has migrated, sunset v1 and return
410 Gone. If they have not, extend the pin by 3 months and escalate via their account manager.
The v1 translation layer stays in place for 6 months. It is 15 lines of code. The cost of maintaining it is near zero. The cost of breaking Powell’s checkout system is losing the account.
Testing the Translation Layer
Translation functions are pure and trivial to test. There are no mocks needed — just input and output.
from decimal import Decimalfrom uuid import uuid4
from src.api.v1.orders import _to_v1from src.api.v2.orders import _to_v2from src.services.order_service import OrderSummary, OrderTotal
def _make_summary() -> OrderSummary: return OrderSummary( id=uuid4(), tenant_id=uuid4(), items=[], total=OrderTotal(amount=Decimal("29.99"), currency="USD"), status="shipped", )
def test_v1_flattens_total() -> None: """v1 returns total as a flat Decimal.""" summary = _make_summary() response = _to_v1(summary) assert response.total == Decimal("29.99") assert not hasattr(response.total, "currency")
def test_v2_structures_total() -> None: """v2 returns total as a Money object with currency.""" summary = _make_summary() response = _to_v2(summary) assert response.total.amount == Decimal("29.99") assert response.total.currency == "USD"
def test_v1_and_v2_same_source_data() -> None: """Both versions translate from the same service output.""" summary = _make_summary() v1 = _to_v1(summary) v2 = _to_v2(summary) assert v1.id == v2.id assert v1.status == v2.status assert v1.total == v2.total.amountKey Takeaways
- URL path versioning is the pragmatic default. It is explicit, cacheable, visible in logs, and requires no special client configuration. Header versioning is elegant in theory and invisible in debugging.
- Versions are translation layers, not feature branches. Both v1 and v2 call the same
OrderService.get_order(). Business logic divergence between versions is a design failure. - Not every change needs a new version. Adding optional response fields, new endpoints, and new response enum values are safe. Removing fields, changing types, and adding required request fields are breaking.
- Deprecation is a 6-month process. Mark in OpenAPI, emit headers, monitor usage, notify tenants, sunset. Rushing it breaks trust. Skipping it accumulates dead code.
- Contract testing with schemathesis catches schema drift. It generates test cases from your OpenAPI spec and verifies every endpoint conforms. Run it in CI against staging before every production deploy.
- Per-tenant version pinning respects enterprise realities. Not every tenant can migrate on your timeline. A version pin with an expiry date gives them runway without holding back the platform.
Your API can now evolve without breaking existing integrations. But evolution at scale introduces a new problem: the endpoints that worked fine with 100 books per tenant now take 5 seconds with 50,000. In the next post, we profile, detect N+1 queries, and load test ShelfWise under realistic multi-tenant traffic.