Consumer-First Design
APIs exist for consumer tasks, not for the provider's data model. A mobile dashboard, a partner's invoice integration and an internal inventory call want different granularity, different fields and different guarantees from the same domain.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Tasks, not tables
The unit of API design is the consumer task: "load the dashboard", "create an invoice and know it was created exactly once", "reserve inventory for this order". Consumers do not want your entities; they want their task finished. An API that mirrors the schema forces every consumer to reassemble tasks out of entity fragments — the Over-Fetching and Under-Fetching and chatty-API problems are both downstream of this one decision.
Writing the task list first also exposes the real variance between consumers. "Load the dashboard" needs one aggregated read, tolerates thirty seconds of staleness and must survive a lossy network. "Reserve inventory" needs strong consistency, an idempotency story and a two-digit-millisecond budget. No single endpoint style serves both well — and that is information, not a problem.
| Consumer | Task | What the environment demands |
|---|---|---|
| Mobile app | Load dashboard, update profile, upload image | Few round trips, small payloads, tolerance for stale data, resumable uploads. Old versions live for months. |
| External developer | Create invoice, query status, receive webhook | Stable errors, Idempotency Keys: The Mechanism, documentation that is the whole truth, a compatibility horizon measured in years. |
| Internal service | Reserve inventory, fetch pricing, emit payment result | Typed contracts, low latency, high volume, fast evolution with tracked consumers. See gRPC: Schema, Codegen and Streams. |
Consumer context changes the right answer
Granularity is the clearest example. For the mobile client, a coarse GET /dashboard that aggregates five entities is a gift: one round trip on a 300ms-RTT radio instead of five. For the internal service, the same aggregation is waste — it needs exactly one price, ten thousand times a minute, and every extra joined entity is latency and load. Serve both from one endpoint and you will serve both badly.
The same divergence appears in every later lesson: pagination style depends on whether the consumer resumes or jumps; error detail depends on whether a human or a program reads it; versioning policy depends on whether you can force consumers to upgrade. Consumer context is not a nice-to-have persona exercise — it is the input the rest of the design consumes.
When consumer needs genuinely conflict, the answer is usually not a compromise endpoint but a deliberate split: a Backend for Frontend for the experience-shaped reads, and narrow capability APIs for the service-to-service calls.
Write the client code first
The cheapest consumer-first technique costs one page: before designing the API, write the code a consumer would *like* to write for each task — the ideal SDK call, the ideal fetch sequence for the screen. If the imagined code is awkward, the API it implies will be worse, because real consumers also handle errors, retries and empty states.
This is the API-design version of test-first: the "test" is the consumer's program. It catches chattiness ("why does rendering one screen take eight awaits?"), missing aggregates, and guarantee gaps ("this retry loop is unsafe — nothing here is idempotent") while they are still free to fix.
1const dash = await api.dashboard.load() // one round trip, staleness OK2render(dash)3 4const upload = await api.images.createUpload({ bytes: file.size })5await putWithResume(upload.url, file) // direct to storage, resumable6await api.profile.update({ avatar: upload.id }) // safe to retry: idempotentKey points
- Design for consumer tasks, not entities; consumers want their task finished in the calls their environment can afford.
- Different consumers legitimately need different granularity, freshness and guarantees from the same domain.
- Conflicting consumer needs are resolved by splitting surfaces (BFF + capability APIs), not by one compromise endpoint.
- Writing the ideal client code first exposes chattiness and guarantee gaps before they ship.
- Consumer context is the input to every later decision: pagination style, error detail, versioning policy.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: exposes the entity model directly because it is what the ORM already has.
- 2Mobile client → API: needs eight calls to render one screen; ships them because there is no alternative.
- 3Users → app: the screen takes seconds on mobile networks; the app team adds a caching layer with its own bugs.
- 4Internal service → API: fetches a heavyweight aggregate to read one field; p99 latency and payload cost climb.
- 5Team → API v2: a "performance rewrite" is scheduled — actually a consumer-first redesign, done late and under pressure.
- Mobile experience: latency multiplies by round trips; every screen pays the chattiness tax.
- Backend load: N calls per screen instead of one; the provider scales infrastructure to compensate for contract shape.
- Team velocity: every client builds and maintains its own aggregation/caching layer to survive the entity-shaped API.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Enumerate consumers and their tasks first; make "which consumer, which task?" a required field on every endpoint proposal.
- • Shape reads around tasks (screens, workflows) and writes around domain operations, not around tables.
- • Split surfaces when consumer needs conflict: experience APIs for clients, capability APIs for services (see [[backend-for-frontend]]).
- • Prototype the ideal consumer code for each task and review the API against it.
- • Count round trips per consumer task in real traffic: a screen that needs more than two sequential calls is a contract smell.
- • Watch which fields consumers actually read (SDK telemetry, GraphQL field stats, sampled logging): entity-shaped responses show single-digit field usage.
- • Track per-consumer latency budgets separately; an internal p99 regression and a mobile round-trip regression have different fixes.
- • Task-shaped endpoints evolve with the product: when the dashboard changes, its endpoint changes, without disturbing the capability APIs underneath.
- • New consumer types get new surfaces rather than mutations of existing ones — the partner API stays stable while the BFF iterates weekly.
- • More surface to own: task-shaped endpoints multiply with consumer types, and each needs an owner and tests.
- • Aggregated reads couple the aggregator to several sources; its latency is their slowest member (see [[api-composition]]).
- • Task-shaped APIs are opinionated: a consumer with a genuinely new task needs API work, where a generic entity API would have (badly) let them self-serve.