← All writing

/ 4 min read

APIs a frontend can live with: designing the contract first

The small decisions in an API contract that decide whether every screen built on it is simple or painful: errors, pagination, concurrency, batches and lifecycle.

  • API design
  • OpenAPI
  • Architecture

I spent two years building backend services before moving to the frontend, and I have since spent four years consuming APIs designed by other people. The second experience taught me more about API design than the first. A contract that looks clean from the server side can force every screen into workarounds, and a few early decisions decide which kind you get.

When I design the services behind a product, such as the tenancy and organisation services of an ERP, I write the OpenAPI contract before any handler exists and review it with the people who will build screens on it. These are the parts I look at first.

One error format, everywhere

The frontend has to turn every failure into something a person can act on. That is only possible if failures look the same across the whole API: one envelope with a stable machine readable code, a human message, field level details for validation errors, and a trace id that support can search for in the logs.

{
  "error": {
    "code": "validation_failed",
    "message": "Some fields need attention.",
    "fields": [
      { "path": "validFrom", "code": "before_parent_start", "message": "Must be on or after the parent unit's start date." }
    ],
    "traceId": "9f1c2e7a4b"
  }
}
A validation error the form can map straight onto its fields.

With this in place the form component is written once: it reads fields, puts each message under its input and shows the trace id in a quiet line at the bottom. Without it, each screen invents its own parsing and half of them show "Something went wrong".

Lists are envelopes, not arrays

Returning a bare array from a list endpoint is the decision teams regret most often. The day the list needs a total, a cursor or a warning, every client breaks. A paginated envelope from the first version costs nothing and leaves room to grow.

{
  "items": [ ... ],
  "page": { "nextCursor": "eyJpZCI6IjQ4MSJ9", "limit": 50 },
  "total": 1284
}

Cursor pagination keeps pages stable while data changes underneath, which matters on screens that refresh while someone is reading them. Sorting and filtering are typed parameters in the contract, not a free text query string, so the frontend can generate its filter controls from the specification.

Two people editing the same record

In enterprise tools two people regularly open the same record. Without protection, the last one to press save silently overwrites the other. Optimistic concurrency with HTTP's own headers solves it: every read returns an ETag, every update sends it back in If-Match, and a stale update gets 412 Precondition Failed instead of succeeding.

On the frontend that becomes a clear message, "this record changed while you were editing it", with the option to reload and reapply. It is far easier to build that once against a predictable status code than to discover lost updates from a support ticket.

Batches report per item

Bulk actions, such as importing a spreadsheet of organisation units or validating fifty assignments, should never be all or nothing from the user's point of view. The response lists a result for each item, so the screen can show which rows succeeded, which failed and why, and let someone fix only the failures.

{
  "results": [
    { "index": 0, "status": "ok", "id": "unit_812" },
    { "index": 1, "status": "error", "error": { "code": "cycle_detected", "message": "A unit cannot report to its own child." } }
  ]
}

Make the lifecycle explicit

Most business objects have a lifecycle: pending, active, suspended, archived. When status is just a field anyone can PATCH, every client has to know which transitions are legal, and sooner or later one of them gets it wrong. I prefer one endpoint per transition, such as POST /tenants/{id}/suspend, each with its own preconditions and its own audit entry.

The contract then documents the state machine by itself, and the frontend can show exactly the actions that make sense in the current state. When a transition is refused, the error says why, for example that a tenant cannot be archived while it still has active organisations, instead of a generic conflict.

Say what a service does not do

The most useful sentence in a service description is often a negative one. The tenancy service in that ERP never stores users and never decides access, and its contract says so. That single line prevents months of drift, because nobody adds a "quick" user lookup to the wrong service when the boundary is written down.

Review the contract with the screens in mind

A contract review goes faster when someone holds the designs next to it and asks, for each screen: which calls does this need, what happens when each one fails, and what does the user see while it loads? Gaps show up immediately, such as a list that cannot be filtered by the field the design filters by, or a detail view that needs three calls where one would do.

  • One error envelope with codes, field details and a trace id.
  • Paginated envelopes for every list, with cursors and typed filters.
  • ETag and If-Match on anything two people might edit.
  • Per item results for batch operations.
  • One endpoint per lifecycle transition, with its reason when refused.
  • Explicit boundaries: what each service owns and what it does not.

Next article ↗

Drag, connect, publish: building a visual flow editor in React