Resource Modeling and URL Design: Collections, Sub-resources, Naming, Custom Actions and When You Need a Verb
In good URL design, resources come from the domain's language rather than its tables, every resource has one canonical address, and work that needs a verb becomes either a custom method or a new resource with its own lifecycle. We design Bookstore's institutional order addresses, the singleton cart, the ISBN, UUID v7 and slug id choices, POST /orders/{id}:cancel and a returns resource, with real HTTP responses and Spectral rules.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, is opening up to institutional customers. The first request Hakan brings is clear: Nevin Hanım, the purchasing officer at Lakeside School, wants to see her own campus's orders, and Linus, the school's approver, wants to see the orders of every campus. The team has two proposals. Emre wants to carry the database hierarchy into the URL:
GET /institutions/lakeside-school/units/main-campus/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
Ayşe wants the order to have a single address and the institution list to be a filter:
GET /orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
GET /institutions/lakeside-school/orders?unitId=main-campus
Two questions. If Grace, from another institution, asks for the same order address, what should she get: 403 or 404? And if, in Emre's design, the order is reachable both from /orders/{orderId} and from the institution path, in how many places do you have to write the "who can see this order?" check?
Finding Resources in the Domain
One of the pitfalls in the previous post was turning tables into endpoints one to one. A better way to find resources is to listen to the domain's own language: at Bookstore people talk about books, orders, returns, carts, institutions and campuses. The tables have names like OrderLines, StockReservations and InstitutionUserUnitMap. Some of them become resources, some stay inside another resource's representation, and some never leave the server. Order lines are not a separate collection; they come inside the order. A stock reservation is an internal mechanism that is of no use to the consumer.
Ask two questions about every resource you find. The first: is it a collection or a singleton? There are many orders, each with its own identity: a collection. A customer's cart is unique and exists together with the customer: a singleton. Google's AIP-156 defines a singleton as a static segment appended to its parent's name and says that a singleton is not created or deleted; it comes and goes with its parent. At Bookstore the cart is /customers/{customerId}/cart. So that customers do not have to know their own id, we accept the alias me; AIP-122 allows such aliases too, but asks that responses always carry the real id:
$ curl -s -H "Authorization: Bearer ada" http://127.0.0.1:52318/customers/me/cart
{"customerId":"ada","items":[{"isbn":"9780441172719","title":"Dune","quantity":1,"unitPrice":{"amount":489.00,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","quantity":2,"unitPrice":{"amount":249.90,"currency":"TRY"}}],"total":{"amount":988.80,"currency":"TRY"}}
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer ada" http://127.0.0.1:52318/customers/grace/cart
404
The Authorization: Bearer ada in the examples is not a real token; a small demo authentication handler in the application takes the name after Bearer as the user. Real JWT validation is not the topic of this series; it is covered in the API Security series. The second question is the id format.
Naming and Id Format
These are the rules this post adds to Bookstore's style guide:
## Resources and URLs
- BS-01 [lint] Path segments are plural nouns in lowercase kebab-case; path parameters are camelCase.
- BS-02 [lint] No CRUD verbs in paths; the HTTP method is the verb.
- BS-03 [lint] A one-shot state change that is not CRUD is a custom method: POST /{collection}/{id}:{verb} (ADR-0002).
- BS-04 Something with its own lifecycle or approval (a return, an approval request) is a resource, not a custom method.
- BS-05 [lint] At most two path parameters (one level of nesting); deeper hierarchies are query filters.
- BS-06 Every resource has exactly one canonical URL; other collections that list it are filtered views.
- BS-07 Ids: ISBN-13 for books, UUID v7 for orders and returns, lowercase slugs for institutions and units; "me" is an alias for the caller.
The plural debate is the easy one: /books is the collection, /books/{isbn} a member of it. Mixing /book/{isbn} with /books means the client has to check the documentation every time it builds a URL. We chose kebab-case for paths and camelCase for JSON and query parameters; the Azure guidelines make the same split. Consistency matters more here than the choice itself.
The id format is a separate decision for each resource. For books we use the 13-digit ISBN: a natural key everybody already knows, used outside our system too, and it does not change. A new edition gets a new ISBN, which means a new book in the catalog. For orders the database's int identity is not exposed; a sequential number leaks the total order count and is guessable. Following the "Exposing Ids" post in the Solution Patterns series, we expose a UUID v7. Institutions have a readable slug such as lakeside-school. The trap with slugs is the urge to change them when the institution is renamed; but the slug is an id and the name is a separate field. The slug stays the same even if the name changes.
Hierarchy Depth and One Canonical URL
Back to the guess. Bookstore chose Ayşe's proposal: the order's single canonical address is /orders/{orderId}, and the institution list is a filtered view of that collection. Three users asking for the same order:
$ curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 | jq -c '{id, status, institutionId, unitId}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","status":"Pending","institutionId":"lakeside-school","unitId":"main-campus"}
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer linus" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer grace" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
404
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer grace" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a7
404
For Grace, an existing order and a non-existent one get the same answer. That is deliberate: a 403 would say "an order with this id exists, but it is not yours", and someone trying ids one by one could learn which orders exist. The standard that defines the meaning of HTTP methods and status codes, RFC 9110, says plainly that a server wishing to hide a resource's existence may answer 404 instead of 403. The institution list uses the same visibility rule:
$ curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '.items[] | {id, unitId, status}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","unitId":"main-campus","status":"Pending"}
{"id":"01a12f4d-f2c0-73b8-a687-7991815ef6d1","unitId":"main-campus","status":"Shipped"}
{"id":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","unitId":"main-campus","status":"Delivered"}
$ curl -s -H "Authorization: Bearer linus" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '.items[] | {id, unitId, status}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","unitId":"main-campus","status":"Pending"}
{"id":"01a1a612-9c80-7e46-8590-67e08b9d2434","unitId":"east-campus","status":"Confirmed"}
{"id":"01a12f4d-f2c0-73b8-a687-7991815ef6d1","unitId":"main-campus","status":"Shipped"}
{"id":"01a0fbdc-1660-71a3-af67-19acad3c2d6d","unitId":"east-campus","status":"Delivered"}
{"id":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","unitId":"main-campus","status":"Delivered"}
$ curl -s -H "Authorization: Bearer nevin" "http://127.0.0.1:52318/institutions/lakeside-school/orders?unitId=east-campus" | jq -c '{status, title, detail}'
{"status":403,"title":"Unit not allowed","detail":"Buyers can only list the orders of their own unit (main-campus)."}
$ curl -s -H "Authorization: Bearer grace" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '{status, title, detail}'
{"status":404,"title":"Institution not found","detail":"No institution lakeside-school exists."}
The split between 403 and 404 is part of the design as well. Nevin works at Lakeside; she already knows East Campus exists, there is nothing to hide from her, so "you are not allowed" (403) is more honest and more useful. Grace is from another institution; we do not even tell her that Lakeside is a Bookstore customer (404).
The real problem with Emre's proposal is not the depth but the second path it opens. When the same order can be reached through two URLs, the authorization check, the cache key, the URL in the logs and two separate operations in the contract all say the same thing twice. One day someone adds a new field to only one path, or updates the authorization check in only one of them; BOLA, Broken Object Level Authorization, the most common hole in API security, where a user reads someone else's record by changing an id, is often born like this. The API Security series covers this hole in detail. The hierarchy in the URL does not authorize anything; writing /institutions/lakeside-school/units/main-campus does not prove the caller belongs to that campus. The check must always happen on the server, in one rule.
That is why we added two rules to the style guide: every resource has one canonical URL (BS-06), and a path has at most two parameters (BS-05). The second is checked by the linter. Spectral's answer when Emre's path is added to the contract:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
264:65 error bookstore-max-two-path-params Too deep: /institutions/{institutionId}/units/{unitId}/orders/{orderId} paths./institutions/{institutionId}/units/{unitId}/orders/{orderId}
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)

When You Need a Verb: Four Options for Cancelling an Order
Not everything fits into CRUD. Cancelling an order is not a field update but a state change with rules: only Pending or Confirmed orders can be cancelled, cancelling releases stock and starts the refund. There are four options on the table:
| Option | Example | Pro | Con |
|---|---|---|---|
| PATCH the status field | PATCH /orders/{id} with {"status":"Cancelled"} | No new endpoint | Rules and side effects hide behind a field change; every status transition becomes a special case in PATCH |
| Verb sub-path | POST /orders/{id}/cancel | Easy to read, works with every tool | cancel looks like a sub-resource; unclear what GET returns |
| Custom method | POST /orders/{id}:cancel | The common pattern of Google AIP-136 and the Azure guidelines; separates the resource from the operation | The colon surprises some clients and shells |
| Make the operation a resource | POST /orders/{id}/cancellations | Fits Zalando's "no verbs" rule, keeps history | A needless lifecycle for an operation that finishes instantly |
Bookstore's rule rests on one distinction: if the operation is a state change that completes in one go, it is a custom method; if it produces something with its own lifecycle or approval, it is a resource. A cancellation completes instantly: custom method. A return is requested, a store employee approves or rejects it, then the money is refunded; so a return is a resource. The decision as an ADR:
# ADR-0002: Operations that need a verb
Status: Accepted (2026-11-15)
## Context
Cancelling an order is a state change with rules (only Pending or Confirmed orders),
not a field update. Options: PATCH status, POST /orders/{id}/cancel,
POST /orders/{id}:cancel (Google AIP-136, Azure guidelines) or a cancellation resource.
## Decision
- One-shot state changes use a custom method: POST /orders/{orderId}:cancel.
- Changes with their own lifecycle become resources: POST /orders/{orderId}/returns.
- status is read-only in every representation; it never changes through PUT or PATCH.
## Consequences
- The colon can never appear in our ids (UUIDs, ISBNs and slugs do not contain it).
- Clients that percent-encode the colon (%3A) reach the same endpoint.
- The kebab-case lint rule allows one :customMethod suffix at the end of a path.
The last item comes from real experience. When we first added the custom method to the contract, the rule we wrote in the previous post caught it:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
114:28 error bookstore-path-kebab-case Path is not kebab-case: /orders/{orderId}:cancel paths./orders/{orderId}:cancel
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
A lint rule is the style guide's code; when the style guide changes, the rule changes in the same pull request. We allowed one :customMethod suffix at the end of a path in the kebab-case rule; Redocly CLI, for its part, accepted the path with a colon on the first try.
Trying the Custom Method
$ curl -i -X POST -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6:cancel
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:28:37 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","status":"Cancelled","institutionId":"lakeside-school","unitId":"main-campus","createdAt":"2026-11-09T16:10:00Z","items":[{"isbn":"9780451524935","title":"Nineteen Eighty-Four","quantity":40,"unitPrice":{"amount":197.10,"currency":"TRY"}}],"total":{"amount":7884.00,"currency":"TRY"}}
$ curl -s -X POST -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6:cancel | jq -c '{status, title, detail}'
{"status":409,"title":"Order cannot be cancelled","detail":"Order 01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 is Cancelled; only Pending or Confirmed orders can be cancelled."}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer alan" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434%3Acancel
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer alan" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434:cancel
404
Each of the four outputs is a decision. A successful cancellation returns 200 with the current order, as the Azure guidelines also ask. The second cancellation is a 409: the operation conflicts with the order's current state. A colon percent-encoded as %3A reaches the same endpoint; some HTTP libraries encode a colon in the path like that, and the server has to accept both. A GET on :cancel is a 404, because the custom method is only defined for POST.
The colon has a surprise in the shell too. If you keep the id in a variable and build the URL as "$id:cancel", zsh takes :c for a modifier and produces 01a1ancel; PowerShell reads $id:cancel as a scoped variable and gives an empty string. Bash has no problem. The safe spelling in both shells is "${id}:cancel". A note about this in the contract's examples and the README saves a partner time on their first try.
Modelling the return as a resource gives a different flow:
$ curl -i -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -d '{"reason":"Damaged covers on 3 copies"}' http://127.0.0.1:52318/orders/01a0a3f9-00c0-7392-af22-582a23b8c1e9/returns
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:28:37 GMT
Server: Kestrel
Location: /orders/01a0a3f9-00c0-7392-af22-582a23b8c1e9/returns/01a1dc2d-4a55-7d20-ad3b-34c8bc0bf72b
Transfer-Encoding: chunked
{"id":"01a1dc2d-4a55-7d20-ad3b-34c8bc0bf72b","orderId":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","status":"Requested","reason":"Damaged covers on 3 copies","requestedAt":"2026-11-14T22:28:38.101163Z"}
$ curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -d '{"reason":"Wrong edition"}' http://127.0.0.1:52318/orders/01a12f4d-f2c0-73b8-a687-7991815ef6d1/returns | jq -c '{status, title, detail}'
{"status":409,"title":"Order cannot be returned","detail":"Order 01a12f4d-f2c0-73b8-a687-7991815ef6d1 is Shipped; only Delivered orders can be returned."}
The return request now has its own address (Location) and its own state (Requested), and the store employee's approval screen will use that address. The detailed rules for 201 with Location and for 409 are the subject of the next post.
In ASP.NET Core
The custom method uses a complex segment in the route template: orders.MapPost("/{orderId:guid}:cancel", CancelOrder). The first colon is the route constraint (:guid), the one after the closing brace is the literal :cancel; routing decodes %3A before matching the path, so the encoded request lands on the same endpoint. The canonical URL and the institution list call the same OrderData.CanSee(user, order) function, and there is a single OrderNotFound result for invisible and missing orders. Route templates and constraints in detail are in the "Routing in ASP.NET Core Web API: Attribute Routing, Route Templates and Constraints" post of the ASP.NET Core Web API series.
Common Pitfalls
RPC-style URLs. Paths like /getBooks, /orders/createOrder and /books/search-by-author move the method into the URL. Lint rule BS-02 catches the first two; the third is really a /books?author=… filter.
Four or more levels of nesting. Paths like /institutions/{id}/units/{unitId}/buyers/{buyerId}/orders/{orderId}/items ask for ids the client does not know and copy database relationships into the contract. A buyer who moves to another campus loses all their URLs.
More than one canonical path to the same resource. Aliases (like me) are fine, but the response always carries the single real id. Opening a second full path splits the authorization check and the cache in two.
Hiding verbs inside nouns. /order-cancellation-requests looks like a resource, but if the request is processed instantly and disappears, it is just a verb with a longer name. The test is not the shape of the name but whether the thing has a lifecycle of its own.
Putting the database id in the URL. /orders/1042 reveals your order volume and invites a try with 1041; if your visibility check is ever incomplete, data leaks.
A Small Experiment
★☆☆ Request /customers/me/cart and /customers/ada/cart as Ada and compare the responses. Then request /customers/grace/cart as Ada. Why a 404 and not a 403?
★★☆ Institutional purchase approval (PurchaseApproval) is coming to Bookstore: a buyer asks for approval of an order, and an approver accepts or rejects it. Would you design it as POST /orders/{orderId}:approve or as a separate resource? Add your URLs and status codes to the contract and check them with npm run lint:contract.
★★★ Write a helper check in Spectral for the canonical URL rule: warn when more than one path returns the same schema (#/components/schemas/Order) as a single resource in responses. Make sure the rule does not flag the institution list by mistake (it returns a collection).
Our resources and their addresses are settled. In the next post we look at which method is used to reach these addresses and which status code the server answers with: PUT or PATCH, 400 or 422, 409 or 412.