Skip to content
Home / Skills / Api Design / REST Principles
AP

REST Principles

Api Design core v1.0.0

REST API Principles

Overview

RESTful APIs follow architectural constraints for scalability, simplicity, and uniformity. This skill covers resource design, HTTP method semantics, HATEOAS, and the Richardson Maturity Model.


Key Concepts

Richardson Maturity Model

┌─────────────────────────────────────────────────────────────┐
│              Richardson Maturity Model                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Level 3: Hypermedia Controls (HATEOAS)                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Response includes links to related actions:         │   │
│  │  {                                                   │   │
│  │    "id": "123",                                     │   │
│  │    "_links": {                                      │   │
│  │      "self": {"href": "/orders/123"},               │   │
│  │      "cancel": {"href": "/orders/123/cancel"},      │   │
│  │      "payment": {"href": "/orders/123/payment"}     │   │
│  │    }                                                │   │
│  │  }                                                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                          ▲                                  │
│  Level 2: HTTP Verbs     │                                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  GET    /orders/123     → Read                      │   │
│  │  POST   /orders         → Create                    │   │
│  │  PUT    /orders/123     → Replace                   │   │
│  │  PATCH  /orders/123     → Partial update            │   │
│  │  DELETE /orders/123     → Remove                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                          ▲                                  │
│  Level 1: Resources      │                                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  /orders, /orders/123, /users/456/orders            │   │
│  │  (but still POST everywhere)                        │   │
│  └─────────────────────────────────────────────────────┘   │
│                          ▲                                  │
│  Level 0: Swamp of POX   │                                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  POST /api with action in body                      │   │
│  │  {"action": "getOrder", "orderId": "123"}           │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

HTTP Method Semantics

MethodIdempotentSafeCacheableUsage
GETYesYesYesRetrieve resource
POSTNoNoRarelyCreate resource
PUTYesNoNoReplace resource
PATCHNo*NoNoPartial update
DELETEYesNoNoRemove resource
HEADYesYesYesGet headers only
OPTIONSYesYesNoGet allowed methods

*PATCH can be idempotent if designed carefully


Best Practices

1. Use Nouns for Resources

/users/123 not /getUser?id=123.

2. Use Plural Resource Names

/orders not /order.

3. Nest for Relationships

/users/123/orders for user’s orders.

4. Use Query Parameters for Filtering

/orders?status=pending&limit=10.

5. Return Appropriate Status Codes

200 for success, 201 for created, 404 for not found.


Code Examples

Example 1: RESTful Resource Design

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    
    private final OrderService orderService;
    
    /**
     * GET /orders - List orders with filtering
     */
    @GetMapping
    public ResponseEntity<PagedResponse<OrderResponse>> listOrders(
            @RequestParam(required = false) OrderStatus status,
            @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate from,
            @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate to,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt,desc") String sort) {
        
        OrderFilter filter = OrderFilter.builder()
            .status(status)
            .fromDate(from)
            .toDate(to)
            .build();
        
        Page<Order> orders = orderService.findAll(filter, PageRequest.of(page, size, parseSort(sort)));
        
        return ResponseEntity.ok(PagedResponse.from(orders, OrderResponse::from));
    }
    
    /**
     * POST /orders - Create new order
     */
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        
        Order order = orderService.create(request);
        
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(order.getId())
            .toUri();
        
        return ResponseEntity.created(location)
            .body(OrderResponse.from(order));
    }
    
    /**
     * GET /orders/{id} - Get single order
     */
    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable String id) {
        return orderService.findById(id)
            .map(order -> ResponseEntity.ok(OrderResponse.from(order)))
            .orElse(ResponseEntity.notFound().build());
    }
    
    /**
     * PUT /orders/{id} - Full replacement
     */
    @PutMapping("/{id}")
    public ResponseEntity<OrderResponse> replaceOrder(
            @PathVariable String id,
            @Valid @RequestBody UpdateOrderRequest request) {
        
        return orderService.findById(id)
            .map(existing -> {
                Order updated = orderService.replace(id, request);
                return ResponseEntity.ok(OrderResponse.from(updated));
            })
            .orElse(ResponseEntity.notFound().build());
    }
    
    /**
     * PATCH /orders/{id} - Partial update
     */
    @PatchMapping("/{id}")
    public ResponseEntity<OrderResponse> patchOrder(
            @PathVariable String id,
            @RequestBody JsonPatch patch) {
        
        try {
            Order order = orderService.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Order", id));
            
            Order patched = orderService.applyPatch(order, patch);
            return ResponseEntity.ok(OrderResponse.from(patched));
            
        } catch (JsonPatchException e) {
            throw new BadRequestException("Invalid patch document", e);
        }
    }
    
    /**
     * DELETE /orders/{id} - Remove order
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteOrder(@PathVariable String id) {
        if (!orderService.existsById(id)) {
            return ResponseEntity.notFound().build();
        }
        
        orderService.delete(id);
        return ResponseEntity.noContent().build();
    }
    
    /**
     * Nested resource: GET /orders/{id}/items
     */
    @GetMapping("/{orderId}/items")
    public ResponseEntity<List<OrderItemResponse>> getOrderItems(
            @PathVariable String orderId) {
        
        return orderService.findById(orderId)
            .map(order -> ResponseEntity.ok(
                order.getItems().stream()
                    .map(OrderItemResponse::from)
                    .toList()
            ))
            .orElse(ResponseEntity.notFound().build());
    }
    
    /**
     * Action endpoint: POST /orders/{id}/cancel
     */
    @PostMapping("/{id}/cancel")
    public ResponseEntity<OrderResponse> cancelOrder(
            @PathVariable String id,
            @RequestBody(required = false) CancelOrderRequest request) {
        
        Order order = orderService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Order", id));
        
        if (!order.isCancellable()) {
            throw new BusinessRuleException("Order cannot be cancelled in status: " + order.getStatus());
        }
        
        Order cancelled = orderService.cancel(id, request != null ? request.getReason() : null);
        return ResponseEntity.ok(OrderResponse.from(cancelled));
    }
}

Example 2: HATEOAS Implementation

@RestController
@RequestMapping("/api/v1/orders")
public class HateoasOrderController {
    
    @GetMapping("/{id}")
    public ResponseEntity<EntityModel<OrderResponse>> getOrder(@PathVariable String id) {
        Order order = orderService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Order", id));
        
        OrderResponse response = OrderResponse.from(order);
        EntityModel<OrderResponse> model = EntityModel.of(response);
        
        // Self link
        model.add(linkTo(methodOn(HateoasOrderController.class).getOrder(id)).withSelfRel());
        
        // Related resource links
        model.add(linkTo(methodOn(HateoasOrderController.class).getOrderItems(id))
            .withRel("items"));
        model.add(linkTo(methodOn(UserController.class).getUser(order.getUserId()))
            .withRel("customer"));
        
        // Action links based on state
        if (order.isCancellable()) {
            model.add(linkTo(methodOn(HateoasOrderController.class).cancelOrder(id, null))
                .withRel("cancel"));
        }
        
        if (order.isPayable()) {
            model.add(linkTo(methodOn(PaymentController.class).createPayment(id, null))
                .withRel("payment"));
        }
        
        if (order.isShippable()) {
            model.add(linkTo(methodOn(ShipmentController.class).createShipment(id, null))
                .withRel("shipment"));
        }
        
        return ResponseEntity.ok(model);
    }
    
    @GetMapping
    public ResponseEntity<PagedModel<EntityModel<OrderResponse>>> listOrders(
            @PageableDefault Pageable pageable) {
        
        Page<Order> orders = orderService.findAll(pageable);
        
        PagedModel<EntityModel<OrderResponse>> pagedModel = pagedResourcesAssembler
            .toModel(orders, order -> {
                EntityModel<OrderResponse> model = EntityModel.of(OrderResponse.from(order));
                model.add(linkTo(methodOn(HateoasOrderController.class).getOrder(order.getId()))
                    .withSelfRel());
                return model;
            });
        
        return ResponseEntity.ok(pagedModel);
    }
}

// Response with embedded links
@Data
class OrderResponse {
    private String id;
    private String status;
    private BigDecimal total;
    private Instant createdAt;
    
    @JsonProperty("_links")
    private Map<String, Link> links;
    
    @JsonProperty("_embedded")
    private Map<String, Object> embedded;
    
    public static OrderResponse from(Order order) {
        OrderResponse response = new OrderResponse();
        response.id = order.getId();
        response.status = order.getStatus().name();
        response.total = order.getTotal();
        response.createdAt = order.getCreatedAt();
        return response;
    }
    
    public OrderResponse withLinks(Map<String, Link> links) {
        this.links = links;
        return this;
    }
}

@Data
class Link {
    private String href;
    private String method;
    private String type;
    
    public static Link get(String href) {
        Link link = new Link();
        link.href = href;
        link.method = "GET";
        return link;
    }
    
    public static Link post(String href) {
        Link link = new Link();
        link.href = href;
        link.method = "POST";
        link.type = "application/json";
        return link;
    }
}

Example 3: Query Parameter Design

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    /**
     * Comprehensive filtering, sorting, and pagination
     * 
     * GET /products?category=electronics&minPrice=100&maxPrice=500
     *              &inStock=true&sort=price,asc&fields=id,name,price
     *              &page=0&size=20
     */
    @GetMapping
    public ResponseEntity<PagedResponse<ProductResponse>> searchProducts(
            // Filtering
            @RequestParam(required = false) String category,
            @RequestParam(required = false) String brand,
            @RequestParam(required = false) BigDecimal minPrice,
            @RequestParam(required = false) BigDecimal maxPrice,
            @RequestParam(required = false) Boolean inStock,
            @RequestParam(required = false) List<String> tags,
            @RequestParam(required = false) String q,  // Full-text search
            
            // Sorting
            @RequestParam(defaultValue = "createdAt,desc") String sort,
            
            // Pagination
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") @Max(100) int size,
            
            // Field selection (sparse fieldsets)
            @RequestParam(required = false) Set<String> fields) {
        
        ProductSearchCriteria criteria = ProductSearchCriteria.builder()
            .category(category)
            .brand(brand)
            .minPrice(minPrice)
            .maxPrice(maxPrice)
            .inStock(inStock)
            .tags(tags)
            .searchText(q)
            .build();
        
        Pageable pageable = PageRequest.of(page, size, parseSort(sort));
        Page<Product> products = productService.search(criteria, pageable);
        
        // Apply field selection
        Function<Product, ProductResponse> mapper = fields != null && !fields.isEmpty()
            ? p -> ProductResponse.sparse(p, fields)
            : ProductResponse::from;
        
        return ResponseEntity.ok(PagedResponse.from(products, mapper));
    }
    
    /**
     * Advanced filtering with filter parameter
     * GET /products?filter=category:eq:electronics,price:gte:100,price:lte:500
     */
    @GetMapping("/search")
    public ResponseEntity<PagedResponse<ProductResponse>> advancedSearch(
            @RequestParam(required = false) String filter,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        List<FilterCriteria> criteria = parseFilterString(filter);
        
        Specification<Product> spec = criteria.stream()
            .map(this::toSpecification)
            .reduce(Specification::and)
            .orElse(null);
        
        Page<Product> products = productRepository.findAll(spec, PageRequest.of(page, size));
        
        return ResponseEntity.ok(PagedResponse.from(products, ProductResponse::from));
    }
    
    private List<FilterCriteria> parseFilterString(String filter) {
        if (filter == null || filter.isBlank()) {
            return Collections.emptyList();
        }
        
        return Arrays.stream(filter.split(","))
            .map(f -> {
                String[] parts = f.split(":");
                return new FilterCriteria(parts[0], parts[1], parts[2]);
            })
            .collect(Collectors.toList());
    }
    
    record FilterCriteria(String field, String operator, String value) {}
}

Example 4: Content Negotiation

@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
    
    /**
     * Content negotiation via Accept header
     * Accept: application/json
     * Accept: application/xml
     * Accept: text/csv
     * Accept: application/pdf
     */
    @GetMapping(value = "/{id}", produces = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE,
        "text/csv",
        "application/pdf"
    })
    public ResponseEntity<?> getReport(
            @PathVariable String id,
            @RequestHeader(value = "Accept", defaultValue = "application/json") String accept) {
        
        Report report = reportService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Report", id));
        
        return switch (accept) {
            case "application/pdf" -> ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_PDF)
                .header("Content-Disposition", "attachment; filename=\"report.pdf\"")
                .body(reportService.toPdf(report));
                
            case "text/csv" -> ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("text/csv"))
                .header("Content-Disposition", "attachment; filename=\"report.csv\"")
                .body(reportService.toCsv(report));
                
            case "application/xml" -> ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_XML)
                .body(ReportXmlResponse.from(report));
                
            default -> ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(ReportResponse.from(report));
        };
    }
    
    /**
     * Versioning via Accept header
     * Accept: application/vnd.company.report.v2+json
     */
    @GetMapping(value = "/{id}", produces = "application/vnd.company.report.v2+json")
    public ResponseEntity<ReportV2Response> getReportV2(@PathVariable String id) {
        Report report = reportService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Report", id));
        
        return ResponseEntity.ok(ReportV2Response.from(report));
    }
}

Example 5: Bulk Operations

@RestController
@RequestMapping("/api/v1/products")
public class BulkOperationsController {
    
    /**
     * Batch create: POST /products/batch
     */
    @PostMapping("/batch")
    public ResponseEntity<BatchResponse<ProductResponse>> createBatch(
            @Valid @RequestBody List<CreateProductRequest> requests) {
        
        if (requests.size() > 100) {
            throw new BadRequestException("Batch size exceeds maximum of 100");
        }
        
        List<BatchItemResult<ProductResponse>> results = new ArrayList<>();
        
        for (int i = 0; i < requests.size(); i++) {
            try {
                Product product = productService.create(requests.get(i));
                results.add(BatchItemResult.success(i, ProductResponse.from(product)));
            } catch (Exception e) {
                results.add(BatchItemResult.failure(i, e.getMessage()));
            }
        }
        
        HttpStatus status = results.stream().allMatch(BatchItemResult::isSuccess)
            ? HttpStatus.CREATED
            : HttpStatus.MULTI_STATUS;
        
        return ResponseEntity.status(status).body(new BatchResponse<>(results));
    }
    
    /**
     * Batch update: PATCH /products/batch
     */
    @PatchMapping("/batch")
    public ResponseEntity<BatchResponse<ProductResponse>> updateBatch(
            @RequestBody List<BatchUpdateRequest> requests) {
        
        List<BatchItemResult<ProductResponse>> results = requests.stream()
            .map(req -> {
                try {
                    Product updated = productService.partialUpdate(req.getId(), req.getChanges());
                    return BatchItemResult.success(req.getIndex(), ProductResponse.from(updated));
                } catch (ResourceNotFoundException e) {
                    return BatchItemResult.<ProductResponse>failure(req.getIndex(), "Not found");
                } catch (Exception e) {
                    return BatchItemResult.<ProductResponse>failure(req.getIndex(), e.getMessage());
                }
            })
            .toList();
        
        return ResponseEntity.status(HttpStatus.MULTI_STATUS)
            .body(new BatchResponse<>(results));
    }
    
    /**
     * Batch delete: DELETE /products/batch?ids=1,2,3
     */
    @DeleteMapping("/batch")
    public ResponseEntity<BatchDeleteResponse> deleteBatch(
            @RequestParam List<String> ids) {
        
        if (ids.size() > 100) {
            throw new BadRequestException("Batch size exceeds maximum of 100");
        }
        
        int deleted = 0;
        List<String> notFound = new ArrayList<>();
        
        for (String id : ids) {
            if (productService.existsById(id)) {
                productService.delete(id);
                deleted++;
            } else {
                notFound.add(id);
            }
        }
        
        return ResponseEntity.ok(new BatchDeleteResponse(deleted, notFound));
    }
}

record BatchResponse<T>(List<BatchItemResult<T>> results) {
    public long successCount() {
        return results.stream().filter(BatchItemResult::isSuccess).count();
    }
    public long failureCount() {
        return results.stream().filter(r -> !r.isSuccess()).count();
    }
}

record BatchItemResult<T>(int index, boolean success, T data, String error) {
    static <T> BatchItemResult<T> success(int index, T data) {
        return new BatchItemResult<>(index, true, data, null);
    }
    static <T> BatchItemResult<T> failure(int index, String error) {
        return new BatchItemResult<>(index, false, null, error);
    }
}

Anti-Patterns

❌ Verbs in URLs

# WRONG
POST /createUser
GET /getUser?id=123

# CORRECT
POST /users
GET /users/123

❌ Ignoring HTTP Method Semantics

Using POST for everything loses the benefits of caching and idempotency.


References