In the previous lesson we fixed the working method and decided, with judgement, which nouns of the Aroma Store domain deserve to be resources. Now comes the next step: giving them an address. The URI is the most visible and most long-lived part of an API —clients write it into their code, save it in bookmarks, paste it into tickets— and that is also why it is the most expensive thing to change. This lesson establishes the naming rules, settles when to nest and when not to, chooses the type of identifier and tackles the problem that no CRUD solves on its own: how to model actions such as paying for an order or moderating a review. We will finish with the complete URI map of Aroma Store, which the rest of the module will take as given.

Contents

  1. What a resource is and what a URI is
  2. Nouns, not verbs
  3. Collections and elements
  4. Naming rules
  5. Hierarchy and nesting
  6. Singleton resources
  7. Path parameters versus query parameters
  8. Identifier design
  9. Actions that are not CRUD
  10. Aroma Store's URI map

  1. What a resource is and what a URI is

Let us recall the definition from 01-04: a resource is anything with identity that it makes sense to operate on; the URI is its stable identifier; the representation is one of its possible concrete forms (the JSON that travels, the PDF, the HTML).

Three practical consequences that govern this entire lesson:

  • A resource can have several representations (/orders/ord_5001/invoice in JSON or in PDF), but it keeps a single URI. The representation is chosen by content negotiation (02-05), not by changing the URL with .pdf.
  • A URI identifies, it does not describe the operation. What you do with the resource is stated by the method (02-03).
  • URIs should outlive internal changes. If /coffees/cof_001 stops working because the database has been refactored, we have broken the contract.

  1. Nouns, not verbs

The most quoted rule of REST design, and the one most often broken:

❌ Verb in the path ✅ Noun + method
GET /getCoffees GET /coffees
POST /createOrder POST /orders
POST /updateStock?id=cof_001 PATCH /coffees/cof_001
GET /deleteReview?id=rev_101 DELETE /reviews/rev_101
POST /listCustomerOrders GET /customers/cus_842/orders

Why it matters, beyond aesthetics:

  • The method is already the verb. GET /getCoffees repeats the verb and, worse, allows the incoherent POST /getCoffees.
  • HTTP's semantics are lost. GET /deleteReview is a functional disaster: a search engine or a browser prefetch could delete reviews while following links, because GET is safe by definition (02-03).
  • Predictability is lost. With verbs, every endpoint has to be memorised: getCoffees, fetchOrders, listReviews, readCustomer.

Watch out for one important nuance: identifiers in the code do use verbs (getCoffees(), createOrder()), as the style guide states. What carries no verb is the URI.

  1. Collections and elements

Every resource-oriented API is structured around two shapes:

  • Collection: a set of resources of the same type. /coffees.
  • Element (or individual resource): a specific member. /coffees/cof_001.

The pattern reads from left to right like a navigation path:

/v1/customers/cus_842/orders/ord_5001
 │   │         │       │      └── element: one specific order
 │   │         │       └───────── collection: that customer's orders
 │   │         └───────────────── element: one specific customer
 │   └─────────────────────────── collection: all the customers
 └─────────────────────────────── API version

Each level alternates collection → element → collection → element. If your URI breaks that alternation (/customers/orders/cus_842), there is almost always a design error.

And each shape admits different operations, which anticipates 02-03:

Collection /coffees Element /coffees/cof_001
GET Lists, filtered and paginated Returns that coffee
POST Creates a new one Not used (except for an action sub-resource)
PUT Not used (replacing a whole collection is dangerous) Replaces that coffee
PATCH Not used Modifies fields of that coffee
DELETE Not used (deleting the whole catalogue by accident) Deletes that coffee

  1. Naming rules

These are the rules Aroma Store adds to its style guide.

4.1. Consistent plurals

/coffees, /customers, /orders, /reviews, /carts. Always plural, even when it sounds odd, because the alternative —singular for the element and plural for the collection— forces you to remember two forms per resource.

✅ /coffees          /coffees/cof_001
❌ /coffees          /coffee/cof_001
❌ /coffeeList       /coffees/cof_001

The only exception is singletons (section 6), which by definition are not collections.

4.2. Always lowercase

The path component of a URL is case-sensitive (unlike the host name). /Coffees and /coffees are two different resources as far as the standard is concerned, and accepting both doubles the surface of the contract and ruins caching.

4.3. Hyphens for compound words (kebab-case)

✅ /tasting-notes       /payment-methods     /orders/ord_5001/gift-note
❌ /tastingNotes        /tasting_notes       /TastingNotes

Reasons: it is the dominant convention on the web, it is more readable and search engines treat the hyphen as a word separator (relevant if part of the API is indexed or if you share documentation links). Notice the deliberate asymmetry: kebab-case in the URI, camelCase in the JSON. It is an apparent inconsistency, but these are two worlds with their own settled conventions; what matters is that each world is consistent with itself.

4.4. No file extensions

✅ /coffees/cof_001                with the header Accept: application/json
❌ /coffees/cof_001.json
❌ /coffees/cof_001.xml

The format is a question of representation, and it is negotiated with headers (02-05). Adding .json mixes identity and format: if you add XML or PDF tomorrow, every resource has three URIs for the same thing.

4.5. No trailing slash

/coffees and /coffees/ are technically different paths. Pick one —Aroma Store uses no trailing slash— and redirect the other with a 301 (02-04) instead of serving both.

4.6. No technical suffixes or internal jargon

❌ /api/v1/coffeesController/getAll
❌ /v1/tbl_coffees
❌ /v1/coffeesDTO

The name of the class, the table or the implementation pattern is none of the consumer's business. Watch out for /api too: if the host is already api.aromastore.example, repeating it in the path is redundant.

4.7. No problematic characters

No accents, ñ, spaces or capitals in the path segments you control. That is why the collection is /origins and never /orígenes: even though modern browsers encode non-ASCII characters, in practice they end up showing as /or%C3%ADgenes in logs, curl examples and old clients.

  1. Hierarchy and nesting

Nesting expresses belonging: /coffees/cof_001/reviews are the reviews of that coffee.

5.1. The practical rule

Nest a sub-resource only if it makes no sense outside its parent, or if the belonging relationship is the natural way of reaching it.

Examples in Aroma Store:

URI Nest? Reason
/coffees/cof_001/reviews Yes The reviews of a coffee are a central use case (the product page)
/carts/crt_77/items/cof_002 Yes A cart item does not exist without its cart
/orders/ord_5001/payment Yes The payment belongs to one specific order
/customers/cus_842/orders Yes "My orders" is a real use case for the SPA and for Aroma Mobile
/customers/cus_842/orders/ord_5001/items/1/coffee No Four levels: illegible and fragile. Link to the coffee, do not nest it
/origins/ethiopia/coffees No The origin is an attribute: it is handled with a ?origin=Ethiopia filter

5.2. Two levels maximum

A rule that saves a lot of grief: do not go beyond /collection/{id}/subcollection/{id}. Past that point the URI becomes illegible, it couples the client to a hierarchy that may change and it forces you to validate long chains of belonging.

✅ /orders/ord_5001/items
❌ /customers/cus_842/orders/ord_5001/items/itm_3/coffee/cof_001

When you need to go deeper, cut the hierarchy and use a top-level collection with a filter:

# Instead of nesting three levels
curl "https://api.aromastore.example/v1/reviews?coffeeId=cof_001&status=pending_moderation"

5.3. Dual access: nested and flat

The same resource can be reachable through two paths if each one serves a different use case. In Aroma Store:

# Product page: the reviews of a coffee (SPA and Aroma Mobile)
GET /v1/coffees/cof_001/reviews

# Internal panel: all the reviews pending moderation, for any coffee
GET /v1/reviews?status=pending_moderation

Rules to stop this becoming a problem:

  • The element lives at one single canonical URI: /reviews/rev_101. The nested form is only for listing and creating.
  • The representation's self link always points at the canonical one, so that two paths do not generate two identities.
  • Nested creation is more convenient: POST /coffees/cof_001/reviews does not need to repeat coffeeId in the body, because it is already in the URI.
graph TD
    C["GET /v1/coffees/cof_001/reviews<br/><i>nested view</i>"] --> R["rev_101<br/>rev_102"]
    P["GET /v1/reviews?status=pending_moderation<br/><i>flat view with a filter</i>"] --> R
    R --> CAN["Canonical URI of the element:<br/><b>/v1/reviews/rev_101</b><br/>(the one that goes in _links.self)"]

  1. Singleton resources

A singleton is a resource of which only one instance exists in its context. It has no collection and no identifier of its own, and that is why it goes in the singular.

/v1/customers/cus_842/preferences   the customer's preferences (language, currency, newsletter)
/v1/orders/ord_5001/payment         the payment for that order
/v1/orders/ord_5001/shipment        the shipment handled by SwiftShip
/v1/orders/ord_5001/invoice         the invoice for that order
/v1/coffees/cof_001/image           the coffee's main image

A singleton typically admits GET, PUT and sometimes DELETE, but not POST (there is no collection to add to), with the exception of singletons that model an action, which do use POST (section 9).

GET /v1/customers/cus_842/preferences HTTP/1.1
Host: api.aromastore.example
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json

{
  "language": "en",
  "currency": "EUR",
  "newsletter": true,
  "preferredRoast": "medium",
  "_links": {
    "self": { "href": "/v1/customers/cus_842/preferences" },
    "customer": { "href": "/v1/customers/cus_842" }
  }
}

Beware of the badly used singleton: /customers/cus_842/address is fine if the customer can only have one; the moment they can have several it becomes /customers/cus_842/addresses and that is a breaking change (02-07). If in doubt, start with a collection.

  1. Path parameters versus query parameters

The most common confusion in URI design. The rule is simple and almost never fails:

The path identifies the resource. The query string modifies how the collection is returned.

Goes in the path Goes in the query string
Resource identifiers: /coffees/cof_001 Filters: ?origin=Colombia&roast=medium
Belonging hierarchy: /coffees/cof_001/reviews Sorting: ?sort=-priceEuros
Singleton sub-resources: /orders/ord_5001/payment Pagination: ?limit=20&offset=40
Actions modelled as sub-resources: /reviews/rev_101/approval Search: ?q=yirgacheffe
Sparse fieldsets: ?fields=id,name,priceEuros
Expansion: ?expand=items.coffee

A side-by-side example:

# ✅ Correct: the id identifies, so it goes in the path
curl https://api.aromastore.example/v1/coffees/cof_001

# ❌ Wrong: the id is not a filter
curl "https://api.aromastore.example/v1/coffees?id=cof_001"

# ✅ Correct: origin is a selection criterion over the collection
curl "https://api.aromastore.example/v1/coffees?origin=Ethiopia"

# ❌ Wrong: turns an attribute value into hierarchy
curl https://api.aromastore.example/v1/coffees/origin/ethiopia

Two nuances worth knowing:

  • A filter that returns a single element is still a collection. GET /coffees?name=Ethiopia Yirgacheffe returns {"data": [...], "total": 1}, not the bare object, and it returns 200 with an empty list if there are no matches (not 404). The reason: the "filtered collection" resource exists even when it is empty.
  • Query parameters affect caching. Every different combination is a different URL and therefore a different cache entry. That is one more argument for not multiplying parameters needlessly (04-06).

  1. Identifier design

The identifier you put in the URI is contract forever. The options:

Type Example Advantages Drawbacks
Auto-increment integer /coffees/1 Short, readable, cheap to index Leaks business volume, enumerable, clashes when merging databases
UUID v4 /coffees/6f1c... Unguessable, generable by the client, unique across systems Long, illegible, worse index locality
UUID v7 / ULID /coffees/01HQ... Sortable by time, good index behaviour Reveals the moment of creation
Slug /coffees/ethiopia-yirgacheffe Readable, good for SEO Changes if the name changes; duplicates have to be managed
Prefixed id /coffees/cof_001 Self-describing, impossible to confuse types, searchable in logs An in-house convention, not a standard

Aroma Store's decision: prefixed ids

cof_001, cus_842, ord_5001, rev_101, crt_77, inv_88, evt_9f2c. It is the style Stripe made popular and the reasons are very practical:

  1. Self-describing. When you read a log or a ticket, ord_5001 is understandable without context. With a bare 5001 you do not know what it belongs to.
  2. Impossible to cross types. If someone sends POST /v1/orders with {"customerId": "cof_001"}, the server spots the wrong prefix and responds 400 with invalid_data, instead of creating an incoherent order.
  3. Opaque by contract. The documentation says so explicitly: the identifier is an opaque string, do not parse it, do not assume a length, do not assume that the numeric part is sequential. That way we can migrate tomorrow to cof_01HQ8ZK... without breaking anyone.

In production, the part after the prefix should be random and non-sequential. The cof_001 and ord_5001 of this course are didactic; in a real shop, publishing sequential identifiers has two problems:

  • Business information leakage. A competitor who places an order on Monday and another on Friday knows how many orders you received that week. It is the classic German tank problem.
  • Enumeration. With sequential ids, walking ord_5001, ord_5002, ord_5003… is trivial. A third party being able to read other people's orders is an authorisation failure, not an id failure —it is called IDOR and it is covered in 04-02— but guessable ids turn an isolated flaw into a mass leak. The rule is: always authorise, and on top of that do not make it easy.

About slugs: they are excellent for the public web (aromastore.example/coffees/ethiopia-yirgacheffe) and bad as API identity, because they change. If you want them, the usual pattern is for the slug to be one more field of the representation and a filter (?slug=ethiopia-yirgacheffe), while the canonical URI remains the opaque id.

  1. Actions that are not CRUD

Here is the most interesting design problem of the lesson. Many business operations are not "create, read, update, delete":

  • Paying for an order.
  • Cancelling an order.
  • Approving or rejecting a review.
  • Emptying a cart.
  • Resending the confirmation email.

There are three strategies, and it is worth understanding all three before choosing.

Strategy A: change the state with PATCH

PATCH /v1/reviews/rev_101 HTTP/1.1
Content-Type: application/merge-patch+json

{ "status": "published" }
For Against
It does not invent new resources Side effects stay hidden: approving triggers emails, recalculates the coffee's average rating…
Pure CRUD, easy to implement You cannot pass parameters that belong to the action (the reason for a rejection)
Clear semantics for the client It does not distinguish "change a piece of data" from "execute a transition"
Hard to authorise separately: whoever can edit can approve

It is acceptable when the transition is purely a data change, with no logic and no side effects.

Strategy B: a verb in the URI (RPC over HTTP)

POST /v1/reviews/rev_101/approve
POST /v1/orders/ord_5001/pay
For Against
Explicit, immediate intent It reintroduces verbs into the URI, exactly what we avoided in section 2
Easy to explain It degrades quickly: approveWithComment, approveAndNotify
It is what many real APIs do It drops to Richardson level 1 on those endpoints

Strategy C: the action becomes a sub-resource (Aroma Store's choice)

You look for the noun behind the verb: approve → an approval; pay → a payment; cancel → a cancellation. That noun is a resource that is created with POST.

POST /v1/reviews/rev_101/approval HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer <moderator's token>
Content-Type: application/json

{ "note": "Review verified, purchase confirmed" }
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/reviews/rev_101/approval

{
  "status": "published",
  "moderatorId": "cus_003",
  "approvedAt": "2026-03-14T10:32:00Z",
  "_links": {
    "self": { "href": "/v1/reviews/rev_101/approval" },
    "review": { "href": "/v1/reviews/rev_101" }
  }
}

The advantages are exactly the ones we were after:

  • No verbs in the path: approval is a noun and the verb is supplied by POST.
  • The action can carry its own body: the reason for the rejection, the payment reference, the amount.
  • The action is a queryable resource: GET /v1/orders/ord_5001/payment returns the payment that was made, with its date and its reference.
  • It is authorised separately: permission to create /approval is different from permission to edit the review (04-03).
  • It leaves room for idempotency: being a well-delimited POST, you can require an Idempotency-Key on it (02-03).

And Aroma Store's action map ends up like this:

Business action URI Method Verb avoided
Pay for an order /orders/{id}/payment POST pay
Cancel an order /orders/{id}/cancellation POST cancel
Return an order /orders/{id}/return POST return
Approve a review /reviews/{id}/approval POST approve
Reject a review /reviews/{id}/rejection POST reject
Reply to a review /reviews/{id}/replies POST reply
Empty a cart /carts/{id}/items DELETE empty

Notice the last row: before inventing a sub-resource, check whether a standard method already expresses it. Emptying the cart is exactly "delete all its items", so DELETE /carts/crt_77/items is more natural than a POST /carts/crt_77/emptying. It is the exception to the rule in section 3 about not using DELETE on collections, and it is legitimate because here the collection is scoped to one specific cart and deleting it wholesale is a genuine business operation.

  1. Aroma Store's URI map

This is the outcome of the lesson and the map that the rest of the module will take as given. The methods appear to give context; their exact semantics are 02-03 and the response codes are 02-04.

URI Methods Description
/v1/coffees GET, POST, HEAD, OPTIONS Coffee catalogue; filterable, sortable and paginated
/v1/coffees/{coffeeId} GET, PUT, PATCH, DELETE, HEAD One specific coffee
/v1/coffees/{coffeeId}/image GET, PUT, DELETE Main image (singleton, not JSON)
/v1/coffees/{coffeeId}/reviews GET, POST Reviews of a coffee (nested view)
/v1/customers GET, POST Registered customers (internal panel only)
/v1/customers/{customerId} GET, PATCH, DELETE One specific customer
/v1/customers/{customerId}/preferences GET, PUT Customer preferences (singleton)
/v1/customers/{customerId}/orders GET "My orders"
/v1/carts POST Creates a cart
/v1/carts/{cartId} GET, DELETE One specific cart
/v1/carts/{cartId}/items GET, POST, DELETE Cart items; DELETE empties it
/v1/carts/{cartId}/items/{coffeeId} GET, PUT, DELETE One item; PUT sets the quantity
/v1/orders GET, POST Orders; POST confirms a cart
/v1/orders/{orderId} GET, PATCH One specific order
/v1/orders/{orderId}/items GET Order items (immutable)
/v1/orders/{orderId}/payment GET, POST Order payment (action + query)
/v1/orders/{orderId}/cancellation POST Cancels the order
/v1/orders/{orderId}/return POST Requests a return
/v1/orders/{orderId}/shipment GET, PUT Shipment; SwiftShip updates it with PUT
/v1/orders/{orderId}/invoice GET Invoice (JSON or PDF, depending on Accept)
/v1/reviews GET All the reviews (flat view, filterable)
/v1/reviews/{reviewId} GET, PATCH, DELETE One review (canonical URI)
/v1/reviews/{reviewId}/approval POST Approves the review
/v1/reviews/{reviewId}/rejection POST Rejects the review
/v1/reviews/{reviewId}/replies GET, POST The shop's replies to the review

Decisions noted down so we do not forget them:

  • There is no POST /v1/reviews: a review is always born attached to a coffee, so it is only created at /coffees/{coffeeId}/reviews. The flat view is read-only and serves the internal panel.
  • There is no DELETE /v1/orders/{id}: an order is not deleted, it is cancelled. The accounting record is sacred.
  • /v1/origins has been considered and discarded for v1: the origin is an attribute and is filtered with ?origin=. If it ever has data of its own (altitude, cooperative, photo), it will become a collection and can be added without breaking anything (02-07).

Common Mistakes and Tips

  • Putting the verb in the path "just for this one operation". You start with one exception and you end up with twenty. If you need an action, look for its noun.
  • Nesting out of habit. /customers/cus_842/orders/ord_5001 forces the server to validate that the order belongs to that customer and forces the client to know two ids to fetch one thing. Nest to list, use the canonical URI for the element.
  • Using the query string to identify. /coffees?id=cof_001 breaks caching by URL, complicates self links and does not allow sub-resources.
  • Adding .json at the end. It mixes identity and format; that is what Accept is for.
  • Pluralising badly. /coffees and /coffee/cof_001 living side by side is the most frequent documentation bug in the world.
  • Exposing the database id out of convenience. Changing engine or merging environments will force you to break the contract. An opaque id leaves you free.
  • Tip: write the URIs before the code and read them out loud. If reading /orders/ord_5001/cancellation with POST is understandable without explanation, it is well designed.
  • Tip: keep a table like the one in section 10 in the repository. It is the index of the contract and the place where any new endpoint gets discussed before it exists.

Exercises

Exercise 1: fix a set of URIs

A team has proposed these paths for Aroma Store. Correct them and justify each change.

GET  /v1/api/getCoffees.json
POST /v1/Coffee/create
GET  /v1/coffees?id=cof_001
POST /v1/coffees/cof_001/reviews/rev_101/approve
GET  /v1/customers/cus_842/orders/ord_5001/items/1/coffee/cof_001/reviews
DELETE /v1/carts/crt_77/emptyCart
GET  /v1/coffees/origin/ethiopia/roast/light

Exercise 2: model a new action

Aroma Store wants a customer to be able to gift an order: when confirming it they give the recipient's email address and a message, and the system sends a notification and hides the price on the delivery note.

Model this feature with the three strategies from section 9 (PATCH, verb in the URI, sub-resource), show the HTTP request for each one and pick the one that fits the style guide, justifying your choice.

Exercise 3: decide on nesting

For each need, decide the URI and say whether you nested it or not and why:

  1. The internal panel wants to see all the sold items of coffee cof_001 in the last month.
  2. Aroma Mobile wants the reviews written by customer cus_842.
  3. SwiftShip wants to update the shipment status of order ord_5001.
  4. The SPA wants the price change history of cof_001.

Solutions

Solution 1

Proposal Correction Reason
GET /v1/api/getCoffees.json GET /v1/coffees /api is redundant (it is already in the host), the get verb is redundant (the method supplies it) and .json is redundant (Accept negotiates it)
POST /v1/Coffee/create POST /v1/coffees Lowercase, plural and no verb: POST on the collection already means create
GET /v1/coffees?id=cof_001 GET /v1/coffees/cof_001 The identifier goes in the path; the query string filters collections
POST /v1/coffees/cof_001/reviews/rev_101/approve POST /v1/reviews/rev_101/approval Verb → noun, and it is trimmed back to the review's canonical URI: the coffee is redundant
GET /v1/customers/.../coffee/cof_001/reviews GET /v1/coffees/cof_001/reviews Five levels of unnecessary nesting: the review depends on the coffee, not on the customer's order
DELETE /v1/carts/crt_77/emptyCart DELETE /v1/carts/crt_77/items The standard method already expresses the action on the sub-collection
GET /v1/coffees/origin/ethiopia/roast/light GET /v1/coffees?origin=Ethiopia&roast=light Origin and roast are attributes, not hierarchy: they are filters

Solution 2

Strategy A — PATCH on the order:

PATCH /v1/orders/ord_5001
Content-Type: application/merge-patch+json

{ "gift": { "recipient": "[email protected]", "message": "Congratulations!" } }

It works, but sending the notification is left as a hidden side effect of a field change, and there is nowhere to check afterwards whether the notification was sent.

Strategy B — verb in the URI:

POST /v1/orders/ord_5001/gift-it
Content-Type: application/json

{ "recipient": "[email protected]", "message": "Congratulations!" }

Crystal-clear intent, but a verb in the path: it breaks the style guide and opens the door to giftWithoutNotice.

Strategy C — sub-resource (the chosen one):

POST /v1/orders/ord_5001/gift
Content-Type: application/json

{ "recipient": "[email protected]", "message": "Congratulations!", "hidePrice": true }
HTTP/1.1 201 Created
Location: /v1/orders/ord_5001/gift

{
  "recipient": "[email protected]",
  "message": "Congratulations!",
  "hidePrice": true,
  "notificationSent": true,
  "notifiedAt": "2026-03-14T10:35:00Z",
  "_links": { "self": { "href": "/v1/orders/ord_5001/gift" },
              "order": { "href": "/v1/orders/ord_5001" } }
}

This is the chosen one: a noun (gift), its own body for the action's parameters, queryable afterwards with GET, and cancellable with DELETE /v1/orders/ord_5001/gift as long as the order has not shipped. It fits exactly alongside /payment, /cancellation and /approval.

Solution 3

  1. GET /v1/order-items?coffeeId=cof_001&dateFrom=2026-02-14 — not nested. What is being searched for cuts across every order, so the /orders/{id}/items hierarchy is no use: a flat top-level collection queryable with filters is needed. Beware the temptation to write /v1/orders/items: it would collide with /v1/orders/{orderId}, because the router cannot tell an id called items from a fixed segment. (A reasonable alternative: a reporting resource /v1/sales?coffeeId=..., if the panel needs aggregates rather than individual items.)
  2. GET /v1/reviews?customerId=cus_842 — not nested under the coffee, because the criterion is the author. Nesting it as /customers/cus_842/reviews would also be defensible if "my reviews" were a screen of its own in Aroma Mobile; both obey the rule, and in that case it would be wise to pick just one so as not to duplicate contract.
  3. PUT /v1/orders/ord_5001/shipment — nested and a singleton: a shipment does not exist outside its order and there is only one. PUT because SwiftShip sends the shipment's complete state on every update (02-03).
  4. GET /v1/coffees/cof_001/price-history — nested: the history makes no sense outside its coffee and it is read-only. Note the kebab-case in the compound word. If the history were queried globally across the whole catalogue, it would become /v1/price-history?coffeeId=cof_001.

Conclusion

URIs are the public and most long-lived face of the API, and you now have concrete rules for designing them: plural, lowercase nouns, kebab-case for compound words, no extensions and no verbs, nesting only when it expresses real belonging and never beyond two levels, singulars for singletons that are genuinely unique, identification in the path and collection modification in the query string, and opaque prefixed identifiers that do not leak business information. Above all you have solved the problem that throws everybody: actions that are not CRUD are modelled as sub-resources created with POST, which gives each action its own body, later queryability and independent authorisation. Aroma Store's URI map is settled.

We now know which resources exist and where they live. What is missing is stating precisely what can be done with each of them. In the next lesson, 02-03 HTTP Methods, we will walk through GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS applied to this map; we will understand why safety and idempotency are far more than theory when SwiftShip retries a request or a customer clicks the pay button twice; we will compare PUT against PATCH with JSON Merge Patch and JSON Patch in depth; and we will design the idempotency keys for paying an order.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved