RESTful API Principles

Understanding the architectural style that powers modern web APIs

← Back to Index

What is REST?

REST (Representational State Transfer) is an architectural style for designing networked applications, particularly web services. It was defined by Roy Fielding in his 2000 doctoral dissertation and has since become the dominant approach for building web APIs.

It's important to understand that REST is not a protocol, standard, or technology—it's a set of architectural constraints and principles. When an API follows these principles, we call it "RESTful." The beauty of REST is that it leverages the existing infrastructure of the web (HTTP) rather than creating something new.

Why REST Became Dominant

Before REST became popular, web services were primarily built using SOAP (Simple Object Access Protocol), which was complex, verbose, and required specialized tooling. REST gained popularity because:

The Core Philosophy: Resources and Representations

The fundamental concept in REST is the resource. A resource is any piece of information that can be named and addressed—users, products, orders, blog posts, comments, etc. Each resource:

Key Insight: REST treats everything as a resource that can be accessed and manipulated using standard HTTP methods. Think of resources as nouns (users, products, orders) and HTTP methods as verbs (get, create, update, delete).

  • Resource: The "thing" being accessed — /users/123
  • Representation: The format of the data — JSON, XML, etc.
  • State Transfer: Passing resource state between client and server
// The REST mental model:
// Resources are NOUNS, HTTP methods are VERBS

Resource: /users          ← The collection of all users
Resource: /users/123      ← A specific user (user with ID 123)
Resource: /users/123/orders  ← Orders belonging to user 123

// Actions on resources use HTTP methods:
GET    /users/123   ← "Get me user 123"
POST   /users       ← "Create a new user"
PUT    /users/123   ← "Replace user 123 with this data"
PATCH  /users/123   ← "Update these specific fields of user 123"
DELETE /users/123   ← "Delete user 123"

REST vs SOAP

Aspect REST SOAP
Protocol Uses HTTP directly Protocol-agnostic (usually HTTP)
Data Format JSON, XML, HTML, plain text XML only
Complexity Lightweight, simple Heavy, complex
Caching Built-in HTTP caching No native caching
State Stateless Can be stateful
Use Case Web APIs, mobile apps Enterprise, banking

The 6 REST Constraints

1. Client-Server Separation

// Client and server are independent
// Client doesn't care HOW data is stored
// Server doesn't care HOW data is displayed

// Client (Frontend)
fetch('/api/users/123')
    .then(response => response.json())
    .then(user => displayUserProfile(user));

// Server (Backend) - Could be Java, Python, Node.js...
@GetMapping("/api/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id);
}

2. Statelessness

// BAD: Server stores client state (NOT RESTful)
@GetMapping("/api/next-page")
public List<Item> getNextPage() {
    // Server remembers which page client is on - NOT STATELESS
    int currentPage = session.getAttribute("currentPage");
    return itemService.getPage(currentPage + 1);
}

// GOOD: Client sends all needed information (RESTful)
@GetMapping("/api/items")
public List<Item> getItems(
        @RequestParam int page,
        @RequestParam int size) {
    // Each request contains everything needed
    return itemService.getPage(page, size);
}

// Client includes authentication in EVERY request
GET /api/items?page=2&size=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Why Stateless?
  • Scalability: Any server can handle any request
  • Reliability: Server failure doesn't lose client state
  • Simplicity: No session synchronization needed

3. Cacheability

// Responses must define themselves as cacheable or not

@GetMapping("/api/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
    Product product = productService.findById(id);

    return ResponseEntity.ok()
        // Cache for 1 hour
        .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
        // ETag for validation
        .eTag(String.valueOf(product.getVersion()))
        .body(product);
}

// HTTP Response Headers:
// Cache-Control: max-age=3600
// ETag: "v5"

// For data that changes frequently:
@GetMapping("/api/stock-prices")
public ResponseEntity<List<Price>> getPrices() {
    return ResponseEntity.ok()
        .cacheControl(CacheControl.noCache()) // Don't cache
        .body(priceService.getCurrentPrices());
}

4. Uniform Interface

// The most fundamental constraint - consistent API design

// a) Resource Identification via URIs
GET  /api/users/123          // User with ID 123
GET  /api/users/123/orders   // Orders for user 123
GET  /api/orders/456         // Order with ID 456

// b) Manipulation through Representations
// Client receives JSON representation, modifies it, sends back
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"  // Client changes this
}

// c) Self-Descriptive Messages
// Request tells server everything it needs
PUT /api/users/123
Content-Type: application/json
Authorization: Bearer token123

{"name": "John Updated"}

// Response tells client everything it needs
HTTP/1.1 200 OK
Content-Type: application/json
Last-Modified: Wed, 22 Jan 2026 10:00:00 GMT

{"id": 123, "name": "John Updated"}

5. Layered System

// Client doesn't know (or care) about intermediate layers

Client
   ↓
[Load Balancer]     ← Client doesn't see this
   ↓
[API Gateway]       ← Or this
   ↓
[Cache Layer]       ← Or this
   ↓
[Application Server]
   ↓
[Database]

// Benefits:
// - Security: Hide internal architecture
// - Scalability: Add layers as needed
// - Flexibility: Change layers without client changes

6. Code on Demand (Optional)

// Server can send executable code to client
// This is the only OPTIONAL constraint

// Example: Server sends JavaScript to client
GET /api/validation-rules

// Response:
{
    "script": "function validate(email) { return email.includes('@'); }"
}

// Client executes the received code
// (Rarely used in practice due to security concerns)

RESTful URL Design

Resource Naming Conventions

// GOOD: Use nouns, plural form, lowercase
GET  /api/users              // Collection of users
GET  /api/users/123          // Single user
GET  /api/users/123/orders   // User's orders (sub-resource)
POST /api/users              // Create new user

// BAD: Verbs in URLs
GET  /api/getUsers           // ✗ Don't use verbs
POST /api/createUser         // ✗ HTTP method is the verb
GET  /api/deleteUser/123     // ✗ Use DELETE method instead

// BAD: Inconsistent naming
GET  /api/user               // ✗ Use plural (users)
GET  /api/User/123           // ✗ Use lowercase
GET  /api/user_orders        // ✗ Use hyphens or nested resources

HTTP Methods (CRUD Operations)

Method CRUD URL Example Description
GET Read /api/users Get all users
GET Read /api/users/123 Get user 123
POST Create /api/users Create new user
PUT Update /api/users/123 Replace user 123
PATCH Update /api/users/123 Partial update user 123
DELETE Delete /api/users/123 Delete user 123

Complete REST Controller Example

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    // GET /api/users - Get all users
    @GetMapping
    public ResponseEntity<List<User>> getAllUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {

        List<User> users = userService.findAll(page, size);
        return ResponseEntity.ok(users);
    }

    // GET /api/users/123 - Get single user
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/users - Create new user
    @PostMapping
    public ResponseEntity<User> createUser(
            @Valid @RequestBody CreateUserRequest request) {

        User created = userService.create(request);

        URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.getId())
            .toUri();

        // Return 201 Created with Location header
        return ResponseEntity.created(location).body(created);
    }

    // PUT /api/users/123 - Replace entire user
    @PutMapping("/{id}")
    public ResponseEntity<User> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request) {

        return userService.update(id, request)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // PATCH /api/users/123 - Partial update
    @PatchMapping("/{id}")
    public ResponseEntity<User> patchUser(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {

        return userService.patch(id, updates)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/users/123 - Delete user
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        if (userService.delete(id)) {
            return ResponseEntity.noContent().build(); // 204 No Content
        }
        return ResponseEntity.notFound().build(); // 404 Not Found
    }

    // GET /api/users/123/orders - Sub-resource
    @GetMapping("/{id}/orders")
    public ResponseEntity<List<Order>> getUserOrders(
            @PathVariable Long id) {

        return ResponseEntity.ok(orderService.findByUserId(id));
    }
}

HTTP Status Codes

Code Name When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST (resource created)
204 No Content Successful DELETE (no body)
400 Bad Request Invalid request data
401 Unauthorized Authentication required
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
409 Conflict Resource state conflict
422 Unprocessable Entity Validation errors
500 Internal Server Error Server-side error
// Proper status code usage
@PostMapping("/api/users")
public ResponseEntity<?> createUser(@RequestBody UserDTO dto) {

    // 400 Bad Request - malformed request
    if (dto == null) {
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("Request body is required"));
    }

    // 409 Conflict - business rule violation
    if (userService.existsByEmail(dto.getEmail())) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorResponse("Email already registered"));
    }

    // 422 Unprocessable Entity - validation failed
    List<String> errors = validate(dto);
    if (!errors.isEmpty()) {
        return ResponseEntity.unprocessableEntity()
            .body(new ValidationErrorResponse(errors));
    }

    // 201 Created - success
    User created = userService.create(dto);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

HATEOAS (Hypermedia)

HATEOAS (Hypermedia As The Engine Of Application State) - responses include links to related actions.

// Response without HATEOAS
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"
}

// Response WITH HATEOAS - client discovers available actions
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com",
    "_links": {
        "self": { "href": "/api/users/123" },
        "orders": { "href": "/api/users/123/orders" },
        "delete": { "href": "/api/users/123", "method": "DELETE" },
        "update": { "href": "/api/users/123", "method": "PUT" }
    }
}

Spring HATEOAS Example

@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
    User user = userService.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));

    return EntityModel.of(user,
        linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
        linkTo(methodOn(UserController.class).getUserOrders(id)).withRel("orders"),
        linkTo(methodOn(UserController.class).getAllUsers(0, 10)).withRel("users")
    );
}

API Versioning Strategies

// 1. URL Path Versioning (Most Common)
GET /api/v1/users
GET /api/v2/users

@RequestMapping("/api/v1/users")
public class UserControllerV1 { }

@RequestMapping("/api/v2/users")
public class UserControllerV2 { }


// 2. Query Parameter Versioning
GET /api/users?version=1
GET /api/users?version=2


// 3. Header Versioning
GET /api/users
Accept: application/vnd.myapi.v1+json

@GetMapping(produces = "application/vnd.myapi.v1+json")
public List<UserV1> getUsersV1() { }

@GetMapping(produces = "application/vnd.myapi.v2+json")
public List<UserV2> getUsersV2() { }


// 4. Custom Header Versioning
GET /api/users
X-API-Version: 2
Strategy Pros Cons
URL Path Simple, visible, cacheable URL pollution
Query Param Optional, easy to default Easy to forget
Header Clean URLs Hidden, harder to test

Error Response Design

// Consistent error response format
public class ApiError {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> fieldErrors; // For validation errors
}

// Example error response
{
    "timestamp": "2026-01-23T10:30:00",
    "status": 400,
    "error": "Bad Request",
    "message": "Validation failed",
    "path": "/api/users",
    "fieldErrors": [
        {
            "field": "email",
            "message": "must be a valid email address"
        },
        {
            "field": "age",
            "message": "must be at least 18"
        }
    ]
}

// Global exception handler
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ApiError error = new ApiError(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            "Not Found",
            ex.getMessage(),
            request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        List<FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
            .toList();

        ApiError error = new ApiError(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            "Validation Failed",
            "Request validation failed",
            request.getRequestURI(),
            fieldErrors
        );
        return ResponseEntity.badRequest().body(error);
    }
}

Best Practices

DO:

  • Use nouns for resources: /users, /orders, /products
  • Use HTTP methods correctly: GET for read, POST for create, etc.
  • Return appropriate status codes: 201 for created, 404 for not found
  • Use plural resource names: /users not /user
  • Support pagination: ?page=1&size=20
  • Support filtering: ?status=active&role=admin
  • Version your API: /api/v1/users
  • Use consistent error format: Same structure for all errors

DON'T:

  • Don't use verbs in URLs: /getUsers, /createUser
  • Don't ignore status codes: Return 200 for everything
  • Don't store state on server: Keep it stateless
  • Don't expose internal IDs: Use public identifiers when needed
  • Don't return stack traces: Log them, return friendly messages
  • Don't ignore security: Always use HTTPS, validate input

Summary

  • REST: Architectural style based on resources and HTTP
  • Resources: Identified by URIs, manipulated via HTTP methods
  • Stateless: Each request contains all information needed
  • HTTP Methods: GET (read), POST (create), PUT (replace), PATCH (update), DELETE
  • Status Codes: Use appropriate codes (200, 201, 400, 404, etc.)
  • HATEOAS: Responses include links to related actions
  • Versioning: Plan for API evolution from day one