Designing Resources and Endpoints
Name resources as nouns, let HTTP methods carry the verbs, and pick status codes clients can rely on before writing any handler code.
GET /orders list 200
POST /orders create 201 + Location
GET /orders/{id} read one 200 or 404
PUT /orders/{id} replace 200 or 204
DELETE /orders/{id} remove 204A REST API is a set of resources, each with a URL, and a small set of methods that act on them. Deciding those URLs and methods first is cheaper than changing them later, because once a client ships, every path and status code is part of a contract you have to keep.
Nouns in the path, verbs in the method
A collection is a plural noun (/orders); one member is the collection plus an id (/orders/42). The action comes from the HTTP method, so /getOrders and /orders/42/delete are design mistakes: they duplicate what GET and DELETE already say, and caches, proxies and retry logic cannot reason about them.
| Request | Meaning | Success |
|---|---|---|
GET /orders | List (filtered, paged) | 200 |
POST /orders | Create; server picks the id | 201 + Location |
GET /orders/42 | Read one | 200, or 404 |
PUT /orders/42 | Replace the whole resource | 200 or 204 |
PATCH /orders/42 | Change some fields | 200 |
DELETE /orders/42 | Remove | 204 |
Safe and idempotent
GET is safe: it must not change state, so crawlers, prefetchers and monitoring can call it freely. PUT and DELETE are idempotent: sending the same request twice leaves the server in the same state as sending it once. POST is neither, which is why a client that retries a timed-out POST can create two orders. Payment and order APIs often accept an Idempotency-Key header so the server can recognise a retry.
Nesting and ids
Nest only for true ownership: /orders/42/items makes sense because items do not exist without their order. Stop at one level; /customers/7/orders/42/items/3 forces clients to know ids they do not need. Prefer ids that do not leak business data or row counts, such as UUIDs, when the API is public.
Mistakes worth avoiding
- Returning 404 when the path exists but the method is wrong. That is 405, with an
Allowheader. - Mixing
/orderand/orders, orsnake_caseandcamelCasein one API. - Using query strings for identity (
/orders?id=42). Query parameters are for filtering, sorting and paging a collection. - Versioning every endpoint separately. If you need versions, put one prefix such as
/v1in front of all of them.
The example puts the whole API surface into one route table. Matching the path first and the method second is what makes the 404/405 distinction possible.
Example
When to use it
- Agree the URL and status code table for an orders service before the mobile and web teams start building against it.
- Replace RPC-style paths such as /createUser and /deleteUser with /users and the matching HTTP methods.
- Answer a PATCH to a collection with 405 and an Allow header instead of a misleading 404.
More examples
Path templates with named parameters
Writing routes as templates keeps the documented URL and the code identical, which is what OpenAPI specs and frameworks do. Each parameter matches one path segment only, so a longer path never matches by accident.
record Template(String text, Pattern pattern, List<String> names) {
// Turn "/customers/{customerId}/orders/{orderId}" into a regex plus the parameter names.
static Template of(String text) {
List<String> names = new ArrayList<>();
StringBuilder regex = new StringBuilder();
Matcher m = Pattern.compile("\\{(\\w+)}").matcher(text);
int last = 0;
while (m.find()) {
regex.append(Pattern.quote(text.substring(last, m.start()))).append("([^/]+)");
names.add(m.group(1));
last = m.end();
}
regex.append(Pattern.quote(text.substring(last)));
return new Template(text, Pattern.compile(regex.toString()), List.copyOf(names));
}
Optional<Map<String, String>> match(String path) {
Matcher m = pattern.matcher(path);
if (!m.matches()) return Optional.empty();
Map<String, String> params = new LinkedHashMap<>();
for (int i = 0; i < names.size(); i++) params.put(names.get(i), m.group(i + 1));
return Optional.of(params);
}
}
void main() {
Template t = Template.of("/customers/{customerId}/orders/{orderId}");
for (String path : List.of("/customers/7/orders/42", "/customers/7/orders", "/customers/7/orders/42/items")) {
IO.println(path + " -> " + t.match(path).map(Object::toString).orElse("no match"));
}
}
// Output:
// /customers/7/orders/42 -> {customerId=7, orderId=42}
// /customers/7/orders -> no match
// /customers/7/orders/42/items -> no matchWhy a retried POST duplicates and a retried PUT does not
Idempotency is about the state the server is left in, not the response: a second DELETE may well return 404. When clients must retry POSTs safely, accept an Idempotency-Key header and store the first result under it.
Map<String, String> customers = new LinkedHashMap<>();
int nextId = 1;
// POST: the server picks the id, so every call creates something new.
String post(String name) {
String id = String.valueOf(nextId++);
customers.put(id, name);
return id;
}
// PUT: the client names the resource, so repeating the call changes nothing.
void put(String id, String name) {
customers.put(id, name);
}
void main() {
post("Ada");
post("Ada"); // the client timed out and retried
IO.println("after two POSTs: " + customers);
customers.clear();
put("ada", "Ada");
put("ada", "Ada");
IO.println("after two PUTs: " + customers);
}
// Output:
// after two POSTs: {1=Ada, 2=Ada}
// after two PUTs: {ada=Ada}The method table as code
HTTP clients and gateways use exactly this property to decide whether they may retry a failed request on their own. PATCH is not idempotent in general, although a particular PATCH can be designed to be.
enum Verb {
GET(true, true, "200 with the resource"),
POST(false, false, "201 with a Location header"),
PUT(false, true, "200 with the resource, or 204"),
PATCH(false, false, "200 with the updated resource"),
DELETE(false, true, "204 with no body");
final boolean safe;
final boolean idempotent;
final String success;
Verb(boolean safe, boolean idempotent, String success) {
this.safe = safe;
this.idempotent = idempotent;
this.success = success;
}
boolean retryable() { return idempotent; }
}
void main() {
for (Verb v : Verb.values()) {
IO.println(String.format("%-6s safe=%-5s retryable=%-5s %s", v, v.safe, v.retryable(), v.success));
}
}
// Output:
// GET safe=true retryable=true 200 with the resource
// POST safe=false retryable=false 201 with a Location header
// PUT safe=false retryable=true 200 with the resource, or 204
// PATCH safe=false retryable=false 200 with the updated resource
// DELETE safe=false retryable=true 204 with no body
Discussion