← Back to the section

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/orders
  • OrderItem/orders/{id}/items
  • DeliveryAddress/delivery-addresses
  • Payment/payments

HTTP methods: which one and when

HTTP provides several methods, each carrying its own meaning. Violating that meaning leads to unexpected behavior.

MethodPurposeSafe to repeat?Typical status
GETRead dataYes200
POSTCreate / commandNo201 + Location header
PUTFull replacement of a resourceYes200
PATCHPartial updateYes (in practice)200
DELETEDeleteYes204

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 /orderItems or /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/.
  • Versioning a REST API — how to introduce /v2 without breaking clients.
  • Query parameters and pagination — filters, sorting, cursor pagination.
  • Errors and RFC 9457 — the standard error format.