Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

REST API Design

Designing APIs that developers love to use

REST (Representational State Transfer) is an architectural style for designing web APIs. Think of it as a set of rules for how systems should communicate over HTTP.

Diagram
  1. Stateless - Each request contains all information needed
  2. Resource-Based - Everything is a resource (noun)
  3. HTTP Methods - Use standard HTTP verbs (GET, POST, etc.)
  4. Uniform Interface - Consistent way to interact with resources
  5. Cacheable - Responses can be cached
  6. Client-Server - Separation of concerns

Resources are the core of REST. A resource is anything that can be identified and manipulated.

Good resources are:

  • Nouns, not verbs (/users, not /getUsers)
  • Hierarchical (/users/123/orders)
  • Plural for collections (/users, not /user)
  • Consistent naming conventions
Diagram
✅ Good❌ BadWhy?
/users/getUsersVerbs in URL
/users/123/user/123Inconsistent plural
/users/123/orders/orders?userId=123Better hierarchy
/products/456/product?id=456Resource in path, not query

HTTP methods define what action to perform on a resource.

OperationHTTP MethodIdempotent?Safe?Use Case
CreatePOST❌ No❌ NoCreate new resource
ReadGET✅ Yes✅ YesRetrieve resource
Update (Full)PUT✅ Yes❌ NoReplace entire resource
Update (Partial)PATCH❌ No*❌ NoUpdate part of resource
DeleteDELETE✅ Yes❌ NoRemove resource

*PATCH can be idempotent if designed correctly

GET is for reading data. It’s safe (no side effects) and idempotent.

GET /users/123
GET /users?status=active&page=1
GET /users/123/orders

Characteristics:

  • ✅ No request body (usually)
  • ✅ Can be cached
  • ✅ Should not modify data
  • ✅ Idempotent (safe to retry)

POST is for creating new resources. It’s not idempotent (calling twice creates two resources).

POST /users
Content-Type: application/json
{
"name": "John Doe",
"email": "[email protected]"
}

Characteristics:

  • ❌ Not idempotent (creates new resource each time)
  • ❌ Not safe (modifies server state)
  • ✅ Returns 201 Created with Location header
  • ✅ Request body contains data

Response:

HTTP/1.1 201 Created
Location: /users/456
Content-Type: application/json
{
"id": 456,
"name": "John Doe",
"email": "[email protected]"
}

PUT replaces an entire resource. It’s idempotent (calling twice has same effect as once).

PUT /users/123
Content-Type: application/json
{
"name": "Jane Doe",
"email": "[email protected]",
"status": "active"
}

Characteristics:

  • ✅ Idempotent (same result if called multiple times)
  • ✅ Creates resource if it doesn’t exist (some APIs)
  • ✅ Replaces entire resource
  • ✅ Use when you have full resource data

PATCH updates part of a resource. Should be idempotent if designed correctly.

PATCH /users/123
Content-Type: application/json
{
"name": "Jane Doe"
}

Characteristics:

  • ⚠️ Can be idempotent (should be designed that way)
  • ✅ Only updates specified fields
  • ✅ More efficient than PUT (less data)
  • ✅ Use when you have partial data

DELETE removes a resource. It’s idempotent (deleting twice = same as once).

DELETE /users/123

Characteristics:

  • ✅ Idempotent (deleting non-existent = same result)
  • ✅ Usually returns 204 No Content
  • ✅ Can return 404 if already deleted (still idempotent)

Status codes communicate the result of the request. Use them correctly!

CodeMeaningUse Case
200 OKRequest succeededGET, PUT, PATCH
201 CreatedResource createdPOST (with Location header)
204 No ContentSuccess, no bodyDELETE, PUT (sometimes)
CodeMeaningUse Case
400 Bad RequestInvalid requestMalformed JSON, missing fields
401 UnauthorizedNot authenticatedMissing/invalid token
403 ForbiddenNot authorizedValid token, but no permission
404 Not FoundResource doesn’t existInvalid ID, wrong URL
409 ConflictResource conflictDuplicate email, version conflict
429 Too Many RequestsRate limitedToo many requests
CodeMeaningUse Case
500 Internal Server ErrorServer errorUnexpected exception
502 Bad GatewayUpstream errorDownstream service failed
503 Service UnavailableService downMaintenance, overloaded

✅ Do:

  • Use 201 for successful creation
  • Use 204 for successful deletion
  • Use 400 for client errors (bad input)
  • Use 404 for not found
  • Use 409 for conflicts

❌ Don’t:

  • Return 200 for errors (use 4xx/5xx)
  • Return 500 for client errors (use 4xx)
  • Return 200 for creation (use 201)

Versioning allows you to evolve your API without breaking existing clients.

Version in the URL path:

/api/v1/users
/api/v2/users

Pros:

  • ✅ Explicit and clear
  • ✅ Easy to route
  • ✅ Cacheable
  • ✅ Most common approach

Cons:

  • ❌ URLs change
  • ❌ More maintenance

Version in HTTP headers:

GET /users
Accept: application/vnd.api+json;version=1

Pros:

  • ✅ Clean URLs
  • ✅ No URL changes

Cons:

  • ❌ Less discoverable
  • ❌ Harder to cache
  • ❌ More complex

Version as query parameter:

/api/users?version=1

Pros:

  • ✅ Simple
  • ✅ Optional

Cons:

  • ❌ Easy to forget
  • ❌ Not RESTful (version isn’t a resource property)

❌ Bad:

POST /users/123/delete
GET /users/create?name=John
POST /users/123/update

✅ Good:

DELETE /users/123
POST /users (with body)
PUT /users/123 (with body)

❌ Bad:

/getUsers
/createUser
/updateUser
/deleteUser

✅ Good:

GET /users
POST /users
PUT /users/123
DELETE /users/123

❌ Bad:

/user
/order

✅ Good:

/users
/orders

❌ Bad:

/orders?userId=123

✅ Good:

/users/123/orders

❌ Bad:

/users
/customers
/clients

✅ Good:

/users (consistent across API)

❌ Bad:

// Always returns 200, even for errors
{
"success": false,
"error": "User not found"
}

✅ Good:

HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": "User not found",
"code": "USER_NOT_FOUND"
}

❌ Bad:

GET /users // Returns 10,000 users

✅ Good:

GET /users?page=1&limit=20
GET /users?offset=0&limit=20
GET /users?cursor=abc123&limit=20

Response:

{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 1000,
"hasNext": true
}
}
GET /users?status=active&role=admin&sort=name&order=asc
GET /users?search=john&limit=10

9. Use HATEOAS (Hypermedia as the Engine of Application State)

Section titled “9. Use HATEOAS (Hypermedia as the Engine of Application State)”

Include links to related resources:

{
"id": 123,
"name": "John Doe",
"links": {
"self": "/users/123",
"orders": "/users/123/orders",
"profile": "/users/123/profile"
}
}

At the code level, REST APIs translate to controllers, services, and DTOs.

user_controller.py
from flask import Flask, request, jsonify
from typing import Optional
app = Flask(__name__)
class UserController:
def __init__(self, user_service):
self.user_service = user_service
def get_user(self, user_id: int):
"""GET /users/:id"""
user = self.user_service.get_user(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user), 200
def create_user(self):
"""POST /users"""
data = request.get_json()
# Validate input
if not data or 'email' not in data:
return jsonify({"error": "Email required"}), 400
user = self.user_service.create_user(data)
return jsonify(user), 201, {'Location': f'/users/{user["id"]}'}
def update_user(self, user_id: int):
"""PUT /users/:id"""
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400
user = self.user_service.update_user(user_id, data)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user), 200
def delete_user(self, user_id: int):
"""DELETE /users/:id"""
success = self.user_service.delete_user(user_id)
if not success:
return jsonify({"error": "User not found"}), 404
return '', 204
# Routes
@app.route('/users/<int:user_id>', methods=['GET', 'PUT', 'DELETE'])
def user_detail(user_id):
controller = UserController(user_service)
if request.method == 'GET':
return controller.get_user(user_id)
elif request.method == 'PUT':
return controller.update_user(user_id)
elif request.method == 'DELETE':
return controller.delete_user(user_id)
@app.route('/users', methods=['POST'])
def user_create():
controller = UserController(user_service)
return controller.create_user()
user_service.py
from typing import Optional, Dict, Any
class UserService:
def __init__(self, user_repository):
self.user_repository = user_repository
def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Business logic for getting user"""
user = self.user_repository.find_by_id(user_id)
if not user:
return None
# Transform to DTO
return {
"id": user.id,
"name": user.name,
"email": user.email,
"status": user.status
}
def create_user(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Business logic for creating user"""
# Validate business rules
if self.user_repository.find_by_email(data['email']):
raise ValueError("Email already exists")
# Create user
user = self.user_repository.create(data)
return {
"id": user.id,
"name": user.name,
"email": user.email,
"status": user.status
}
def update_user(self, user_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Business logic for updating user"""
user = self.user_repository.find_by_id(user_id)
if not user:
return None
# Update user
updated_user = self.user_repository.update(user_id, data)
return {
"id": updated_user.id,
"name": updated_user.name,
"email": updated_user.email,
"status": updated_user.status
}
def delete_user(self, user_id: int) -> bool:
"""Business logic for deleting user"""
user = self.user_repository.find_by_id(user_id)
if not user:
return False
self.user_repository.delete(user_id)
return True
error_handler.py
from flask import jsonify
from werkzeug.exceptions import HTTPException
class APIError(Exception):
def __init__(self, message, status_code=400):
self.message = message
self.status_code = status_code
@app.errorhandler(APIError)
def handle_api_error(error):
return jsonify({
"error": error.message,
"status": error.status_code
}), error.status_code
@app.errorhandler(404)
def handle_not_found(error):
return jsonify({
"error": "Resource not found",
"status": 404
}), 404
@app.errorhandler(500)
def handle_server_error(error):
return jsonify({
"error": "Internal server error",
"status": 500
}), 500

Make POST requests idempotent using idempotency keys:

POST /orders
Idempotency-Key: abc123-xyz789
Content-Type: application/json
{
"productId": 456,
"quantity": 2
}

Server behavior:

  • First request: Create order, store idempotency key
  • Duplicate request: Return same order (don’t create new one)

Support multiple formats:

GET /users/123
Accept: application/json
GET /users/123
Accept: application/xml

Let clients choose fields:

GET /users/123?fields=id,name,email

🎯 Resources are Nouns

Use nouns for resources, HTTP methods for actions. /users with GET, not /getUsers.

📊 Proper Status Codes

Use appropriate status codes. 201 for creation, 404 for not found, 400 for bad requests.

🔄 Idempotency Matters

GET, PUT, DELETE are idempotent. Design POST/PATCH to be idempotent when possible.

🏗️ Layered Architecture

Controllers handle HTTP, Services handle business logic, Repositories handle data access.