API versioning without forced client upgradesβtransformation gates adapt responses to each client's registered version
π This repository accompanies the article The Challenges of Evolving Your Service β API Versioning on ITNEXT / Medium.
π¬ Enjoyed this? Subscribe to Architecture Corner β a newsletter covering software architecture, system design, and engineering practices.
π Table of Contents
Traditional API versioning often forces clients to upgrade when breaking changes occur. This project demonstrates an alternative approach:
- Client-Locked Versions: Each client "registers" with a specific API version when they start using the service
- Transformation Gates: A middleware layer transparently transforms data between versions
- Single Source of Truth: The application internally uses the latest version (V3)
- Backward Compatibility: Clients never need to upgrade unless they want new features
β
No Forced Upgrades: Clients keep working with their original API contract
β
Gradual Migration: Clients upgrade when ready, not when the provider demands it
β
Single Codebase: All business logic uses the latest version internally
β
Type Safety: TypeScript ensures transformations are correct
β
Transparent: Clients don't know transformation is happening
The example uses a simple task management API with three evolving versions:
{
id: string
title: string
completed: boolean // Simple true/false
createdAt: string
}Use Case: Basic task tracking with done/not done toggle
{
id: string
title: string
status: "todo" | "in_progress" | "done" // β Replaced 'completed'
dueDate: string | null // β New optional field
createdAt: string
}Breaking Change: completed boolean replaced with richer status enum
Transformation:
- V1βV2:
completed=falseβstatus="todo",completed=trueβstatus="done" - V2βV1:
status="todo"|"in_progress"βcompleted=false,status="done"βcompleted=true
{
id: string
title: string
status: "todo" | "in_progress" | "done"
dueDate: string | null
priority: number // β New: 1 (low) to 5 (high)
tags: string[] // β New: categorization
createdAt: string
}New Features: Priority levels and tag categorization
Transformation:
- V2βV3: Add
priority=3(medium),tags=[]as defaults - V3βV2: Strip
priorityandtagsfields
βββββββββββββββ
β Client β
β (Uses V1) β
ββββββββ¬βββββββ
β Request: { completed: false }
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Version Gate Middleware β
β Detects: X-API-Version or Client β
β Sets context: apiVersion = 'v1' β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Request Transformation β
β Transforms V1 β V3 for processing β
β { completed: false } β β
β { status: 'todo', priority: 3 } β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Route Handler (V3 Logic) β
β All business logic uses V3 β
β Single source of truth β
ββββββββββββββββ¬βββββββββββββββββββββββ
β V3 Response
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Response Transformation β
β Transforms V3 β V1 for client β
β { status: 'todo', priority: 3 } β β
β { completed: false } β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
Response: { completed: false }
βββββββββββββββ
β Client β
β (Sees V1) β
βββββββββββββββ
- Node.js 18 or higher
- npm or yarn package manager
# Clone the repository
git clone https://github.com/yourusername/evolving-api.git
cd evolving-api
# Install dependencies
npm install
# Start development server
npm run devThe server starts on http://localhost:3000
Once the server is running, test the version system with the pre-seeded demo clients:
# See the same data in different versions
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v1-demo" # V1 format (completed boolean)
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v2-demo" # V2 format (status enum)
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v3-demo" # V3 format (full metadata)You should see the same tasks transformed into different formats! π
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/clients/register |
Register a client with a specific API version |
| GET | /api/clients |
List all registered clients |
| GET | /api/clients/:clientId |
Get client information |
| PUT | /api/clients/:clientId/version |
Update client's API version (migration) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/tasks |
List all tasks |
| GET | /api/tasks/:id |
Get a single task |
| POST | /api/tasks |
Create a new task |
| PUT | /api/tasks/:id |
Update a task (partial update) |
| DELETE | /api/tasks/:id |
Delete a task |
π X-Client-Id header is MANDATORY for all API calls (except registration and listing clients).
This ensures:
- β All clients are properly registered and tracked
- β Version consistency per client
- β No anonymous/untracked API usage
- β Better monitoring and analytics
-
Explicit Header (for testing/debugging)
X-API-Version: v1 X-Client-Id: your-client-id
Temporarily override your registered version (useful for testing upgrades)
-
Client Registry (primary method)
X-Client-Id: your-client-id
Automatically uses the version you registered with
GET /- Welcome/documentationPOST /api/clients/register- Register new clientGET /api/clients- List clients (demo/admin)
# 1. Register as V1 client
curl -X POST http://localhost:3000/api/clients/register \
-H "Content-Type: application/json" \
-d '{"clientId": "mobile-app-v1", "version": "v1"}'
# 2. Create a task using V1 format (completed boolean)
# X-Client-Id is REQUIRED for all task operations
curl -X POST http://localhost:3000/api/tasks \
-H "X-Client-Id: mobile-app-v1" \
-H "Content-Type: application/json" \
-d '{"title": "Deploy to production", "completed": false}'
# Response (V1 format):
# {
# "id": "task-4",
# "title": "Deploy to production",
# "completed": false,
# "createdAt": "2026-02-16T10:30:00.000Z"
# }
# 3. List tasks - automatically transformed to V1
curl http://localhost:3000/api/tasks \
-H "X-Client-Id: mobile-app-v1"# Create task as V3 client with full metadata
curl -X POST http://localhost:3000/api/tasks \
-H "X-Client-Id: client-v3-demo" \
-H "Content-Type: application/json" \
-d '{
"title": "Write documentation",
"status": "done",
"priority": 5,
"tags": ["docs", "important"]
}'
# Returns task ID: task-4 (or similar)
# Now read the SAME task with different clients to see version transformation:
# As V1 client - sees boolean
curl http://localhost:3000/api/tasks/task-4 -H "X-Client-Id: client-v1-demo"
# { "id": "task-4", "title": "Write documentation", "completed": true, ... }
# As V2 client - sees status enum (no priority/tags)
curl http://localhost:3000/api/tasks/task-4 -H "X-Client-Id: client-v2-demo"
# { "id": "task-4", "title": "Write documentation", "status": "done", ... }
# As V3 client - sees everything
curl http://localhost:3000/api/tasks/task-4 -H "X-Client-Id: client-v3-demo"
# { "id": "task-4", "title": "Write documentation", "status": "done",
# "priority": 5, "tags": ["docs", "important"], ... }
# You can also override temporarily for testing with X-API-Version:
curl http://localhost:3000/api/tasks/task-4 \
-H "X-Client-Id: client-v1-demo" \
-H "X-API-Version: v3"
# Temporarily see v3 format even though client is registered as v1# Client decides to upgrade from V1 to V2
curl -X PUT http://localhost:3000/api/clients/mobile-app-v1/version \
-H "Content-Type: application/json" \
-d '{"version": "v2"}'
# Now all requests from this client automatically use V2 format
curl http://localhost:3000/api/tasks \
-H "X-Client-Id: mobile-app-v1"
# Tasks now show status enum instead of completed booleanThe server starts with:
- 3 clients:
client-v1-demo,client-v2-demo,client-v3-demo - 3 tasks: Various states demonstrating the version system
# 1. See all versions of the same data (using pre-seeded clients)
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v1-demo"
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v2-demo"
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v3-demo"
# 2. Create from V1, read from V3
curl -X POST http://localhost:3000/api/tasks \
-H "X-Client-Id: client-v1-demo" \
-H "Content-Type: application/json" \
-d '{"title": "Test", "completed": true}'
curl http://localhost:3000/api/tasks -H "X-Client-Id: client-v3-demo"
# Should see the task with status="done", priority=3, tags=[]See examples/requests.http for comprehensive test scenarios.
evolving-api/
βββ src/
β βββ index.ts # Main application entry
β βββ types/
β β βββ shared.ts # Common types (ApiVersion, ClientVersion)
β βββ versions/
β β βββ v1/
β β β βββ types.ts # V1Task (completed boolean)
β β βββ v2/
β β β βββ types.ts # V2Task (status enum)
β β β βββ adapters.ts # V1βV2 transformations
β β βββ v3/
β β β βββ types.ts # V3Task (priority, tags)
β β β βββ adapters.ts # V2βV3 transformations
β β βββ adapters.ts # Composite transformations
β βββ middleware/
β β βββ version-gate.ts # Version detection
β β βββ transform.ts # Response transformation
β β βββ logger.ts # Request logging
β βββ routes/
β β βββ tasks.ts # Task CRUD handlers
β β βββ clients.ts # Client registration handlers
β βββ store/
β βββ tasks.ts # In-memory task storage
β βββ clients.ts # In-memory client registry
βββ examples/
β βββ requests.http # Example HTTP requests
βββ package.json
βββ tsconfig.json
βββ README.md
All data is stored in V3 format. Transformations only happen at API boundaries (request/response). This ensures:
- Single source of truth
- No data duplication
- Easier to add new versions (only need new adapters)
Each version has adapters that can transform both directions:
- Upward: V1βV2βV3 (for incoming requests)
- Downward: V3βV2βV1 (for outgoing responses)
TypeScript ensures transformations are correct:
export const v3ToV1 = (task: V3Task): V1Task => {
return {
id: task.id,
title: task.title,
completed: task.status === 'done', // type-safe mapping
createdAt: task.createdAt,
};
};Request β Logger β Version Gate β Route Handler β Transform β Response
- Logger: Logs all requests with version info
- Version Gate: Detects and sets API version
- Route Handler: Processes using V3 logic
- Transform: Converts V3 response to client's version
This is a demo. In production, you'd:
- Use proper authentication (JWT, OAuth)
- Store client versions in a real database
- Implement rate limiting per client
- Add API key validation
This project leverages modern TypeScript tooling and a lightweight web framework:
- Hono - Ultrafast web framework for the edge (4.0+)
- TypeScript - Type-safe JavaScript (5.3+)
- Node.js - JavaScript runtime (18+)
- @hono/node-server - Node.js adapter for Hono
- tsx - TypeScript execution and REPL
- API Gateway Pattern: Version routing at gateway level
- Adapter Pattern: Object structure transformation
- Middleware Pattern: Request/response interception
- Strategy Pattern: Different versioning strategies per resource
- The Challenges of Evolving Your Service β API Versioning β The article this project is based on
- Architecture Corner Newsletter β Software architecture, system design, and engineering practices
- Hono Documentation
- API Versioning Best Practices
- Stripe API Versioning - Real-world example
- Roy Fielding on Versioning
Contributions are welcome! This is a demonstration project designed for learning and experimentation.
How to contribute:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Ideas for contributions:
- Additional versioning strategies (e.g., GraphQL versioning)
- Database persistence layer examples
- Client SDK examples in different languages
- Additional adapter patterns for other data transformations
- Performance benchmarks
Distributed under the MIT License. See LICENSE file for more information.
MIT License - free to use, modify, and distribute with attribution
Built with β€οΈ using Hono and TypeScript
Demonstrating elegant API versioning patterns for modern web services