An API versioned for the first time in six years

The API has had no version number since 2019, on a rule that nothing incompatible ships. The rule held for six years and then met a change it could not accommodate: a field whose meaning has to change, read by four consumers, none of whom can be asked to interpret it differently on a date.

The symptom

{
  "id": "ord_8814",
  "total": { "amount": 4900, "currency": "GBP" }
}

// "total" has meant the order total EXCLUDING tax
// since 2019. the business now sells in jurisdictions
// where the displayed price includes it, and the field
// has to mean the amount the customer pays.
the options within the additive rule:

  add total_inclusive alongside total
    → every consumer must be told which to use, and
      the wrong one is silently plausible.
  add a tax_treatment field describing which total is
  which
    → correct, and it makes every consumer's code
      conditional on a field they have never read.
  change the meaning of total
    → a silent break for four consumers.

none of these is additive in any useful sense.

A field whose meaning changes is the case additive-only cannot handle, because there is no shape change to detect — the payload is identical and the number means something different. That is the worst possible break: no error, no type mismatch, and a wrong figure.

Why it happens

The additive rule is a good discipline and not a law. It works while every change is a new capability, and it fails when the domain itself changes — which happens roughly once a decade and cannot be designed around in advance.

The fix

Three strategies, and what each costs to operate

  a media type
    Accept: application/vnd.td.v2+json
    correct by the book. invisible in an access log,
    invisible in a browser, needs a Vary header for
    caching, and a consumer who omits it gets a default
    whose meaning changes over time.

  a query parameter
    ?version=2
    caches badly, and mixes a routing concern with a
    filtering one. a consumer who omits it is in the
    same position as above.

  the URI
    /api/v2/orders
    inelegant, and: greppable in the access log,
    visible in a bug report, cacheable with no Vary,
    testable with curl and no flags, and impossible
    to omit.

Every argument against URI versioning is aesthetic — a URI should identify a resource and a version is not part of its identity — and every argument for it is operational. After six years of never needing a version, the deciding question was which one somebody can debug at three in the morning.

What is versioned

// the representation, not the API
Route::prefix('api/v1')->group(function () {
    Route::get('orders/{order}', OrderController::class)
        ->defaults('resource', OrderV1Resource::class);
});

Route::prefix('api/v2')->group(function () {
    Route::get('orders/{order}', OrderController::class)
        ->defaults('resource', OrderV2Resource::class);
});

// one controller. it loads, authorises, and hands the
// order to whichever resource the route named.

Duplicating the controller is the obvious move and it duplicates the authorisation, the loading and the error handling — three things that must not diverge between versions. Only the representation should differ, and expressing that as a route default rather than a conditional keeps the controller unaware that versions exist at all.

final class OrderV2Resource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'total' => $this->money($this->total->inclusive()),
            'total_excluding_tax' => $this->money($this->total->exclusive()),
            'tax' => $this->money($this->total->tax()),
        ];
    }
}

Not versioning everything

188 routes. 22 of them return an order or contain one.

  versioned:      22
  shared:        166, mounted under both prefixes and
                 returning identical responses

the alternative — versioning the whole API — means 188
routes duplicated for a change affecting 22, and a v3
in two years duplicating them again.

what that costs: a consumer on /api/v2 sees v1 shapes
for 166 endpoints, which is only confusing if you
believe the version describes the API rather than the
representations in it.

Versioning per representation rather than per API is the decision that keeps this bounded, and it needs stating in the documentation because it violates what most people assume a version prefix means. The alternative scales with the number of endpoints rather than with the number of breaking changes.

The deprecation window

the four integrators, and their release cadence:

  A   continuous. deployed the same week.
  B   fortnightly.
  C   quarterly, with a December change freeze.
  D   "when something breaks". last deploy: 14 months.

a 90-day window gives A twelve opportunities, C one,
and D none.

what we published: a sunset date nine months out, and a
commitment to move it if a named consumer asks with a
plan.

C asked. it moved by six weeks. D was called.
HTTP/1.1 200 OK
Deprecation: Mon, 31 Mar 2025 00:00:00 GMT
Sunset: Wed, 31 Dec 2025 00:00:00 GMT
Link: <https://docs.example.com/api/v2>; rel="successor-version"

The headers are the standard mechanism and are ignored by everybody, which is why the email to four named people is the one that produces migrations. A window in calendar time treats consumers as interchangeable and they differ by an order of magnitude in how often they can act.

The usage log that decides when v1 dies

// one line of middleware, and it is the whole
// decommissioning strategy
Log::channel('api_usage')->info('request', [
    'version'    => $request->route()->getPrefix(),
    'api_key_id' => $request->user()?->apiKeyId,
    'route'      => $request->route()->getName(),
]);
nine months later:

  v1, by consumer:
    integrator-A       0
    integrator-B       0
    integrator-C       0
    integrator-D   4,102 a day
    unknown key      188 a day   ← a fifth consumer

the unknown key is a monitoring script written by
somebody at C in 2021, which nobody at C knew about.

The fifth consumer is the argument for the usage log, and it would have been broken by a sunset date that everybody had agreed to. “We have told all our consumers” is a statement about the consumers you know, and the log is the only thing that describes the ones you do not.

Two specifications, and the drift between them

$ ./bin/generate-openapi --version=v1 > openapi-v1.yaml
$ ./bin/generate-openapi --version=v2 > openapi-v2.yaml

# and the mistake we made first: a shared components
# section, referenced by both, changed for v2.
#
# v1's documentation silently described v2's shape for
# three weeks.

# the fix: each version owns its schemas entirely.
# duplication that is correct, because divergence is
# the whole point of a version.

Contract tests per version

#[DataProvider('consumerFixtures')]
public function testAConsumerFixtureStillPasses(string $path): void
{
    $fixture = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);

    $response = $this->withToken($fixture['token'])
        ->json($fixture['method'], $fixture['path']);

    foreach ($fixture['asserts'] as $pointer => $expected) {
        $response->assertJsonPath($pointer, $expected);
    }
}

// consumer-fixtures/integrator-b/orders-v1.json
// consumer-fixtures/integrator-b/orders-v2.json
// published by them, fetched by our pipeline nightly.

Fixtures authored by the consumers is what makes these contract tests rather than our own assertions restated, and four named integrators is small enough for a shared directory and a nightly fetch. When the sunset was proposed, this directory answered who would break before anybody had to ask.

Verifying it worked

$ curl -s /api/v1/orders/8814 | jq -c '.total'
{"amount":4900,"currency":"GBP"}          # excluding tax
$ curl -s /api/v2/orders/8814 | jq -c '.total'
{"amount":5880,"currency":"GBP"}          # including tax

$ vendor/bin/phpunit --group=contract
  7 fixtures, 4 consumers, 2 versions.  OK

$ ./bin/api-usage --version=v1 --since=30d --by-consumer
  integrator-D   123,060
  unknown-key      5,640

$ ./bin/openapi-diff v1 v2 --components
  shared schemas: 0        # the fix held

Zero shared schemas between the two documents is the check that the specification drift cannot recur, and it is a grep. The usage counts are what the sunset decision will be made from, and at the time of writing v1 is still serving two consumers nine months in.

What this costs

Two shapes to maintain, and a precedent for a third. Every future change now has a version to argue about, where previously the additive rule made the decision — and the rule was a constraint that produced good design by forcing the change to be compatible.

The per-representation versioning is also a thing that has to be explained to every new consumer, because it violates what a version prefix normally implies. That explanation lives in one paragraph of the documentation, which is where explanations of surprising decisions go to be unread.

And v1 will not die on the published date. One consumer deploys when something breaks, a second has a script nobody at that company knows about, and the honest expectation is that v1 runs until somebody at integrator D has a reason to touch their integration — which may be years. A sunset date is a plan and the usage log is the truth.