MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding REST: Richardson Maturity Model

Evaluate REST APIs: maturity levels, HATEOAS, and API design evolution.

RESTAPIArchitectureBackend

By MinhVo

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.

REST API Architecture

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:

  1. Client-Server: Separation of concerns between UI and data storage
  2. Stateless: Each request contains all information needed to process it
  3. Cacheable: Responses must define themselves as cacheable or not
  4. Uniform Interface: A standardized way to interact with resources
  5. Layered System: Client can't tell if it's connected directly to the server
  6. 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:

MethodPurposeIdempotentSafe
GETRetrieve a resourceYesYes
PUTReplace a resourceYesNo
PATCHPartially updateNo*No
DELETERemove a resourceYesNo
POSTCreate or processNoNo
// 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/123

REST API Design

Architecture 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));
    },
  });
});

HATEOAS Navigation

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

  1. Use nouns for resource URIs, not verbs: /users not /getUsers. The HTTP method conveys the action.

  2. Return appropriate status codes: 201 Created for successful creation, 204 No Content for successful deletion, 404 Not Found for missing resources, 422 Unprocessable Entity for validation errors.

  3. Use plural nouns for collections: /users, /orders, /products. Use singular only when the resource is truly singular (e.g., /me).

  4. Nest resources to express relationships: /users/123/orders for orders belonging to user 123. But avoid deep nesting — more than 2-3 levels suggests a design problem.

  5. Implement proper error responses: Include a machine-readable error code, a human-readable message, and optionally a details array for validation errors.

  6. Support pagination for all collection endpoints: Never return unbounded lists. Default to a reasonable page size (20-50 items).

  7. Use HATEOAS selectively: Full HATEOAS is expensive to implement and rarely used by clients. Consider adding links only for state-dependent actions.

  8. 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

PitfallImpactSolution
Using verbs in URIsNot RESTful, confusingUse nouns + HTTP methods
Ignoring status codesClients can't handle errors programmaticallyMap outcomes to proper HTTP status codes
No paginationMemory exhaustion, slow responsesAlways paginate collection endpoints
Chatty APIsToo many round tripsSupport embedded resources, batch operations
Breaking changes without versioningClient applications breakVersion your API from the start
Inconsistent namingDeveloper confusionEstablish and follow naming conventions

Comparison with Alternatives

FeatureREST (Level 2)REST (Level 3)GraphQLgRPC
DiscoverabilityLowHighMedium (introspection)Low
CachingHTTP caching (excellent)HTTP caching (excellent)ComplexLimited
Over-fetchingCommonCommonEliminatedEliminated
ToolingExcellentGoodGoodExcellent
Learning curveLowMediumMediumHigh
Browser supportNativeNativeNativeRequires proxy
StreamingLimitedLimitedSubscriptionsNative

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:

  1. Level 2 is the pragmatic sweet spot — proper HTTP methods, status codes, and resource URIs
  2. HATEOAS (Level 3) adds discoverability — but at implementation and maintenance cost
  3. Use HTTP methods semantically — GET for reads, POST for creation, PUT for replacement, DELETE for removal
  4. Return proper status codes — they enable programmatic error handling by clients
  5. Paginate all collections — never return unbounded lists
  6. Version your API from day one — breaking changes are inevitable
  7. 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.