Resources are Nouns
Use nouns for resources, HTTP methods for actions. /users with GET, not /getUsers.
REST (Representational State Transfer) is an architectural style for designing web APIs that was introduced by Roy Fielding in his 2000 doctoral dissertation. REST provides a set of constraints and principles that, when followed, create APIs that are scalable, maintainable, and easy to understand.
REST is built on the idea that web APIs should work like the web itself—using standard HTTP methods to manipulate resources identified by URLs. Instead of creating custom protocols or action-based endpoints, REST uses the existing HTTP infrastructure in a standardized way.
Key Insight: REST treats everything as a resource—a noun that can be identified by a URL. Actions are performed using standard HTTP methods (verbs). This separation of concerns makes APIs predictable and intuitive.
Resources are the core of REST. A resource is anything that can be identified and manipulated.
Good resources are:
/users, not /getUsers) - Resources represent entities, not actions/users/123/orders) - Reflect relationships between resources/users, not /user) - Collections are plural, individual resources are identified by ID| Good | Bad | Why? |
|---|---|---|
/users | /getUsers | Verbs in URL violate REST principles. Use GET method instead. |
/users/123 | /user/123 | Inconsistent plural. Collections should be plural for clarity. |
/users/123/orders | /orders?userId=123 | Better hierarchy. Shows relationship between user and orders. |
/products/456 | /product?id=456 | Resource identifier should be in path, not query parameter. |
HTTP methods define what action to perform on a resource.
| Operation | HTTP Method | Idempotent? | Safe? | Use Case |
|---|---|---|---|---|
| Create | POST | No | No | Create new resource |
| Read | GET | Yes | Yes | Retrieve resource |
| Update (Full) | PUT | Yes | No | Replace entire resource |
| Update (Partial) | PATCH | No* | No | Update part of resource |
| Delete | DELETE | Yes | No | Remove resource |
*PATCH can be idempotent if designed correctly
GET is for reading data. It’s safe (no side effects) and idempotent.
GET /users/123GET /users?status=active&page=1GET /users/123/ordersCharacteristics:
POST is for creating new resources. It’s not idempotent (calling twice creates two resources).
POST /usersContent-Type: application/json
{ "name": "John Doe",}Characteristics:
Response:
HTTP/1.1 201 CreatedLocation: /users/456Content-Type: application/json
{ "id": 456, "name": "John Doe",}PUT replaces an entire resource. It’s idempotent (calling twice has same effect as once).
PUT /users/123Content-Type: application/json
{ "name": "Jane Doe", "status": "active"}Characteristics:
PATCH updates part of a resource. Should be idempotent if designed correctly.
PATCH /users/123Content-Type: application/json
{ "name": "Jane Doe"}Characteristics:
DELETE removes a resource. It’s idempotent (deleting twice = same as once).
DELETE /users/123Characteristics:
Status codes communicate the result of the request. Use them correctly!
| Code | Meaning | Use Case |
|---|---|---|
| 200 OK | Request succeeded | GET, PUT, PATCH |
| 201 Created | Resource created | POST (with Location header) |
| 204 No Content | Success, no body | DELETE, PUT (sometimes) |
| Code | Meaning | Use Case |
|---|---|---|
| 400 Bad Request | Invalid request | Malformed JSON, missing fields |
| 401 Unauthorized | Not authenticated | Missing/invalid token |
| 403 Forbidden | Not authorized | Valid token, but no permission |
| 404 Not Found | Resource doesn’t exist | Invalid ID, wrong URL |
| 409 Conflict | Resource conflict | Duplicate email, version conflict |
| 429 Too Many Requests | Rate limited | Too many requests |
| Code | Meaning | Use Case |
|---|---|---|
| 500 Internal Server Error | Server error | Unexpected exception |
| 502 Bad Gateway | Upstream error | Downstream service failed |
| 503 Service Unavailable | Service down | Maintenance, overloaded |
Do:
Don’t:
Versioning allows you to evolve your API without breaking existing clients.
Version in the URL path:
/api/v1/users/api/v2/usersPros:
Cons:
Version in HTTP headers:
GET /usersAccept: application/vnd.api+json;version=1Pros:
Cons:
Version as query parameter:
/api/users?version=1Pros:
Cons:
Bad:
POST /users/123/deleteGET /users/create?name=JohnPOST /users/123/updateWhy it’s bad: Uses verbs in URLs and wrong HTTP methods. Actions should be expressed through HTTP methods, not URL paths.
Good:
DELETE /users/123POST /users (with body)PUT /users/123 (with body)Why it’s good: Uses standard HTTP methods correctly. DELETE for deletion, POST for creation, PUT for updates.
Bad:
/getUsers/createUser/updateUser/deleteUserWhy it’s bad: URLs contain verbs, violating REST principles. The HTTP method already indicates the action.
Good:
GET /usersPOST /usersPUT /users/123DELETE /users/123Why it’s good: URLs contain only nouns (resources). Actions are expressed through HTTP methods.
Bad:
/user/orderWhy it’s bad: Singular nouns for collections are inconsistent and confusing. Is /user a single user or collection?
Good:
/users/ordersWhy it’s good: Plural nouns clearly indicate collections. Individual resources are identified by ID: /users/123.
Bad:
/orders?userId=123Why it’s bad: Uses query parameters to express relationships. Less intuitive and doesn’t show resource hierarchy.
Good:
/users/123/ordersWhy it’s good: Hierarchical URL clearly shows that orders belong to a user. More intuitive and RESTful.
Bad:
/users/customers/clientsWhy it’s bad: Inconsistent naming for the same concept. Confusing for API consumers who must remember different terms.
Good:
/users (consistent across API)Why it’s good: Consistent naming throughout the API. Once developers learn the pattern, they can predict other endpoints.
Bad:
// Always returns 200, even for errors{ "success": false, "error": "User not found"}Why it’s bad: Always returning 200 makes it impossible to use HTTP status codes for error handling. Clients must parse response body to detect errors.
Good:
HTTP/1.1 404 Not FoundContent-Type: application/json
{ "error": "User not found", "code": "USER_NOT_FOUND"}Why it’s good: Uses appropriate HTTP status code (404) for not found. Clients can handle errors based on status codes. Response body provides additional context.
Bad:
GET /users // Returns 10,000 usersWhy it’s bad: Returns all resources at once, causing performance issues, high memory usage, and slow response times.
Good:
GET /users?page=1&limit=20GET /users?offset=0&limit=20GET /users?cursor=abc123&limit=20Why it’s good: Pagination limits response size, improves performance, and reduces memory usage. Supports different pagination strategies (page-based, offset-based, cursor-based).
Response:
{ "data": [...], "pagination": { "page": 1, "limit": 20, "total": 1000, "hasNext": true }}GET /users?status=active&role=admin&sort=name&order=ascGET /users?search=john&limit=10Include 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.
from flask import Flask, request, jsonifyfrom 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()import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/users")public class UserController { private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping("/{id}") public ResponseEntity<UserDTO> getUser(@PathVariable int id) { // GET /users/:id Optional<UserDTO> user = userService.getUser(id);
if (user.isEmpty()) { return ResponseEntity.notFound().build(); }
return ResponseEntity.ok(user.get()); }
@PostMapping public ResponseEntity<UserDTO> createUser(@RequestBody CreateUserRequest request) { // POST /users UserDTO user = userService.createUser(request);
return ResponseEntity .status(HttpStatus.CREATED) .header("Location", "/users/" + user.getId()) .body(user); }
@PutMapping("/{id}") public ResponseEntity<UserDTO> updateUser( @PathVariable int id, @RequestBody UpdateUserRequest request) { // PUT /users/:id Optional<UserDTO> user = userService.updateUser(id, request);
if (user.isEmpty()) { return ResponseEntity.notFound().build(); }
return ResponseEntity.ok(user.get()); }
@DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable int id) { // DELETE /users/:id boolean deleted = userService.deleteUser(id);
if (!deleted) { return ResponseEntity.notFound().build(); }
return ResponseEntity.noContent().build(); }}import { Request, Response } from 'express';
class UserController { // Controller layer (HTTP handling) constructor(private userService: UserService) {}
async getUser(req: Request, res: Response): Promise<void> { // GET /users/:id const userId = parseInt(req.params.id); const user = await this.userService.getUser(userId);
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
res.status(200).json(user); }
async createUser(req: Request, res: Response): Promise<void> { // POST /users const data = req.body;
// Validate input if (!data || !data.email) { res.status(400).json({ error: 'Email required' }); return; }
const user = await this.userService.createUser(data); res.status(201) .header('Location', `/users/${user.id}`) .json(user); }
async updateUser(req: Request, res: Response): Promise<void> { // PUT /users/:id const userId = parseInt(req.params.id); const data = req.body;
if (!data) { res.status(400).json({ error: 'Request body required' }); return; }
const user = await this.userService.updateUser(userId, data);
if (!user) { res.status(404).json({ error: 'User not found' }); return; }
res.status(200).json(user); }
async deleteUser(req: Request, res: Response): Promise<void> { // DELETE /users/:id const userId = parseInt(req.params.id); const success = await this.userService.deleteUser(userId);
if (!success) { res.status(404).json({ error: 'User not found' }); return; }
res.status(204).send(); }}
// Routesimport express from 'express';const router = express.Router();const controller = new UserController(userService);
router.get('/users/:id', (req, res) => controller.getUser(req, res));router.post('/users', (req, res) => controller.createUser(req, res));router.put('/users/:id', (req, res) => controller.updateUser(req, res));router.delete('/users/:id', (req, res) => controller.deleteUser(req, res));#include <cpprest/http_listener.h>#include <cpprest/json.h>#include <cpprest/uri.h>
class UserController { // Controller layer (HTTP handling)private: UserService& userService;
public: UserController(UserService& userService) : userService(userService) {}
void getUser(web::http::http_request request) { // GET /users/:id auto path = web::uri::decode(request.relative_uri().path()); int userId = extractUserId(path);
auto user = userService.getUser(userId);
if (!user.has_value()) { request.reply(web::http::status_codes::NotFound, json::value::object({ { "error", "User not found" } })); return; }
request.reply(web::http::status_codes::OK, user.value()); }
void createUser(web::http::http_request request) { // POST /users request.extract_json().then([this, request](json::value body) { if (!body.has_field("email")) { request.reply(web::http::status_codes::BadRequest, json::value::object({ { "error", "Email required" } })); return; }
auto user = userService.createUser(body);
web::http::http_response response(web::http::status_codes::Created); response.headers().add("Location", "/users/" + std::to_string(user.id)); response.set_body(user.toJson()); request.reply(response); }); }
void updateUser(web::http::http_request request) { // PUT /users/:id auto path = web::uri::decode(request.relative_uri().path()); int userId = extractUserId(path);
request.extract_json().then([this, request, userId](json::value body) { auto user = userService.updateUser(userId, body);
if (!user.has_value()) { request.reply(web::http::status_codes::NotFound, json::value::object({ { "error", "User not found" } })); return; }
request.reply(web::http::status_codes::OK, user.value().toJson()); }); }
void deleteUser(web::http::http_request request) { // DELETE /users/:id auto path = web::uri::decode(request.relative_uri().path()); int userId = extractUserId(path);
bool success = userService.deleteUser(userId);
if (!success) { request.reply(web::http::status_codes::NotFound, json::value::object({ { "error", "User not found" } })); return; }
request.reply(web::http::status_codes::NoContent); }
private: int extractUserId(const std::string& path) { // Extract user ID from path like "/users/123" // Simplified implementation return 0; }};using Microsoft.AspNetCore.Mvc;
[ApiController][Route("users")]public class UserController : ControllerBase { // Controller layer (HTTP handling) private readonly UserService userService;
public UserController(UserService userService) { this.userService = userService; }
[HttpGet("{id}")] public IActionResult GetUser(int id) { // GET /users/:id var user = userService.GetUser(id);
if (user == null) { return NotFound(new { error = "User not found" }); }
return Ok(user); }
[HttpPost] public IActionResult CreateUser([FromBody] CreateUserRequest request) { // POST /users if (request == null || string.IsNullOrEmpty(request.Email)) { return BadRequest(new { error = "Email required" }); }
var user = userService.CreateUser(request);
return CreatedAtAction( nameof(GetUser), new { id = user.Id }, user ); }
[HttpPut("{id}")] public IActionResult UpdateUser(int id, [FromBody] UpdateUserRequest request) { // PUT /users/:id if (request == null) { return BadRequest(new { error = "Request body required" }); }
var user = userService.UpdateUser(id, request);
if (user == null) { return NotFound(new { error = "User not found" }); }
return Ok(user); }
[HttpDelete("{id}")] public IActionResult DeleteUser(int id) { // DELETE /users/:id bool success = userService.DeleteUser(id);
if (!success) { return NotFound(new { error = "User not found" }); }
return NoContent(); }}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 Trueimport java.util.Optional;
@Servicepublic class UserService { private final UserRepository userRepository;
public UserService(UserRepository userRepository) { this.userRepository = userRepository; }
public Optional<UserDTO> getUser(int id) { // Business logic for getting user return userRepository.findById(id) .map(this::toDTO); }
public UserDTO createUser(CreateUserRequest request) { // Validate business rules if (userRepository.findByEmail(request.getEmail()).isPresent()) { throw new ConflictException("Email already exists"); }
// Create user User user = userRepository.save(toEntity(request)); return toDTO(user); }
public Optional<UserDTO> updateUser(int id, UpdateUserRequest request) { // Business logic for updating user return userRepository.findById(id) .map(user -> { user.update(request); return toDTO(userRepository.save(user)); }); }
public boolean deleteUser(int id) { // Business logic for deleting user if (!userRepository.existsById(id)) { return false; }
userRepository.deleteById(id); return true; }
private UserDTO toDTO(User user) { return new UserDTO( user.getId(), user.getName(), user.getEmail(), user.getStatus() ); }}from flask import jsonifyfrom 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 }), 500import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvicepublic class ErrorHandler {
@ExceptionHandler(NotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) { return ResponseEntity .status(404) .body(new ErrorResponse("Resource not found", 404)); }
@ExceptionHandler(BadRequestException.class) public ResponseEntity<ErrorResponse> handleBadRequest(BadRequestException e) { return ResponseEntity .status(400) .body(new ErrorResponse(e.getMessage(), 400)); }
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGeneric(Exception e) { return ResponseEntity .status(500) .body(new ErrorResponse("Internal server error", 500)); }}import { Request, Response, NextFunction } from 'express';
export class APIError extends Error { statusCode: number;
constructor(message: string, statusCode: number = 400) { super(message); this.statusCode = statusCode; this.name = 'APIError'; }}
export function errorHandler( err: Error, req: Request, res: Response, next: NextFunction): void { if (err instanceof APIError) { res.status(err.statusCode).json({ error: err.message, status: err.statusCode }); return; }
// Handle 404 if (err.name === 'NotFoundError') { res.status(404).json({ error: 'Resource not found', status: 404 }); return; }
// Handle 500 res.status(500).json({ error: 'Internal server error', status: 500 });}
// Usage in Express appimport express from 'express';const app = express();
app.use(errorHandler);#include <cpprest/http_listener.h>#include <cpprest/json.h>#include <exception>
class APIError : public std::exception {private: std::string message; int statusCode;
public: APIError(const std::string& message, int statusCode = 400) : message(message), statusCode(statusCode) {}
const char* what() const noexcept override { return message.c_str(); }
int getStatusCode() const { return statusCode; }};
void handleError(web::http::http_request request, const std::exception& e) { // Handle API errors const APIError* apiError = dynamic_cast<const APIError*>(&e);
if (apiError) { json::value errorResponse = json::value::object({ { "error", json::value::string(apiError->what()) }, { "status", json::value::number(apiError->getStatusCode()) } });
request.reply( static_cast<web::http::status_code>(apiError->getStatusCode()), errorResponse ); return; }
// Handle generic errors json::value errorResponse = json::value::object({ { "error", json::value::string("Internal server error") }, { "status", json::value::number(500) } });
request.reply(web::http::status_codes::InternalError, errorResponse);}using Microsoft.AspNetCore.Mvc;
public class APIError : Exception { public int StatusCode { get; }
public APIError(string message, int statusCode = 400) : base(message) { StatusCode = statusCode; }}
[ApiController]public class ErrorHandler : ControllerBase { [ExceptionHandler(typeof(APIError))] public IActionResult HandleAPIError(APIError error) { return StatusCode(error.StatusCode, new { error = error.Message, status = error.StatusCode }); }
[ExceptionHandler(typeof(NotFoundException))] public IActionResult HandleNotFound(NotFoundException error) { return NotFound(new { error = "Resource not found", status = 404 }); }
[ExceptionHandler] public IActionResult HandleGeneric(Exception error) { return StatusCode(500, new { error = "Internal server error", status = 500 }); }}
// Or use middleware approachpublic class ErrorHandlingMiddleware { private readonly RequestDelegate next;
public ErrorHandlingMiddleware(RequestDelegate next) { this.next = next; }
public async Task InvokeAsync(HttpContext context) { try { await next(context); } catch (APIError ex) { context.Response.StatusCode = ex.StatusCode; await context.Response.WriteAsJsonAsync(new { error = ex.Message, status = ex.StatusCode }); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsJsonAsync(new { error = "Internal server error", status = 500 }); } }}Make POST requests idempotent using idempotency keys:
POST /ordersIdempotency-Key: abc123-xyz789Content-Type: application/json
{ "productId": 456, "quantity": 2}Server behavior:
Support multiple formats:
GET /users/123Accept: application/json
GET /users/123Accept: application/xmlLet clients choose fields:
GET /users/123?fields=id,name,emailResources 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.