When a developer first sees your API, they read the URL. A well-designed path speaks for itself: GET /orders/{id}/items is clear without documentation. A bad path (/getOrderItemList?orderId=5) forces you to dig into Swagger every time.
In this article we will look at how to build URLs correctly: how to name resources, how to pick HTTP methods, and how deep the nesting can go.
Four goals of a good URL
Before we get to the rules, it helps to understand what exactly we are trying to achieve.
Predictability — a developer who knows one endpoint can guess the rest. If there is GET /orders, it is reasonable to assume GET /orders/{id} and POST /orders.
Consistency — the same rules everywhere. Not /user-items in one place and /orderItems in another.
Readability — the URL reads like a sentence: GET /orders/{id}/items — "get the items of an order".
Stability — the URL is a public contract. Changing it breaks other people's code. Changing a URL is the same as changing the signature of a public library method.
How to write paths
There are a few simple rules to follow at all times.
Lowercase letters only, with a hyphen between words (this is called kebab-case):
/order-items ✓
/delivery-addresses ✓
/OrderItems ✗ uppercase letters
/order_items ✗ underscore (snake_case)
/deliveryAddresses ✗ camelCase
No trailing slash, no file extensions in the name:
/orders ✓
/orders/ ✗ trailing slash is redundant
/orders.json ✗ the format is set via the Accept header
No verbs in the path — HTTP methods already cover the actions:
GET /orders ✓ GET method = read
POST /orders ✓ POST method = create
GET /getOrders ✗ verb in the URL is redundant
POST /createOrder ✗ verb in the URL is redundant
Operational endpoints — outside the main API
Most APIs are built on a base path /api/v1/.... But there are operational endpoints that the infrastructure needs — they live separately:
/health— is the application running./ready— is it ready to accept traffic (used in Kubernetes)./info— version and metadata./metrics— Prometheus / Micrometer metrics.
They are not versioned and do not require user authentication (or they are protected via a separate management port).
Collections and single resources
One of the most common mistakes is confusion over the number of the noun in the path.
A collection (a list of objects) — plural:
/orders list of orders
/orders/{id} a specific order
/users list of users
/users/{id} a specific user
A singleton (a resource that exists as a single instance for the given context) — singular:
/users/{id}/profile user profile (each user has one)
/settings global settings (one set for the whole system)
A typical mistake is mixing the numbers:
/order ✗ use /orders
/orders/{id}/item ✗ use /orders/{id}/items
Names come from the domain language
A resource should be named the way the concept is named in your project. If the object is called Order in the code, then the path is /orders, not /purchases or /transactions. This makes it easier to navigate the code and the documentation — the same word everywhere.
Examples of matching:
Order→/ordersOrderItem→/orders/{id}/itemsDeliveryAddress→/delivery-addressesPayment→/payments
HTTP methods: which one and when
HTTP provides several methods, each carrying its own meaning. Violating that meaning leads to unexpected behavior.
| Method | Purpose | Safe to repeat? | Typical status |
|---|---|---|---|
GET | Read data | Yes | 200 |
POST | Create / command | No | 201 + Location header |
PUT | Full replacement of a resource | Yes | 200 |
PATCH | Partial update | Yes (in practice) | 200 |
DELETE | Delete | Yes | 204 |
An important rule: GET must not change data. This is the most dangerous mistake — if an operation has a side effect (charging money, cancelling an order), it goes through POST.
// Cancelling an order is a command with a side effect
@PostMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }
// Don't do this: GET implies a safe read
@GetMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }
POST is also used for commands that do not create a new resource but do something irreversible: POST /orders/{id}/confirm, POST /payments/{id}/refund.
Nesting: no deeper than two levels
REST allows nested paths, but you should not overuse them.
Up to two levels is fine:
/orders/{id} 1 level
/orders/{id}/items 2 levels
/orders/{id}/items/{id} 2 levels + identifier
Three levels and deeper is already hard:
/users/{id}/orders/{id}/items/{id} ✗ too deep
When you feel like going three levels deep, it is usually better to switch to a flat path with a filter:
/items?orderId={id} ✓ instead of /orders/{id}/items
Nesting is justified when the child resource does not exist without the parent. For example, an order item (OrderItem) makes no sense without an order (Order) — so /orders/{id}/items is logical. But if a resource can exist on its own, prefer flat + filter.
The identifier always goes in the path, not the body
If an endpoint operates on a specific object, its identifier belongs in the URL, not in the request body:
PUT /orders/{id} ✓
PUT /orders { "id": 5, ... } ✗ id in the request body
In the URL design itself, the path variable for the identifier is always written as {id} — the context provides the resource name in the preceding segment:
/orders/{id} first {id} = order identifier
/orders/{id}/items/{id} second {id} = item identifier
In short
- The URL is a public contract; changing it breaks client code.
- Write paths in lowercase with hyphens (kebab-case):
/order-items, not/orderItemsor/order_items. - No trailing slash, no extensions (
.json) in the path. - No verbs in the URL — HTTP methods cover the actions.
- Collections are plural (
/orders); a singleton is singular (/profile). - Resource names come from the project's domain language.
- GET is read-only; POST is for creating and for commands with side effects.
- At most two levels of nesting; deeper means a flat path with a filter (
?orderId=...). - The identifier goes in the path, not in the request body.
- Operational endpoints (
/health,/ready,/metrics) live outside the main/api/v1/.
What to read next
- Versioning a REST API — how to introduce
/v2without breaking clients. - Query parameters and pagination — filters, sorting, cursor pagination.
- Errors and RFC 9457 — the standard error format.