REST API Design Best Practices

Core principle: Your API is a contract with clients. Make it clear, predictable, and backwards-compatible when possible.

REST principles and stateless communication

REST represents application data as resources and uses standard HTTP semantics to work with those resources. A REST client should not need to know the server's internal classes or database tables. It should understand the resource URL, request format, response format, and status codes.

REST APIs are stateless: every request contains the information needed to process it. Do not depend on an in-memory session between requests. Store durable state in a database and send authentication credentials, such as a bearer token, with each protected request. This makes scaling and load balancing easier.

Content negotiation and media types

Use Content-Type to describe the request body and Accept to describe the response formats a client can read. JSON is common for Spring Boot APIs, but the contract should still be explicit.

POST /api/v1/users
Content-Type: application/json
Accept: application/json

{
  "name": "Asha Rao",
  "email": "asha@example.com"
}

DTOs and stable API contracts

Use request and response DTOs instead of exposing JPA entities directly. DTOs prevent internal fields from leaking, allow different create and update rules, and let the database model evolve without unexpectedly breaking clients.

public record CreateUserRequest(
    @NotBlank String name,
    @Email @NotBlank String email
) {}

public record UserResponse(Long id, String name, String email) {}

Validation and API security

Validate external input at the controller boundary with Bean Validation. Return a safe, consistent 400 response for invalid data. Never trust client-supplied roles, prices, ownership IDs, or file names.

@PostMapping
ResponseEntity<UserResponse> create(
    @Valid @RequestBody CreateUserRequest request) {
    return ResponseEntity.status(HttpStatus.CREATED)
        .body(userService.create(request));
}

Protect private endpoints with Spring Security, use HTTPS in production, configure CORS for known browser origins, apply rate limits at the edge, and avoid returning stack traces, tokens, or sensitive database fields.

OpenAPI documentation

Publish an OpenAPI contract so frontend developers and API consumers can discover endpoints, parameters, authentication requirements, and response schemas. Keep the documented examples aligned with the implemented API. In Spring Boot, an OpenAPI integration can expose a development-only Swagger UI; protect or disable it when it should not be public.

CRUD request and response examples

OperationRequestSuccess response
CreatePOST /api/v1/users201 Created with Location
Read oneGET /api/v1/users/123200 OK with a user DTO
UpdatePUT /api/v1/users/123200 OK with the replacement
DeleteDELETE /api/v1/users/123204 No Content

Return 404 Not Found when the resource does not exist and 409 Conflict when a request violates a unique business rule, such as registering an email that is already in use.

Testing controllers and integration flows

Test the public HTTP contract, not only private methods. Controller tests should verify request validation, status codes, headers, and JSON fields. Integration tests should also verify database mappings, transactions, security rules, and the complete create-read-update-delete flow.

@WebMvcTest(UserController.class)
class UserControllerTest {
    @Autowired MockMvc mvc;

    @Test
    void rejectsInvalidEmail() throws Exception {
        mvc.perform(post("/api/v1/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"name\":\"Asha\",\"email\":\"bad\"}"))
            .andExpect(status().isBadRequest());
    }
}

Caching and observability

Cache safe, read-only responses when they are reused frequently. Use Cache-Control, ETag, or Last-Modified so clients can make conditional requests. Do not cache personalized or sensitive responses accidentally.

Add structured logs with a request or correlation ID, record method, route, status, and duration, and publish metrics for error rates and latency. Distributed tracing helps follow a request across services.

URL Naming Conventions

Resource Good URL Why
List all users GET /api/v1/users Plural nouns, versioned
Get one user GET /api/v1/users/123 Resource ID in path
Create user POST /api/v1/users POST to collection
Update user PUT /api/v1/users/123 Full replacement
Partial update PATCH /api/v1/users/123 Partial changes
Delete user DELETE /api/v1/users/123 Idempotent delete
User orders GET /api/v1/users/123/orders Nested resources

HTTP Status Codes Reference

Code Meaning Example Use
200 OK - Success GET user details, successful PUT/PATCH
201 Created - Resource created POST new user (include Location header)
204 No Content - Success, no body DELETE user, successful action
400 Bad Request - Invalid input Missing field, wrong format
401 Unauthorized - Authentication needed No token, invalid token
403 Forbidden - Authenticated but not allowed User can't access admin endpoint
404 Not Found - Resource doesn't exist User ID doesn't exist
409 Conflict - Request conflicts with state Email already exists
500 Internal Server Error - Server error Unexpected exception
503 Service Unavailable - Down for maintenance Database unreachable

Standard Error Response Format

Return consistent error information so clients can understand what went wrong:

// Standard error response structure
{
  "timestamp": "2024-07-24T10:30:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "path": "/api/v1/users",
  "details": {
    "email": "Email is invalid",
    "age": "Age must be between 18 and 120"
  }
}

// Spring Boot implementation
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationError(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        
        Map<String, String> details = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            details.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse error = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            "Validation Failed",
            details
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

Pagination and Query Parameters

// Common pagination patterns
GET /api/v1/users?page=0&size=20&sort=createdAt,desc

// Filtering
GET /api/v1/users?status=active&role=admin

// Searching
GET /api/v1/users?search=john&fields=name,email

// Spring implementation
@GetMapping
public Page<UserResponse> getUsers(
    @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
    Pageable pageable,
    @RequestParam(required = false) String status,
    @RequestParam(required = false) String search
) {
    return userService.findUsers(pageable, status, search);
}

API Versioning Strategies

Option 1: URI Path Versioning (Most Common)

GET /api/v1/users      // Version 1
GET /api/v2/users      // Version 2
GET /api/v3/users      // Version 3

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

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

Option 2: Request Header Versioning

GET /api/users
Header: X-API-Version: 1

GET /api/users
Header: X-API-Version: 2

@RestController
@RequestMapping("/api/users")
class UserController {
    
    @GetMapping(headers = "X-API-Version=1")
    public UserResponseV1 getUsersV1() { }
    
    @GetMapping(headers = "X-API-Version=2")
    public UserResponseV2 getUsersV2() { }
}

Option 3: Accept Header (Content Negotiation)

GET /api/users
Accept: application/vnd.company.v1+json

GET /api/users
Accept: application/vnd.company.v2+json

API Design Best Practices

✅ DO:

  • Use version in URL for clarity (/api/v1/users)
  • Use plural nouns for resources (/users, not /user)
  • Use HTTP methods correctly (GET reads, POST creates, etc.)
  • Return appropriate status codes
  • Include Location header with 201 Created responses
  • Paginate large result sets
  • Use camelCase in JSON responses
  • Document your API (OpenAPI/Swagger)
  • Use consistent error response format
  • Validate input thoroughly

❌ DON'T:

  • Use verbs in URLs (/getUser, /createOrder)
  • Use status codes incorrectly (e.g., 200 OK for errors)
  • Return HTML error pages for API errors
  • Return bare 500 errors without details
  • Expose sensitive information in responses
  • Break backwards compatibility without versioning
  • Mix different response formats
  • Forget to paginate large datasets
  • Use GET for state-changing operations

Complete User API Example

@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    // List all users with pagination
    @GetMapping
    public ResponseEntity<Page<UserResponse> listUsers(
        @PageableDefault(size = 20) Pageable pageable,
        @RequestParam(required = false) String status
    ) {
        Page<UserResponse> users = userService.findAll(pageable, status);
        return ResponseEntity.ok(users);
    }
    
    // Get single user
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
    }
    
    // Create new user
    @PostMapping
    public ResponseEntity<UserResponse> createUser(
        @Valid @RequestBody CreateUserRequest request
    ) {
        UserResponse created = userService.create(request);
        URI location = URI.create("/api/v1/users/" + created.id());
        return ResponseEntity.created(location).body(created);
    }
    
    // Update existing user
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
        @PathVariable Long id,
        @Valid @RequestBody UpdateUserRequest request
    ) {
        return userService.update(id, request)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
    }
    
    // Delete user
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        boolean deleted = userService.delete(id);
        return deleted ?
            ResponseEntity.noContent().build() :
            ResponseEntity.notFound().build();
    }
}

REST API design FAQs

Should REST URLs use nouns or verbs?

Use nouns for resources, such as /users and /orders. Use the HTTP method to express the operation.

Should every API use URL versioning?

Versioning is useful when a contract must evolve without breaking existing clients. URI versioning is easy to discover, while header versioning keeps URLs stable.

Should controllers return JPA entities?

No. Response DTOs provide a safer boundary and prevent persistence details, lazy relationships, or private fields from becoming part of the public contract.

See Basic REST API Guide →

Continue learning