API Design

Architectural Styles · Lesson 07

Client-adapting APIs

A single backend must serve a terse mobile app on a 3G signal, a data-hungry web dashboard, and a partner integration that wants things its own way — all at the same time. Solving that tension without forking your codebase is what client-adapting APIs are about.

⏱ 14 min Difficulty: advanced Prereq: as-06-comparison.html

By the end you'll be able to

The problem: one shape does not fit all

Imagine a /users/{id} endpoint that returns a full user record: name, email, address, preferences, last-100-login timestamps, billing details, and avatar URL. A native mobile app on a metered data plan needs only the name and avatar. A web dashboard needs everything. A partner integration needs a different subset in a different field-naming convention. If you return the full record to everyone, you are taxing mobile users with bytes they discard and leaking billing fields to clients that should not see them.

Think of it like a newspaper printing run: the core news story is the same regardless of whether it goes to a broadsheet, a tabloid, or a digital summary. The compositor shapes the same content into the right form for the right medium without changing the story itself. Your API needs a compositor layer.

Technique 1 — HTTP content negotiation

HTTP has built-in vocabulary for clients to declare their preferences. The Accept header family lets a client say "I want this media type, this language, and I can handle this encoding." The server then honours the best match it supports and confirms via the response Content-Type header. This is called proactive negotiation (or server-driven negotiation).

Accept — media-type negotiation

A client that can render either JSON or MessagePack (a compact binary format) can list both and assign relative quality with q weights:

GET /v1/reports/2024-Q4 HTTP/1.1
Host: api.example.com
Accept: application/x-msgpack;q=0.9, application/json;q=0.8

# Server responds with the highest-quality format it supports
HTTP/1.1 200 OK
Content-Type: application/json   # server chose JSON; it does not speak msgpack
Vary: Accept                        # tells caches: response differs by Accept

If the server cannot satisfy any listed type it returns 406 Not Acceptable.

Accept-Language — localisation without multiple endpoints

Rather than publishing /en/products and /fr/products as separate routes, a single endpoint reads Accept-Language: fr-CA, fr;q=0.9, en;q=0.8 and returns translated content. The canonical URL is preserved, which matters for cache efficiency.

Accept-Encoding — on-the-wire compression

Every HTTP response can be compressed transparently. A client declares Accept-Encoding: br, gzip (Brotli preferred, gzip fallback). The server compresses and adds Content-Encoding: br to the response. The client decompresses automatically. A 200 KB JSON payload typically shrinks to 15–30 KB under Brotli — an 85% saving that costs a few milliseconds of CPU.

✅ Always include Vary

Whenever a response depends on a request header (Accept, Accept-Language, Accept-Encoding), echo that header in the response Vary field. This signals to every intermediate cache — CDN, proxy, browser — that different header values produce different responses and must not be served interchangeably.

Technique 2 — sparse fieldsets / field selection

Content negotiation handles format, but not which fields. For that, a common pattern is a fields (or select) query parameter that lets the caller enumerate only the fields it needs.

# Mobile app: I only need name and avatar to paint a list row
GET /v1/users/42?fields=id,name,avatar_url

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "name": "Priya Sharma",
  "avatar_url": "https://cdn.example.com/avatars/42.webp"
}

# Web dashboard: give me everything
GET /v1/users/42   # no fields param → full record

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "name": "Priya Sharma",
  "avatar_url": "https://cdn.example.com/avatars/42.webp",
  "email": "priya@example.com",
  "address": { "city": "Bangalore", "country": "IN" },
  "billing_tier": "pro",
  "created_at": "2023-01-12T08:00:00Z"
}

Google's JSON API style guide calls this concept partial responses and uses the same fields parameter across its APIs. The server fetches or computes only the needed sub-tree, which can meaningfully reduce both network bytes and database reads when done at the query layer.

Technique 3 — the Backend-for-Frontend (BFF) pattern

Sometimes the adaptation required is too complex for a query parameter. A mobile home screen might need data stitched from three microservices, flattened into a single payload, with mobile-specific field names. A generic API cannot absorb that complexity without becoming a mess of special cases.

The Backend-for-Frontend (BFF) pattern solves this by introducing a thin server-side layer — one BFF per client type — that speaks the core APIs but translates, aggregates, and reshapes responses for its specific client. The BFF lives on the server (fast network to microservices, no CORS issues) and exposes a narrow surface tuned to exactly what its client needs.

This pattern is closely related to the API gateway (covered in rel-04): a gateway handles cross-cutting concerns like auth and rate limiting; a BFF handles client-specific composition. They can coexist or be merged.

Core API microservices Mobile BFF (reshape/trim) Mobile app 3G · sparse payload Web dashboard Accept + ?fields Partner BFF (rename/filter) Partner API custom schema BFF BFF direct + negotiation
Mobile and partner clients route through thin BFF layers that reshape payloads; the web client hits the core API directly using content negotiation and sparse fieldsets.

Per-client versioning

When a native mobile app ships a new version, old installs remain in the wild for months. A BFF lets you version the client-facing surface independently: v2 of the mobile BFF can consume a new microservice field without forcing the web BFF or partners to change. This is far cheaper than global API versioning (covered in rel-01) because the blast radius of each change is bounded to one client type.

🎯 Interview angle

A common system design question is: "How would you design one API to serve a mobile app, a web app, and third-party partners?" A strong answer names the tool for each need: content negotiation and sparse fieldsets for lightweight adaptation; a BFF per client type when the shapes diverge significantly; an API gateway below both for shared cross-cutting concerns. State the trade-off: BFFs add an operational surface; skip them until the client shapes genuinely diverge.

⚠️ Common trap

Designing a one-size-fits-all response that is optimised for the most complex client (usually the web dashboard). Mobile clients then over-fetch on every call — downloading and parsing bytes they throw away. On a slow connection this wastes seconds per interaction and drains battery. The fix costs nothing at design time: add a fields parameter and document it.

Under the hood: content negotiation on the wire

Content negotiation looks simple from the outside — client says what it wants, server picks. Under the hood it is a precise algorithm that runs on every request and has subtleties that affect caching and correctness.

The full negotiation exchange, header by header

// Client request — declares all preferences in one shot
GET /v1/reports/2024-Q4 HTTP/1.1
Host: api.example.com
Accept: application/json;q=1.0, application/x-msgpack;q=0.9, */*;q=0.1
Accept-Language: fr-CA;q=1.0, fr;q=0.9, en;q=0.7
Accept-Encoding: br;q=1.0, gzip;q=0.9, identity;q=0.5

// Server selection algorithm (per RFC 7231 §5.3):
//   1. For each Accept dimension, filter to types the server can produce.
//   2. Within those, pick the one with the highest q value.
//   3. If q is equal, prefer the more specific type (application/json > */*)
//   4. If nothing matches any listed type → 406 Not Acceptable

// Server supports: JSON only / French and English / gzip only
// Selection:
//   Content-Type: application/json (q=1.0 — only match server has)
//   Content-Language: fr (q=0.9 — fr-CA not available, fr is the next best)
//   Content-Encoding: gzip (q=0.9 — br not available, gzip next best)

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: fr
Content-Encoding: gzip
Vary: Accept, Accept-Language, Accept-Encoding
                                 ^--- CRITICAL: tells every cache these three headers
                                      change the response body; cache separately for each
                                      combination — a French gzip and an English plain
                                      response must not share a cache slot

The Vary header is not optional decoration. Without it a CDN will cache the first response it sees and serve it to all subsequent clients, regardless of their Accept-Language or Accept-Encoding. A French-speaking user would receive cached English content; a client that cannot decompress brotli would receive cached brotli bytes. The Vary header turns the cache key from just the URL into URL + Accept + Accept-Language + Accept-Encoding.

Sparse fieldset request — how the server parses and applies it

The ?fields= parameter is a server-side convention, not an HTTP standard — so the server must implement the parsing. Here is the typical implementation pattern and its edge cases:

// Request
GET /v1/users/42?fields=id,name,address.city,preferences.theme

// Server parse step — split on comma, build a field allowlist
allowed = {"id", "name", "address.city", "preferences.theme"}

// Server apply step — walk the serialised object, keep only allowed paths
// Full object in the database:
{
  "id": 42,                          // ✓ in allowlist
  "name": "Priya Sharma",            // ✓ in allowlist
  "email": "priya@example.com",      // ✗ omitted
  "address": {
    "city": "Bangalore",             // ✓ via "address.city"
    "country": "IN",                // ✗ "address.country" not in list
    "postcode": "560001"            // ✗ omitted
  },
  "preferences": {
    "theme": "dark",               // ✓ via "preferences.theme"
    "notifications": true          // ✗ omitted
  },
  "billing_tier": "pro"             // ✗ omitted
}

// Response body after field filter applied
{
  "id": 42,
  "name": "Priya Sharma",
  "address": { "city": "Bangalore" },
  "preferences": { "theme": "dark" }
}

Three important implementation details: (1) if the database query selects all columns then applies the filter in the serialiser, the database reads are unchanged — the savings are only in network bytes and deserialization time. For real savings, push the field list into the SELECT clause or projection. (2) Nested paths like address.city mean the parent key address must still appear in the output (as an object) even though address.country is omitted. (3) A request for a field that doesn't exist should be silently ignored — not a 400 — to keep clients decoupled from schema evolution.

The full wire exchange for a negotiated sparse-fieldset request

$ curl -v "https://api.example.com/v1/users/42?fields=id,name,avatar_url" \ -H "Accept: application/json" \ -H "Accept-Encoding: br, gzip" \ -H "Accept-Language: fr, en;q=0.8" * Trying 203.0.113.12:443... * Connected to api.example.com (203.0.113.12) port 443 * HTTP/2 stream 1 was created > GET /v1/users/42?fields=id,name,avatar_url HTTP/2 > Host: api.example.com > Accept: application/json > Accept-Encoding: br, gzip > Accept-Language: fr, en;q=0.8 < HTTP/2 200 < content-type: application/json; charset=utf-8 < content-encoding: br < content-language: fr < vary: Accept, Accept-Encoding, Accept-Language < content-length: 47 < {"id":42,"name":"Priya Sharma","avatar_url":"https://cdn.example.com/avatars/42.webp"} # ^ brotli-decompressed by curl; raw wire bytes are 47 (vs ~112 uncompressed)

How to debug & inspect it

Content negotiation and sparse fieldset bugs are often invisible — the server returns 200, the client gets data, but it is the wrong data, the wrong language, or a bloated payload. The fix requires inspecting the exact headers exchanged.

Testing Accept variants with curl

# Test that the server honours Accept — request msgpack, expect 406 if not supported $ curl -I -H "Accept: application/x-msgpack" https://api.example.com/v1/users/42 HTTP/2 406 content-type: application/problem+json # 406 is correct — the server can't satisfy the Accept request # Verify language negotiation $ curl -sI -H "Accept-Language: fr" https://api.example.com/v1/users/42 \ | grep -i "content-language\|vary" content-language: fr vary: Accept-Language # If content-language is missing, localisation is not actually happening # If vary is missing, a CDN may cache the English response and serve it to French clients # Verify encoding negotiation and actual compression savings $ curl -so /dev/null -w "%{size_download}\n" \ -H "Accept-Encoding: gzip" https://api.example.com/v1/users/42 312 $ curl -so /dev/null -w "%{size_download}\n" https://api.example.com/v1/users/42 1247 # 312 vs 1247 bytes — 75% savings confirmed; if both numbers are identical, # the server is not compressing even though it told the client it could # Test sparse fieldsets — confirm only requested fields come back $ curl -s "https://api.example.com/v1/users/42?fields=id,name" | python3 -m json.tool { "id": 42, "name": "Priya Sharma" } # If "email" or "billing_tier" appear, the field filter isn't being applied # Confirm Vary is set correctly (critical for cache correctness) $ curl -sI https://api.example.com/v1/users/42 | grep -i vary vary: Accept, Accept-Encoding, Accept-Language # Missing Vary: Accept-Encoding is a bug — CDN will serve compressed bytes # to a client that didn't send Accept-Encoding # Check CDN is caching per-language correctly (two requests, different languages) $ curl -sI -H "Accept-Language: en" https://api.example.com/v1/users/42 | grep -i "x-cache\|age" x-cache: MISS $ curl -sI -H "Accept-Language: fr" https://api.example.com/v1/users/42 | grep -i "x-cache\|age" x-cache: MISS # Both should MISS first time (different cache keys), then HIT independently # If the second shows HIT immediately, Vary: Accept-Language is being ignored by the CDN
SymptomLikely causeFix
Client receives English response despite Accept-Language: frVary: Accept-Language missing — CDN served a cached English responseAdd Vary: Accept-Language to all responses that vary by language; purge the CDN cache
Client receives brotli bytes it can't decodeServer set Content-Encoding: br but CDN cached it and served to a client without Accept-Encoding: br; Vary: Accept-Encoding was missingAdd Vary: Accept-Encoding to every compressed response
Response to ?fields=id,name still includes all fieldsField filter not implemented; or parameter name mismatch (server reads select, client sends fields)Verify the server middleware reads and applies the correct parameter name; add a test for the filtered path
406 Not Acceptable when client sends Accept: application/jsonServer has a bug — it either doesn't check Accept or doesn't match application/jsonConfirm the server's content-negotiation logic matches application/json against its supported types; check for a charset suffix mismatch (application/json;charset=utf-8 vs application/json)
CDN cache hit rate is 0% on a language-varied endpointVary with many headers creates too many cache keys — CDN drops themUse Vary only for dimensions that genuinely change the response; consider separate URL paths per language for high-traffic content
A nested field like address.city returns the full address objectField filter applies only to top-level fields, not dot-notation pathsImplement a recursive field-path walker in the serialiser; or document that only top-level fields are supported

Debug checklist:

  1. Use curl -v to see both request headers you sent and response headers you received — look for Content-Type, Content-Language, Content-Encoding, and Vary.
  2. Check Vary on every response that varies by a request header — missing Vary is a silent cache-poisoning bug.
  3. Test sparse fieldsets by requesting exactly two fields and confirming with python3 -m json.tool that no extra fields appear.
  4. Test 406 behaviour: send an Accept header for a format you know the server doesn't support — it should return 406, not a 200 with the wrong content type.
  5. Run two requests with different Accept-Language values, then a third with the first language again — the third should hit the CDN cache (x-cache: HIT, Age: >0) while the second with the new language was a MISS.

🧠 Quick check

1. A client sends Accept: application/msgpack;q=1.0, application/json;q=0.5 and the server only speaks JSON. What status code should the server return?

The client listed JSON at q=0.5, which means "I accept JSON, just prefer msgpack." The server picks the best match it can serve (JSON) and returns 200. A 406 would only be correct if the client listed no formats the server supports.

2. Why must a server include Vary: Accept-Encoding when it conditionally compresses responses?

Vary signals that the response body varies with the named request header. Without it, a CDN could cache the Brotli-compressed version and serve it to a client that declared no Accept-Encoding — sending unreadable bytes.

3. When is a Backend-for-Frontend the right choice over a ?fields= parameter?

A sparse fieldset only trims fields from a single resource. A BFF earns its cost when each client type needs a fundamentally different composition of data — stitched from multiple services, renamed, or restructured. Adding a BFF for minor differences is over-engineering.

4. A response that varies by Accept-Language should also include which header?

Vary: Accept-Language ensures caches key on the language header, so a French speaker never receives a cached English response. There is no need to disable caching entirely — you just need correct cache keying.

✍️ Exercise: design a field-selection API for a social feed endpoint

You are designing GET /v1/feed, which returns a list of post objects. Each post has: id, author_id, author_name, author_avatar, body, media_urls (array of up to 10 URLs), like_count, comment_count, created_at, and raw_ml_signals (an internal object used by the recommendation model).

A mobile client with a slow connection only needs to paint a list row. A web client wants the full post. An internal analytics service needs all fields including ML signals.

Design the fields parameter, decide what the default (no parameter) returns, and explain how you would gate raw_ml_signals so only the internal service can request it.

Model answer:

# Mobile: minimal list row
GET /v1/feed?fields=id,author_name,author_avatar,body,like_count,comment_count

# Web: everything except sensitive ML field (default — no fields param)
GET /v1/feed

# Internal analytics: all fields including ML signals (scoped API key required)
GET /v1/feed?fields=*,raw_ml_signals
Authorization: Bearer INTERNAL_KEY_abc

Default (no fields param) returns the public-safe full record, excluding raw_ml_signals. The internal field is only returned when explicitly requested and the bearer token carries an internal:feed:ml scope. If a public token requests raw_ml_signals, the field is silently omitted (not a 403 — to avoid revealing that the field exists).

Rubric: ✓ defines a fields parameter ✓ explicit default behaviour ✓ shows mobile vs web difference ✓ gates sensitive field on auth scope ✓ handles the security edge case (silent omission vs 403) — all five = strong answer.

Key takeaways

Sources & further reading