Free lesson · System Design Crash Course
REST as a Discipline (Not "JSON over HTTP")
Lesson 5.1 - REST as a Discipline (Not "JSON over HTTP")
The first API styles lesson.
Let me start with an uncomfortable observation. Most APIs that call themselves "REST" are not actually REST. They're HTTP-with-JSON. They use URLs and verbs in a vaguely RESTful way, but they violate enough REST principles that, strictly speaking, they're something else - sometimes called "RPC over HTTP" or "REST-ish" or just "HTTP API."
This isn't a pedantic complaint. The differences matter. When someone says "let's build a REST API," they might mean any of three things: a strict REST API following the full discipline (very rare), a pragmatic HTTP-JSON API (most common), or an RPC-style API that just uses HTTP transport (also common). Each one has different design properties, scaling characteristics, and trade-offs.
This lesson covers what REST actually means, what the pragmatic version looks like, and where the line is between them. By the end, you'll be able to design APIs that consciously choose where they sit on this spectrum, instead of accidentally falling somewhere in the middle.
Where REST Came From
REST stands for Representational State Transfer. It was defined by Roy Fielding in his 2000 PhD dissertation, where he laid out an architectural style for the web. Fielding was one of the original authors of HTTP, and his dissertation tried to articulate the principles that had made the web successful.
REST isn't a protocol or a framework. It's a set of constraints. If your API follows all of them, it's RESTful. If it follows some, it's REST-ish. If it follows none of them, it's just HTTP.
The constraints Fielding defined:
- Client-server - separate concerns of client and server
- Stateless - each request contains everything needed to process it
- Cacheable - responses indicate whether they're cacheable
- Uniform interface - consistent way to identify and manipulate resources
- Layered system - clients don't need to know about intermediaries
- Code-on-demand - server can send executable code to client (optional, rarely used)
Most "REST APIs" honor 1, 2, and 5 by default (because HTTP supports them). They often partially honor 4 (URLs identify resources; HTTP methods act on them). They almost never fully honor 3 (real cacheability requires careful header use). And the "uniform interface" constraint, taken seriously, includes hypermedia controls (HATEOAS) - which nearly no API does.
So when we talk about "REST in practice," we're really talking about a subset of the original constraints. That's fine, as long as we know what we're picking and what we're skipping.
The Core Concept: Resources and Representations
The most important thing to internalize about REST: everything is a resource, and APIs manipulate representations of those resources.
A resource is anything you can identify with a URL. Examples:
- A user:
https://api.example.com/users/42 - A user's posts:
https://api.example.com/users/42/posts - A specific post:
https://api.example.com/posts/123 - The collection of all posts:
https://api.example.com/posts
Notice these are nouns, not verbs. URLs identify things, not actions. The URL says what; the HTTP method says how.
The methods (which we covered in Module 4) act on the resource:
GET /users/42- retrieve the user (returns a representation)PUT /users/42- replace the user with the bodyPATCH /users/42- partially update the userDELETE /users/42- delete the userPOST /users- create a new user in the collection
A representation is the serialized form of the resource at some point in time. When you GET a user, you don't get "the user" - you get a JSON (or XML, or whatever) representation of the user's state when the request was served. The actual user lives in some database; the representation is just a snapshot in some format.
This is why content negotiation exists. You can ask for the same resource in different formats:
GET /users/42 HTTP/1.1
Accept: application/json
vs
GET /users/42 HTTP/1.1
Accept: application/xml
The server might return the same resource as JSON or XML based on what the client asked for. The resource is the same; the representation differs.
Statelessness - The Most Misunderstood Constraint
REST requires that each request contain everything needed to process it. The server should not retain client state between requests.
This doesn't mean the server is stateless overall - databases still hold state. It means the conversation between client and server is stateless. The server doesn't remember "this is the second of three calls in a workflow" or "this client previously authenticated."
Practical implications:
- Authentication via tokens, not sessions. Each request includes an Authorization header. The server doesn't track "logged-in users" - it validates the token on each request.
- No session-affinity needed. Any server can handle any request. This is why REST scales horizontally so well.
- No "preferences" remembered. If the client wants a specific page or sort order, it includes that in the URL/query/headers each time.
The benefit: any server in your fleet can handle any request. Load balancers can route freely. New servers come online and immediately serve traffic. Failed servers can be replaced without state loss.
The cost: each request carries more data (auth headers, context). Some operations that would be natural with sessions become awkward.
Statelessness is what makes REST scale. It's also the constraint that's most often violated - APIs with "session cookies" or "sticky sessions" are stepping away from true REST.
HTTP Methods Used Correctly
The methods have specific semantics that REST takes seriously:
Safe methods - don't change server state. GET, HEAD, OPTIONS.
Idempotent methods - making the same request twice has the same effect as making it once. GET, HEAD, OPTIONS, PUT, DELETE.
Non-idempotent methods - POST (each call creates a new resource).
Sometimes idempotent - PATCH (depends on what the patch does).
Why this matters:
- Retries are safe for idempotent methods. If a request times out, you can safely retry it. This is huge for resilience.
- Caching works for safe methods. GET responses can be cached (with proper headers).
- Monitoring tools assume these semantics. A retry storm on POST creates duplicates; a retry storm on GET is mostly harmless.
A common REST mistake: using GET for state-changing operations. GET /users/42/delete is wrong. GET should be safe. If the operation changes state, use POST/PUT/PATCH/DELETE.
Another common mistake: using PUT for partial updates. PUT means "replace the resource with the body." If you're only updating one field, that should be PATCH. Sloppy use of PUT for partial updates causes subtle bugs (fields you didn't include get nulled out).
HTTP Status Codes Used Correctly
Status codes are part of the REST contract. Use them precisely:
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE |
| 201 | Created | Successful POST that created a resource |
| 202 | Accepted | Async work started; not yet complete |
| 204 | No Content | Success but no body to return (often for DELETE) |
| 301 | Moved Permanently | Resource has a new permanent URL |
| 302 | Found | Temporary redirect |
| 304 | Not Modified | Conditional GET; client's cached copy is current |
| 400 | Bad Request | Client error in request format |
| 401 | Unauthorized | Missing/invalid authentication |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Request conflicts with current state |
| 422 | Unprocessable Entity | Validation error |
| 429 | Too Many Requests | Rate-limited |
| 500 | Internal Server Error | Generic server problem |
| 502 | Bad Gateway | Server received bad response from upstream |
| 503 | Service Unavailable | Server overloaded or down for maintenance |
| 504 | Gateway Timeout | Upstream took too long |
Using these correctly matters because:
- Client libraries handle them. Most HTTP libraries treat 5xx as retryable, 4xx as not.
- Monitoring catches problems. Spike in 500s = server bug. Spike in 429s = rate limiting issue.
- CDNs and caches respect them. Different statuses get cached differently.
A common mistake: returning 200 OK with an "error": "..." body. The HTTP status should reflect what happened. If the request failed, return a 4xx or 5xx. Don't make clients parse the body to discover errors.
URL Design Patterns
A few practical patterns that work well:
Collections and Items
GET /users # List users (collection)
POST /users # Create a new user (in collection)
GET /users/42 # Get specific user (item)
PUT /users/42 # Replace user 42
PATCH /users/42 # Update user 42 partially
DELETE /users/42 # Delete user 42
Nested Resources
GET /users/42/posts # Get all posts by user 42
POST /users/42/posts # Create a post for user 42
GET /users/42/posts/123 # Get post 123 by user 42
Filtering and Pagination
GET /posts?author=42&status=published&page=2&per_page=20
Use query parameters for these. They're not part of the resource identity, just modifiers on the request.
Search
GET /search?q=puppies
Or a "verb-like" resource:
POST /search
{ "query": "puppies", "filters": {...} }
The POST form is acceptable for complex searches where the query is too long for a URL. It's a small REST violation (POST instead of GET for a safe operation) but pragmatic.
flowchart TD
Collection["/users<br/>(collection)"]
Item["/users/42<br/>(item)"]
Sub["/users/42/posts<br/>(nested collection)"]
SubItem["/users/42/posts/123<br/>(nested item)"]
Filter["/users?status=active<br/>(filtered collection)"]
Collection -->|"item navigation"| Item
Item -->|"nested"| Sub
Sub -->|"item"| SubItem
Collection -->|"filter"| Filter
style Collection fill:#a78bfa,stroke:#8b5cf6,color:#000
style Item fill:#34d399,stroke:#10b981,color:#000
style Sub fill:#fbbf24,stroke:#f59e0b,color:#000
A diagram of typical REST URL design patterns. At the top, a collection URL like slash users - this lists or creates users. Drilling down to a specific item gives you slash users slash 42 - this gets, updates, or deletes that one user. You can nest further to slash users slash 42 slash posts to access the collection of that user's posts, and then slash users slash 42 slash posts slash 123 for a specific post. For filtering and queries, you append query parameters like slash users with question mark status equals active. The pattern is consistent: URLs identify resources hierarchically; the HTTP method says what to do with them; query parameters modify the operation. Once you internalize this pattern, REST APIs become predictable to design and use.
Pragmatic REST vs Strict REST
The biggest divergence in real-world APIs from strict REST is around HATEOAS (Hypermedia as the Engine of Application State).
Strict REST says: responses should include links to related resources and available actions. The client should discover the API by following these links, like a human browses the web - not by hard-coding URLs.
Example of a HATEOAS response:
{
"id": 42,
"name": "Jane",
"_links": {
"self": { "href": "/users/42" },
"posts": { "href": "/users/42/posts" },
"deactivate": { "href": "/users/42/deactivate", "method": "POST" }
}
}
The client doesn't need to know /users/42/posts exists - it follows the link from the user resource.
In practice, almost no API does this fully. Clients tend to hard-code URLs. SDKs and client libraries enumerate all the operations. The hypermedia approach is theoretically elegant but rarely worth the complexity.
So most "REST APIs" pragmatically:
- Use resources and methods (✓)
- Stateless (✓)
- Honor HTTP semantics for caching, status codes (✓)
- Do NOT use full hypermedia (✗)
This is fine. It's called pragmatic REST or "REST level 2" (Richardson's Maturity Model). It captures most of the practical benefits without the operational burden of full HATEOAS.
Versioning
How do you change an API without breaking existing clients? Three main approaches:
1. URL versioning. /v1/users, /v2/users. Easy to understand, but creates parallel URLs.
2. Header versioning. Accept: application/vnd.api.v2+json. Cleaner URLs, but less discoverable.
3. No versioning. Just promise to be backward-compatible forever. Tricky but increasingly popular (Stripe takes a hybrid approach with this).
URL versioning is the most common in practice. It's straightforward, easy to debug, and well-supported by tooling. Use it unless you have a strong reason not to.
A subtle point: breaking changes should require a version bump; additive changes shouldn't. Adding a new field, a new endpoint, or a new optional parameter is fine. Removing a field, renaming, or changing semantics breaks clients and warrants a new version.
A Common Mistake: Action URLs
Beginners often design APIs with verbs in the URL:
POST /users/42/sendEmail
POST /orders/123/cancel
POST /accounts/9/activate
This is RPC-style, not REST. It treats the URL as a function name. It's not wrong - it works - but it abandons the resource-orientation of REST.
The REST-style version would be:
POST /users/42/emails # Create an email (in the user's emails collection)
PATCH /orders/123 # { "status": "cancelled" }
PATCH /accounts/9 # { "status": "active" }
The resource-oriented version is more uniform but sometimes awkward (especially for actions that don't map naturally to state changes). Most real APIs end up with a mix - mostly resource-oriented, with a few action-style endpoints for the things that don't fit.
Pragmatism wins. Don't bend over backwards to force every operation into resource form.
A Real-World Example: Stripe's API
Stripe is widely regarded as one of the best-designed APIs in the industry. It's pragmatic REST, not strict. Some of what it does well:
1. Predictable resource URLs. /customers/cus_xyz, /charges/ch_abc, /subscriptions/sub_123. Clear nouns.
2. Standard methods. GET to retrieve, POST to create, PUT/PATCH-style for updates (Stripe uses POST for updates, which is a slight deviation but consistent).
3. Standard status codes. Errors come back as 4xx with structured error bodies.
4. Pagination via cursors. ?starting_after=cus_abc&limit=20. Cursor-based pagination scales better than offset-based.
5. API versioning with great rigor. Each customer is pinned to a specific API version date (2023-10-16). Stripe maintains old versions essentially forever. Clients upgrade only when they choose to.
6. Idempotency keys. Stripe lets clients include an Idempotency-Key header on POST requests. If the same key is reused, Stripe returns the cached response - making retries safe. This is a brilliant addition that most APIs lack.
7. Webhooks for asynchronous events. Stripe doesn't try to push everything synchronously; it uses webhooks for async (which we'll cover in Lesson 5.6).
Stripe shows that pragmatic REST, done well, is excellent. You don't need full HATEOAS to ship a great API. You need consistency, predictability, and care for the developer experience.
Six Takeaways from This Lesson
-
Most "REST APIs" are pragmatic REST, not strict REST. That's usually fine, as long as you know what you're picking.
-
Resources and representations are the core idea. URLs identify things; methods act on them.
-
Statelessness is what makes REST scale. Each request is self-contained.
-
Use HTTP methods and status codes precisely. They're part of the contract.
-
HATEOAS is theoretically elegant but rarely worth the complexity in practice. Pragmatic REST captures most of the value.
-
Good REST APIs (like Stripe's) feel predictable, consistent, and easy to work with. That's the real goal, not protocol purity.
Try It
Exercise: Warmup (10 minutes). Pick a familiar API (Twitter, GitHub, Stripe). For 3 of its endpoints, identify the resource, the method, and the expected status codes for success/failure.
Exercise: Core (20 minutes). Design a REST API for a simple to-do app. Define the resources, URLs, methods, and status codes. Have a peer critique it for consistency and correctness.
Exercise: Stretch (30 minutes). Read Stripe's API documentation (just the structure, not every endpoint). Note the patterns they use - versioning, error format, pagination, idempotency keys. See how they balance pragmatism and rigor.
What's Next
Lesson 5.2 covers gRPC - the protocol that has become standard for internal service-to-service communication. We'll see what protobuf is, why binary protocols win for some use cases, and where gRPC fits in modern architectures.