REST API Design: Principles, Patterns and Real-World Best Practices
- REST is built on HTTP — use the protocol as it was designed: methods carry actions, status codes carry outcomes, URLs identify resources.
- Resources are nouns, collections are plural, relationships are nested. If your URL contains a verb, you're doing RPC, not REST.
- Statelessness enables horizontal scaling — every request carries its own auth, no server-side sessions, any server can handle any request.
- REST is an architectural style — not a framework — that uses HTTP methods (GET, POST, PUT, DELETE) on noun-based resource URLs
- Resources are plural nouns (/products, /users/123/orders), actions are HTTP verbs — never put verbs in URLs
- Statelessness means every request carries its own auth (JWT/OAuth2) — the server holds no session between requests
- Versioning (/v1/, /v2/) is non-negotiable — once an API is public, you cannot change response shapes without breaking clients
- HTTP status codes are part of your contract: 201 for creation, 400 for bad input, 404 for missing resources, 409 for conflicts
- The biggest trap: returning 200 OK for everything — clients lose the ability to distinguish success from failure programmatically
API returning 500 errors intermittently
kubectl logs -l app=forge-api --tail=200 | grep -i 'exception\|error\|500' | head -50kubectl top pods -l app=forge-apiSlow API responses — latency > 2 seconds across multiple endpoints
curl -w 'DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n' -o /dev/null -s http://localhost:8080/api/v1/productsSELECT pid, now() - pg_stat_activity.query_start AS duration, query FROM pg_stat_activity WHERE state = 'active' AND query NOT LIKE '%pg_stat%' ORDER BY duration DESC LIMIT 5;Clients getting 429 Too Many Requests unexpectedly
kubectl logs -l app=forge-api --tail=1000 | grep '429' | awk '{print $1}' | sort | uniq -c | sort -rn | head -10curl -I http://localhost:8080/api/v1/products | grep -i 'x-rate-limit'Swagger/OpenAPI spec doesn't match actual API behavior — clients get unexpected fields or missing data
mvn test -Dtest=*ContractTest* -Dsurefire.failIfNoSpecifiedTests=falsecurl http://localhost:8080/v3/api-docs | jq '.paths["/api/v1/products"]' > current-spec.json && diff committed-spec.json current-spec.jsonProduction Incident
{"success": false, "error": "Payment declined"}. The mobile app rendered the success screen because it checked the status code, saw 200, and never parsed the body.success: false field. The frontend team insisted the API should return a proper error status code — that's what status codes are for. Both teams were partially right, but the API design was the root cause.{"success": false, "error": "Invalid email format"}. Payment failures returned 200 with {"success": false, "error": "Payment declined"}. Server exceptions returned 200 with {"success": false, "error": "Internal error"}. The mobile app used a standard HTTP client library configured with the reasonable default that 2xx = success. It rendered the confirmation screen without parsing the body because why would you need to — the status code already told you it worked.@ControllerAdvice in Spring Boot that maps exception types to correct status codes and a consistent error response envelope. Added contract tests using Spring Cloud Contract that verify status codes match the OpenAPI spec — if a spec says 400 for invalid input, the test fails if the code returns 200. Added mobile app integration tests that reject any non-2xx response before rendering success UI. The fix took three days; the trust repair with the mobile team took longer.Production Debug GuideCommon REST API failures and how to diagnose them without guessing
curl -v -H 'Authorization: Bearer <token>' <endpoint> and inspect the request headers that actually reach the backend.Content-Type: application/json header — many HTTP clients don't set this automatically for POST bodies. Check that the Spring @RequestBody annotation matches the expected deserialization type. Verify the JSON body structure matches the Java class — a missing required field or wrong type will fail silently in some configurations and throw 415 in others. Enable debug logging for org.springframework.web.servlet to see the exact content negotiation failure.SELECT count(*) FROM pg_stat_activity WHERE application_name LIKE '%HikariPool%'. If connections are maxed out, increase maximumPoolSize or fix connection leaks. Check for thread starvation in Tomcat — if server.tomcat.threads.max is too low, requests queue and timeout. Look for synchronous blocking calls in async/reactive pipelines — a single Thread.sleep() or blocking JDBC call in a WebFlux handler will starve the event loop.OFFSET 20 LIMIT 10) shifts when rows are added or deleted — page 2 sees different rows than it would have a second earlier. Switch to cursor-based pagination using a stable, unique sort key (typically created_at combined with id to break ties). The cursor encodes the last-seen value, not a row count, so inserts don't shift the window.@CrossOrigin annotation to the controller or configure a global CorsFilter in Spring Security. Verify preflight OPTIONS requests return the correct Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Check if an API gateway or CDN strips CORS headers before forwarding — this is common with misconfigured Cloudflare or AWS API Gateway setups. Test with curl -X OPTIONS -H 'Origin: https://your-frontend.com' <endpoint> -v.spring.jpa.show-sql=true and logging.level.org.hibernate.SQL=DEBUG. If one endpoint triggers 100+ SQL queries for a list of 10 items, you have an N+1. Fix with @EntityGraph, JOIN FETCH in the repository query, or a @BatchSize annotation on the lazy collection. Also check for missing database indexes — run EXPLAIN ANALYZE on the slow query to see if it's doing a sequential scan.Every modern application you use — from booking a flight to liking a photo to checking your bank balance — is powered by APIs talking to each other behind the scenes. REST (Representational State Transfer) is the dominant architectural style for building those communication channels. It's not a framework or a library you install; it's a set of constraints that, when followed correctly, produce APIs that are predictable, scalable, and genuinely pleasant to consume. Get it wrong and you saddle every developer who ever touches your API with unnecessary confusion, brittle integrations, and midnight debugging sessions.
The problem REST solves is coordination at scale. When a mobile app, a web frontend, a third-party partner integration, and an internal microservice all need to talk to the same backend, you need a shared language. Without clear conventions, every team invents their own rules — and you end up with endpoints like /getUser, /fetchUserData, /retrieveUserById, and /user_get all doing subtly different things with subtly different response shapes. I've inherited codebases like this. It's not fun.
REST gives you the vocabulary to make those decisions consistently and defensibly. It's not about religious adherence to a spec — it's about reducing the cognitive load for everyone who consumes your API, including future you.
By the end of this article you'll be able to design a clean, production-grade REST API from scratch: naming resources correctly, choosing the right HTTP methods, returning meaningful status codes, handling versioning without breaking clients, and structuring responses that make frontend developers want to send you a thank-you note. You'll also walk away with the mental models to critique existing APIs and explain your design decisions in a technical interview — not by reciting rules, but by understanding why the rules exist.
Resource-Oriented Design: Nouns over Verbs
In REST, everything is a Resource. A resource is a thing — a product, a user, an order, a payment. The most common rookie mistake is putting verbs in the URL: /getAllUsers, /createOrder, /deleteProduct. That's not REST; that's RPC tunneled over HTTP. Proper RESTful design uses plural nouns to represent collections and HTTP methods to define the action.
This makes your API intuitive and predictable. If I see /products, I already know that GET will list them and POST will create one. I don't need to read documentation to guess whether the endpoint is /getProducts, /listProducts, or /fetchAllProducts. The verb is in the HTTP method, not the URL.
Nested resources express ownership relationships. /users/123/orders means 'orders belonging to user 123' — not 'orders, and also here's a user ID.' The hierarchy in the URL reflects the data model. If orders don't conceptually belong to users (maybe they're standalone), don't nest them.
Query parameters handle filtering, sorting, and pagination. Don't create /products/electronics and /products/furniture as separate endpoints — use /products?category=electronics. This keeps your routing simple and your API flexible. Below is a production-ready controller implementation using Spring Boot conventions that demonstrates these patterns.
package io.thecodeforge.api.v1; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import jakarta.validation.Valid; import java.net.URI; import java.util.List; /** * TheCodeForge - REST Resource Controller * Resources are plural nouns: /products * Actions are HTTP Methods: GET, POST, PUT, DELETE * Versioning is in the path: /api/v1/ */ @RestController @RequestMapping("/api/v1/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping public ResponseEntity<Page<ProductResponse>> getAllProducts( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String category, @RequestParam(defaultValue = "createdAt") String sortBy, @RequestParam(defaultValue = "desc") String order) { Page<ProductResponse> products = productService.findAll(page, size, category, sortBy, order); return ResponseEntity.ok(products); } @PostMapping public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody CreateProductRequest request) { ProductResponse created = productService.create(request); URI location = ServletUriComponentsBuilder .fromCurrentRequest() .path("/{id}") .buildAndExpand(created.getId()) .toUri(); // 201 Created with Location header pointing to new resource return ResponseEntity.created(location).body(created); } @GetMapping("/{id}") public ResponseEntity<ProductResponse> getProductById(@PathVariable String id) { return productService.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); // 404 if not found } @PutMapping("/{id}") public ResponseEntity<ProductResponse> updateProduct( @PathVariable String id, @Valid @RequestBody UpdateProductRequest request) { return productService.update(id, request) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteProduct(@PathVariable String id) { boolean deleted = productService.delete(id); return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); } }
POST /api/v1/products -> 201 Created with Location header
GET /api/v1/products/{id} -> 200 OK or 404 Not Found
PUT /api/v1/products/{id} -> 200 OK or 404 Not Found
DELETE /api/v1/products/{id} -> 204 No Content or 404 Not Found
- Collections are plural nouns: /products, /users, /orders — never /product or /getProducts
- Individual resources use IDs: /products/42, /users/abc-123 — the ID uniquely identifies the instance
- Nested resources express ownership: /users/123/orders means 'orders belonging to user 123'
- Query parameters handle filtering and sorting: /products?category=electronics&sort=price&order=desc
- If your URL contains a verb, you're doing RPC, not REST — step back and identify the noun
/getUser or /createOrder create inconsistent APIs where every consumer has to guess the naming pattern. Plural nouns with HTTP methods create a predictable contract — GET /products always lists, POST /products always creates, every time, no exceptions. Rule: if the URL contains a verb like 'get', 'fetch', 'create', or 'delete', rename it to a noun and let the HTTP method carry the action semantics.HTTP Status Codes: The Semantic Layer Clients Depend On
HTTP status codes are not decorative. They are the semantic layer that every HTTP client, proxy, cache, and monitoring tool depends on to understand what happened. Returning 200 OK for everything — errors included — breaks this contract and forces every consumer to parse your response body before knowing if the request succeeded.
The status code should tell the client the outcome before they read a single byte of the body. 2xx means success — proceed with parsing the response. 4xx means the client did something wrong — bad input, missing auth, resource doesn't exist. 5xx means the server failed — retry might help, probably later. This classification is what enables retries, circuit breakers, caching, and monitoring dashboards to work without understanding your specific API.
The most common status codes you'll use: 200 OK for successful GET, PUT, PATCH. 201 Created for successful POST that creates a resource — always include a Location header. 204 No Content for successful DELETE or PUT when there's nothing to return. 400 Bad Request for validation failures and malformed input. 401 Unauthorized for missing or invalid authentication. 403 Forbidden for valid auth but insufficient permissions. 404 Not Found for resources that don't exist. 409 Conflict for operations that can't complete due to state (duplicate SKU, optimistic lock failure). 422 Unprocessable Entity for semantically invalid input that's syntactically correct. 429 Too Many Requests for rate limiting. 500 Internal Server Error for unhandled exceptions — never expose stack traces.
Spring Boot makes consistent status codes easy with @ControllerAdvice. Instead of handling exceptions in every controller, you define a global handler that maps exception types to status codes and response bodies. This is non-negotiable for any production API.
package io.thecodeforge.api.exception; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import jakarta.servlet.http.HttpServletRequest; import java.time.Instant; import java.util.List; import java.util.stream.Collectors; /** * TheCodeForge - Global Exception Handler * Maps exceptions to proper HTTP status codes and consistent error envelopes. * Never return 200 for errors. Never expose stack traces. */ @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) { ErrorResponse error = ErrorResponse.builder() .status(404) .code("RESOURCE_NOT_FOUND") .message(ex.getMessage()) .path(request.getRequestURI()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage())) .collect(Collectors.toList()); ErrorResponse error = ErrorResponse.builder() .status(400) .code("VALIDATION_FAILED") .message("Request validation failed") .path(request.getRequestURI()) .timestamp(Instant.now()) .errors(fieldErrors) .build(); return ResponseEntity.badRequest().body(error); } @ExceptionHandler(DuplicateResourceException.class) public ResponseEntity<ErrorResponse> handleConflict(DuplicateResourceException ex, HttpServletRequest request) { ErrorResponse error = ErrorResponse.builder() .status(409) .code("DUPLICATE_RESOURCE") .message(ex.getMessage()) .path(request.getRequestURI()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.CONFLICT).body(error); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex, HttpServletRequest request) { // Log the full stack trace server-side, never expose to clients log.error("Unhandled exception at {}: {}", request.getRequestURI(), ex.getMessage(), ex); ErrorResponse error = ErrorResponse.builder() .status(500) .code("INTERNAL_ERROR") .message("An unexpected error occurred. Please try again later.") .path(request.getRequestURI()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } }
{"status":400,"code":"VALIDATION_FAILED","message":"Request validation failed","errors":[{"field":"email","message":"must be a valid email"}]}
404 Not Found:
{"status":404,"code":"RESOURCE_NOT_FOUND","message":"Product with id 'xyz-999' not found"}
500 Internal Server Error:
{"status":500,"code":"INTERNAL_ERROR","message":"An unexpected error occurred. Please try again later."}
Statelessness and Authentication
A core constraint of REST is statelessness. This means the server doesn't remember who you are between requests based on a session stored on the server. There's no HttpSession lookup, no sticky sessions, no 'log in once and we remember you.' Every request must contain all the information needed to process it — including authentication credentials.
This sounds like extra work, but it's what enables horizontal scaling. If your request can be handled by any server in the cluster without that server having prior knowledge of your session, you can add servers trivially. Load balancers don't need sticky sessions. Blue-green deployments work without session draining. A server can crash and the next request just hits another server — no session lost.
The standard approach for stateless authentication is JWT (JSON Web Tokens) or OAuth2 access tokens. The client sends a token in the Authorization: Bearer <token> header with every request. The server validates the token cryptographically (verifying the signature) without a database lookup. The token carries claims — user ID, roles, expiration time — that the server extracts and uses for authorization.
JWTs are self-contained but not revocable by default. If a token is valid for 24 hours and you need to revoke it (user logs out, account compromised), you can't — the token is valid until it expires. Solutions include short-lived tokens (5-15 minutes) with refresh tokens, or maintaining a token blacklist in Redis. The blacklist re-introduces some state, but it's a small, fast cache rather than full session storage.
package io.thecodeforge.api.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; import java.util.stream.Collectors; /** * TheCodeForge - Stateless JWT Authentication Filter * Every request carries its own auth — no server-side sessions. * This is what enables horizontal scaling without sticky sessions. */ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { this.tokenProvider = tokenProvider; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { if (tokenProvider.validateToken(token)) { String userId = tokenProvider.getUserId(token); List<String> roles = tokenProvider.getRoles(token); List<SimpleGrantedAuthority> authorities = roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } } catch (JwtException e) { // Invalid token — clear context and let the request proceed unauthenticated // The endpoint's @PreAuthorize will reject if auth is required SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
-> SecurityContext populated with userId and roles
-> Endpoint executes with authenticated principal
Request with invalid/expired token:
-> SecurityContext cleared
-> If endpoint requires auth, returns 401 Unauthorized
Versioning: Evolving Without Breaking Clients
Once an API is public, you cannot change response shapes without breaking someone's integration. That app someone built against v1 three years ago? It's still running. It doesn't care that you've added features, renamed fields, or 'improved' the structure. It expects the exact JSON shape it was coded against, and when you change it, it breaks — usually silently, parsing null where it expected a string.
Versioning is how you evolve without breaking. The most common strategies are URL path versioning (/api/v1/products), query parameter versioning (/products?version=1), and header versioning (Accept: application/vnd.forge.v1+json). URL path versioning is the industry standard because it's visible, cacheable, and debuggable. When someone reports a bug, they can tell you they're hitting /v1/products — no ambiguity.
The versioning rule: backward-compatible changes do NOT require a new version. Adding an optional field, adding a new endpoint, adding optional query parameters — these don't break existing clients. Only breaking changes require a new version: removing fields, renaming fields, changing types, changing required status of fields, removing endpoints.
When you introduce v2, keep v1 running with a published deprecation timeline. '12 months from today, v1 will be sunset' gives consumers time to migrate. Monitor v1 usage — when traffic drops to near-zero, sunset it. Don't maintain versions forever; the operational cost compounds.
package io.thecodeforge.api; /** * TheCodeForge - API Versioning with URL Path Strategy * v1 returns the original shape. v2 returns the evolved shape. * Both exist simultaneously until v1 is deprecated and sunset. */ // V1 Controller - Original response shape @RestController @RequestMapping("/api/v1/products") public class ProductControllerV1 { @GetMapping("/{id}") public ResponseEntity<ProductV1Response> getProduct(@PathVariable String id) { Product product = productService.findById(id); // V1 response: flat structure, price as double return ResponseEntity.ok(new ProductV1Response( product.getId(), product.getName(), product.getPrice() // double )); } } // V2 Controller - Evolved response shape @RestController @RequestMapping("/api/v2/products") public class ProductControllerV2 { @GetMapping("/{id}") public ResponseEntity<ProductV2Response> getProduct(@PathVariable String id) { Product product = productService.findById(id); // V2 response: nested structure, price as Money object return ResponseEntity.ok(new ProductV2Response( product.getId(), product.getName(), new Money(product.getPrice(), product.getCurrency()), // structured product.getInventory() // new field in v2 )); } } // DTOs record ProductV1Response(String id, String name, double price) {} record ProductV2Response(String id, String name, Money price, InventoryStatus inventory) {} record Money(BigDecimal amount, String currency) {} record InventoryStatus(int available, int reserved) {}
{"id":"abc-123","name":"Forge Laptop","price":1299.99}
V2 Response (GET /api/v2/products/abc-123):
{"id":"abc-123","name":"Forge Laptop","price":{"amount":1299.99,"currency":"USD"},"inventory":{"available":42,"reserved":3}}
Pagination: Handling Large Collections Without Melting
Returning all 500,000 products in a single GET /products response is not a flex — it's a denial of service attack on your own infrastructure. Large responses exhaust memory, timeout connections, and crash mobile apps. Pagination is not optional for any collection endpoint.
There are two main approaches: offset-based and cursor-based pagination. Offset-based is simpler to implement (?page=3&size=20 → OFFSET 40 LIMIT 20) but has a fundamental flaw: if records are inserted or deleted between page fetches, the offset shifts. Page 3 might see rows it already saw on page 2, or skip rows entirely. This is fine for slowly-changing data but breaks badly for high-velocity tables.
Cursor-based pagination solves this by encoding the last-seen value rather than a row count. The cursor might be an encoded JSON like {"created_at":"2026-03-05T10:30:00Z","id":"abc-123"} (base64 encoded in the actual API). The next page query becomes WHERE (created_at, id) > ('2026-03-05T10:30:00Z', 'abc-123') ORDER BY created_at, id LIMIT 20. Inserts and deletes don't shift the window because the cursor points to a specific position in the sort order, not a row count.
Always include pagination metadata in the response: total count (if cheap to compute), current page info, and links to next/previous pages. Don't make clients construct URLs themselves — they'll get it wrong.
package io.thecodeforge.api.pagination; import java.util.List; /** * TheCodeForge - Cursor-Based Pagination Response * Cursor encodes last-seen position in sort order. * More stable than offset for high-velocity tables. */ public record PaginatedResponse<T>( List<T> data, Pagination pagination ) { public record Pagination( int size, String nextCursor, // null if no more pages String prevCursor, // null if first page boolean hasMore ) {} } // Repository implementation @Repository public class ProductRepository { public PaginatedResponse<Product> findWithCursor(String cursor, int limit) { CursorData cursorData = cursor != null ? decodeCursor(cursor) : null; // Query: fetch limit+1 to check if there are more List<Product> products; if (cursorData == null) { products = jdbcTemplate.query( "SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT ?", productRowMapper, limit + 1 ); } else { products = jdbcTemplate.query( "SELECT * FROM products " + "WHERE (created_at, id) < (?, ?) " + "ORDER BY created_at DESC, id DESC LIMIT ?", productRowMapper, cursorData.createdAt(), cursorData.id(), limit + 1 ); } boolean hasMore = products.size() > limit; if (hasMore) { products = products.subList(0, limit); // Remove the extra row } String nextCursor = hasMore ? encodeCursor(products.get(products.size() - 1)) : null; return new PaginatedResponse<>(products, new Pagination(limit, nextCursor, cursor, hasMore)); } private String encodeCursor(Product p) { return Base64.getEncoder().encodeToString( String.format("{\"created_at\":\"%s\",\"id\":\"%s\"}", p.getCreatedAt().toString(), p.getId()).getBytes()); } private CursorData decodeCursor(String cursor) { // Decode and parse — error handling omitted for brevity String json = new String(Base64.getDecoder().decode(cursor)); // Parse JSON to CursorData record return parseJson(json); } }
{
"data": [{"id":"abc-123","name":"Product 1"}, ...],
"pagination": {
"size": 20,
"nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMy0wNVQxMDozMDowMFoiLCJpZCI6ImFiYy0xMjMifQ==",
"prevCursor": null,
"hasMore": true
}
}
GET /api/v1/products?cursor=eyJjcmVhdGVkX2F0Ijo...&limit=20
-> Returns next page, cursor advances
?page=3&size=20) is simpler and allows jumping to arbitrary pages — useful for admin UIs with page number navigation. Cursor pagination is more stable for high-velocity data and infinite-scroll UIs where you never need to jump to page 47. Most public APIs use cursor pagination because they serve mobile apps with infinite scroll, not admin dashboards with page numbers.HATEOAS: Should You Actually Implement It?
HATEOAS (Hypermedia as the Engine of Application State) is Level 3 of the Richardson Maturity Model — the highest level of REST maturity. In HATEOAS, responses include links that tell the client what actions are available next. A product response might include links to self, reviews, add-to-cart, and related-products. The client navigates the API by following links rather than constructing URLs.
The theoretical benefit is decoupling: clients don't hardcode URLs, so you can change URL structure without breaking them. The practical reality is mixed. For internal APIs with 2-3 known consumers, HATEOAS adds complexity without much benefit — you control both sides, so URL changes can be coordinated. For public APIs with thousands of unknown consumers, HATEOAS can reduce coupling and enable API discoverability.
If you do implement HATEOAS, use a standard format like HAL (Hypertext Application Language) or JSON:API. These define conventions for embedding links in responses so clients can parse them consistently. Spring HATEOAS provides first-class support for building HAL responses.
The honest assessment: most production APIs operate at Richardson Maturity Level 2 (proper resources + HTTP methods + status codes) and stop there. Level 3 is elegant but the implementation and maintenance cost often outweighs the benefits unless you're building a truly public, long-lived API consumed by developers you'll never meet.
package io.thecodeforge.api.v1; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.stream.Collectors; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; /** * TheCodeForge - HATEOAS Implementation with Spring * Responses include navigational links enabling API discoverability. * Level 3 Richardson Maturity Model. */ @RestController @RequestMapping("/api/v1/products") public class HateoasProductController { @GetMapping("/{id}") public EntityModel<ProductResponse> getProduct(@PathVariable String id) { Product product = productService.findById(id); ProductResponse response = toResponse(product); return EntityModel.of(response, linkTo(methodOn(HateoasProductController.class).getProduct(id)).withSelfRel(), linkTo(methodOn(ReviewController.class).getReviewsForProduct(id)).withRel("reviews"), linkTo(methodOn(CartController.class).addToCart(id, null)).withRel("add-to-cart"), linkTo(methodOn(HateoasProductController.class).getAllProducts(0, 20)).withRel("collection") ); } @GetMapping public CollectionModel<EntityModel<ProductResponse>> getAllProducts( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { List<EntityModel<ProductResponse>> products = productService.findAll(page, size).stream() .map(p -> EntityModel.of(toResponse(p), linkTo(methodOn(HateoasProductController.class).getProduct(p.getId())).withSelfRel())) .collect(Collectors.toList()); return CollectionModel.of(products, linkTo(methodOn(HateoasProductController.class).getAllProducts(page, size)).withSelfRel(), linkTo(methodOn(HateoasProductController.class).getAllProducts(page + 1, size)).withRel("next") ); } }
{
"id": "abc-123",
"name": "Forge Laptop",
"price": 1299.99,
"_links": {
"self": {"href": "/api/v1/products/abc-123"},
"reviews": {"href": "/api/v1/products/abc-123/reviews"},
"add-to-cart": {"href": "/api/v1/cart/add/abc-123"},
"collection": {"href": "/api/v1/products"}
}
}
| HTTP Method | Idempotent? | Safe? | Correct Usage |
|---|---|---|---|
| GET | Yes | Yes | Retrieve a resource or collection. Never modify state. |
| POST | No | No | Create a new resource. Return 201 + Location header. |
| PUT | Yes | No | Full replacement of a resource. Client sends complete object. |
| PATCH | Depends | No | Partial update. Only changed fields sent. Can be idempotent if designed carefully. |
| DELETE | Yes | No | Remove a resource. Return 204 No Content on success. |
| OPTIONS | Yes | Yes | Describe available methods (CORS preflight). No side effects. |
| HEAD | Yes | Yes | GET without body. Check if resource exists, get headers only. |
🎯 Key Takeaways
- REST is built on HTTP — use the protocol as it was designed: methods carry actions, status codes carry outcomes, URLs identify resources.
- Resources are nouns, collections are plural, relationships are nested. If your URL contains a verb, you're doing RPC, not REST.
- Statelessness enables horizontal scaling — every request carries its own auth, no server-side sessions, any server can handle any request.
- HTTP status codes are semantic, not decorative. 200 for errors breaks every HTTP client, cache, and monitoring tool that trusts the protocol.
- Versioning (/v1/, /v2/) is non-negotiable for public APIs. Backward-compatible changes don't need new versions; breaking changes do.
- Pagination is mandatory. Cursor-based is more stable than offset for high-velocity data. Always return pagination metadata.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat makes an API truly 'RESTful'? Walk me through the Richardson Maturity Model.SeniorReveal
- QHow do you secure a stateless REST API? What are the trade-offs between JWT and opaque tokens?SeniorReveal
- QExplain the difference between PUT and PATCH. When would you use each, and what does idempotency mean in this context?Mid-levelReveal
- QHow would you design API versioning for a public REST API with 10,000 active consumers?SeniorReveal
- QWhat HTTP status code should POST return on success, and what headers should be included?JuniorReveal
- QExplain cursor-based pagination and why it's more stable than offset-based for high-velocity data.Mid-levelReveal
Frequently Asked Questions
What is the difference between PUT and PATCH?
PUT is a full replacement. You send the entire resource, and the server replaces the existing one. If you omit a field, it may be nullified. PATCH is a partial update — you send only the fields you want to change, and the server merges them into the existing resource. Use PUT when you have the complete object. Use PATCH when updating a single field without wanting to re-send everything.
Should I use plural or singular nouns for resources?
Always use plural nouns. /users and /users/123 is the industry standard. The plural /users represents the collection; /users/123 represents one member of that collection. This consistency makes the API predictable — you never have to guess whether it's /user or /users.
How do I handle filtering and sorting in REST?
Use query parameters. Don't create separate endpoints like /products/sorted-by-price. Instead, use GET /products?sort=price&order=desc&category=electronics&min_price=100. This keeps your resource paths clean and your API flexible. The resource is /products; the query parameters modify how you retrieve it.
What is HATEOAS and should I implement it?
HATEOAS (Hypermedia as the Engine of Application State) means your API responses include links telling the client what actions are available next. A product response might include links to 'reviews', 'add-to-cart', 'related-products'. It's Level 3 of the Richardson Maturity Model. For public APIs consumed by many unknown clients, HATEOAS reduces coupling — clients discover URLs rather than hardcoding them. For internal APIs with known consumers, the complexity usually isn't justified. Most successful public APIs (Stripe, Twilio) operate at Level 2 with excellent documentation instead.
Why use UUIDs instead of auto-increment IDs in API URLs?
Auto-increment integers (/users/1, /users/2, /users/3) are enumerable. Attackers can discover your entire dataset by incrementing IDs. They also leak information: /users/1000000 tells someone you have at least a million users. UUIDs are opaque and non-sequential — you can't guess valid IDs, and they reveal nothing about your data distribution. Keep auto-increment IDs as internal database primary keys for join performance; use UUIDs as public API identifiers.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.