Why REST level 3
Most APIs that call themselves "RESTful" stop at level 2 of the Richardson Maturity Model: resources with sensible URLs and correct HTTP verbs. Level 3 — hypermedia controls (HATEOAS) — is the one teams skip. This page is the factual case for crossing that line, and what it changes for the way you build and run frontends.
The four levels, in one breath
- Level 0 — one endpoint, one verb. RPC tunnelled over HTTP.
- Level 1 — resources.
/orders/8f3a2cinstead of/api?do=getOrder. - Level 2 — verbs and status codes.
GET,POST,DELETE,200,404,409. - Level 3 — hypermedia controls. The response tells the client what it can do next — the available transitions travel with the data.
Levels 0→2 are about addressing and transport. Level 3 is about control: the server stops returning only state and starts returning state plus the set of actions valid on it right now.
Why teams stop at level 2
Because level 2 already "works". The frontend knows the URL templates, knows the verbs, hardcodes them, and ships. The cost is invisible at first — then it compounds:
- Every authorization rule on the server is re-implemented on the client ("only the owner can cancel", "can't cancel once shipped"). Two copies of one rule, drifting.
- Routes and verbs are baked into the frontend. Rename or move an endpoint and you ship a frontend release to match.
- The UI guesses domain state. A button is shown, the user clicks, the server answers
409 Conflict— the frontend was a step behind reality.
These aren't bugs in the code. They're a consequence of where the knowledge lives.
The DDD framing
In domain-driven design, an aggregate owns its invariants and its state machine: which transitions are legal depends on the aggregate's current state and the caller's role. cancel is legal on a pending order owned by the caller; it is meaningless on a shipped one.
Level 2 leaks that state machine across the wire: the backend enforces it, and the frontend re-derives it to decide whether to show a button. The transition rules of a single aggregate now live in two codebases, in two languages, maintained by two teams.
Level 3 keeps the state machine where it belongs. An affordance is a domain transition, made explicit on the wire. The server computes the legal transitions for this aggregate, in this state, for this caller — and emits exactly those. The frontend reads them. The ubiquitous language (cancel, publish, refund) travels as rel names instead of being re-encoded as client-side if branches.
// GET /orders/8f3a2c — a pending order, owned by the caller
{
"id": "8f3a2c",
"status": "pending",
"_actions": {
"cancel": { "href": "/orders/8f3a2c/cancel", "method": "POST" },
"refund": { "href": "/orders/8f3a2c/refund", "method": "POST" }
}
}
// once shipped, the same call simply omits "cancel" — the transition is goneThe client never asks "is this order cancellable?". It asks "did the server offer cancel?" — and the answer is authoritative by construction.
What changes for the frontend
This is the factual payoff. Crossing to level 3 changes how a frontend is built and maintained:
- Authorization and business rules live once. The presence of a link is the permission. The frontend stops carrying a second, drifting copy of the server's rules.
- No hardcoded URLs or verbs.
hrefandmethodcome from the response. Reshape your routing and clients follow without a release. - The UI tracks domain state by construction. When a transition becomes illegal, the rel disappears and the control with it — no stale buttons, far fewer
409-after-click surprises. - Fewer frontend deploys. Authorization changes, state-machine changes, and feature flags become server-side decisions. The frontend already renders whatever it's handed.
- Smaller blast radius. A backend change can't silently break a frontend assumption that no longer exists — the assumption was deleted along with the duplication.
The trade is real but small: responses carry a little more metadata, and the frontend gives up knowing the API in exchange for reading it. In return you delete an entire category of front/back drift bugs.
The API discovers itself
There's a second payoff, beyond the frontend: the API becomes self-describing. A caller doesn't need an out-of-band map of every endpoint — it fetches one resource and reads the actions attached to it. The response is the documentation of what's possible next.
// GET /orders/8f3a2c
{
"id": "8f3a2c",
"status": "pending",
"_self": { "href": "/orders/8f3a2c", "method": "GET" },
"_actions": {
"track": { "href": "/orders/8f3a2c/tracking", "method": "GET" },
"cancel": { "href": "/orders/8f3a2c/cancel", "method": "POST" },
"refund": { "href": "/orders/8f3a2c/refund", "method": "POST" }
}
}From that single call, a consumer — human or machine — learns the available transitions, where each one lives, and how to invoke it. No spec to fetch on the side, no tribal knowledge about which verb goes where. Follow a link, get the next resource, read its actions, repeat: the API is explored by traversal — the way you browse a site by following links rather than memorising URLs.
That's concrete leverage:
- Onboarding shrinks. A new frontend developer reads live responses, not a separate spec that may already be stale.
- The contract can't lie. The actions a caller sees are computed from real state, so the "documentation" is always in sync with what the server will actually accept.
- Clients can stay generic. Tooling, admin panels, and tests can drive the API purely by following rels, without hardcoding the route table.
The user journey lives — and is tested — on the server
Because each transition is decided server-side, the sequence of legal transitions — the user journey — lives there too. "A pending order can be cancelled or refunded; once shipped, only track remains" is a backend fact, not an emergent property of frontend code.
That makes the journey testable at its source. You can assert, against real state, that the right affordances appear and disappear as the aggregate moves through its lifecycle:
// the owner of a pending order is offered cancel + refund
const pending = await GET('/orders/8f3a2c', { as: owner })
expect(can(pending, 'cancel')).toBe(true)
expect(can(pending, 'refund')).toBe(true)
// once shipped, the cancel transition is gone — for everyone
const shipped = await ship(order)
expect(can(shipped, 'cancel')).toBe(false)
expect(can(shipped, 'track')).toBe(true)A suite like this is a guarantee about the journey itself: every state offers exactly the transitions it should, to exactly the callers who should have them. The frontend can trust that contract instead of re-testing the same rules through the UI — the navigation is verified once, where the rules live.
Where Affordant fits
Affordant is the machinery for level 3 on both sides of the wire, over one shared contract:
@affordant/serveremits the affordances, gating each transition on authoritative state — where your domain rules already live.affordantreads them on the client:can/actionFor/follow.@affordant/reactwraps those as hooks.
You don't adopt a framework to reach level 3 — you adopt a convention. Affordant just makes the convention typed, symmetric, and hard to let drift.
Next steps
- See the wire contract for the exact envelope.
- Read server side to emit affordances from authoritative state.
- Browse framework usage for rendering off
can().