Collection vs Singleton Resource Naming
This page sits under Resource Modeling Best Practices in the API Design Fundamentals & Architecture reference. Most resources are members of a collection, a few genuinely are not, and confusing the two produces URL shapes that fight the HTTP method semantics for the life of the API.
The decision trigger
| Situation | Model as |
|---|---|
| Zero or more of these exist per parent | collection: /orders, /orders/{id} |
| Exactly one exists per parent, always | singleton: /accounts/{id}/settings |
| One exists per authenticated caller | singleton alias: /me |
| One exists now but may become many | collection — the migration later is breaking |
| A computed view over other resources | singleton: /reports/monthly-summary |
| A command rather than a thing | neither — see the sub-resource verb pattern |
The fourth row is the expensive one. “There is only one billing address per customer” holds until the day it does not, and turning /customers/{id}/address into a collection changes every client’s URL construction. If plurality is plausible in the domain, model the collection now and let it hold one member.
Spec snippet: both shapes
# openapi.yaml (OpenAPI 3.1.0)
paths:
# ── Collection ─────────────────────────────────────────────
/orders:
get:
operationId: listOrders # 200 with an envelope
post:
operationId: createOrder # 201 + Location, server assigns the id
/orders/{orderId}:
get:
operationId: getOrder # 200 or 404
put:
operationId: replaceOrder # full replacement
patch:
operationId: updateOrder # partial update
delete:
operationId: deleteOrder # 204, then 404 on subsequent GET
# ── Singleton ──────────────────────────────────────────────
/accounts/{accountId}/settings:
get:
operationId: getAccountSettings # 200 — always exists
put:
operationId: replaceAccountSettings # 200, not 201: no creation
patch:
operationId: updateAccountSettings
# No POST: there is nothing to create.
# No DELETE: "no settings" is not a state this resource can be in.
# ── Singleton alias for the authenticated principal ────────
/me:
get:
operationId: getCurrentUser # same schema as /users/{userId}
The comments encode the rule: a singleton has no POST because creation is meaningless, and its PUT returns 200 rather than 201 because nothing comes into existence. Getting these status codes right matters because clients branch on them — a 201 from a settings update tells a client a new resource was created and it should record the Location.
Step-by-step
Step 1 — Test for plurality in the domain, not in today’s data
Ask whether two of the thing could ever exist under the same parent. A user has one primary email address today; the day they can have two, /users/{id}/email becomes a lie and /users/{id}/emails is a breaking change. If two are conceivable, model the collection now — a collection with one member costs one level of URL nesting and nothing else.
Step 2 — Name collections plurally and consistently
✓ /orders /orders/{orderId}
✓ /customers /customers/{customerId}
✓ /payment-methods /payment-methods/{paymentMethodId}
✗ /order/{orderId} singular collection
✗ /customerList type name in the path
✗ /getOrders verb in a path
✗ /orders/{orderId}/getInvoice verb in a sub-path
Consistency beats correctness in this specific argument: whichever convention you pick, a client author should never have to check the reference to know whether it is /order or /orders. The naming rules can be enforced mechanically — see Writing Custom Spectral Rules.
Step 3 — Make /me an alias, not a type
// me-route.ts — /me resolves to the same handler and the same representation
app.get("/me", requireAuth, async (req, res) => {
const user = await users.byId(req.principal.userId);
// The canonical id travels in the body AND in a Content-Location header,
// so a client can cache, link or re-request by the stable URL.
res.setHeader("Content-Location", `/users/${user.id}`);
res.json(user); // identical schema to GET /users/{id}
});
Content-Location is the specified way to say “this representation also lives at that URL” (RFC 9110 §8.7). Without it, a client holding a /me response has no way to construct a link to the same user for another caller.
Step 4 — Decide what DELETE on a singleton means
A settings resource that always exists cannot be deleted in the ordinary sense. Three honest options:
| Intent | Correct expression |
|---|---|
| Reset to defaults | PUT the default representation, or POST /settings/reset |
| Clear an optional value | PATCH with null for that member |
| The resource genuinely can be absent | DELETE → 204, then GET → 404 |
Choosing the third silently — DELETE returning 204 while GET still returns 200 with defaults — is the failure mode: the client is told the resource is gone and observes it is not.
Standard compliance
| Concern | Standard | Clause | Note |
|---|---|---|---|
| Resource identification | RFC 9110 | §3.1 | A URI identifies a resource; plurality is convention |
POST semantics |
RFC 9110 | §9.3.3 | Process the representation per the target’s semantics |
PUT semantics |
RFC 9110 | §9.3.4 | Replace the target resource’s state |
201 Created + Location |
RFC 9110 | §15.3.2 | Only when a new resource comes into existence |
405 Method Not Allowed |
RFC 9110 | §15.5.6 | Must include Allow |
Content-Location |
RFC 9110 | §8.7 | The canonical URL of the returned representation |
Nothing in HTTP requires plural collection names — it is convention, not specification. What is specified is the method semantics, which is why the singleton’s method table matters more than its spelling.
The two shapes also deserve different caching policies, because their representations change for different reasons:
Caching implications
A singleton’s URL is stable and its representation changes rarely, which makes it an excellent candidate for a strong ETag and conditional requests: a client polling /accounts/{id}/settings should get 304 almost every time. A collection URL is the opposite — its representation changes whenever any member changes, and it usually varies by query parameters — so validators there are weaker and shorter-lived. Applying the same caching policy to both wastes an opportunity on one and causes staleness complaints on the other. The mechanics are covered in ETag and If-None-Match Conditional Requests.
SDK / codegen downstream effect
// Collection: id-bearing methods, list returns an envelope
await client.orders.listOrders({ limit: 50 });
await client.orders.getOrder({ orderId: "ord_1" });
- // Singleton modelled as a collection: an id nobody has
- await client.settings.getSettings({ settingsId: "?" });
+ // Singleton modelled correctly: no id parameter at all
+ await client.accounts.getAccountSettings({ accountId: "acc_1" });
Modelling a singleton as a collection produces a generated method demanding an identifier the caller cannot supply, which is usually worked around by passing a literal "default" or "current" — a string that then appears in logs, caches and support tickets forever. The naming rules that keep these method signatures readable are in Naming Operation IDs for Readable SDK Methods.
Common mistakes
| Mistake | Correct approach |
|---|---|
Singular collection paths (/order/{id}) |
Plural for collections and members |
| Modelling a plausible-plural as a singleton | Model the collection; it can hold one member |
POST on a singleton |
Return 405 with Allow; there is nothing to create |
PUT on a singleton returning 201 |
Return 200; no resource was created |
/me as a separate resource type |
Alias the canonical member; send Content-Location |
DELETE on a singleton that still returns 200 afterwards |
Either make absence a real state or use PUT/PATCH |
| Verbs in resource paths | Nouns for resources; a command sub-resource for actions |
FAQ
Should resource paths be plural or singular?
Plural for collections and their members — /orders and /orders/{orderId} — and singular only for genuine singletons such as /accounts/{id}/settings or an alias like /me. The reason is that /orders/{orderId} reads as “the order with this id within the orders collection”, which is exactly the relationship the URL expresses, and it leaves the collection URL free for listing and creation. The stronger argument, though, is consistency: whichever rule you adopt, apply it everywhere and enforce it in lint, because the real cost of mixed conventions is that every client author must check the reference for every endpoint.
Is a /me endpoint a good idea?
Yes, provided it is an alias rather than a separate resource type. Clients frequently need the authenticated principal before they know its identifier, and forcing a token-introspection round trip first is unhelpful. Return the same schema as GET /users/{userId}, include the canonical identifier in the body, and set Content-Location: /users/{id} so the client can construct links and cache entries against the stable URL. What to avoid is a /me representation that differs from the canonical one, because then clients must handle two shapes for the same entity and inevitably drift apart.
Can a singleton support DELETE?
Only if “absent” is a state the resource can genuinely be in, and you should decide that before implementing rather than after. For a settings document that always exists, DELETE usually means “reset to defaults”, which is expressed more honestly as a PUT of the default representation or an explicit POST /settings/reset command. The failure mode to avoid is a DELETE that returns 204 while a subsequent GET still returns 200 — the client has been told the resource is gone and can immediately observe that it is not, which undermines every other guarantee in the API.
Related
- Resource Modeling Best Practices — up-link: the section covering resource boundaries and hierarchy
- API Design Fundamentals & Architecture — up-link: the parent reference for contract-first design
- How to Design RESTful Resource Hierarchies for Microservices — where these resources sit relative to service boundaries
- Handling 405 vs 501 for Unsupported Methods — the response when a method does not apply to a resource
- ETag and If-None-Match Conditional Requests — the caching policy each shape deserves