Skip to main content

API Versioning: Strategies and Tradeoffs

Here's an uncomfortable truth: the API you design today will need to change.

Requirements evolve. A field name turns out to be confusing. A resource model that made sense in month one doesn't survive contact with six months of real usage. A mobile client on an older app version can't be forced to update — so when you change the API, it has to keep working for the old client too.

This is the versioning problem. And the painful part isn't technical — it's temporal. Versioning is one of the few API decisions that's nearly impossible to retrofit cleanly. If you ship without a versioning strategy and then need one, you're choosing between breaking all your existing clients or living forever with a chaotic hybrid.

The good news: you have exactly three real strategies to choose from. Each has clear tradeoffs. By the end of this article, you'll know which one fits TaskFlow — and why — and how to manage the full lifecycle of a version from introduction through to retirement.


Quick Reference

Three versioning strategies:

StrategyExampleProsCons
URL path/v1/tasksVisible, cacheable, simpleURLs change; "URI should identify a resource, not a version" argument
HeaderAPI-Version: 2025-06-01Clean URLs; REST-purist friendlyHarder to test in a browser; less visible
Query param/tasks?version=1Easy to testCan be stripped by proxies; mixes routing with filtering

Breaking vs non-breaking changes:

Change TypeBreaking?
Adding a new optional field to a response❌ Non-breaking
Adding a new optional request parameter❌ Non-breaking
Adding a new endpoint❌ Non-breaking
Renaming a field✅ Breaking
Removing a field✅ Breaking
Changing a field's type✅ Breaking
Changing a status code✅ Breaking
Making an optional field required✅ Breaking

Deprecation lifecycle:

  1. Announce deprecation with a sunset date
  2. Add Deprecation and Sunset headers to responses
  3. Communicate to known API consumers directly
  4. Monitor traffic to the old version
  5. Remove after the sunset date passes

Gotchas:

  • ⚠️ "We'll never need to version" is almost always wrong — plan for it before you launch
  • ⚠️ Additive changes are non-breaking only if clients ignore unknown fields — verify your clients do this
  • ⚠️ A version isn't just a number — it's a support commitment with a cost

See also:


Version Information

Relevant specifications and standards:

Last verified: June 2025


What You Need to Know First

Required reading (in order):

  1. REST APIs: What They Are and How They Work — the uniform interface constraint is central to understanding the purist argument against URL versioning
  2. Resource Design: URLs, Nouns, and Hierarchies — URL structure decisions interact directly with versioning strategy
  3. Request & Response Design: Payloads, Headers, and Conventions — breaking vs non-breaking changes happen in the payload layer

Helpful background:

  • Basic understanding of how HTTP headers work (covered in Article 4)
  • Familiarity with the concept of semantic versioning (v1, v2) is helpful but not required

What We'll Cover in This Article

By the end of this guide, you'll understand:

  • Why versioning exists — what breaking and non-breaking changes are
  • The three versioning strategies in depth — URL path, header, and query parameter
  • How to evaluate and choose a strategy for your API
  • The deprecation lifecycle — how to retire a version without breaking clients
  • How to apply versioning to TaskFlow

What We'll Explain Along the Way

Don't worry if you're unfamiliar with these — we'll define them as we encounter them:

  • Semantic versioning vs date-based versioning
  • HTTP Deprecation and Sunset headers
  • Content negotiation (briefly)
  • API gateways and how they interact with versioning

Why Versioning Exists

Let's start with a scenario.

It's six months after TaskFlow launches. Three clients are in production: a web app, an iOS app, and an Android app. Combined, they're making a million API calls a day.

The product team comes to you with a request: the assigneeId field on tasks needs to be renamed to assignedTo — it's more consistent with the rest of the product's language. Also, the status field needs to change from a plain string to an object that includes both the status value and a display label:

// Before
{
"status": "open"
}

// After
{
"status": {
"value": "open",
"label": "Open"
}
}

Both changes seem reasonable. Both are breaking changes — and they immediately put you in a difficult position.

The web app can be updated instantly. The iOS and Android apps have to go through app store review and user adoption. Some users never update. On any given day, you might have v1.0, v1.2, and v1.4 of the iOS app all making API calls simultaneously — and you can't force any of them to update.

If you just change the API, the older app versions break. If you don't change the API, the product can't evolve.

Versioning is the escape hatch. It lets you introduce breaking changes in a new version while keeping the old version running for clients that haven't migrated yet.


Breaking vs Non-Breaking Changes

Not every change requires a new version. Understanding the distinction is essential — creating a new version for every small change is wasteful and burdensome for clients.

Non-Breaking Changes

These changes are safe to deploy without a new version:

Adding new fields to responses:

// v1 response — clients already handle this
{ "id": "task_99", "title": "Review PR", "status": "open" }

// After adding new field — existing clients ignore unknown fields
{ "id": "task_99", "title": "Review PR", "status": "open", "priority": "high" }

Adding a field is safe because well-behaved clients ignore fields they don't recognize. This is called the robustness principle — be conservative in what you send, liberal in what you accept.

Adding new optional request parameters:

// Before
GET /tasks?status=open

// After — new optional parameter added
GET /tasks?status=open&priority=high

// Old clients still work — they just don't use the new parameter

Adding new endpoints:

// Before — /tasks/task_99/attachments didn't exist
// After — it does
// Old clients never call it — no impact

Adding new values to an enum (with caution):

// Adding "archived" as a new status value is non-breaking
// IF clients handle unknown enum values gracefully
// It IS breaking if clients throw an error on unknown values

This last one requires care. It's only truly non-breaking if you can verify that all clients handle unknown values without crashing. In practice, mobile app clients sometimes hardcode enum handling in ways that break on new values.

Breaking Changes

These changes require a new version:

Renaming a field:

// Breaking — clients reading "assigneeId" will find nothing
{ "assignedTo": "user_42" } // was "assigneeId"

Removing a field:

// Breaking — clients expecting "dueDate" will find nothing
{ "id": "task_99", "title": "Review PR", "status": "open" }
// "dueDate" removed

Changing a field's type:

// Breaking — clients expecting a string will receive an object
{ "status": { "value": "open", "label": "Open" } } // was "status": "open"

Changing a field from optional to required:

// Before — projectId was optional in POST /tasks
// After — projectId is required
// Old clients that don't send projectId now get 422 errors

Changing status codes:

// Before — returning 200 for a successful update
// After — returning 204 (no body)
// Old clients expecting a body will break

Changing URL structure:

// Before
GET /tasks?assigneeId=user_42

// After — different filter parameter name
GET /tasks?assignedTo=user_42
// Old clients still send "assigneeId" — filter no longer works

The general test: if a client written against the current version would need code changes to handle the new version, it's a breaking change.


The Three Versioning Strategies

Strategy 1: URL Path Versioning

Embed the version number directly in the URL path:

https://api.taskflow.com/v1/tasks
https://api.taskflow.com/v2/tasks

This is by far the most commonly used strategy. GitHub, Stripe (for major versions), Twitter/X, Twilio, and the vast majority of public APIs use URL path versioning.

How it works in practice:

# v1 — original design
GET /v1/tasks
POST /v1/tasks
GET /v1/tasks/task_99

# v2 — renamed fields, new response shape
GET /v2/tasks
POST /v2/tasks
GET /v2/tasks/task_99

Both versions run simultaneously. A v1 client continues using /v1/ endpoints indefinitely (until v1 is deprecated). A v2 client uses /v2/ endpoints. The server routes each request to the appropriate handler.

// Express routing — versions as separate router modules
import express from "express";
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";

const app = express();

app.use("/v1", v1Router);
app.use("/v2", v2Router);

Pros:

  • Visible and explicit. The version is right there in every URL. You can see it in browser address bars, logs, and curl commands without any configuration.
  • Easy to test. You can test v1 and v2 simultaneously in a browser or with curl — no header manipulation needed.
  • Cacheable. HTTP caches key on URLs. /v1/tasks and /v2/tasks are distinct cache entries with no interference.
  • Routeable. API gateways, load balancers, and reverse proxies can route requests by URL prefix without inspecting headers — simpler infrastructure configuration.
  • Copy-paste friendly. URLs shared in documentation, Slack messages, and error reports always carry the version context.

Cons:

  • Violates REST purity (arguably). Roy Fielding argued that a URL should identify a resource, not a version of a representation of a resource. /v1/tasks/task_99 and /v2/tasks/task_99 are the same resource — task 99 — just with different representations. The version belongs in the Accept header, not the URL.
  • URLs proliferate. Every endpoint doubles (or triples) when you add a version. Documentation and client codebases grow accordingly.
  • "Permanent" URLs aren't. If you promise clients that /v1/tasks/task_99 is a stable URL, you eventually have to break that promise when you deprecate v1.

The verdict on REST purity: Fielding is technically correct. In practice, the ergonomic advantages of URL versioning — visibility, testability, cacheability — outweigh the theoretical objection for most teams. The vast majority of production APIs accept this tradeoff.


Strategy 2: Header Versioning

Express the version in a request header, keeping URLs clean:

GET /tasks
API-Version: 2025-06-01
Authorization: Bearer eyJhbGci...

Two common variants exist:

Custom version header:

API-Version: 2          // Integer version
API-Version: 2025-06-01 // Date-based version (Stripe's current approach)

Accept header with media type versioning (content negotiation):

Accept: application/vnd.taskflow.v2+json

The second form uses MIME type versioning — the version is embedded in the Accept header's media type. This is the approach Roy Fielding advocated and is technically the most "correct" REST approach. It's also used by GitHub's API for their newer endpoints.

How it works in practice:

// Express middleware — version routing by header
import { Request, Response, NextFunction } from "express";

function versionRouter(req: Request, res: Response, next: NextFunction) {
const version = req.headers["api-version"] ?? "1";

// Attach version to request for handlers to use
req.apiVersion = version;
next();
}

// Handler checks version and responds accordingly
async function getTask(req: Request, res: Response) {
const task = await db.tasks.findById(req.params.taskId);

if (req.apiVersion === "2") {
// v2 response shape — status as object
return res.json({
data: {
...task,
status: { value: task.status, label: capitalize(task.status) },
assignedTo: task.assigneeId, // renamed field
},
});
}

// v1 response shape — original
return res.json({ data: task });
}

Pros:

  • Clean URLs. /tasks/task_99 is the same URL regardless of version. Resource identity is stable.
  • REST-purist correct. The version is a representation concern — it belongs in a header, not a resource identifier.
  • Graceful defaults. You can define a default version that's used when no header is present, reducing friction for casual callers.

Cons:

  • Invisible. The version doesn't appear in the URL. Logs, browser address bars, and shared links carry no version context without extra effort.
  • Harder to test. You can't test different versions by changing a URL in a browser. You need a tool like curl, Postman, or a custom browser extension.
  • Cache complications. HTTP caches key on URLs. If two different versions share the same URL, caches must vary on the API-Version header — which requires a Vary: API-Version response header and reduces cache hit rates.
  • Proxy stripping. Some HTTP proxies and API gateways strip non-standard headers. A custom API-Version header might disappear before reaching your server.
  • Discovery. New clients have to know to send the header. There's no obvious default behavior visible in the URL.

Strategy 3: Query Parameter Versioning

Append the version as a query parameter:

GET /tasks?version=1
GET /tasks?version=2

How it works in practice:

async function getTasks(req: Request, res: Response) {
const version = req.query.version ?? "1";

if (version === "2") {
// v2 response
}
// v1 response
}

Pros:

  • Visible and testable. Like URL path versioning, the version is visible in the URL and easy to test in a browser.
  • Optional by default. Clients without a ?version= parameter get the default version — no change required for old clients when you introduce versioning.
  • Easy to add retroactively. You can add query parameter versioning to an existing API without changing any existing URLs.

Cons:

  • Mixes concerns. Query parameters are for filtering and options. Stuffing a routing concern (versioning) into query parameters muddies the convention established in Article 4.
  • Proxy stripping. Some proxies and caches strip or normalize query parameters. A CDN might cache /tasks?version=1 and /tasks?version=2 as the same URL.
  • Easy to forget. Unlike URL path versioning (where /v1/ is structurally unavoidable), query parameters are easy to omit accidentally — and the behavior of a request without ?version= depends on what your default is.
  • Documentation complexity. Every endpoint documentation now needs to describe versioned behavior per query parameter value, which is harder to organize than separate versioned API references.

Choosing a Strategy

Here's the honest decision framework:

Diagram: A decision tree for choosing a versioning strategy. URL path versioning is the practical default for most teams. Header versioning is preferred when REST purity or clean URLs are a strong priority.

The pragmatic default for most teams is URL path versioning. The visibility and testability advantages compound over time — especially as a team grows, as support tickets require log analysis, and as third-party developers integrate with your API.

Header versioning makes sense when:

  • You have a strong team culture around REST correctness
  • Your API is consumed by a small, known set of sophisticated clients
  • You're willing to invest in tooling that compensates for the invisibility (structured logging with version fields, API gateways that normalize headers)

Query parameter versioning is rarely the right choice for a new API. Its advantages over URL path versioning are minimal, and its disadvantages (proxy stripping, mixed concerns) are real. The main case for it is retroactively adding versioning to an existing unversioned API where changing URL structure isn't feasible.


Versioning Strategies Compared

DimensionURL PathHeaderQuery Param
Visibility in logs✅ Always visible❌ Requires structured logging✅ Visible
Browser testability✅ Just change the URL❌ Requires tool✅ Just change the URL
Caching✅ Natural, per-URL⚠️ Requires Vary header⚠️ Proxy-dependent
REST purity❌ Version in URI✅ Version in representation❌ Version in URI
Infrastructure routing✅ Trivial (URL prefix)⚠️ Requires header inspection⚠️ Requires query parsing
Addable retroactively❌ URL structure changes✅ Add header support✅ Add param support
Discovery by new clients✅ Obvious from docs/URLs❌ Requires documentation✅ Visible in examples
Industry adoption✅ Most common⚠️ Used by some large APIs❌ Rare

Semantic Versioning vs Date-Based Versioning

Once you've chosen where the version goes, you need to decide what the version identifier is.

Semantic Versioning (v1, v2, v3)

Integer versions are simple and widely understood. They communicate magnitude — v2 is a bigger change than a patch, v3 is bigger than v2.

/v1/tasks
/v2/tasks

Conventions:

  • A new major version (v2 → v3) implies significant breaking changes
  • Most APIs skip minor/patch versions at the URL level (those happen invisibly as non-breaking changes)
  • Old versions remain available for a defined period after the new version launches

Used by: GitHub (REST API), Twilio, Stripe (for major versions), most public APIs.

Date-Based Versioning

The version identifier is a date in YYYY-MM-DD format. Each date represents the API's state on that date — all changes made after that date aren't visible to clients requesting that version.

GET /tasks
API-Version: 2025-06-01

GET /tasks
API-Version: 2025-01-15

Conventions:

  • A new "version" is just a new date — no major/minor judgment required
  • Clients pin to the date they last tested against
  • Changes accumulate; new dates expose accumulated changes

Used by: Stripe (current approach for their header-versioned API), Paddle.

Pros: No need to decide "is this change big enough for a new major version?" Every breaking change gets a date. Clients have a clear audit trail — "we tested against 2025-06-01."

Cons: The version identifier isn't self-evidently ordered in magnitude. Communicating "there's a significant new version available" requires more ceremony than bumping from v1 to v2.

TaskFlow uses integer major versions (v1, v2) with URL path versioning — the most widely understood combination, and the most practical for a public-facing API.


The Deprecation Lifecycle

A version isn't just an API design decision — it's a support commitment. Every version you ship is something you have to maintain, test, monitor, and eventually retire. The deprecation lifecycle is how you do that responsibly.

Step 1: Announce Before You Act

Never surprise clients with a deprecation. Announce it in advance — at least 3 months for internal APIs, at least 6–12 months for public APIs with unknown third-party consumers.

Announce through:

  • Developer documentation (a clear deprecation notice at the top of the deprecated version's docs)
  • API changelog or blog post
  • Direct email to known API consumers (if you have their contact info via API keys or developer accounts)
  • In-response deprecation headers (covered next)

Step 2: Add Deprecation Headers to Every Response

Once a version is deprecated, add two headers to every response from that version. These headers are defined in RFC 8594:

GET /v1/tasks
Authorization: Bearer eyJhbGci...

Response:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
Link: <https://api.taskflow.com/v2/tasks>; rel="successor-version"
  • Deprecation: true — Signals that this endpoint/version is deprecated
  • Sunset — The exact date and time this version will stop responding
  • Link with rel="successor-version" — Points clients to where they should migrate

Well-behaved API clients and monitoring tools can detect these headers and alert developers automatically. Stripe uses this pattern extensively.

Step 3: Monitor Traffic to the Deprecated Version

Before you pull the plug, verify that traffic has actually declined. Checking:

// Log version usage per API key / client ID
// Example structured log entry
{
"timestamp": "2025-06-15T10:30:00Z",
"apiVersion": "v1",
"clientId": "client_abc",
"endpoint": "GET /v1/tasks",
"requestId": "req_8kJd92nA"
}

Build a dashboard showing requests per version over time. If significant traffic is still hitting v1 when the sunset date approaches, you have two choices: extend the sunset date (and communicate the extension) or reach out to the high-traffic clients directly.

Removing a version with active traffic will break real users. Monitor first.

Step 4: Respond with 410 Gone After Sunset

When the sunset date passes, the deprecated version stops responding with real data. Return 410 Gone with a clear migration message:

GET /v1/tasks
Authorization: Bearer eyJhbGci...

HTTP/1.1 410 Gone
Content-Type: application/json

{
"error": {
"code": "version_sunset",
"message": "API v1 was sunset on January 1, 2026. Please migrate to v2.",
"details": [
{
"field": "migrationGuide",
"message": "https://docs.taskflow.com/migration/v1-to-v2"
}
]
}
}

410 Gone (rather than 404 Not Found) signals permanence — this URL existed and is now intentionally gone. Clients and search engines treat 410 differently from 404.

The Full Lifecycle in One View

Diagram: The API version deprecation lifecycle. After v2 launches, v1 enters a deprecation period: headers are added, traffic is monitored, clients are contacted. On the sunset date, v1 begins returning 410 Gone.


Applying Versioning to TaskFlow

Strategy Choice

TaskFlow uses URL path versioning with integer major versions.

Rationale: TaskFlow's API is consumed by web and mobile clients, and will eventually be opened to third-party integrators. The visibility and testability of URL path versioning outweigh the theoretical REST purity advantage of header versioning. Third-party developers benefit from seeing the version in every URL they work with.

Current State: v1

All TaskFlow endpoints are currently at v1:

GET    /v1/tasks
POST /v1/tasks
GET /v1/tasks/{taskId}
PATCH /v1/tasks/{taskId}
DELETE /v1/tasks/{taskId}
# ... all other endpoints

A base URL redirect is useful for clients who want to pin to "latest stable":

GET /tasks  →  302 Found  →  /v1/tasks

Though this redirect should be documented explicitly — clients relying on it can't control which version they get when v2 launches.

The v1 → v2 Migration Scenario

Let's apply everything to the breaking change scenario from the beginning of this article: renaming assigneeId to assignedTo and changing status from a string to an object.

v1 task response:

{
"data": {
"id": "task_99",
"title": "Review pull request",
"assigneeId": "user_42",
"status": "open",
"dueDate": "2025-08-01",
"createdAt": "2025-06-15T10:30:00Z",
"updatedAt": "2025-06-15T10:30:00Z"
}
}

v2 task response:

{
"data": {
"id": "task_99",
"title": "Review pull request",
"assignedTo": "user_42",
"status": {
"value": "open",
"label": "Open"
},
"dueDate": "2025-08-01",
"createdAt": "2025-06-15T10:30:00Z",
"updatedAt": "2025-06-15T10:30:00Z"
}
}

Server implementation — versioned response serializer:

import { Task } from "../models/task";

// Shared internal representation — the database model
interface TaskRecord {
id: string;
title: string;
assigneeId: string; // Internal field name
status: string;
dueDate: string | null;
createdAt: string;
updatedAt: string;
}

// v1 serializer — original shape
function serializeTaskV1(task: TaskRecord) {
return {
id: task.id,
title: task.title,
assigneeId: task.assigneeId, // Original field name
status: task.status, // Plain string
dueDate: task.dueDate,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}

// v2 serializer — new shape
function serializeTaskV2(task: TaskRecord) {
const statusLabels: Record<string, string> = {
open: "Open",
in_progress: "In Progress",
complete: "Complete",
archived: "Archived",
};

return {
id: task.id,
title: task.title,
assignedTo: task.assigneeId, // Renamed field
status: {
// Changed to object
value: task.status,
label: statusLabels[task.status] ?? task.status,
},
dueDate: task.dueDate,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
}

// Route handler — shared logic, versioned serialization
import { Request, Response } from "express";

async function getTask(req: Request, res: Response) {
const task = await db.tasks.findById(req.params.taskId);

if (!task) {
return res.status(404).json({
error: {
code: "not_found",
message: `Task '${req.params.taskId}' does not exist.`,
details: [],
},
});
}

// Version is determined by the route prefix (/v1 vs /v2)
// req.apiVersion is set by a middleware that reads the URL prefix
const serialized =
req.apiVersion === "2" ? serializeTaskV2(task) : serializeTaskV1(task);

return res.json({ data: serialized });
}

Key design decision: the database model and business logic are shared. Only the serialization layer differs between versions. This means a bug fix in task retrieval logic benefits both v1 and v2 clients automatically — you're not maintaining two separate codebases.

Migration Guide Structure

Every breaking version change deserves a migration guide. TaskFlow's v1 → v2 migration guide would include:

# Migrating from TaskFlow API v1 to v2

## What Changed

### Renamed Fields

- `assigneeId``assignedTo` on task objects

### Changed Field Types

- `status` changed from `string` to `{ value: string, label: string }`

## How to Update Your Client

### JavaScript / TypeScript

**Before (v1):**

```typescript
const assignee = task.assigneeId;
const isOpen = task.status === "open";
```

After (v2):

const assignee = task.assignedTo;
const isOpen = task.status.value === "open";

Timeline

  • v2 launched: June 15, 2025
  • v1 deprecated: June 15, 2025
  • v1 sunset: January 1, 2026 (6 months)

---

## Common Misconceptions

### ❌ Misconception: "We won't need versioning — our API is internal"

**Reality:** Internal APIs break internal clients just as painfully as public APIs break external ones. An internal mobile app on employee devices can't be force-updated overnight. An internal service another team depends on can't always be redeployed in lockstep with your API change.

The difference between internal and public APIs is how long you have to migrate clients — not whether versioning matters. Internal APIs can get away with shorter deprecation windows (weeks instead of months), but the lifecycle still applies.

### ❌ Misconception: Adding a new field is always non-breaking

**Reality:** It's non-breaking **if and only if** clients ignore unknown fields. Most JSON parsers do this by default in dynamic languages (JavaScript, Python, Ruby). Statically typed languages (Swift, Kotlin, older Java libraries) sometimes throw on unknown fields unless explicitly configured not to.

Before relying on additive changes being non-breaking, verify your clients' JSON parsing behavior. Add to your API documentation: "Clients must ignore unknown fields in responses."

### ❌ Misconception: A new major version means rewriting everything

**Reality:** A new major version is just a new serialization layer on top of the same business logic. The v2 router handles v2-specific URL patterns. The v2 serializers transform the internal model into the v2 response shape. The database queries, validation logic, and service layer are shared. The actual delta between v1 and v2 is often small.

### ❌ Misconception: The sunset date is a deadline for clients to update

**Reality:** The sunset date is a deadline for _you_ to stop maintaining the old version — after which there's no SLA and no guarantee of uptime. Communicate it as: "After this date, we will not respond to v1 requests." Make sure clients understand it's a hard stop, not a suggestion.

---

## Troubleshooting Common Issues

### Problem: Clients aren't responding to deprecation notices

**Symptoms:** Traffic to v1 isn't declining despite the deprecation announcement. Clients appear to have not noticed.

**Diagnosis approach:**

1. Check if your deprecation headers are actually being sent — use curl to verify
2. Check if clients are inspecting headers at all (many aren't)
3. Identify the top 10 API keys still sending v1 traffic — contact those developers directly
4. Add a more prominent notice — a warning in the response body alongside the deprecation headers

```json
// Optional: warning in response body for human attention
{
"data": { ... },
"_warnings": [
{
"code": "deprecated_version",
"message": "API v1 is deprecated and will sunset on Jan 1, 2026. Migrate to v2: https://docs.taskflow.com/v2"
}
]
}

The _warnings field is non-standard but effective for catching developers who don't inspect headers.

Problem: Maintaining two versions is creating duplicated code and bugs

Symptoms: A bug fix in v1 isn't applied to v2, or vice versa. The two version codebases are diverging.

Fix: Enforce the principle that business logic is version-agnostic. Only serializers and request parsers should be versioned:

src/
routes/
v1/ ← v1 request parsing, v1 response serialization
v2/ ← v2 request parsing, v2 response serialization
services/
tasks.ts ← business logic — shared across all versions
models/
task.ts ← database model — shared

A bug in services/tasks.ts gets fixed once and benefits all versions automatically.

Problem: "Should I version every endpoint, or only the ones that changed?"

Resolution: Version the entire API surface, not individual endpoints. A client declares "I'm using v2" — they shouldn't have to track which individual endpoints changed between v1 and v2.

In practice, unchanged endpoints in v2 can simply delegate to the v1 handler internally. The v2 router has the endpoint; it just calls the same logic as v1:

// v2 router — comments didn't change between v1 and v2
v2Router.get("/tasks/:taskId/comments", v1Handlers.getComments);

Check Your Understanding

Quick Quiz

  1. A teammate proposes adding a priority field to all task responses. Is this a breaking change?

    Show Answer

    Not breaking — if clients are built to ignore unknown fields, adding a new field to a response doesn't require existing client code to change. Confirm that all your clients (especially mobile apps) handle unknown JSON fields without throwing errors.

    If any client throws on unknown fields, this becomes a breaking change for that client specifically.

  2. You need to change the dueDate field from a string ("2025-08-01") to an object ({ "date": "2025-08-01", "overdue": true }). What's the minimum versioning response?

    Show Answer

    This is a breaking change — changing a field's type will break any client that reads dueDate as a string.

    Required steps:

    1. Create v2 of the API with the new dueDate shape
    2. Keep v1 running with the original string shape
    3. Add Deprecation and Sunset headers to v1 responses
    4. Announce the deprecation with a timeline
    5. Monitor traffic and migrate clients
    6. Return 410 Gone for v1 after the sunset date
  3. What does the Sunset response header communicate?

    Show Answer

    The exact date and time a deprecated API version will stop accepting requests. Per RFC 8594, it's expressed as an HTTP date:

    Sunset: Sat, 01 Jan 2026 00:00:00 GMT

    After this date, the server will return 410 Gone for all requests to the deprecated version.

  4. Why does URL path versioning naturally solve the HTTP caching problem that header versioning creates?

    Show Answer

    HTTP caches key on URLs. /v1/tasks and /v2/tasks are distinct URLs — caches store them as completely separate entries. No version mixing is possible.

    With header versioning, /tasks is the same URL regardless of the API-Version header. A cache that doesn't inspect headers might serve a v1-cached response to a v2 client. To prevent this, the server must include Vary: API-Version in responses, which tells caches to treat the same URL as a different cache entry for each header value — reducing cache hit rates.

Hands-On Exercise

Challenge: The TaskFlow team wants to make two changes to the project resource:

  1. The name field on projects will be renamed to title (for consistency with tasks)
  2. A new memberCount field will be added showing how many users are in the project

Tasks:

  • Identify which change is breaking and which is not
  • Design the v2 project response shape
  • Write the v1 and v2 serializers in TypeScript
  • Write the Deprecation and Sunset headers for a v1 response after v2 launches (sunset in 6 months from today)
Show Answer

Breaking vs non-breaking:

  • Renaming nametitle is breaking — clients reading project.name will get undefined
  • Adding memberCount is non-breaking — clients that don't use it simply ignore it

v1 project response:

{
"data": {
"id": "proj_5",
"name": "TaskFlow Web App",
"createdAt": "2025-01-10T09:00:00Z",
"updatedAt": "2025-06-01T14:00:00Z"
}
}

v2 project response:

{
"data": {
"id": "proj_5",
"title": "TaskFlow Web App",
"memberCount": 8,
"createdAt": "2025-01-10T09:00:00Z",
"updatedAt": "2025-06-01T14:00:00Z"
}
}

TypeScript serializers:

interface ProjectRecord {
id: string;
name: string; // Internal field
memberCount: number;
createdAt: string;
updatedAt: string;
}

function serializeProjectV1(project: ProjectRecord) {
return {
id: project.id,
name: project.name, // Original field name
createdAt: project.createdAt,
updatedAt: project.updatedAt,
// memberCount NOT included — it didn't exist in v1
};
}

function serializeProjectV2(project: ProjectRecord) {
return {
id: project.id,
title: project.name, // Renamed field
memberCount: project.memberCount, // New field
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}

Deprecation headers (6-month sunset from June 2025):

HTTP/1.1 200 OK
Deprecation: true
Sunset: Sun, 01 Jan 2026 00:00:00 GMT
Link: <https://api.taskflow.com/v2/projects>; rel="successor-version"

Summary: Key Takeaways

  • Versioning exists because APIs change and clients can't always update in lockstep. Breaking changes require a new version. Non-breaking changes (adding fields, adding endpoints, adding optional parameters) can be deployed without a version bump.

  • Three strategies exist: URL path versioning (/v1/tasks), header versioning (API-Version: 2), and query parameter versioning (/tasks?version=1). URL path versioning is the pragmatic default for most teams — visible, testable, cacheable.

  • The breaking vs non-breaking test: If a client written against the current version would need code changes to handle the new version, it's breaking. Common breaking changes: renaming fields, removing fields, changing field types, making optional fields required.

  • Versioning is a support commitment. Every version you ship, you maintain. Design for the minimum number of versions that serve your clients — not the maximum.

  • The deprecation lifecycle has five steps: announce early, add Deprecation/Sunset headers, monitor traffic, contact high-traffic clients, return 410 Gone after sunset.

  • Share business logic across versions. Only serializers and request parsers differ between versions. A single fix in your service layer benefits all versions at once.

  • TaskFlow uses URL path versioning (/v1/) with integer major versions. All current endpoints live at /v1/. The v2 migration scenario — renaming assigneeId and changing the status type — illustrates the full lifecycle.


What's Next?

You now know how to design URLs, choose methods, shape payloads, and manage change over time.

The natural next step is OpenAPI Specification: Documenting Your API (coming soon) — where we turn TaskFlow's design into a machine-readable contract. An OpenAPI spec is what makes your API verifiable, auto-documentable, and testable without writing a single line of client code. It's also the foundation for the auth and security work in Article 7.


References