REST API Design Principles
The decisions that make an API pleasant to consume: resource naming, method semantics and idempotency, choosing the right status code, HATEOAS, and the Richardson maturity model.
On this page
An API is a user interface for developers. The same qualities apply — predictability, consistency, good error messages — and the cost of getting them wrong is the same, except that you cannot ship a fix without breaking someone.
Key Takeaways
- Resources are nouns, plural, kebab-case. The verb is the HTTP method.
- Idempotency is a promise clients rely on to retry safely. Know which of your methods make it.
- Status codes are a contract, not decoration — clients branch on them.
201must return aLocationheader;202must tell the caller how to check progress.- Version from day one, even if the first version never changes.
Naming resources
GET /api/v1/orders list
POST /api/v1/orders create
GET /api/v1/orders/{id} read
PATCH /api/v1/orders/{id} partial update
DELETE /api/v1/orders/{id} delete
GET /api/v1/orders/{id}/lines sub-resource collection
POST /api/v1/orders/{id}/shipments create a related resourceFour conventions do most of the work. Use plural nouns so the collection and the item share a
prefix. Use kebab-case for multi-word segments, because URLs are case-sensitive and mixed casing
generates support tickets. Keep nesting to one level — /customers/1/orders/2/lines/3 is
unnecessary when /lines/3 identifies the line uniquely. And keep verbs out of paths; POST /orders already says create.
Filtering, sorting and pagination belong in the query string, not the path:
GET /api/v1/orders?status=PENDING&createdAfter=2026-01-01&sort=createdAt,desc&page=0&size=20Method semantics
| Method | Safe | Idempotent | Body | Notes |
|---|---|---|---|---|
GET | Yes | Yes | No | Cacheable. Never mutate state |
HEAD | Yes | Yes | No | Headers only |
OPTIONS | Yes | Yes | No | Allowed methods, CORS preflight |
POST | No | No | Yes | Create, or non-idempotent actions |
PUT | No | Yes | Yes | Full replacement |
PATCH | No | Not necessarily | Yes | Partial update |
DELETE | No | Yes | Optional | Repeat deletes return 404 or 204 |
Safe means no observable state change. Idempotent means N identical requests have the same effect
as one. These are not academic distinctions: proxies and client libraries retry safe and idempotent
requests automatically. A GET with a side effect will have that side effect twice, and nobody will
believe you when you say the client only sent it once.
POST is the odd one out, and that is exactly why idempotency keys exist:
@PostMapping("/api/v1/payments")
public ResponseEntity<PaymentResponse> create(
@RequestHeader("Idempotency-Key") @NotBlank String key,
@Valid @RequestBody CreatePaymentRequest request) {
// A replay of the same key returns the original result rather than
// charging the customer a second time.
return idempotencyStore.find(key)
.map(previous -> ResponseEntity.ok(previous.response()))
.orElseGet(() -> {
PaymentResponse response = paymentService.charge(request);
idempotencyStore.save(key, request.fingerprint(), response, Duration.ofHours(24));
return ResponseEntity
.created(URI.create("/api/v1/payments/" + response.id()))
.body(response);
});
}Store the request fingerprint alongside the key. If the same key arrives with a different body,
that is a client bug and should return 422 rather than silently replaying an unrelated response.
Status codes worth getting right
Three that are routinely misused. 202 Accepted means the work has not happened yet — return a
URL the client can poll, or it has no way to find out. 204 No Content must have an empty body;
sending JSON with it is a protocol violation that breaks strict clients. And 200 with an error
inside the body is the worst option available: every layer between you and the caller — caches,
proxies, retry middleware, monitoring — reads the status line, and you have told all of them the
request succeeded.
Long-running work
@PostMapping("/api/v1/exports")
public ResponseEntity<Void> startExport(@Valid @RequestBody ExportRequest request) {
String jobId = exportService.enqueue(request);
return ResponseEntity.accepted()
.location(URI.create("/api/v1/exports/" + jobId))
.header("Retry-After", "5")
.build();
}
@GetMapping("/api/v1/exports/{jobId}")
public ExportStatus status(@PathVariable String jobId) {
return exportService.status(jobId); // PENDING | RUNNING | SUCCEEDED | FAILED
}The Retry-After header tells a polling client how long to wait, which stops it hammering the
status endpoint every 100ms.
Conditional requests
ETag and If-None-Match let a client skip a transfer entirely when nothing changed, and
If-Match gives you optimistic concurrency for free:
@GetMapping("/api/v1/products/{id}")
public ResponseEntity<Product> get(@PathVariable String id) {
Product product = service.find(id);
return ResponseEntity.ok()
.eTag('"' + product.version().toString() + '"')
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(product);
}
@PutMapping("/api/v1/products/{id}")
public ResponseEntity<Product> update(@PathVariable String id,
@RequestHeader("If-Match") String ifMatch,
@Valid @RequestBody Product product) {
// A stale ETag means someone else wrote first; 412 tells the client to re-read.
if (!service.versionMatches(id, ifMatch)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
return ResponseEntity.ok(service.update(id, product));
}This is the cheapest lost-update protection there is, and it works with any HTTP client without a custom protocol.
The Richardson maturity model
| Level | Description | Reality |
|---|---|---|
| 0 | One URI, one method, RPC over HTTP | SOAP-era |
| 1 | Multiple resources | Better, but still one verb |
| 2 | Proper HTTP verbs and status codes | Where good APIs live |
| 3 | Hypermedia controls | Rare, and usually not worth it |
Level 2 is the honest target. It gives you caching, retries, standard tooling and clients that behave sensibly. Level 3 asks clients to discover transitions from links, which requires clients built for that — and in practice almost every consumer hard-codes URLs from documentation anyway.
The parts of level 3 that do pay off are narrow and cheap. Pagination links (next, prev, first,
last) genuinely remove URL construction from the client. Action affordances — including a cancel
link only when an order is actually cancellable — let a UI render the right buttons without
reimplementing your state machine. Take those two and skip the rest.
Consistency beats cleverness
The single biggest quality signal in an API is that the twentieth endpoint behaves like the first.
Same date format everywhere — ISO 8601 with an offset, always. Same envelope for collections. Same
error shape. Same casing for JSON fields; pick camelCase or snake_case and never mix. Same
pagination parameter names.
Write these down as a short document before the second endpoint exists, and review against it. It is far cheaper than discovering in month six that three teams chose three date formats and every one of them is now in a client's production code.
What to take away
Model nouns, use the methods for what they mean, and return status codes clients can branch on. Support idempotency keys wherever a retry could charge someone twice. Add ETags where concurrent edits are possible. Then be relentlessly consistent — that is what makes an API feel well designed.
Frequently Asked Questions
PUT or PATCH for updates?
Is HATEOAS worth implementing?
Should the URL contain verbs for actions that are not CRUD?
Related tutorials
- Spring REST Controllers Deep DiveEverything a Spring controller can bind, how ResponseEntity builds responses properly, content negotiation, custom argument resolvers, and keeping controllers thin.
- API Documentation with OpenAPIGenerating documentation people actually use: springdoc-openapi setup, annotations worth adding, grouping large APIs, documenting errors and auth, and the API-first workflow.
- API Versioning StrategiesComparing URI, header, query and media-type versioning honestly, deciding what counts as breaking, running two versions at once, and retiring one without breaking clients.
- Pagination, Filtering & SortingPagination that stays fast at depth: why OFFSET degrades, keyset and cursor pagination, dynamic filtering with Specifications, safe sorting, and the RFC 8288 Link header.