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.
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
| Principle | Description |
|---|---|
| Stateless | Each request contains all information needed |
| Uniform Interface | Consistent resource identification |
| Client-Server | Separation of concerns |
| Cacheable | Responses indicate cacheability |
| Layered System | Client 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
URL Versioning (Recommended)
@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
Add Links to Responses
@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"));
}
Response with Links
{
"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
| Practice | Benefit |
|---|---|
| Use nouns | Intuitive resource naming |
| Proper HTTP methods | Semantic operations |
| Consistent errors | Easy debugging |
| Pagination | Scalable responses |
| Versioning | API evolution |
| DTOs | Clean contracts |
Well-designed REST APIs are easier to use, maintain, and scale.
Advertisement
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
Spring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
Spring BootSpring Security Method-Level Authorization: Complete @PreAuthorize Guide
Master method-level security in Spring Boot. Learn @PreAuthorize, @PostAuthorize, SpEL expressions, custom permissions, and domain object security.
Spring BootSpring Security Architecture: Complete Guide to How Security Works
Master Spring Security internals. Learn FilterChain, SecurityContext, Authentication flow, and how Spring Security protects your application.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.