Skip to content
Home System Design REST API Design: Principles, Patterns and Real-World Best Practices

REST API Design: Principles, Patterns and Real-World Best Practices

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Architecture → Topic 2 of 13
Master REST API design: resource naming, HTTP methods, status codes, versioning, and HATEOAS.
⚙️ Intermediate — basic System Design knowledge assumed
In this tutorial, you'll learn
Master REST API design: resource naming, HTTP methods, status codes, versioning, and HATEOAS.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
REST API Emergency Debug Cheat Sheet
When REST APIs break in production, run these commands in order to diagnose and recover. Resist the urge to restart pods until you understand what's actually failing.
🟡API returning 500 errors intermittently
Immediate ActionCheck application logs for stack traces and identify the failing endpoint pattern
Commands
kubectl logs -l app=forge-api --tail=200 | grep -i 'exception\|error\|500' | head -50
kubectl top pods -l app=forge-api
Fix NowIf OOMKilled, increase memory limits. If CPU throttled, check for runaway loops. If thread exhaustion, check connection pool health: `curl http://localhost:8080/actuator/health | jq '.components.db'` — if the pool shows 0 available connections, you have a leak or a max pool size misconfiguration.
🟠Slow API responses — latency > 2 seconds across multiple endpoints
Immediate ActionDetermine if the bottleneck is the database or the application layer
Commands
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/products
SELECT 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;
Fix NowIf TTFB is high but DB queries are fast, the app is doing too much computation or has N+1 queries — enable SQL logging. If DB queries are slow, check for missing indexes with `EXPLAIN ANALYZE <slow query>`. If DNS lookup is slow, check /etc/resolv.conf and consider running a local DNS cache.
🟡Clients getting 429 Too Many Requests unexpectedly
Immediate ActionCheck rate limiting configuration and identify which client or IP is hitting limits
Commands
kubectl logs -l app=forge-api --tail=1000 | grep '429' | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
curl -I http://localhost:8080/api/v1/products | grep -i 'x-rate-limit'
Fix NowIf a legitimate client is rate-limited due to a spike, temporarily increase the limit or implement request queuing with exponential backoff guidance in the 429 response. If it's an attacker or runaway script, block the IP at the gateway or WAF level. Include `Retry-After` header in 429 responses so well-behaved clients know when to retry.
🟡Swagger/OpenAPI spec doesn't match actual API behavior — clients get unexpected fields or missing data
Immediate ActionRun contract tests to identify drift between the spec and implementation
Commands
mvn test -Dtest=*ContractTest* -Dsurefire.failIfNoSpecifiedTests=false
curl http://localhost:8080/v3/api-docs | jq '.paths["/api/v1/products"]' > current-spec.json && diff committed-spec.json current-spec.json
Fix NowIf endpoints are missing or status codes differ, update the `@ApiResponse` and `@Schema` annotations in the controller to match reality — or fix the code to match the spec. The spec is the contract; if you can't tell which is wrong, the spec is wrong.
Production Incident200 OK for Everything — Frontend Can't Detect FailuresAn API returned HTTP 200 for all responses, including validation errors and server exceptions, with an error flag buried in the JSON body. The mobile app team didn't check the body — they saw 200 and proceeded. Users saw 'Order confirmed' screens for orders that were never placed.
SymptomCustomer support reports a spike in 'phantom orders' — users claim they placed orders but no order exists in the database. The mobile app shows a confirmation screen with a green checkmark. Backend logs show 200 responses for those requests, but the response bodies contain {"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.
AssumptionThe backend team assumed the mobile app was parsing the response body correctly and blamed the frontend team for not reading the 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.
Root causeThe API was designed by a developer who believed 'HTTP status codes don't matter — it's all in the JSON body.' This is a surprisingly common anti-pattern. Every endpoint returned 200 OK regardless of outcome. Validation errors returned 200 with {"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.
FixRefactored all endpoints to return proper HTTP status codes: 400 for validation errors, 402 for payment failures, 404 for missing resources, 409 for conflicts, 500 for unhandled server errors. Created a global @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.
Key Lesson
HTTP status codes are part of your API contract — they are not decorative, they are semanticReturning 200 for errors breaks every HTTP client that trusts status codes, which is all of them by defaultA global exception handler (@ControllerAdvice) enforces consistent error responses across all endpoints without manual repetitionContract tests prevent status code drift between what the API spec promises and what the code actually returns
Production Debug GuideCommon REST API failures and how to diagnose them without guessing
Clients report intermittent 401 Unauthorized despite valid tokensCheck JWT token expiration and clock skew between servers — if Server A's clock is 30 seconds ahead, tokens minted by Server B may appear expired. If using OAuth2 with token introspection, verify the introspection endpoint is reachable and not rate-limited. Check if a load balancer or API gateway is stripping the Authorization header on certain routes. Run: curl -v -H 'Authorization: Bearer <token>' <endpoint> and inspect the request headers that actually reach the backend.
GET requests return correct data but POST/PUT return 415 Unsupported Media TypeVerify the client sends 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.
API returns 500 errors under load but works fine with single requestsCheck connection pool exhaustion in HikariCP: 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.
Pagination returns duplicate or missing records across pagesCheck if new records are being inserted between page fetches. Offset-based pagination (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.
CORS errors in browser console — 'No Access-Control-Allow-Origin header'Add @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.
Response times spike from 50ms to 5 seconds randomly on certain endpointsCheck for N+1 query problems: enable Hibernate SQL logging with 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.

ProductController.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
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();
    }
}
▶ Output
GET /api/v1/products -> 200 OK with paginated list
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
Mental Model
Resource Naming — The URL Is Your Contract
URLs are the nouns of your API. HTTP methods are the verbs. Never mix them — it's like writing 'eatFoodApple' instead of 'eat(apple).'
  • 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
📊 Production Insight
Verb-based URLs like /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.
🎯 Key Takeaway
URLs are nouns, HTTP methods are verbs — never mix them. Plural nouns for collections, IDs for individuals, nested URLs for ownership relationships. If your URL contains a verb like 'get' or 'create', you're doing RPC, not REST — stop and identify the resource you're actually operating on.
Resource Naming Decisions
IfNeed to retrieve a list of items with optional filtering
UseGET /resources?filter=value — returns 200 with array, 400 if filter is invalid
IfNeed to create a new item
UsePOST /resources with JSON body — returns 201 with created item and Location header
IfNeed to retrieve a specific item by ID
UseGET /resources/{id} — returns 200 with item or 404 if not found
IfNeed to represent a sub-resource (orders belonging to a user)
UseGET /users/{userId}/orders — nested URL expresses ownership; don't nest if no ownership exists
IfNeed to perform an action that doesn't map to CRUD (send email, cancel order)
UsePOST /resources/{id}/actions/cancel — use a sub-resource for non-CRUD actions, or model the action as a resource (POST /cancellations)

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.

GlobalExceptionHandler.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
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);
    }
}
▶ Output
400 Bad Request:
{"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."}
⚠ Never Expose Stack Traces in API Responses
Returning raw exception stack traces to clients is a security vulnerability. Attackers use them to map your internal architecture — package names reveal frameworks, class names reveal patterns, line numbers help pinpoint vulnerable code. Always catch exceptions in a global handler, log the full trace server-side, and return a clean error envelope with an opaque error code the client can report to support.
📊 Production Insight
HTTP status codes enable the entire HTTP ecosystem to work without understanding your specific API. A 503 tells a load balancer to try another server. A 429 with a Retry-After header tells a client when to come back. A 304 Not Modified saves bandwidth. Returning 200 for everything throws away this entire layer of infrastructure intelligence. Rule: the status code tells the client what happened; the body tells them why.
🎯 Key Takeaway
Status codes are semantic. 2xx means success, 4xx means client error, 5xx means server error. Every HTTP client, proxy, cache, and monitoring tool understands this convention. Returning 200 for errors breaks the entire ecosystem. Use @ControllerAdvice to enforce consistent status codes globally — never handle exceptions inline in controllers.
Choosing HTTP Status Codes
IfSuccessful read or update with response body
Use200 OK — the request succeeded and here's the data
IfSuccessful creation of a new resource
Use201 Created — include Location header pointing to the new resource
IfSuccessful operation with no response body needed
Use204 No Content — common for DELETE, sometimes PUT
IfClient sent invalid input (missing field, wrong format)
Use400 Bad Request — include field-level error details in body
IfClient is not authenticated
Use401 Unauthorized — 'who are you?' (include WWW-Authenticate header)
IfClient is authenticated but not authorized for this action
Use403 Forbidden — 'I know who you are, but you can't do this'
IfResource doesn't exist
Use404 Not Found — don't distinguish between 'never existed' and 'deleted' unless necessary
IfOperation can't complete due to current state (duplicate, locked)
Use409 Conflict — explain the conflict in the body
IfClient exceeded rate limits
Use429 Too Many Requests — include Retry-After header
IfServer failed unexpectedly
Use500 Internal Server Error — log the full trace, return generic message

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.

JwtAuthenticationFilter.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
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);
    }
}
▶ Output
Request with valid token:
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
🔥Why Statelessness Enables Scale
With server-side sessions, if a user's session is stored on Server A, their subsequent requests must also hit Server A (sticky sessions). This complicates load balancing, prevents seamless failover, and makes auto-scaling awkward. With stateless auth, every request is self-contained — any server can handle any request. This is the architectural foundation that makes REST APIs horizontally scalable.
📊 Production Insight
Statelessness is the scaling constraint. If your endpoint reads from HttpSession or relies on server-side state to identify the user, it's not stateless and it won't scale horizontally without sticky sessions. JWT tokens carry auth state in the request itself — no server-side session store needed. For service-to-service calls within your cluster, mTLS or service mesh identity is even simpler than JWT.
🎯 Key Takeaway
Statelessness means every request carries its own auth credentials — the server holds no session. This is what enables horizontal scaling: any server can handle any request. JWT tokens are the standard mechanism — self-contained, cryptographically verified, no database lookup required. If your API relies on HttpSession or sticky sessions, it's not RESTful and it won't scale without complexity.
Authentication Strategy Decisions
IfPublic API consumed by third-party developers
UseUse OAuth2 with scoped access tokens — industry standard, supports refresh, allows fine-grained permissions
IfInternal microservice-to-microservice communication
UseUse mTLS (mutual TLS) or service mesh identity (Istio, Linkerd) — no user context needed, identity is the certificate
IfSingle-page app or mobile app with user login
UseUse JWT in Authorization header — stateless, self-contained, no server session needed. Store refresh token securely.
IfNeed to invalidate tokens before expiration (immediate logout, compromised account)
UseUse short-lived JWTs (5-15 minutes) with refresh tokens, or maintain a token blacklist in Redis. Accept the minor state trade-off.

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.

VersionedControllers.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
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) {}
▶ Output
V1 Response (GET /api/v1/products/abc-123):
{"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}}
💡Backward-Compatible Changes Don't Need New Versions
Adding an optional field to a response? No new version needed — well-written clients ignore fields they don't recognize. Adding a new endpoint? No new version — clients don't call endpoints they don't know about. Only changes that break existing behavior require a version bump: removing fields, changing types, changing required/optional status.
📊 Production Insight
URL path versioning (/v1/, /v2/) is the industry standard because it's visible in logs, cacheable by CDNs, and unambiguous in bug reports. Header versioning (Accept: application/vnd.forge.v2+json) is elegant but invisible — when something breaks, you're debugging blind. Keep N-1 versions running with a published deprecation timeline. Monitor version usage metrics and sunset old versions when traffic justifies it.
🎯 Key Takeaway
Versioning is non-negotiable for public APIs. URL path versioning (/v1/, /v2/) is the standard because it's visible and unambiguous. Backward-compatible changes (adding optional fields, new endpoints) don't need new versions. Breaking changes (removing fields, changing types) require a new version with a deprecation timeline for the old one. Monitor version usage and sunset old versions when safe.
Versioning Decisions
IfAdding a new optional field to an existing response
UseNo version bump — backward compatible, clients ignore unknown fields
IfAdding a new endpoint
UseNo version bump — backward compatible, doesn't affect existing endpoints
IfRemoving a field from a response
UseNew version required — v1 keeps the field, v2 removes it
IfChanging a field type (string to object, int to string)
UseNew version required — clients parsing the old type will break
IfRenaming a field
UseNew version required — or add new field and deprecate old (preferred for minor changes)

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=20OFFSET 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.

PaginatedResponse.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
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);
    }
}
▶ Output
GET /api/v1/products?limit=20
{
"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
🔥When to Use Offset vs Cursor Pagination
Offset pagination (?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.
📊 Production Insight
Unpaginated collection endpoints are a time bomb. One query that worked fine with 100 rows will take down your API when the table hits 100,000. Rule: every collection endpoint is paginated from day one. Default to cursor-based for high-velocity data, offset-based for stable data with page-jump requirements. Always return pagination metadata so clients know if there's more data.
🎯 Key Takeaway
Pagination is mandatory for collection endpoints. Cursor-based pagination is more stable than offset for high-velocity tables because inserts don't shift the window. Always return pagination metadata (hasMore, nextCursor) — don't make clients guess. Default to 20-50 items per page; allow clients to request up to a reasonable max (100).

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.

HateoasProductController.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
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")
        );
    }
}
▶ Output
GET /api/v1/products/abc-123
{
"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"}
}
}
💡The Pragmatic Take on HATEOAS
Most successful APIs in production — Stripe, Twilio, GitHub — operate at Level 2 without full HATEOAS. They use excellent documentation, stable URLs, and versioning instead. HATEOAS is intellectually appealing but adds implementation complexity and response size. If you're building an internal API or an API with a small number of known consumers, Level 2 is sufficient. If you're building a public platform API meant to last decades, HATEOAS is worth serious consideration.
📊 Production Insight
HATEOAS trades implementation complexity for client decoupling. For internal APIs with known consumers, the trade-off rarely pays off — you can coordinate URL changes. For public APIs with unknown consumers and long lifecycles, HATEOAS enables evolution without breaking clients who follow links rather than hardcoding URLs. Be honest about which category your API falls into before investing in Level 3 maturity.
🎯 Key Takeaway
HATEOAS is Level 3 Richardson Maturity — responses include links for API navigation. It reduces coupling between client and server URL structure. Most production APIs operate at Level 2 and do fine. Implement HATEOAS if you're building a long-lived public API consumed by developers you'll never meet. For internal APIs, good documentation and versioning usually suffice.
🗂 HTTP Methods at a Glance
Idempotency, safety, and correct usage for each method
HTTP MethodIdempotent?Safe?Correct Usage
GETYesYesRetrieve a resource or collection. Never modify state.
POSTNoNoCreate a new resource. Return 201 + Location header.
PUTYesNoFull replacement of a resource. Client sends complete object.
PATCHDependsNoPartial update. Only changed fields sent. Can be idempotent if designed carefully.
DELETEYesNoRemove a resource. Return 204 No Content on success.
OPTIONSYesYesDescribe available methods (CORS preflight). No side effects.
HEADYesYesGET 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

    Returning 200 OK for everything, including errors
    Symptom

    Clients cannot distinguish between success and failure without parsing the response body. Mobile apps render success screens for failed operations. Monitoring dashboards show 100% success rates while users experience errors. Caches store error responses as if they were valid data.

    Fix

    Return proper status codes: 201 for creations, 204 for successful deletes with no content, 400 for bad client input, 401 for unauthorized, 403 for forbidden, 404 for missing resources, 409 for conflicts, 429 for rate limits, 500 for server errors. Use @ControllerAdvice to enforce consistent status codes globally across all endpoints.

    Putting verbs in URLs instead of using HTTP methods
    Symptom

    API has endpoints like /getUser, /createOrder, /deleteProduct/123, /fetchAllProducts. Every consumer guesses the naming pattern. You end up with /getUser, /retrieveUser, and /fetchUserById doing the same thing in different services.

    Fix

    URLs are nouns. HTTP methods are verbs. Use /users with GET (list), POST (create). Use /users/123 with GET (retrieve), PUT (update), DELETE (remove). The action is in the method, not the URL.

    Leaking stack traces and internal details in error responses
    Symptom

    API returns raw Java exceptions with full stack traces, class names, package paths, line numbers, and sometimes database column names. Security scanners flag the API. Attackers use stack traces to map internal architecture and identify vulnerable components.

    Fix

    Use @ControllerAdvice with @ExceptionHandler to catch all exceptions. Log the full stack trace server-side for debugging. Return a clean error envelope to clients: {"error": {"code": "VALIDATION_FAILED", "message": "Email is required"}}. Never expose internal implementation details.

    Inconsistent JSON naming conventions across endpoints
    Symptom

    Some endpoints return camelCase (userId, createdAt), others return snake_case (user_id, created_at), some mix both. Frontend developers write different deserialization logic per endpoint. Auto-generated API clients break on mixed conventions.

    Fix

    Pick one convention and enforce it globally. For Java/Spring backends, camelCase is the Jackson default — stick with it unless your API contract requires snake_case. Configure Jackson globally in application.yml rather than per-field annotations. Run linting on OpenAPI specs to catch inconsistencies before they ship.

    No pagination on collection endpoints
    Symptom

    GET /products returns all 500,000 products. Response size hits 50MB. API latency spikes to 30 seconds. Mobile apps crash with OutOfMemoryError. Database connections timeout because the query runs too long. The endpoint worked fine with 100 products in development.

    Fix

    Implement pagination on every collection endpoint from day one, even if you only have 10 rows today. Use cursor-based pagination for high-velocity data. Default to 20-50 items, allow up to 100 max. Return pagination metadata (hasMore, nextCursor, totalCount if cheap). Reject requests for page sizes over the max rather than silently clamping.

    Exposing auto-increment database IDs as public resource identifiers
    Symptom

    Endpoints use sequential integers: /products/1, /products/2, /users/47. Attackers enumerate your entire dataset by incrementing IDs. Competitors scrape your catalog. Users access other users' data by guessing IDs. Your internal database structure leaks into your public API contract.

    Fix

    Use UUIDs or other opaque identifiers as public resource IDs: /products/550e8400-e29b-41d4-a716-446655440000. Keep auto-increment integers as internal database primary keys for join efficiency. Map between them in the service layer. This prevents enumeration attacks and decouples your API contract from database schema.

Interview Questions on This Topic

  • QWhat makes an API truly 'RESTful'? Walk me through the Richardson Maturity Model.SeniorReveal
    A truly RESTful API reaches Level 3 of the Richardson Maturity Model, though most production APIs stop at Level 2. Level 0 (The Swamp of POX) uses HTTP as a transport tunnel — one endpoint, POST everything, action in the body. Level 1 (Resources) introduces resource-based URLs like /users/123 instead of a single RPC endpoint. Level 2 (HTTP Verbs) uses proper methods (GET, POST, PUT, DELETE) and status codes (200, 201, 404, 409). This is where most production APIs operate. Level 3 (HATEOAS) means responses include links telling the client what actions are available next — enabling true discoverability and decoupling clients from URL structure. Most teams skip Level 3 because the implementation cost exceeds the benefit for internal APIs, but it's worth considering for long-lived public APIs.
  • QHow do you secure a stateless REST API? What are the trade-offs between JWT and opaque tokens?SeniorReveal
    Since REST is stateless by design, we don't use server-side sessions. The client sends a token in the Authorization: Bearer header with every request. JWTs (JSON Web Tokens) are self-contained — they carry the user's claims (user ID, roles, expiration) and are verified cryptographically without a database lookup. This makes them fast but not revocable: if a token is valid for 24 hours, you can't revoke it early without maintaining a blacklist, which re-introduces state. Opaque tokens (random strings) require a server lookup on every request — either against a database or a token introspection endpoint — but they're instantly revocable by deleting the server-side record. The trade-off: JWTs are faster but harder to revoke; opaque tokens are revocable but require lookup latency. A common middle ground: short-lived JWTs (15 minutes) with refresh tokens stored securely, so revocation only requires waiting for expiration.
  • QExplain the difference between PUT and PATCH. When would you use each, and what does idempotency mean in this context?Mid-levelReveal
    PUT is a full replacement — the client sends the entire resource, and the server replaces the existing resource completely. If a field is missing from the PUT body, it may be nullified. PUT is idempotent: sending the same PUT request twice produces the same result because you're replacing with the same data. PATCH is a partial update — the client sends only the fields to change, and the server merges them into the existing resource. PATCH is not inherently idempotent because the effect depends on current state — patching 'increment counter by 1' twice produces different results. However, PATCH can be designed to be idempotent if you send target values rather than deltas ('set counter to 5' is idempotent). Use PUT when the client has the full resource and wants to replace it. Use PATCH when updating one field without fetching and resending the entire object — common in mobile apps trying to minimize bandwidth.
  • QHow would you design API versioning for a public REST API with 10,000 active consumers?SeniorReveal
    I'd use URL path versioning (/api/v1/products, /api/v2/products) as the primary strategy because it's visible in logs, cacheable by CDNs, and unambiguous when debugging. Every endpoint is explicitly versioned. For changes within a version, I distinguish between backward-compatible (adding optional fields, new endpoints) and breaking (removing fields, changing types). Backward-compatible changes stay in the current version — well-written clients ignore unknown fields. Breaking changes require a new version. When v2 launches, I publish a deprecation timeline: 'v1 will be sunset 12 months from this date.' I monitor version usage metrics and send deprecation warnings via response headers and email to registered developers. I maintain N-1 versions until traffic justifies sunsetting. I'd also consider a sunset header (Sunset: Sat, 01 Jan 2028 00:00:00 GMT) to signal deprecation programmatically.
  • QWhat HTTP status code should POST return on success, and what headers should be included?JuniorReveal
    A successful POST that creates a resource should return 201 Created, not 200 OK. The response body should contain the created resource, including any server-generated fields like ID, createdAt, or computed defaults. The response must include a Location header pointing to the URL of the new resource: Location: /api/v1/products/abc-123. This allows clients to immediately GET the new resource without constructing the URL themselves. If the creation is asynchronous — queued for background processing rather than completed synchronously — return 202 Accepted with a body containing a status URL the client can poll for completion.
  • QExplain cursor-based pagination and why it's more stable than offset-based for high-velocity data.Mid-levelReveal
    Offset-based pagination uses OFFSET and LIMIT: page 3 with size 20 becomes OFFSET 40 LIMIT 20. The problem is that the offset is a count from the beginning of the table. If rows are inserted or deleted between page fetches, the offset shifts. Page 3 might show rows that were on page 2, or skip rows that shifted. This is particularly bad for high-velocity tables with frequent inserts. Cursor-based pagination encodes the last-seen position in the sort order — typically a combination of a timestamp and ID. The next page query becomes WHERE (created_at, id) > ('2026-03-05T10:30:00Z', 'abc-123') ORDER BY created_at, id LIMIT 20. Inserts don't shift the window because the cursor points to a specific position, not a row count. The trade-off: you can't jump to arbitrary pages (no 'go to page 47'), which makes cursor pagination better for infinite scroll and worse for admin dashboards with page numbers.

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.

🔥
Naren Founder & Author

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.

← PreviousMicroservices ArchitectureNext →GraphQL vs REST
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged