pa-02 — API Design Across REST, gRPC, and Events
Once you've drawn service boundaries (pa-01), the contracts across those boundaries are what keep services decoupled over time. The Apple JD asks for "strong API design abilities across REST, gRPC, and event-driven interfaces." The architect's job here is less "design a pretty endpoint" and more "design interfaces that evolve without breaking the dozens of consumers you'll never meet."
This lab builds the mechanics behind durable contracts: a compatibility
checker (the rules a tool like buf breaking enforces), an
idempotency store (safe retries of non-idempotent operations), and
opaque pagination cursors (change the implementation without breaking
the contract).
1. What is it?
An API contract is the explicit, versioned interface a service exposes plus its guarantees. Three styles, same goal (let producer and consumer evolve independently):
- REST — resources + verbs over HTTP; contract = the resource shapes, status codes, idempotency semantics, pagination, and versioning.
- gRPC — typed RPC over HTTP/2 (gw-02) with protobuf schemas;
contract = the
.protoand its backward/forward-compatibility rules. - Events — async messages (pa-03/04); contract = the event schema in a schema registry with compatibility rules, plus delivery guarantees (ordering, at-least-once).
Compatibility is the property that makes a contract a contract: backward (new consumers read old producers' data) and forward (old consumers read new producers' data). Protobuf's tag-numbered fields make this tractable; the checker here encodes the rules.
2. Why does it matter?
-
It's what makes independent deployability real. Boundaries (pa-01) only decouple if a producer can change without a lockstep consumer deploy. That requires compatible contract evolution — and automated enforcement, or it silently rots.
-
The dangerous changes are invisible without rules. Reusing a protobuf tag, changing a field's type, or tightening optional→required doesn't fail to compile — it corrupts data or breaks consumers at runtime, in production, far from the change. A compatibility gate in CI is the architect's seatbelt.
-
Idempotency is non-negotiable at scale. Networks retry; queues deliver at-least-once (pa-03/05); gateways retry (gw-06). Without idempotency keys, "create order" and "charge card" double-apply. Designing idempotency into the contract is core API design.
-
Opaque contracts preserve freedom. An opaque cursor lets you change pagination from offset to keyset without breaking clients; a leaked internal id or an offset-based contract freezes your implementation. Good API design hides what should be free to change.
3. How does it work?
Contract compatibility (the rules in code)
Protobuf identifies fields by a stable tag number, not position or
name — that's what lets schemas evolve. The checker (Check(old, new))
applies the rules the ecosystem enforces:
| Change | Verdict | Why |
|---|---|---|
| add an optional field | safe | old data lacks it → default; old readers ignore it |
| add a required field | breaking | old messages can't satisfy it |
| remove a field | warning | reserve the tag so it's never reused |
| change a field's type (same tag) | breaking | wire-incompatible; data misread |
| rename (same tag/type) | warning | wire-OK; source-level change |
| optional → required | breaking | old producers may omit it |
| required → optional | safe | loosening |
HasBreaking is the CI gate: block a merge that breaks the wire.
Idempotency (safe retries)
An idempotency key (client-supplied, unique per logical operation)
lets the server dedupe retries: first call executes and caches the
result; retries return the cached result without re-executing
(IdempotencyStore.Do). Failures aren't cached, so genuine retries of a
failed op still run. This is how non-idempotent operations (POST /orders,
charge) become safe under at-least-once delivery and client retries.
Pagination cursors (opaque contracts)
A cursor encodes the position opaquely (here: offset + a checksum, base64'd). The client treats it as a blob; the server can change the underlying scheme (offset → keyset) without a contract change, and a tampered cursor is rejected rather than silently returning wrong data. The lesson generalizes: expose intent, hide implementation.
Versioning strategies (when compatibility isn't enough)
in-place evolution: add optional fields; never break — preferred.
URI/version header: /v1, /v2 (REST) for breaking changes; run N versions.
new RPC/method: gRPC — add GetUserV2 rather than break GetUser.
new event type/topic: events — emit OrderPlacedV2 alongside V1 during migration.
The architect picks the cheapest path that keeps consumers working, and sunsets old versions on a deprecation schedule (a migration, gw-12).
4. Core terminology
| Term | Definition |
|---|---|
| API contract | The explicit, versioned interface + guarantees a service exposes. |
| Backward / forward compatibility | New code reads old data / old code reads new data. |
| Tag number | Protobuf's stable per-field id; the basis of safe evolution. |
| Schema registry | A store of event/message schemas + compatibility enforcement. |
| Idempotency key | Client-supplied key letting the server dedupe retries of a non-idempotent op. |
| Idempotent operation | One that can be applied multiple times with the same effect as once. |
| Cursor pagination | Opaque position token; decouples the API from the storage scheme. |
| Deprecation | The scheduled sunset of an old contract version. |
| Wire-breaking change | A change that corrupts serialization (tag reuse, type change). |
5. Mental models
-
A contract is a promise you keep while changing your mind. It frees you to rewrite internals. The moment a consumer depends on something not in the contract (field order, an internal id, an offset), you've silently narrowed the promise and recoupled.
-
Tag numbers are seat assignments, not row order. Protobuf reads by seat number (tag), so you can reorder, rename, or add seats freely — but you must never give an old seat number to a different passenger (reuse a tag) or change who sits there (change a type). "Reserve the tag" is "keep the seat empty forever."
-
Idempotency keys are coat-check tickets. You hand in your coat (operation) once and get a ticket (key). Show the ticket again and you get the same coat back — not a second coat. At-least-once delivery hands you the ticket multiple times; the coat-check applies the operation once.
-
An opaque cursor is a valet ticket, not your car keys. You can't use it to drive (peek at the offset / forge a position); you hand it back and the valet fetches the next page. Hiding the mechanism lets the valet reorganize the lot (offset → keyset) without changing your ticket.
6. Common misconceptions
-
"Versioning = put /v2 in the URL." That's the last resort (a breaking change you must run in parallel and migrate off). Most evolution should be in-place via compatible changes; reserve new versions for genuinely breaking ones.
-
"Adding a field is always safe." Adding an optional field is; adding a required one breaks old producers, and reusing a retired tag corrupts the wire. Run a compatibility check; don't eyeball it.
-
"GET is idempotent so I'm fine." The problem is the writes (POST/ PATCH) under retries and at-least-once delivery. Idempotency keys are for the non-idempotent operations, which is most of the interesting ones.
-
"Offset pagination is fine." It leaks the storage model into the contract (you can't switch to keyset later without breaking clients) and is incorrect under concurrent inserts (items shift between pages). Opaque cursors fix both.
-
"REST vs gRPC vs events is a religious choice." It's per-edge: gRPC for typed internal RPC (gw-02), REST for broad external reach, events for decoupling/fan-out (pa-03). One platform uses all three; the architect chooses per interface based on coupling and reach.
7. Interview talking points
-
"How do you evolve an API without breaking consumers?" Compatible- change rules (add optional, never reuse tags, never change types), enforced by a compatibility check in CI (show
buf breaking/ schema-registry compat). Breaking changes get a new version run in parallel + a deprecation schedule. Name backward vs forward compatibility. -
"How do you make a non-idempotent API safe under retries?" Client-supplied idempotency keys; the server dedupes (first executes + caches, retries return the cached result), doesn't cache failures, and expires keys. Essential under gw-06 retries and pa-03/05 at-least-once delivery.
-
"REST vs gRPC vs events — when each?" gRPC: typed, low-latency internal RPC with strong schemas (HTTP/2, gw-02). REST: broad/external reach, cacheability, simplicity. Events: decoupling, fan-out, load-leveling, audit (pa-03). Per-edge decision driven by coupling and consumer reach.
-
"Design pagination." Opaque cursors (encode position + integrity), not offsets — so you can change the storage scheme without breaking the contract and stay correct under concurrent writes. Bound page size; make the cursor tamper-evident.
-
"How do you govern contracts across 50 teams?" A schema registry with enforced compatibility, contract checks in CI (a fitness function, pa-10), consumer-driven contract tests, and a deprecation policy. The architect provides the paved road so teams evolve safely without a central bottleneck.
8. Connections to other labs
- pa-01 — contracts are what keep the boundaries you drew decoupled over time.
- pa-03 / pa-04 — event/message schemas are contracts too, governed by a registry with the same compatibility rules.
- pa-05 — idempotency keys make at-least-once delivery and retried/replayed messages safe (effectively-once).
- pa-10 — the compatibility check becomes a fitness function in CI; consumer-driven contract testing is a testing strategy.
- gw-02 — gRPC/HTTP-2 wire format; gRPC status in trailers.
- gw-06 — retries are why idempotency matters; gw-12 runs version deprecations as migrations.