skip to content

Some HTTP APIs let a caller write something like `?expand[]=customer` so that a related object arrives inline instead of as a bare id. Explain what such an expansion parameter is for, and what constraints you would put on it when designing the API.

level: middleimportance: should knowfreq 40%

answer

  1. expand[]=customer → id becomes object
  2. kills client N+1 round trips
  3. allow-list paths + max depth
  4. to-many expansion = unpaginated list
  5. authorize child as if fetched directly

basics

~20 s

Expansion inlines a related resource that would otherwise be just an id, so the caller avoids a second round trip. Constrain it: an explicit allow-list of expandable paths, a maximum depth, no expansion of unbounded collections, and permission checks on the expanded object.

solid answer

~50 s

By default a resource references its relations by id (`"customer": "cus_123"`), which forces the client into an N+1 pattern: fetch the order, then fetch the customer. An expansion parameter — `?expand[]=customer`, `?expand[]=customer.default_source` — tells the server to substitute the full sub-object inline for the named paths. Design constraints I would impose: - **Allow-list, not reflection.** Only declared paths are expandable; anything else is a `400`. Otherwise callers walk arbitrary object graphs. - **Bounded depth** (commonly 2–4 segments) so `a.b.c.d.e` cannot fan out. - **No unbounded collections.** Expanding a to-many relation inlines an unpaginated list; either forbid it or return a capped, envelope-wrapped slice. - **Authorization on the expanded object**, evaluated as if it were fetched directly. - **Predictable typing**: the field is `string | object`, so document that clients must handle both, or make expansion change only the value, never the field name. - **Cost accounting**: expansion multiplies backend calls, so it belongs in the rate-limit/timeout budget.

code

http · 11 lines
http
GET /v1/orders/or_42 HTTP/1.1

200 OK
{ "id": "or_42", "customer": "cus_123", "total": 4900 }

GET /v1/orders/or_42?expand[]=customer HTTP/1.1

200 OK
{ "id": "or_42",
  "customer": { "id": "cus_123", "object": "customer", "email": "[email protected]" },
  "total": 4900 }

go deeper

for a junior

Know that expansion inlines a related object so the client avoids a second request, and recognise the expand[]= shape.

for a middle

Add the constraints: allow-listed paths, depth limits, the string-or-object union type, and why to-many expansion is dangerous.

for a senior

Lead with authorization of the expanded resource, fan-out cost against rate limits and timeouts, and cache/ETag consequences.

for a principal

Decide where expansion belongs at all: which relations should simply be embedded always, which should stay links with independent caching, and how expansion weight enters quota and SLO budgets.

## Why expansion exists REST representations normally reference other resources by identifier or link rather than embedding them. That keeps each representation small, cacheable, and singly-owned. The cost falls on the client: rendering an order row that shows the customer's name means `GET /orders/42`, read `customer: "cus_123"`, then `GET /customers/cus_123`. For a list of fifty orders that is the classic N+1 round-trip problem, and on a high-latency link the round trips dominate everything else. An **expansion parameter** lets the caller opt into inlining. The canonical shape (popularised by Stripe) is a repeated bracketed parameter: `GET /v1/charges/ch_1?expand[]=customer&expand[]=invoice.subscription` Each value is a dot-path from the resource root. Where the unexpanded response had `"customer": "cus_123"`, the expanded one has `"customer": { "id": "cus_123", "object": "customer", ... }`. For list endpoints the path is usually rooted at the collection's item shape, e.g. `expand[]=data.customer`. ## Expansion versus field selection They are complementary and opposite in direction. Field selection *removes* attributes that are already part of the representation. Expansion *adds* the body of a resource that the representation only referenced. A mature API supports both, and they compose: project the parent down to three fields, expand one relation, project that too. ## The constraints that matter **Allow-list the expandable paths.** If expansion is implemented by generic graph traversal over your ORM, a caller can wander from `order → customer → account → all_orders` and turn one request into a database tour. Every expandable path should be an explicit, documented part of the contract; unknown paths return `400` naming the bad path. **Cap the depth.** Even inside an allow-list, `a.b.c.d` compounds. A hard limit (Stripe uses four levels) makes the worst case enumerable. **Refuse or bound to-many expansion.** Expanding a one-to-one relation adds one object. Expanding `order.items` when an order can have 10,000 items inlines an unpaginated collection into a response that has no pagination envelope for it. Either disallow to-many expansion, or return a capped, explicitly-truncated envelope (`{ data: [...], has_more: true }`) so the client can tell it did not get everything. **Authorize the expanded resource independently.** The most common security bug here is checking permission on the parent and then embedding the child unchecked. Expansion must be equivalent to the caller having issued the sub-`GET` themselves: same authorization, same field-level redaction. If the caller may not read the customer, the correct behaviour is to leave the id unexpanded (or fail the request) — never to leak the object because the parent was readable. **Make the polymorphism explicit.** A field that is a string when unexpanded and an object when expanded is awkward for statically-typed clients. Document it as a union, keep the `id` present inside the expanded object so code can read `typeof x === 'string' ? x : x.id`, and never change the *field name* based on expansion — that breaks every client that did not ask. **Budget the cost.** Each expansion is extra work: more joins, more service calls, more serialization. Count expansions toward rate limits or quota weights, apply a per-request timeout that accounts for fan-out, and consider disallowing deep expansion on high-traffic list endpoints entirely. **Caching.** An expanded response is a different representation with a different ETag and cache key, and its freshness is the *minimum* of the parent's and the embedded resources'. Embedding a fast-changing child into a long-cached parent quietly serves stale data. Either derive the cache lifetime from the shortest-lived component, or do not cache expanded responses. ## When not to offer it If the relation is nearly always needed, embed it unconditionally and stop making callers ask. If the relation is huge or highly dynamic, keep the reference and let clients fetch and cache it separately — a separately-cached child is often cheaper overall than repeatedly re-embedding it. Expansion earns its keep in the middle: relations that some callers need some of the time, where the round trip is the dominant cost.

  • A caller asks to expand a to-many relation that can contain 50,000 rows. What do you do?
    Reject it, or return a bounded, envelope-wrapped slice that says it is truncated. Inlining an unbounded collection produces a response with no pagination controls, unpredictable size, and no way for the client to know data is missing. The honest answer is to keep the relation as a link to a paginated sub-collection.
  • How does expansion interact with HTTP caching?
    An expanded response is a distinct representation, so it needs its own cache key and ETag. Its freshness is bounded by the shortest-lived embedded resource, because a stale child inside a fresh parent is indistinguishable from correct data. In practice many APIs cache expanded responses for a much shorter time, or not at all.

Ordering a burger with the sides included on the same tray instead of queuing again at the counter — fine for one side, absurd if you ask for 'everything on the menu that relates to burgers'.

saying these in an interview costs you the question

  • Implementing expansion as generic ORM graph traversal with no allow-list
  • Checking authorization only on the parent resource and embedding the child unchecked
  • Inlining to-many relations with no cap or truncation signal
  • Assuming an expanded response can reuse the unexpanded response's ETag or cache entry
  • Changing the field's name (not just its value) when expanded, breaking non-expanding clients

context