REST API Design Best Practices
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
| Operation | Request | Success response |
|---|---|---|
| Create | POST /api/v1/users | 201 Created with Location |
| Read one | GET /api/v1/users/123 | 200 OK with a user DTO |
| Update | PUT /api/v1/users/123 | 200 OK with the replacement |
| Delete | DELETE /api/v1/users/123 | 204 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
