REST API Design - The 200 OK for Everything Anti-Pattern
Returning 200 OK for all responses caused phantom orders; frontend never parsed error body.
- 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
Imagine a waiter at a restaurant. You don't walk into the kitchen to cook your own food — you hand a menu order to the waiter, who takes it to the kitchen and brings back exactly what you asked for. A REST API is that waiter: a set of rules for how your app asks for data and how the server responds. The menu is the API's contract — it defines exactly what you can order, how to ask for it, and what you'll get back. If the waiter comes back empty-handed, they tell you why (we're out of that dish) instead of just shrugging. That's what good status codes do.
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.
- 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.
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.
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.
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.
?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.
200 OK for Everything — Frontend Can't Detect Failures
{"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.- HTTP status codes are part of your API contract — they are not decorative, they are semantic
- Returning 200 for errors breaks every HTTP client that trusts status codes, which is all of them by default
- A global exception handler (@ControllerAdvice) enforces consistent error responses across all endpoints without manual repetition
- Contract tests prevent status code drift between what the API spec promises and what the code actually returns
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.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.Key takeaways
Common mistakes to avoid
6 patternsReturning 200 OK for everything, including errors
Putting verbs in URLs instead of using HTTP methods
/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./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
{"error": {"code": "VALIDATION_FAILED", "message": "Email is required"}}. Never expose internal implementation details.Inconsistent JSON naming conventions across endpoints
application.yml rather than per-field annotations. Run linting on OpenAPI specs to catch inconsistencies before they ship.No pagination on collection endpoints
Exposing auto-increment database IDs as public resource identifiers
/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./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
What makes an API truly 'RESTful'? Walk me through the Richardson Maturity Model.
Frequently Asked Questions
That's Architecture. Mark it forged?
8 min read · try the examples if you haven't