Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner6 min readUpdated
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.
  • 201 must return a Location header; 202 must 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 resource

Four 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=20

Method semantics

MethodSafeIdempotentBodyNotes
GETYesYesNoCacheable. Never mutate state
HEADYesYesNoHeaders only
OPTIONSYesYesNoAllowed methods, CORS preflight
POSTNoNoYesCreate, or non-idempotent actions
PUTNoYesYesFull replacement
PATCHNoNot necessarilyYesPartial update
DELETENoYesOptionalRepeat 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:

IdempotentCreate.java
@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

Choosing a status code: success shape first, then whose fault the failure was.

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

AsyncOperation.java
@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:

ConditionalRequests.java
@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

LevelDescriptionReality
0One URI, one method, RPC over HTTPSOAP-era
1Multiple resourcesBetter, but still one verb
2Proper HTTP verbs and status codesWhere good APIs live
3Hypermedia controlsRare, 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?
PUT replaces the whole resource, so omitting a field clears it — that is the contract, and it makes PUT idempotent. PATCH applies a partial change and only touches the fields you send. Most update endpoints actually want PATCH; teams reach for PUT and then implement PATCH semantics, which confuses every client that reads the method name.
Is HATEOAS worth implementing?
Rarely in full. The theoretical benefit — clients discover transitions rather than hard-coding URLs — requires clients that navigate links, and almost none do. What is worth taking from it is pagination links and action affordances, which genuinely reduce client-side coupling for very little effort.
Should the URL contain verbs for actions that are not CRUD?
Sometimes, and that is fine. A state transition like POST /orders/42/cancellations models the action as a resource, which is the purist answer and reads well. POST /orders/42/cancel is pragmatic and unambiguous. What to avoid is a single /api endpoint taking an action field, which throws away everything HTTP gives you.

Related tutorials