Tutorial

Clean Code Python: API Versioning and Backward-Compatible Evolution

A breaking API change that 'only' affects 5% of tenants still breaks real businesses. Without versioning, you can never evolve your API. Here is how to version a multi-tenant FastAPI backend with per-tenant version pinning, deprecation workflows, and contract testing.

Tin Dang avatar
Tin Dang
Parallel rail tracks diverging at a switch junction with clear signage marking each direction

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.

StrategyExampleProsCons
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.

src/api/router.py
from fastapi import APIRouter, FastAPI
from src.api.v1 import router as v1_router
from 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")
src/api/v1/__init__.py
from fastapi import APIRouter
router = APIRouter(tags=["v1"])
# Import route modules to register endpoints
from src.api.v1 import orders # noqa: F401, E402
from src.api.v1 import catalog # noqa: F401, E402

The 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.

src/services/order_service.py
from dataclasses import dataclass
from decimal import Decimal
from 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.

src/api/v1/schemas/orders.py
from decimal import Decimal
from 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: str
src/api/v2/schemas/orders.py
from decimal import Decimal
from 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: str

Translation 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.

src/api/v1/orders.py
from uuid import UUID
from fastapi import APIRouter, Depends
from src.api.deps import get_order_service
from src.api.v1.schemas.orders import OrderResponseV1
from 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)
src/api/v2/orders.py
from uuid import UUID
from fastapi import APIRouter, Depends
from src.api.deps import get_order_service
from src.api.v2.schemas.orders import MoneyV2, OrderResponseV2
from 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 TypeExamplesNew 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.

src/api/middleware/deprecation.py
from datetime import date
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from 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 response

Step 3: Monitor usage. Track request counts per version per tenant. You cannot sunset v1 until you know who is still using it.

src/api/middleware/version_metrics.py
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.

src/core/tenant.py
from dataclasses import dataclass
from datetime import date
from 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 = None
src/api/middleware/version_routing.py
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from 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 None

Contract 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.

tests/contract/test_api_contract.py
import schemathesis
from 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)
Terminal window
# Run against both versions simultaneously
schemathesis run http://localhost:8000/openapi.json \
--hypothesis-max-examples=200 \
--checks all \
--stateful=links

The 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:

  1. Week 1: Ship v2 with the structured total field. Both v1 and v2 are live. Foyles integrates against v2 from day one.
  2. Week 2: Add deprecated=True to all v1 endpoints. Emit Deprecation and Sunset headers. Sunset date: 6 months from now.
  3. Month 2, 4, 5: Email Powell’s with migration guide and v2 endpoint examples. Offer engineering support for their integration update.
  4. 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.

tests/unit/test_order_translation.py
from decimal import Decimal
from uuid import uuid4
from src.api.v1.orders import _to_v1
from src.api.v2.orders import _to_v2
from 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.amount

Key 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.

0

Next in this series

Clean Code Python: Performance — Profiling, N+1 Detection, and Load Testing

Continue reading