Spring Boot 7 min read

REST API Design Best Practices: Complete Guide

Master REST API design with industry best practices. Learn naming conventions, versioning, error handling, pagination, and security patterns.

MR

Moshiour Rahman

Advertisement

What Makes a Good REST API?

A well-designed REST API is intuitive, consistent, and easy to use. Following REST principles and industry best practices results in APIs that developers love.

REST Principles

PrincipleDescription
StatelessEach request contains all information needed
Uniform InterfaceConsistent resource identification
Client-ServerSeparation of concerns
CacheableResponses indicate cacheability
Layered SystemClient can’t tell if connected directly

URL Design

Use Nouns, Not Verbs

# Good - nouns represent resources
GET    /users           # Get all users
GET    /users/123       # Get user 123
POST   /users           # Create user
PUT    /users/123       # Update user 123
DELETE /users/123       # Delete user 123

# Bad - verbs in URLs
GET    /getUsers
POST   /createUser
PUT    /updateUser/123
DELETE /deleteUser/123

Use Plural Nouns

# Good - plural nouns
GET /users
GET /products
GET /orders

# Avoid - singular nouns
GET /user
GET /product

Hierarchical Resources

# Nested resources show relationships
GET /users/123/orders           # Orders for user 123
GET /users/123/orders/456       # Order 456 for user 123
POST /users/123/orders          # Create order for user 123

# Alternative with query parameters
GET /orders?userId=123

Use Hyphens for Multi-Word

# Good - hyphens
GET /user-profiles
GET /order-items
GET /product-categories

# Avoid - underscores or camelCase
GET /user_profiles
GET /orderItems

HTTP Methods

Method Usage

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

    // GET - Retrieve resources (safe, idempotent)
    @GetMapping
    public List<User> getAllUsers() { }

    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) { }

    // POST - Create new resources
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public User createUser(@RequestBody CreateUserRequest request) { }

    // PUT - Full update (idempotent)
    @PutMapping("/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) { }

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

    // DELETE - Remove resources (idempotent)
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) { }
}

HTTP Status Codes

Success Codes

// 200 OK - Successful GET, PUT, PATCH
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    User user = userService.findById(id);
    return ResponseEntity.ok(user);
}

// 201 Created - Successful POST
@PostMapping
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
    User user = userService.create(request);
    URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(user.getId())
            .toUri();
    return ResponseEntity.created(location).body(user);
}

// 204 No Content - Successful DELETE
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();
}

Error Codes

// 400 Bad Request - Validation errors
// 401 Unauthorized - Authentication required
// 403 Forbidden - Permission denied
// 404 Not Found - Resource doesn't exist
// 409 Conflict - Resource conflict
// 422 Unprocessable Entity - Semantic errors
// 500 Internal Server Error - Server error

Error Handling

Consistent Error Response

@Data
@Builder
public class ApiError {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> fieldErrors;

    @Data
    @AllArgsConstructor
    public static class FieldError {
        private String field;
        private String message;
    }
}

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
        ApiError error = ApiError.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.NOT_FOUND.value())
                .error("Not Found")
                .message(ex.getMessage())
                .path(request.getRequestURI())
                .build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
        List<ApiError.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(e -> new ApiError.FieldError(e.getField(), e.getDefaultMessage()))
                .collect(Collectors.toList());

        ApiError error = ApiError.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.BAD_REQUEST.value())
                .error("Validation Failed")
                .message("Invalid request parameters")
                .path(request.getRequestURI())
                .fieldErrors(fieldErrors)
                .build();

        return ResponseEntity.badRequest().body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleGeneral(Exception ex, HttpServletRequest request) {
        ApiError error = ApiError.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                .error("Internal Server Error")
                .message("An unexpected error occurred")
                .path(request.getRequestURI())
                .build();

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Error Response Example

{
    "timestamp": "2024-01-15T10:30:00",
    "status": 400,
    "error": "Validation Failed",
    "message": "Invalid request parameters",
    "path": "/api/users",
    "fieldErrors": [
        {
            "field": "email",
            "message": "must be a valid email address"
        },
        {
            "field": "name",
            "message": "must not be blank"
        }
    ]
}

Pagination

Request Parameters

@GetMapping
public Page<User> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "id") String sortBy,
        @RequestParam(defaultValue = "asc") String sortDir) {

    Sort sort = sortDir.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();

    Pageable pageable = PageRequest.of(page, size, sort);
    return userRepository.findAll(pageable);
}

Response Structure

{
    "content": [
        { "id": 1, "name": "John" },
        { "id": 2, "name": "Jane" }
    ],
    "page": {
        "size": 20,
        "number": 0,
        "totalElements": 100,
        "totalPages": 5
    },
    "links": {
        "self": "/api/users?page=0&size=20",
        "first": "/api/users?page=0&size=20",
        "next": "/api/users?page=1&size=20",
        "last": "/api/users?page=4&size=20"
    }
}

Custom Page Response

@Data
@Builder
public class PageResponse<T> {
    private List<T> content;
    private PageMetadata page;
    private Map<String, String> links;

    @Data
    @Builder
    public static class PageMetadata {
        private int size;
        private int number;
        private long totalElements;
        private int totalPages;
    }

    public static <T> PageResponse<T> from(Page<T> page, String baseUrl) {
        Map<String, String> links = new HashMap<>();
        links.put("self", buildUrl(baseUrl, page.getNumber(), page.getSize()));

        if (page.hasPrevious()) {
            links.put("prev", buildUrl(baseUrl, page.getNumber() - 1, page.getSize()));
        }
        if (page.hasNext()) {
            links.put("next", buildUrl(baseUrl, page.getNumber() + 1, page.getSize()));
        }

        return PageResponse.<T>builder()
                .content(page.getContent())
                .page(PageMetadata.builder()
                        .size(page.getSize())
                        .number(page.getNumber())
                        .totalElements(page.getTotalElements())
                        .totalPages(page.getTotalPages())
                        .build())
                .links(links)
                .build();
    }
}

Filtering and Searching

Query Parameters

@GetMapping
public List<Product> getProducts(
        @RequestParam(required = false) String name,
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        @RequestParam(required = false) ProductStatus status) {

    return productService.search(name, category, minPrice, maxPrice, status);
}

// GET /api/products?category=electronics&minPrice=100&maxPrice=500&status=ACTIVE

Search Endpoint

@GetMapping("/search")
public List<Product> searchProducts(@RequestParam String q) {
    return productService.search(q);
}

// GET /api/products/search?q=laptop

Versioning

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

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

Header Versioning

@GetMapping(value = "/users", headers = "X-API-Version=1")
public List<UserV1> getUsersV1() { }

@GetMapping(value = "/users", headers = "X-API-Version=2")
public List<UserV2> getUsersV2() { }

Media Type Versioning

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

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

Request/Response DTOs

Separate DTOs for Different Operations

// Create request
@Data
public class CreateUserRequest {
    @NotBlank
    private String name;

    @Email
    @NotBlank
    private String email;

    @NotBlank
    @Size(min = 8)
    private String password;
}

// Update request
@Data
public class UpdateUserRequest {
    @NotBlank
    private String name;

    private String phone;
}

// Response DTO
@Data
@Builder
public class UserResponse {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;

    public static UserResponse from(User user) {
        return UserResponse.builder()
                .id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .createdAt(user.getCreatedAt())
                .build();
    }
}

HATEOAS

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

    return EntityModel.of(user,
            linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
            linkTo(methodOn(UserController.class).getAllUsers()).withRel("users"),
            linkTo(methodOn(OrderController.class).getOrdersByUser(id)).withRel("orders"));
}
{
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com",
    "_links": {
        "self": { "href": "/api/users/123" },
        "users": { "href": "/api/users" },
        "orders": { "href": "/api/users/123/orders" }
    }
}

Security

Authentication Header

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Rate Limiting Headers

@GetMapping
public ResponseEntity<List<User>> getUsers() {
    List<User> users = userService.findAll();

    return ResponseEntity.ok()
            .header("X-RateLimit-Limit", "100")
            .header("X-RateLimit-Remaining", "95")
            .header("X-RateLimit-Reset", "1640000000")
            .body(users);
}

Documentation with OpenAPI

@Operation(summary = "Get user by ID", description = "Returns a single user")
@ApiResponses(value = {
    @ApiResponse(responseCode = "200", description = "User found"),
    @ApiResponse(responseCode = "404", description = "User not found")
})
@GetMapping("/{id}")
public User getUser(
        @Parameter(description = "User ID", required = true)
        @PathVariable Long id) {
    return userService.findById(id);
}

Summary

PracticeBenefit
Use nounsIntuitive resource naming
Proper HTTP methodsSemantic operations
Consistent errorsEasy debugging
PaginationScalable responses
VersioningAPI evolution
DTOsClean contracts

Well-designed REST APIs are easier to use, maintain, and scale.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.