Course topics

By WebNest Studio

Spring Boot Tutorial

REST API Best Practices and the Richardson Maturity Model

Anyone can return JSON from a URL. Designing an API that other developers find predictable, safe to evolve and pleasant to use takes deliberate choices about URLs, HTTP methods, status codes, errors, pagination, versioning and security. These choices matter more than the framework: a well-designed API outlives several implementations.

This lesson explains the Richardson Maturity Model — a useful way to judge how "RESTful" an API is — and then collects the REST best practices used by mature API teams, each illustrated with a Spring Boot example.

The Richardson Maturity Model

Leonard Richardson described four levels of REST maturity:

  • Level 0 — The Swamp of POX: one URL, one method (usually POST) for everything, with the action in the body: POST /api {"action":"getUser","id":7}. This is RPC over HTTP.
  • Level 1 — Resources: separate URLs per resource (/users/7, /orders/41), but still one HTTP method for all actions.
  • Level 2 — HTTP Verbs: the correct methods and status codes — GET to read, POST to create (201), PUT/PATCH to update, DELETE to remove (204), 404 for missing resources. Most good production APIs live here.
  • Level 3 — Hypermedia Controls (HATEOAS): responses include links to related resources and available actions (see the HATEOAS lesson).

Resource and URL Design

Use nouns, not verbs: /api/orders, not /api/getOrders. Use plural collection names and ids for items: /api/orders/41. Express relationships with nesting only one level deep: /api/users/7/orders. Use lowercase, hyphen-separated paths. For operations that are not simple CRUD, model them as sub-resources or actions on a resource: POST /api/orders/41/cancellation.

Methods, Idempotency and Status Codes

GET, PUT and DELETE must be idempotent — repeating them has the same effect as doing them once — and GET must be safe (no changes). POST is not idempotent; for payments and orders, accept an Idempotency-Key header so retries do not create duplicates. Use precise status codes: 200, 201 + Location, 202 for accepted async work, 204, 400 (validation), 401, 403, 404, 409 (conflict), 422, 429 (rate limited), 500/503.

Errors, Pagination, Filtering and Versioning

Return errors in one consistent format — RFC 9457 ProblemDetail (built into Spring) — with a machine-readable type, a human-readable detail and field-level validation errors. Paginate every collection (?page=0&size=20&sort=createdAt,desc) and cap the page size. Use query parameters for filtering and searching. Evolve additively and version only for breaking changes (see the API versioning lesson).

Security, Performance and Documentation

Always use HTTPS; authenticate with OAuth2/JWT or sessions, authorise every resource access including ownership; validate all input; rate-limit; never leak stack traces. Support caching with ETag/Cache-Control and compression. Document the API with OpenAPI (springdoc) including examples and error responses, and keep the documentation generated from code so it never drifts.

Examples

Level 0 vs Level 2: the same operations

Java
// Level 0 — RPC style, everything is POST /api
POST /api  {"action": "getOrder",    "id": 41}                 -> 200 {"ok": true,  "order": {...}}
POST /api  {"action": "cancelOrder", "id": 41}                 -> 200 {"ok": false, "error": "not found"}

// Level 2 — resources + HTTP verbs + status codes
GET    /api/orders/41                                          -> 200 {...}
POST   /api/orders            {"items": [...]}                 -> 201 Created, Location: /api/orders/42
PATCH  /api/orders/41         {"shippingAddress": {...}}       -> 200 {...}
POST   /api/orders/41/cancellation                             -> 202 Accepted
DELETE /api/orders/41/items/3                                  -> 204 No Content
GET    /api/orders/9999                                        -> 404 application/problem+json
Output
(Level 2 lets HTTP clients, caches, proxies and monitoring tools understand the API without reading its body.)

Consistent errors with ProblemDetail

Java
# application.yml — also turn Spring MVC's own errors into ProblemDetail
spring:
  mvc:
    problemdetails:
      enabled: true

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail notFound(OrderNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://api.webnest.in/problems/order-not-found"));
        pd.setTitle("Order not found");
        pd.setProperty("orderId", ex.orderId());
        return pd;
    }
}
Output
GET /api/orders/9999
HTTP/1.1 404
Content-Type: application/problem+json
{"type":"https://api.webnest.in/problems/order-not-found","title":"Order not found","status":404,
 "detail":"Order 9999 does not exist","instance":"/api/orders/9999","orderId":9999}

Idempotent POST with an Idempotency-Key and conditional GET with ETag

Java
@PostMapping("/api/payments")
public ResponseEntity<PaymentDto> pay(@RequestHeader("Idempotency-Key") String key,
                                      @Valid @RequestBody PaymentRequest req) {
    // returns the stored result if this key was already processed
    PaymentResult result = payments.processOnce(key, req);
    return ResponseEntity.status(result.created() ? HttpStatus.CREATED : HttpStatus.OK)
        .location(URI.create("/api/payments/" + result.payment().id()))
        .body(result.payment());
}

@GetMapping("/api/courses/{slug}")
public ResponseEntity<CourseDto> course(@PathVariable String slug, WebRequest request) {
    CourseDto course = courses.get(slug);
    String etag = "\"" + course.version() + "\"";
    if (request.checkNotModified(etag)) {
        return null;                                    // Spring sends 304 Not Modified
    }
    return ResponseEntity.ok().eTag(etag).cacheControl(CacheControl.maxAge(Duration.ofMinutes(5))).body(course);
}
Output
POST /api/payments  Idempotency-Key: 9f1c...  -> 201 Created
POST /api/payments  Idempotency-Key: 9f1c...  -> 200 OK (same payment, no double charge)

GET /api/courses/spring-boot                         -> 200, ETag: "12"
GET /api/courses/spring-boot  If-None-Match: "12"    -> 304 Not Modified (no body)

A quick design checklist

Java
[ ] Nouns and plural collections: /api/orders, /api/orders/{id}
[ ] Correct methods; GET safe; PUT/DELETE idempotent; Idempotency-Key for critical POSTs
[ ] Precise status codes, 201 + Location on create, 204 on delete
[ ] ProblemDetail (application/problem+json) for every error, with field errors for validation
[ ] Pagination with a maximum page size on every collection
[ ] Filtering/sorting via query parameters with allow-listed fields
[ ] DTOs, never entities, in requests and responses
[ ] Authentication, per-resource authorization, input validation, rate limiting, HTTPS
[ ] ETag/Cache-Control where data is cacheable; compression on
[ ] Backward-compatible evolution; explicit versioning only for breaking changes
[ ] OpenAPI documentation generated from code, with examples
[ ] Correlation id header and structured logs for every request
Output
(Review new endpoints against this list in code review.)

Common Mistakes

  • Verbs in URLs (/createOrder, /deleteUser) and POST for every operation — a Level 0/1 design.
  • Returning 200 OK with {"success": false} for errors, hiding failures from HTTP clients and monitoring.
  • Returning unbounded collections without pagination.
  • Inconsistent error formats between endpoints and services.
  • Breaking existing clients by renaming fields without versioning or a deprecation period.

Key Points to Remember

  • Richardson levels: 0 single endpoint RPC, 1 resources, 2 HTTP verbs and status codes, 3 hypermedia.
  • Aim for at least Level 2: nouns, correct methods, precise status codes.
  • Use ProblemDetail for errors, pagination for collections, and DTOs for payloads.
  • Make critical POSTs idempotent with an Idempotency-Key; use ETags for caching.
  • Secure, document (OpenAPI) and evolve APIs backward-compatibly.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.