Designing an API two mobile clients can live with

A field was renamed from name to full_name during a routine tidy-up. The web front end was updated in the same pull request. Two days later the crash reports started arriving from a version of the iOS app that had shipped four months earlier, was installed on tens of thousands of phones, and could not be changed by anyone.

The symptom

Fatal Exception: NSInvalidArgumentException
  -[NSNull length]: unrecognized selector sent to instance
  OrderViewController.swift:88

Affected users: 11,402   Versions: 2.1.0, 2.1.1, 2.2.0
First seen: 2017-11-14 09:12   (deploy: api 4.8.0, 09:07)

Five minutes between the deploy and the first crash, and the affected versions included one released that week. Rolling back fixed it in eleven minutes, and the rollback was the easy part — the hard part was that nobody had a rule that would have stopped the change during review.

Why it happens

An API consumed by a web front end deployed alongside it is not really an API; it is an internal function call with HTTP in the middle, and it can be changed freely because both sides ship together. The moment a mobile client exists, that stops being true, and nothing in the code marks the difference.

The useful framing is that a published response shape is a promise to a client you do not control and cannot contact. Old versions run for years. App Store review adds a week to every fix. A user who has disabled automatic updates is running last spring’s build indefinitely, and they are a customer.

The fix

The additive rule, decided before the first endpoint

The rule is short enough to fit on a review checklist, and the value is entirely in having written it down before the argument rather than during it.

// SAFE — a client that does not know about it ignores it
// + adding a field to a response
// + adding an optional request parameter
// + adding a new endpoint
// + adding a value to a status list THE CLIENT ONLY DISPLAYS

// BREAKING — a shipped client can crash on any of these
// - renaming or removing a response field
// - changing a type: 4900 -> "4900", 4900 -> 49.00
// - changing nullability of a field a client dereferences
// - adding a value to a status list THE CLIENT SWITCHES ON
// - changing an HTTP status for an existing condition
// - tightening validation on an existing parameter

The two status-list entries are the same change with opposite answers, and that is the point: whether it breaks depends on what the client does with it, which is knowledge that lives outside the codebase. The practical consequence is that new status values need a conversation with whoever writes the app, and a client that switches on a value must have a default branch from its first version.

Tightening validation is the one that surprises people, because it reads as a bug fix. An endpoint that has been silently accepting a null phone number for a year has clients that send one, and rejecting it is a breaking change regardless of what the specification said.

Resources, so the wire format is not the model

The rename happened because the response was a serialised Eloquent model, so a database column rename was an API change and nothing said so. API resources — new in 5.5 — put a declared layer between the two, and the whole benefit is that the file is obviously a contract.

final class OrderResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id'           => (string) $this->id,
            'status'       => $this->status,
            'total_cents'  => (int) $this->total_cents,
            'currency'     => $this->currency,
            'placed_at'    => $this->placed_at->toIso8601String(),
            'lines'        => LineResource::collection($this->whenLoaded('lines')),

            // renamed in the database in 4.8.0. the wire name does not change.
            'name'         => $this->full_name,
        ];
    }
}

The casts are not decoration. PDO returns integers as strings depending on driver configuration, and a client that has been receiving 4900 and starts receiving "4900" after an infrastructure change will crash in a strongly typed language. Casting explicitly makes the type part of the contract rather than a property of the connection.

Money as integer cents rather than a float is the other decision that has to be made once and never revisited. A float total is wrong eventually, in a way that produces a support ticket about a penny and an afternoon of confusion.

Note

whenLoaded() is what stops a resource causing an N+1. Without it, referencing a relation in toArray() lazily loads it for every item in a collection — a hundred orders becomes a hundred and one queries, and the resource layer gets blamed for a performance problem that is a missing eager load.

Errors with a shape a client can branch on

A client that has to match on an error message is a client that breaks when the message is improved, and it will be — for translation, for tone, or because someone fixed a typo.

// not this
// { "error": "The card was declined by the issuing bank." }

// this
{
    "error": {
        "code": "payment.card_declined",
        "message": "Your card was declined.",
        "retryable": false,
        "fields": {
            "card_number": ["invalid_checksum"]
        }
    }
}

// code     — stable forever, the client branches on this
// message  — for a human, may change freely, may be translated
// retryable— so a client knows whether a retry button makes sense
// fields   — per-field codes, so a form can highlight the right input

Splitting the stable identifier from the human string is the entire idea, and it costs nothing at the point the first error is designed. Retrofitting it later means every shipped client is still matching on prose.

The retryable flag earns its place quickly. Without it every client invents its own rule about which failures deserve a retry button, and those rules disagree with each other and with the server.

Pagination that survives an insert mid-scroll

Offset pagination on a list ordered by recency is wrong in a way that is invisible in testing and constant in production. A new row arriving between page one and page two pushes an item across the boundary, and the client shows it twice — or, on a delete, never shows one at all.

// offset — page 2 overlaps page 1 if anything was inserted
// GET /orders?page=2&per_page=20

// cursor — anchored to a row, immune to inserts
// GET /orders?after=eyJpZCI6OTEyMDR9&limit=20

$after = $request->query('after');

$query = Order::where('customer_id', $user->id)
    ->orderBy('placed_at', 'desc')
    ->orderBy('id', 'desc');          // tiebreak: placed_at is not unique

if ($after) {
    $cursor = json_decode(base64_decode($after), true);

    $query->where(function ($q) use ($cursor) {
        $q->where('placed_at', '<', $cursor['placed_at'])
          ->orWhere(function ($q) use ($cursor) {
              $q->where('placed_at', '=', $cursor['placed_at'])
                ->where('id', '<', $cursor['id']);
          });
    });
}

The tiebreaker on id is what makes it correct rather than nearly correct. Two orders placed in the same second are common, and a cursor on a non-unique column either skips rows or repeats them at exactly that boundary.

Opaque cursors — base64 rather than a readable value — are worth the small inconvenience, because a client that decodes and reconstructs one has coupled itself to the implementation and will break when the sort order changes. It is easier to keep a promise about a token than about its contents.

The cost is that jumping to page seven becomes impossible, which matters for an admin table and not at all for an infinite scroll. Both can exist; the decision is per endpoint, and making it consciously is the whole exercise.

Versioning, and when not to

Versioning is a tool for the changes the additive rule cannot absorb, and reaching for it early guarantees maintaining two of everything for a change that a new optional field would have covered. When a genuine break is unavoidable, the version lives in the URL — not because it is elegant, but because it is visible in a log, a proxy rule and a crash report.

Route::prefix('v1')->group(function () {
    Route::get('orders', 'Api\V1\OrderController@index');
});

Route::prefix('v2')->group(function () {
    Route::get('orders', 'Api\V2\OrderController@index');
});

// and only the resource differs — the controller and the query do not.
// duplicating business logic per version is how a v2 becomes permanent.
final class V1\OrderResource extends JsonResource
{
    public function toArray($request)
    {
        return (new V2\OrderResource($this->resource))->toArray($request)
            + ['name' => $this->full_name];      // the shape v1 promised
    }
}

Keeping the version boundary at the serialisation layer is what makes a version cheap to retire. When v1 has its own controllers, its own validation and its own queries, it stops being a compatibility shim and becomes a second application that nobody wants to delete.

Verifying it worked

# contract tests: recorded responses from shipped app versions
$ vendor/bin/phpunit --group contract
OK (61 tests, 240 assertions)

# what they actually assert
#   - every field the 2.1.0 client reads is present
#   - every one has the type it had
#   - no field the client dereferences became null

# and the metric that says when v1 can go
$ curl -s 'http://es.internal:9200/logs-*/_search' -d '{
    "query": {"term": {"api_version": "v1"}},
    "aggs": {"by_app": {"terms": {"field": "client_version"}}},
    "size": 0}' | jq '.aggregations.by_app.buckets'

[ { "key": "2.1.0", "doc_count": 44102 },
  { "key": "2.2.0", "doc_count": 1881 } ]

The contract tests are recorded rather than written: a real response captured when a client version shipped, replayed against the current code, asserting on presence and type rather than value. They catch exactly the class of change that started this, and they catch it in CI rather than in a crash report.

The aggregation is the other half and it is easy to skip. Without it, retiring v1 is a guess, and a guess about forty thousand daily requests from a version somebody assumed was dead is how the same incident happens a second time.

What this costs

Two versions running is two code paths, two sets of tests and two things to reason about during an incident, and the honest accounting is that it is permanent until somebody does the work of proving the old one is unused. That work is unglamorous and always loses to feature work unless the retirement criterion is written down when the version is created — a request count below which v1 is switched off, agreed in advance, so the decision is a measurement rather than an argument.

The subtler cost is on ordinary development. Every response change now needs a moment of thought about clients that cannot be updated, and the additive rule makes some changes more expensive than they look — a field with a bad name stays badly named, and cleanups that would be trivial internally get deferred indefinitely. The compensation is that the alternative was discovering the cost from eleven thousand crash reports, and that comparison makes the trade easy to defend even when it is annoying in the moment.