Interview Bank · Lesson 4

Resource modeling & URI design — interview questions

44 questions · Core / Senior / Staff · pairs with Lesson 4

Resource modelingURI designStatus codesHTTP methodsREST

Published

Use these as active recall, not reading. Read the question, answer it out loud or in writing from memory first, and only then reveal the model answer to grade yourself. Recognizing an answer ≠ being able to produce it under pressure. Filter by difficulty; reveal-all only for a final review pass.

Why is POST /getOrder an anti-pattern?

It encodes the verb in the path, which is RPC wearing a REST costume. In resource-oriented design the URI names a noun — the thing — and the HTTP method is the verb. The read should be GET /orders/42: the resource is /orders/42, the action is GET.

Should collection names be singular or plural?

Plural, consistently: /orders, /customers, /products. A collection holds many items, so the plural reads naturally — /orders is the set, /orders/42 is one member of it. Mixing singular and plural across the surface is a consistency smell.

What is the difference between a collection URI and an item URI?

A collection URI (/orders) addresses the set: GET lists it, POST adds to it (server assigns the id). An item URI (/orders/42) addresses one member: GET reads it, PUT/PATCH updates it, DELETE removes it. The method's meaning shifts with which level you're at.

What's the right way to create a new order?

POST /orders with the order body. The server assigns the id and returns 201 Created with a Location header pointing at /orders/{new-id}. You don't POST /createOrder and you don't let the client pick the id on a collection POST.

Why don't actions like cancel or publish map cleanly to CRUD?

Because they're genuine verbs that don't reduce to create/read/update/delete. "Cancel an order" isn't naturally a PUT of a new representation — it's a state transition. CRUD covers the easy 80%; modeling the remaining verbs is where the interview gets interesting.

What does nesting like /customers/1/orders communicate?

Containment — "the orders belonging to customer 1." That's a legitimate, readable use of nesting for a real ownership relationship. The mistake isn't nesting one level; it's going deep.

Why prefer opaque identifiers over sequential integers in URIs?

Sequential ints leak business volume (reading order #100002 tells an attacker your throughput) and invite enumeration — walking /orders/1, /orders/2… UUIDs/ULIDs are opaque and non-guessable, so neither attack works from the id alone.

What are slugs for, and what should they not be?

Slugs (/articles/how-to-design-apis) are for humans and SEO — readable, memorable URLs. They should not be your stable primary key: titles change, slugs collide, and they're mutable. Use an opaque id as the canonical key and treat the slug as a lookup alias (often redirecting to the id-based canonical URL).

What are the boring-on-purpose URI formatting conventions?

Lowercase; hyphen-separated words (/purchase-orders, not /purchaseOrders or /purchase_orders); no trailing slash; no file extensions (.json) — use the Accept header for format. Boring and predictable beats clever.

What belongs in query parameters and what doesn't?

Query params carry filtering, sorting, and pagination?status=open&sort=-created_at&page=2. They must not carry identity: the resource's identifier belongs in the path (/orders/42, not /orders?id=42). Path = which resource; query = how to view the collection.

Why is resource modeling usually the first artifact in a design interview?

Because everything else hangs off it — status codes, auth, pagination, caching all assume a resource model. Clean nouns and defensible nesting are one of the earliest senior signals an interviewer reads; get the frame right and the rest of the design falls out cleanly. Get it wrong and every downstream answer inherits the mess.

Why does the noun/verb discipline matter beyond aesthetics?

Because it's what keeps the contract uniform and cacheable. Once verbs leak into paths, every endpoint becomes a bespoke procedure and the HTTP machinery — caching, idempotency, conditional requests, status semantics — stops applying. GET /orders/42 is cacheable and safe by definition; POST /getOrder tells intermediaries nothing. Naming the resource correctly is the load-bearing decision the rest of the design hangs off.

A teammate wants GET /searchOrders. What do you propose instead?

Search is reading a collection with criteria, so GET /orders?status=open&customer_id=7. The collection is the noun; query params carry the filter. searchOrders reintroduces a verb in the path. The one legitimate exception is a search with a body too large/complex for a query string, where teams use POST /orders/search (or :search) — but acknowledge that's a pragmatic concession that costs you GET cacheability.

What are the three principled ways to model a non-CRUD action?
  • Sub-resource / state collection: treat the outcome as a thing — POST /orders/42/cancellations, or a status you read back.
  • Custom method (Google AIP): colon-suffixed verb on the resource — POST /orders/42:cancel.
  • Reify the action as its own resource: promote the verb to a noun — POST /refunds with {order_id}.

The senior signal is naming the trade-off, not just picking one.

When do you reify an action as its own top-level resource?

When the action has its own durable lifecycle, state, and history. A refund is processed → settled → reversible; it's queried, listed, audited, and reported on independently. That earns it standalone existence as /refunds sitting alongside /payments. Decide by lifecycle, not by grammar.

When is a custom method (POST /orders/42:cancel) the right call?

When the action is a momentary transition on an existing resource that has no life of its own, and inventing a noun ("a cancellation") would create something nobody queries. The custom method is honest that it's a non-CRUD verb, keeps the resource in the path, and is explicitly sanctioned by Google AIP for verbs that don't fit standard methods. It's cleaner than forcing a fake resource.

Sub-resource vs custom method for the same action — how do you choose?

If you want the action to leave an auditable, listable record, model it as a sub-resource/state collection (POST /orders/42/cancellations) — now you can GET the history. If it's a pure transition you'll never enumerate, a custom method (:cancel) is leaner and avoids a collection that only ever holds zero or one rows. Both keep the parent resource in the path; they differ on whether the action deserves to be a queryable record.

What status code does an async action like :cancel return if it can't complete synchronously?

202 Accepted — the request is acknowledged but processing isn't done. Return a representation (or Location) for a status/operation resource the client can poll (GET /operations/{id} or the order's own status). Returning 200 immediately would lie about completion.

Why is deep nesting like /customers/1/orders/42/items/9 fragile?

It hard-codes the entire hierarchy into the URL. The day a relationship changes — items move under a fulfillment, an order spans customers, you add line-item history — every client URL breaks. Deep paths couple your contract to today's org chart. They also bloat with redundant ids you don't need to locate the leaf.

What's the shallow alternative to deep nesting, and what do you keep?

Address resources flatly by identity — /orders/42, /items/9 — and express relationships with filtering: /orders?customer_id=1 instead of /customers/1/orders. You get the same query power without coupling the URL to the hierarchy. Keep nesting (≈ one level) only for genuine ownership where the child has no identity of its own.

Link, embed, or separate endpoint — what are the three ways to handle related data?
  • Link: return a reference/URL (hypermedia) the client follows when it wants the related resource.
  • Embed/expand: inline the related data on demand — GET /orders/42?expand=customer or ?fields=.
  • Separate endpoint: the client fetches /customers/7 itself.

The choice is a round-trips-vs-payload-size trade-off.

Over-embedding vs the N+1 problem — describe the tension.

Always embedding everything makes every payload huge — clients download relations they didn't ask for, caching gets coarser, and the response couples to many tables. Never embedding makes clients chatty: list 50 orders, then fire 50 follow-up requests for each customer (the N+1 round-trip). The middle path is opt-in expansion (?expand= / field selection) so the client pays only for what it needs.

Where should a many-to-many relationship be modeled — under which resource?

Neither parent "owns" it, so deep nesting under one side is arbitrary. Either expose the relationship from both sides as filtered sub-collections (/students/1/courses and /courses/9/students) backed by the same join, or — if the link itself carries data (enrollment date, grade) — reify the join as its own resource: /enrollments?student_id=1. Reify when the relationship has attributes; otherwise filtered views suffice.

What is a BOLA / IDOR vulnerability and how does id choice relate to it?

Broken Object-Level Authorization (a.k.a. IDOR): a user swaps the id in /orders/42 for /orders/43 and reads someone else's data because the server checks authentication but not ownership. Opaque ids make resources harder to guess, but they are not the fix — they're defense in depth. The real fix is an authorization check on every object access ("does this caller own this order?"). Calling opaque ids a substitute for authz is a junior mistake.

UUID vs ULID — when does the difference matter?

Both are opaque and non-guessable. ULIDs (and UUIDv7) are time-sortable — they encode a timestamp prefix, so they're roughly monotonic, which keeps database B-tree index inserts sequential and avoids the write-amplification of random UUIDv4. Pick ULID/UUIDv7 when insert performance and natural ordering matter; classic random UUIDv4 when you specifically want no embedded time signal.

Should the URI expose your internal database primary key?

Avoid it. Exposing the raw DB key couples your public contract to a storage detail — you can't reshard, migrate to a different store, or merge tables without breaking URLs, and an auto-increment key leaks volume. Mint a separate external identifier (opaque, stable) that you map to the internal key. The public id is part of your API contract; the internal key isn't.

When is a singleton resource the right shape, and how do you spot the mistake?

When there's exactly one of a thing in a context — /users/me/settings, /account/preferences. A singleton has no id and no plural; you GET/PUT/PATCH it directly. The tell is modeling it as a collection (/users/me/settings/1) — reaching for the plural-collection pattern reflexively instead of fitting it to the domain.

What does /users/me express, and why is it useful?

It's an alias for the authenticated principal — "whoever this token belongs to" — so the client doesn't need to know or send its own id. It avoids leaking other users' ids into client code, sidesteps a class of IDOR mistakes (you can't swap me for someone else), and reads cleanly: /users/me/orders. The server resolves me to the real id from the credentials.

Why does consistency across the surface beat a locally clever endpoint?

Because clients learn your API by pattern: once they know /orders, /orders/42, ?status=, they predict the rest. One odd-but-clever endpoint forces every consumer to special-case it, breaks codegen/SDK assumptions, and costs documentation and support forever. The marginal cleverness almost never pays back the cognitive tax. Predictability is a feature.

How should the path represent filtering by multiple values or ranges?

Keep it in the query string with a consistent grammar: repeated keys or CSV for sets (?status=open&status=pending or ?status=open,pending), and explicit suffixes/operators for ranges (?created_after=…&created_before=… or a documented created_at[gte]=). Pick one convention and apply it everywhere. The path still names the collection; all of this is query, never identity.

Walk through deriving a resource model from a domain — what's your method?

(1) List the nouns the domain holds (order, cart, customer, payment, refund) — those are candidate resources. (2) Decide which are top-level collections vs owned sub-resources by asking "does this have identity on its own?" (3) Map the verbs to HTTP methods; for non-CRUD verbs, decide sub-resource / custom method / reify by lifecycle. (4) Choose relationships: nest one level for true ownership, filter for the rest. (5) Pick opaque ids and consistent conventions. Narrate the trade-offs as you go — the reasoning is what's scored.

How do you handle pluralization edge cases (uncountable nouns, irregulars)?

Default to the natural English plural and stay consistent. For uncountable/mass nouns (/audit-info, /news, /weather) pick a form and document it rather than inventing /informations. For irregulars use the correct plural (/people, not /persons, unless your domain says otherwise). The rule that actually matters: be uniform — clients should be able to guess the plural from the singular without surprises.

How does URI design interact with HTTP caching?

The URI is the cache key, so design affects cacheability directly. Stable, canonical, identity-in-path URIs cache cleanly; GET /orders/42 is cacheable with ETags. Smuggling verbs into paths or forcing reads through POST kills caching. Query-param explosion fragments the cache (every filter combo is a new key), and dynamic ?expand= shapes do too. One canonical URI per resource (avoid duplicate paths for the same thing) keeps caches and conditional requests effective.

How does resource modeling interact with versioning?

Good modeling reduces the need to version: stable nouns, identity-in-path, and opaque ids let you add fields, relations, and optional expansions without breaking clients. Versioning bites when the shape or relationships change — renaming resources, re-homing sub-resources, changing id schemes. Those are exactly the deep-nesting / leaked-internal-id decisions that age badly. So the modeling discipline (shallow nesting, filtering over containment, external ids) is partly a versioning-cost-avoidance strategy. (Versioning mechanics are Lesson 5.)

Interviewer pushes back: "Deep nesting is more RESTful and self-documenting." Your reply?

Nesting documents a relationship but hard-codes one hierarchy into the contract. It reads nicely until the relationship changes or the resource participates in several relationships, at which point the URL lies or breaks. REST cares that resources are identified and addressable — a flat /items/9 plus ?order_id=42 is no less RESTful and far more evolvable. I keep one level for true ownership and filter for everything else; self-documentation comes from consistency and hypermedia/links, not from a long path.

Articulate the pure-REST vs pragmatist tension on custom methods.

Purists argue everything should be a resource: a custom method like :cancel smuggles a verb back into the URI and breaks the uniform interface, so they'd reify state into resources (a cancellation record, a status sub-resource). Pragmatists accept a colon-method for a genuinely non-CRUD verb because inventing nouns nobody queries is its own cost. The staff answer doesn't pick a religion — it picks by lifecycle: durable state and history → reify; momentary transition → sub-resource or custom method. Say the trade-off out loud; that's the signal.

Should a state transition be a PATCH on a status field instead of a custom method?

You can model "cancel" as PATCH /orders/42 {"status":"cancelled"}, and it's defensible when the transition is genuinely just a field change. The risks: it hides business rules (cancelling may trigger refunds, restocking, notifications) behind an innocuous field write, lets clients set arbitrary illegal states, and makes authorization/validation per-transition harder. A custom method or sub-resource names the operation explicitly, so you can authorize and validate that transition and run its side effects. Prefer the explicit form when the transition carries behavior, not just data.

What are the downsides of a flexible ?expand= / field-selection mechanism?

It pushes you toward GraphQL-shaped problems without GraphQL's tooling: every expansion combination is a distinct query and cache key, so caching fragments; arbitrary expansion enables expensive N+1 / fan-out queries you must bound; authorization must be enforced per expanded field, not just on the root; and response shape becomes dynamic, complicating client typing and contract tests. Mitigate with an allowlist of expandable relations, depth/complexity limits, and per-field authz. At some point if clients need fully arbitrary shapes, that's the signal to consider GraphQL.

A mobile team complains your API is too chatty. How do you respond at the design level?

Diagnose first: is it N+1 over a list (add opt-in expand / embedded relations), too many round-trips for one screen (offer a purpose-built composite/aggregate endpoint or a BFF that fans out server-side), or pagination thrash (bigger pages, cursor paging)? Resist the urge to deep-nest everything just to save calls — that re-couples URLs. Options: bounded expansion, a read-optimized aggregate resource, batch endpoints, or a separate BFF layer. Name the caching and coupling cost of each rather than reflexively embedding.

Client-generated vs server-generated ids — what changes in the design?

Server-generated: POST /orders, server mints the id, returns 201 + Location. Simple, but the create isn't naturally idempotent — a retry can double-create, so you bolt on an Idempotency-Key. Client-generated (e.g. client picks a UUID): the create becomes a natural idempotent upsert via PUT /orders/{client-uuid} — a retry targets the same URI and is safe by construction, and offline/mobile clients can mint ids without a round-trip. The cost is trusting clients with the id namespace (collision/forgery handling). Choose by whether you need offline id minting and free idempotency.

Whiteboard prompt: model an e-commerce checkout (browse cart, place order, cancel, refund).

Cart is /carts/{id} with line items at /carts/{id}/items/{id} — one level, real containment. Place order: POST /orders, server assigns id, returns 201 + Location, built from the cart; require an Idempotency-Key so a retry doesn't double-charge. Cancel is a transition on an existing order, so POST /orders/{id}/cancellations or AIP-style POST /orders/{id}:cancel — no top-level noun. Refund has its own lifecycle (processed/settled/reversible) so reify it: POST /refunds with {order_id}, beside /payments. Ids opaque (UUID/ULID); list a customer's orders via /orders?customer_id= rather than deep nesting. That hits nouns-not-verbs, principled action modeling with a stated trade-off, shallow nesting + filtering, and security-aware ids.

What's your test for "does this action deserve to be its own resource?"

Ask: does it have a lifecycle, state, and history you'd query independently? If yes — you'd list it, report on it, transition it through states, audit it — reify (a refund, a shipment, a dispute). If it's a one-shot transition with no afterlife you'd ever enumerate, don't invent a noun nobody reads; use a custom method or status sub-resource. The litmus test is "would I ever GET a collection of these?"

How does URI shape interact with object-level authorization?

Identity-in-path makes the authorization target explicit: every /orders/{id} access needs an ownership check (BOLA defense), and consistent URI structure lets you enforce that with uniform middleware rather than ad-hoc checks. Aliases like /users/me remove a whole class of IDOR by not letting clients name other principals. Nesting can imply scope (/customers/1/orders) but you must still verify the caller may act as customer 1 — the path is a claim, not proof. Flat URIs + per-object authz is usually cleaner than relying on nesting for security.

When would you accept verbs in paths or otherwise break the conventions?

Pragmatically: explicitly-sanctioned custom methods (:cancel) for non-CRUD verbs; a POST .../search when query criteria are too large/sensitive for the URL (trading GET cacheability knowingly); RPC-style or gRPC for chatty internal service-to-service traffic where REST's overhead doesn't pay. The staff move is that these are deliberate, named exceptions with a stated cost, isolated and consistent, not accidental drift. Purity isn't the goal — defensible, evolvable design is.

A framework for whiteboarding a REST resource model — narrate each step out loud:
  1. Clarify the domain & list candidate nouns. Name the things the domain holds (order, cart, customer, payment, refund). Those are your candidate resources before any URL is drawn.
  2. Top-level collections vs owned sub-resources, by identity. Ask "does this have identity on its own?" If yes it's a top-level collection; if it only exists inside a parent, it's an owned sub-resource (one level).
  3. Map verbs to HTTP methods; place non-CRUD verbs deliberately. CRUD → standard methods. For genuine verbs (cancel, publish, refund) choose sub-resource, custom method (:verb), or reify-as-resource — driven by lifecycle, not grammar.
  4. Relationships: one-level nesting for ownership, filtering otherwise. Nest /customers/1/orders only for true containment; express the rest with /orders?customer_id=1. Reify many-to-many joins that carry data.
  5. Identifiers: opaque external ids + per-object authz. Use UUID/ULID, never the raw DB key. Opaque ids are defense in depth, not the authz fix — every object access still needs an ownership (BOLA) check.
  6. Conventions & consistency. Lowercase, hyphenated, plural collections, no extensions; query carries filter/sort/page, never identity. Predictability across the surface beats local cleverness.
  7. Evolvability / versioning cost. Stable nouns, identity-in-path, and external ids let you add fields and expansions without breaking clients — name where each choice would force a version bump later.
Model an e-commerce checkout (cart, place order, cancel, refund) on a whiteboard.

A strong answer covers: Cart is /carts/{id} with line items at /carts/{id}/items/{id} — one level, real containment. Place order: POST /orders, server assigns id, returns 201 + Location, built from the cart; require an Idempotency-Key so a retry doesn't double-charge. Cancel is a transition on an existing order, so POST /orders/{id}/cancellations or AIP-style POST /orders/{id}:cancel — no top-level noun. Refund has its own lifecycle (processed/settled/reversible) so reify it: POST /refunds with {order_id}, beside /payments. Ids opaque (UUID/ULID); list a customer's orders via /orders?customer_id= rather than deep nesting. That hits nouns-not-verbs, principled action modeling with a stated trade-off, shallow nesting + filtering, and security-aware ids.

Design the resource model for a multi-tenant project-management API with users, projects, tasks, comments, and memberships.

A strong answer covers: Top-level collections by identity: /projects, /tasks, /users — each has its own life. Scope tenancy implicitly from the authenticated principal (resolve the tenant from the token), not via a /tenants/{id}/… prefix clients can tamper with. Tasks belong to a project, but keep nesting shallow: /projects/{id}/tasks for the owned list, and address a single task flatly by identity as /tasks/{id} so it doesn't break if it's re-homed; filter cross-cuts with /tasks?assignee=…&status=open. Comments are owned by their parent and have no independent life, so /tasks/{id}/comments (one level). Membership is a many-to-many between users and projects that carries data (role, joined-at), so reify the join: /memberships (or /projects/{id}/members as a filtered view) rather than deep-nesting under one side. Ids opaque (UUID/ULID); enforce per-object authorization on every access (BOLA) and tenant isolation so user A can't read project B in another tenant — the path is a claim, not proof. Use /users/me for the caller. Conventions consistent throughout; ?expand= bounded by an allowlist if clients need related data inline.