REST API Best Practices in Java: Design APIs Developers Love
Build REST APIs that are consistent, discoverable, and maintainable. Naming, versioning, error handling, pagination, and HATEOAS explained.
Moshiour Rahman
Advertisement
The Problem: Your API is a Nightmare
Your API works, but:
/getUsersvs/users/listvs/api/v1/user- inconsistent- Errors return
500 Internal Server Errorwith no details - No pagination - clients crash on large datasets
- Breaking changes with no versioning strategy
- Documentation is outdated or missing
A bad API costs more in support than it saves in development.
Quick Answer (TL;DR)
// Good REST endpoint
@GetMapping("/api/v1/users/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
// Returns proper HTTP status, consistent response, clear error
// GET /api/v1/users/123 → 200 OK with body
// GET /api/v1/users/999 → 404 Not Found with error details
URL Design Principles

Use plural nouns for resources, HTTP methods for actions, and a consistent structure with API prefix, version, and nested resources.
URL Conventions
| Pattern | Example | When to Use |
|---|---|---|
| Collection | /users | List or create |
| Item | /users/123 | Get, update, delete one |
| Nested | /users/123/orders | Related resources |
| Filter | /users?status=active | Query parameters |
| Search | /users/search?q=john | Complex queries |
Bad vs Good URLs
| Bad | Good | Why |
|---|---|---|
/getUsers | GET /users | Use HTTP method, not verb in URL |
/user/list | GET /users | Plural nouns for collections |
/createUser | POST /users | HTTP method indicates action |
/users/delete/123 | DELETE /users/123 | Method, not URL path |
/users/123/update | PUT /users/123 | Same reason |
HTTP Methods
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
// GET /api/v1/users - List all (paginated)
@GetMapping
public Page<UserSummary> listUsers(Pageable pageable) {
return userService.findAll(pageable);
}
// GET /api/v1/users/123 - Get one
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
// POST /api/v1/users - Create
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
// PUT /api/v1/users/123 - Full update
@PutMapping("/{id}")
public UserResponse updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.update(id, request);
}
// PATCH /api/v1/users/123 - Partial update
@PatchMapping("/{id}")
public UserResponse patchUser(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
return userService.patch(id, updates);
}
// DELETE /api/v1/users/123 - Delete
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
Method Reference
| Method | Purpose | Request Body | Response |
|---|---|---|---|
GET | Read | No | Resource(s) |
POST | Create | Yes | Created resource |
PUT | Full replace | Yes | Updated resource |
PATCH | Partial update | Yes | Updated resource |
DELETE | Remove | No | Empty (204) |
HTTP Status Codes
@RestControllerAdvice
public class GlobalExceptionHandler {
// 400 Bad Request - Client sent invalid data
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
.toList();
return new ErrorResponse("VALIDATION_ERROR", "Invalid request", errors);
}
// 401 Unauthorized - Not authenticated
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuth(AuthenticationException ex) {
return new ErrorResponse("UNAUTHORIZED", "Authentication required", null);
}
// 403 Forbidden - Authenticated but not allowed
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleForbidden(AccessDeniedException ex) {
return new ErrorResponse("FORBIDDEN", "Access denied", null);
}
// 404 Not Found - Resource doesn't exist
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage(), null);
}
// 409 Conflict - Business rule violation
@ExceptionHandler(ConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(ConflictException ex) {
return new ErrorResponse("CONFLICT", ex.getMessage(), null);
}
// 500 Internal Server Error - Unexpected failure
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex) {
log.error("Unexpected error", ex);
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred", null);
}
}
Status Code Reference
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH |
201 | Created | Successful POST |
204 | No Content | Successful DELETE |
400 | Bad Request | Invalid input |
401 | Unauthorized | Not logged in |
403 | Forbidden | Logged in but no permission |
404 | Not Found | Resource doesn’t exist |
409 | Conflict | Business rule violation |
422 | Unprocessable | Valid syntax, invalid semantics |
500 | Server Error | Bug, unexpected failure |
Error Response Format
public record ErrorResponse(
String code, // Machine-readable code
String message, // Human-readable message
List<FieldError> errors, // Field-level errors (optional)
String traceId, // For debugging
Instant timestamp
) {
public ErrorResponse(String code, String message, List<FieldError> errors) {
this(code, message, errors, MDC.get("traceId"), Instant.now());
}
}
public record FieldError(String field, String message) {}
Example Error Responses
// 400 Bad Request - Validation error
{
"code": "VALIDATION_ERROR",
"message": "Invalid request",
"errors": [
{"field": "email", "message": "must be a valid email"},
{"field": "age", "message": "must be at least 18"}
],
"traceId": "abc123",
"timestamp": "2024-12-07T10:30:00Z"
}
// 404 Not Found
{
"code": "NOT_FOUND",
"message": "User with id 999 not found",
"errors": null,
"traceId": "def456",
"timestamp": "2024-12-07T10:30:00Z"
}
Pagination
@GetMapping
public Page<UserSummary> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String sort) {
if (size > 100) {
throw new BadRequestException("Page size cannot exceed 100");
}
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSort(sort)));
return userService.findAll(pageable);
}
Response Format
{
"content": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
],
"page": {
"number": 0,
"size": 20,
"totalElements": 150,
"totalPages": 8
},
"links": {
"self": "/api/v1/users?page=0&size=20",
"next": "/api/v1/users?page=1&size=20",
"last": "/api/v1/users?page=7&size=20"
}
}
Custom Page Response
public record PagedResponse<T>(
List<T> content,
PageMetadata page,
Map<String, String> links
) {
public static <T> PagedResponse<T> from(Page<T> page, String baseUrl) {
return new PagedResponse<>(
page.getContent(),
new PageMetadata(
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
),
buildLinks(page, baseUrl)
);
}
}
Versioning Strategies
URL Versioning (Recommended)
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
// Original API
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
// Breaking changes
}
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() { ... }
When to Version
| Change Type | New Version? |
|---|---|
| Add optional field | No |
| Add new endpoint | No |
| Remove field | Yes |
| Rename field | Yes |
| Change field type | Yes |
| Change behavior | Yes |
Request/Response DTOs
// Request DTO - what client sends
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Min(18) int age
) {}
// Response DTO - what client receives (never expose entities)
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
// Summary DTO - for lists (less data)
public record UserSummary(
Long id,
String name
) {}
Filtering and Search
@GetMapping
public Page<UserSummary> searchUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email,
@RequestParam(required = false) UserStatus status,
@RequestParam(required = false) LocalDate createdAfter,
Pageable pageable) {
Specification<User> spec = Specification.where(null);
if (name != null) {
spec = spec.and((root, query, cb) ->
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"));
}
if (status != null) {
spec = spec.and((root, query, cb) ->
cb.equal(root.get("status"), status));
}
if (createdAfter != null) {
spec = spec.and((root, query, cb) ->
cb.greaterThan(root.get("createdAt"), createdAfter.atStartOfDay()));
}
return userRepository.findAll(spec, pageable).map(UserSummary::from);
}
HATEOAS (Hypermedia)
@GetMapping("/{id}")
public EntityModel<UserResponse> getUser(@PathVariable Long id) {
var user = userService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
return EntityModel.of(UserResponse.from(user),
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(UserController.class).listUsers(Pageable.unpaged())).withRel("users"),
linkTo(methodOn(OrderController.class).getOrdersForUser(id)).withRel("orders")
);
}
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"_links": {
"self": {"href": "/api/v1/users/123"},
"users": {"href": "/api/v1/users"},
"orders": {"href": "/api/v1/users/123/orders"}
}
}
OpenAPI Documentation
@Operation(
summary = "Get user by ID",
description = "Returns a single user",
responses = {
@ApiResponse(responseCode = "200", description = "User found"),
@ApiResponse(responseCode = "404", description = "User not found")
}
)
@GetMapping("/{id}")
public UserResponse getUser(
@Parameter(description = "User ID") @PathVariable Long id) {
return userService.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
Common Pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Verbs in URLs | /getUser, /deleteUser | Use HTTP methods |
| Exposing entities | Internal changes break API | Use DTOs |
| No pagination | OOM on large datasets | Always paginate lists |
| Generic errors | Can’t debug | Structured error responses |
| No versioning | Can’t evolve API | Version from day 1 |
Code Repository
Complete API example:
GitHub: techyowls/techyowls-io-blog-public/rest-api-best-practices
Further Reading
- Spring Boot Testing - Test your APIs
- Spring Security JWT - Secure your APIs
- Spring REST Docs
Build APIs developers love to use. Follow TechyOwls for more practical guides.
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
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.
Spring BootSpring 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 OAuth2: Complete Social Login Guide (Google, GitHub)
Implement OAuth2 social login in Spring Boot. Learn Google, GitHub authentication, custom OAuth2 providers, and combining with JWT.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.