Introduction
Not all REST APIs are created equal. Many APIs claim to be RESTful while only scratching the surface of what REST (Representational State Transfer) actually entails. Leonard Richardson's maturity model provides a framework for evaluating how closely an API adheres to REST principles, dividing APIs into four distinct levels of maturity.
Understanding the Richardson Maturity Model helps you evaluate existing APIs, design better new ones, and make informed tradeoffs between pragmatism and purity. While Level 3 (HATEOAS) represents the full REST vision, most production APIs operate effectively at Level 2 — and knowing why that's often the right choice is just as important as knowing what the levels are.
Understanding REST: Core Concepts
What REST Actually Means
Roy Fielding's original REST dissertation describes an architectural style for distributed hypermedia systems. REST isn't a protocol or specification — it's a set of architectural constraints:
- Client-Server: Separation of concerns between UI and data storage
- Stateless: Each request contains all information needed to process it
- Cacheable: Responses must define themselves as cacheable or not
- Uniform Interface: A standardized way to interact with resources
- Layered System: Client can't tell if it's connected directly to the server
- Code on Demand (optional): Servers can extend client functionality
Most "REST APIs" in practice only implement constraints 1-3 and part of 4. The Richardson Maturity Model measures how far along the uniform interface constraint an API goes.
Resources and Representations
At the heart of REST is the concept of resources — any named information object. A resource is identified by a URI and accessed through representations (JSON, XML, HTML, etc.).
// Resource: A specific user
// URI: /users/123
// Representation (JSON):
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"avatar": { "href": "/users/123/avatar" }
}
}The key distinction: the resource is the concept (user #123), while the representation is how it's transmitted (JSON with those specific fields). The same resource can have multiple representations.
HTTP Verbs as Operations
REST leverages HTTP methods as the uniform interface for operating on resources:
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes |
| PUT | Replace a resource | Yes | No |
| PATCH | Partially update | No* | No |
| DELETE | Remove a resource | Yes | No |
| POST | Create or process | No | No |
// Level 0 style (RPC over HTTP):
POST /getUser
POST /createOrder
POST /deleteUser
// Level 2 style (proper HTTP methods):
GET /users/123
POST /orders (with user reference in body)
DELETE /users/123Architecture and Design Patterns
Richardson Maturity Model Levels
Level 0: The Swamp of POX
At Level 0, the API uses HTTP as a transport mechanism but doesn't leverage any of its features. Everything goes through a single endpoint, typically using POST, with the operation encoded in the request body (like XML-RPC or SOAP).
// Level 0: Single endpoint, operation in body
POST /api HTTP/1.1
Content-Type: application/xml
<getUser>
<userId>123</userId>
</getUser>
// Response
<getUserResponse>
<name>Alice</name>
<email>alice@example.com</email>
</getUserResponse>Characteristics:
- Single URI for all operations
- Single HTTP method (usually POST)
- Operation name in the request body
- No use of HTTP semantics
Level 1: Resources
Level 1 introduces the concept of resources — instead of one endpoint, the API uses multiple URIs to identify different things. However, it may still use a single HTTP method.
// Level 1: Separate URIs for different resources
POST /users/123
Body: { "action": "get" }
POST /users/123/orders
Body: { "action": "list" }
POST /users/123/orders/456
Body: { "action": "get" }Characteristics:
- Multiple URIs identifying resources
- May still use single HTTP method
- Action encoded in request body
- Some structure, but not fully leveraging HTTP
Level 2: HTTP Verbs and Status Codes
Level 2 is where most production APIs operate. The API uses HTTP methods correctly (GET for retrieval, POST for creation, PUT for updates, DELETE for removal) and returns appropriate HTTP status codes.
// Level 2: Proper HTTP methods and status codes
// GET retrieves
GET /users/123
→ 200 OK
→ { "id": 123, "name": "Alice", "email": "alice@example.com" }
// POST creates
POST /users
Body: { "name": "Bob", "email": "bob@example.com" }
→ 201 Created
→ Location: /users/124
→ { "id": 124, "name": "Bob", "email": "bob@example.com" }
// PUT replaces
PUT /users/123
Body: { "name": "Alice Smith", "email": "alice.smith@example.com" }
→ 200 OK
// DELETE removes
DELETE /users/123
→ 204 No Content
// Proper error handling
GET /users/999
→ 404 Not Found
→ { "error": "User not found", "code": "USER_NOT_FOUND" }Characteristics:
- Resources identified by URIs
- HTTP methods used semantically
- HTTP status codes for outcomes
- Request/response bodies with proper content types
Level 3: HATEOAS (Hypermedia)
Level 3 adds Hypermedia As The Engine Of Application State (HATEOAS). Responses include links that tell the client what actions are available next, making the API self-documenting and discoverable.
// Level 3: Hypermedia-driven responses
GET /users/123
→ 200 OK
→ {
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"status": "active",
"_links": {
"self": { "href": "/users/123", "method": "GET" },
"update": { "href": "/users/123", "method": "PUT" },
"delete": { "href": "/users/123", "method": "DELETE" },
"deactivate": { "href": "/users/123/deactivate", "method": "POST" },
"orders": { "href": "/users/123/orders", "method": "GET" },
"avatar": { "href": "/users/123/avatar", "method": "GET" }
},
"_embedded": {
"latestOrder": {
"id": 456,
"total": 99.99,
"_links": {
"self": { "href": "/orders/456" }
}
}
}
}Characteristics:
- Responses include hypermedia links
- Links describe available actions
- Clients navigate the API through links (not hardcoded URIs)
- API is self-documenting and evolvable
Step-by-Step Implementation
Building a Level 2 REST API
A practical implementation of a Level 2 API with Express and TypeScript:
import express, { Request, Response, NextFunction } from 'express';
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
const app = express();
app.use(express.json());
// In-memory store for illustration
const users = new Map<number, User>();
let nextId = 1;
// GET /users — List all users
app.get('/users', (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const allUsers = Array.from(users.values());
const start = (page - 1) * limit;
const paginated = allUsers.slice(start, start + limit);
res.json({
data: paginated,
pagination: {
page,
limit,
total: allUsers.length,
pages: Math.ceil(allUsers.length / limit),
},
});
});
// GET /users/:id — Get a specific user
app.get('/users/:id', (req: Request, res: Response) => {
const user = users.get(parseInt(req.params.id));
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND',
});
}
res.json({ data: user });
});
// POST /users — Create a new user
app.post('/users', (req: Request, res: Response) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: 'Name and email are required',
code: 'VALIDATION_ERROR',
});
}
const user: User = {
id: nextId++,
name,
email,
createdAt: new Date(),
};
users.set(user.id, user);
res.status(201).location(`/users/${user.id}`).json({ data: user });
});
// PUT /users/:id — Replace a user
app.put('/users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
if (!users.has(id)) {
return res.status(404).json({ error: 'User not found' });
}
const { name, email } = req.body;
const user: User = { id, name, email, createdAt: new Date() };
users.set(id, user);
res.json({ data: user });
});
// DELETE /users/:id — Delete a user
app.delete('/users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
if (!users.has(id)) {
return res.status(404).json({ error: 'User not found' });
}
users.delete(id);
res.status(204).send();
});Upgrading to Level 3 with HATEOAS
Adding hypermedia links to make the API self-describing:
function addUserLinks(user: User) {
return {
...user,
_links: {
self: { href: `/users/${user.id}`, method: 'GET' },
update: { href: `/users/${user.id}`, method: 'PUT' },
delete: { href: `/users/${user.id}`, method: 'DELETE' },
orders: { href: `/users/${user.id}/orders`, method: 'GET' },
},
};
}
// Updated GET /users/:id
app.get('/users/:id', (req: Request, res: Response) => {
const user = users.get(parseInt(req.params.id));
if (!user) {
return res.status(404).json({
error: 'User not found',
_links: {
users: { href: '/users', method: 'GET' },
},
});
}
res.json({ data: addUserLinks(user) });
});
// Collection response with navigation links
app.get('/users', (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const allUsers = Array.from(users.values());
const start = (page - 1) * limit;
const paginated = allUsers.slice(start, start + limit);
const totalPages = Math.ceil(allUsers.length / limit);
res.json({
data: paginated.map(addUserLinks),
_links: {
self: { href: `/users?page=${page}&limit=${limit}` },
first: { href: `/users?page=1&limit=${limit}` },
last: { href: `/users?page=${totalPages}&limit=${limit}` },
...(page < totalPages && {
next: { href: `/users?page=${page + 1}&limit=${limit}` },
}),
...(page > 1 && {
prev: { href: `/users?page=${page - 1}&limit=${limit}` },
}),
},
});
});Content Negotiation
Supporting multiple response formats through content negotiation:
app.get('/users/:id', (req: Request, res: Response) => {
const user = users.get(parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'Not found' });
res.format({
'application/json': () => {
res.json({ data: addUserLinks(user) });
},
'application/xml': () => {
res.send(userToXml(user));
},
'text/html': () => {
res.send(userToHtml(user));
},
});
});Real-World Use Cases
Use Case 1: E-Commerce API with HATEOAS
An e-commerce API where available actions depend on resource state:
// Order in "pending" state
GET /orders/789
→ {
"id": 789,
"status": "pending",
"total": 149.99,
"_links": {
"self": { "href": "/orders/789" },
"pay": { "href": "/orders/789/payment", "method": "POST" },
"cancel": { "href": "/orders/789/cancel", "method": "POST" },
"modify": { "href": "/orders/789", "method": "PATCH" },
"customer": { "href": "/users/123" }
}
}
// Same order after payment — different links available
GET /orders/789
→ {
"id": 789,
"status": "paid",
"total": 149.99,
"_links": {
"self": { "href": "/orders/789" },
"refund": { "href": "/orders/789/refund", "method": "POST" },
"tracking": { "href": "/orders/789/tracking" },
"invoice": { "href": "/orders/789/invoice", "method": "GET" },
"customer": { "href": "/users/123" }
}
}The client doesn't need to know the business rules — it just follows the links that are present. If a link isn't there, the action isn't available.
Use Case 2: API Versioning Strategies
Different approaches to evolving an API without breaking clients:
// URI versioning (most common)
GET /v1/users/123
GET /v2/users/123
// Header versioning
GET /users/123
Accept: application/vnd.myapi.v2+json
// Query parameter versioning
GET /users/123?version=2
// Content negotiation (RESTful approach)
GET /users/123
Accept: application/json; profile="/schemas/user/v2"Use Case 3: Batch Operations
Handling bulk operations while maintaining REST semantics:
// Batch create
POST /users/batch
Content-Type: application/json
{
"operations": [
{ "method": "POST", "path": "/users", "body": { "name": "Alice" } },
{ "method": "POST", "path": "/users", "body": { "name": "Bob" } },
{ "method": "POST", "path": "/users", "body": { "name": "Charlie" } }
]
}
→ 200 OK
{
"results": [
{ "status": 201, "body": { "id": 1, "name": "Alice" } },
{ "status": 201, "body": { "id": 2, "name": "Bob" } },
{ "status": 400, "body": { "error": "Invalid email" } }
]
}Use Case 4: Filtering, Sorting, and Pagination
RESTful query patterns for collections:
// Filtering
GET /users?status=active&role=admin
// Sorting
GET /users?sort=-createdAt,name
// Prefix - for descending, comma-separated for multiple fields
// Pagination (cursor-based — preferred for large datasets)
GET /users?cursor=eyJpZCI6MTAwfQ&limit=20
→ {
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}
}
// Pagination (offset-based — simpler but less efficient)
GET /users?page=3&limit=20
→ {
"data": [...],
"pagination": {
"page": 3,
"limit": 20,
"total": 156,
"pages": 8
}
}Best Practices for Production
-
Use nouns for resource URIs, not verbs:
/usersnot/getUsers. The HTTP method conveys the action. -
Return appropriate status codes:
201 Createdfor successful creation,204 No Contentfor successful deletion,404 Not Foundfor missing resources,422 Unprocessable Entityfor validation errors. -
Use plural nouns for collections:
/users,/orders,/products. Use singular only when the resource is truly singular (e.g.,/me). -
Nest resources to express relationships:
/users/123/ordersfor orders belonging to user 123. But avoid deep nesting — more than 2-3 levels suggests a design problem. -
Implement proper error responses: Include a machine-readable error code, a human-readable message, and optionally a details array for validation errors.
-
Support pagination for all collection endpoints: Never return unbounded lists. Default to a reasonable page size (20-50 items).
-
Use HATEOAS selectively: Full HATEOAS is expensive to implement and rarely used by clients. Consider adding links only for state-dependent actions.
-
Version your API: Use URI versioning (
/v1/) for simplicity, or content negotiation for purity. Plan for breaking changes from day one.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using verbs in URIs | Not RESTful, confusing | Use nouns + HTTP methods |
| Ignoring status codes | Clients can't handle errors programmatically | Map outcomes to proper HTTP status codes |
| No pagination | Memory exhaustion, slow responses | Always paginate collection endpoints |
| Chatty APIs | Too many round trips | Support embedded resources, batch operations |
| Breaking changes without versioning | Client applications break | Version your API from the start |
| Inconsistent naming | Developer confusion | Establish and follow naming conventions |
Comparison with Alternatives
| Feature | REST (Level 2) | REST (Level 3) | GraphQL | gRPC |
|---|---|---|---|---|
| Discoverability | Low | High | Medium (introspection) | Low |
| Caching | HTTP caching (excellent) | HTTP caching (excellent) | Complex | Limited |
| Over-fetching | Common | Common | Eliminated | Eliminated |
| Tooling | Excellent | Good | Good | Excellent |
| Learning curve | Low | Medium | Medium | High |
| Browser support | Native | Native | Native | Requires proxy |
| Streaming | Limited | Limited | Subscriptions | Native |
Advanced Patterns
API Gateway Pattern
// Gateway routes to microservices based on resource
const routes: Record<string, string> = {
'/users': 'http://user-service:3001',
'/orders': 'http://order-service:3002',
'/products': 'http://product-service:3003',
};
app.use('/api/*', async (req: Request, res: Response) => {
const resource = Object.keys(routes).find(r => req.path.startsWith(r));
if (!resource) return res.status(404).json({ error: 'Not found' });
const target = routes[resource];
const response = await fetch(`${target}${req.path}`, {
method: req.method,
headers: { 'Content-Type': 'application/json' },
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status).json(await response.json());
});Conditional Requests (ETags)
app.get('/users/:id', (req: Request, res: Response) => {
const user = users.get(parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'Not found' });
const etag = `"${hashObject(user)}"`;
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).send();
}
res.json({ data: user });
});
app.put('/users/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const user = users.get(id);
if (!user) return res.status(404).json({ error: 'Not found' });
const etag = `"${hashObject(user)}"`;
if (req.headers['if-match'] && req.headers['if-match'] !== etag) {
return res.status(412).json({ error: 'Precondition failed' });
}
// Update user...
res.json({ data: user });
});Testing Strategies
describe('REST API', () => {
it('should return 201 with Location header on creation', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' });
expect(res.status).toBe(201);
expect(res.headers.location).toMatch(/^\/users\/\d+$/);
expect(res.body.data.name).toBe('Alice');
});
it('should return proper status codes for each operation', async () => {
const create = await request(app).post('/users').send({ name: 'Bob' });
const id = create.body.data.id;
const get = await request(app).get(`/users/${id}`);
expect(get.status).toBe(200);
const del = await request(app).delete(`/users/${id}`);
expect(del.status).toBe(204);
const getDeleted = await request(app).get(`/users/${id}`);
expect(getDeleted.status).toBe(404);
});
it('should include hypermedia links at Level 3', async () => {
const res = await request(app).get('/users/1');
expect(res.body.data._links).toBeDefined();
expect(res.body.data._links.self).toBeDefined();
});
});Future Outlook
REST remains the dominant API paradigm for web services, but it's evolving. The JSON:API specification standardizes Level 2+ conventions including pagination, filtering, and compound documents. OpenAPI 3.1 brings full JSON Schema compatibility for better documentation and code generation.
HTTP/3 (QUIC-based) improves REST performance with multiplexed connections and reduced latency. The RFC 9457 standard for HTTP Problem Details provides a standardized error response format.
For new projects, the choice between REST, GraphQL, and gRPC depends on your needs. REST excels when you need caching, broad tooling support, and a simple mental model. GraphQL wins for complex data requirements with many relationships. gRPC dominates for internal service-to-service communication where performance matters.
Conclusion
The Richardson Maturity Model provides a lens for evaluating REST API quality that goes beyond "uses HTTP and returns JSON." Understanding the four levels — from the Swamp of POX to full HATEOAS — helps you make conscious design decisions about where your API should sit.
Key takeaways:
- Level 2 is the pragmatic sweet spot — proper HTTP methods, status codes, and resource URIs
- HATEOAS (Level 3) adds discoverability — but at implementation and maintenance cost
- Use HTTP methods semantically — GET for reads, POST for creation, PUT for replacement, DELETE for removal
- Return proper status codes — they enable programmatic error handling by clients
- Paginate all collections — never return unbounded lists
- Version your API from day one — breaking changes are inevitable
- Design for the consumer — the best API is one that's easy to use correctly
Master the Richardson Maturity Model, and you'll design APIs that are consistent, evolvable, and a pleasure to integrate with. Whether you aim for Level 2 pragmatism or Level 3 purity, understanding the levels ensures your choice is deliberate, not accidental.