Spring Boot 8 min read

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.

MR

Moshiour Rahman

Advertisement

The Problem: Your API is a Nightmare

Your API works, but:

  • /getUsers vs /users/list vs /api/v1/user - inconsistent
  • Errors return 500 Internal Server Error with 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

REST URL Structure

Use plural nouns for resources, HTTP methods for actions, and a consistent structure with API prefix, version, and nested resources.

URL Conventions

PatternExampleWhen to Use
Collection/usersList or create
Item/users/123Get, update, delete one
Nested/users/123/ordersRelated resources
Filter/users?status=activeQuery parameters
Search/users/search?q=johnComplex queries

Bad vs Good URLs

BadGoodWhy
/getUsersGET /usersUse HTTP method, not verb in URL
/user/listGET /usersPlural nouns for collections
/createUserPOST /usersHTTP method indicates action
/users/delete/123DELETE /users/123Method, not URL path
/users/123/updatePUT /users/123Same 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

MethodPurposeRequest BodyResponse
GETReadNoResource(s)
POSTCreateYesCreated resource
PUTFull replaceYesUpdated resource
PATCHPartial updateYesUpdated resource
DELETERemoveNoEmpty (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

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid input
401UnauthorizedNot logged in
403ForbiddenLogged in but no permission
404Not FoundResource doesn’t exist
409ConflictBusiness rule violation
422UnprocessableValid syntax, invalid semantics
500Server ErrorBug, 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

@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 TypeNew Version?
Add optional fieldNo
Add new endpointNo
Remove fieldYes
Rename fieldYes
Change field typeYes
Change behaviorYes

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
) {}

@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

PitfallProblemFix
Verbs in URLs/getUser, /deleteUserUse HTTP methods
Exposing entitiesInternal changes break APIUse DTOs
No paginationOOM on large datasetsAlways paginate lists
Generic errorsCan’t debugStructured error responses
No versioningCan’t evolve APIVersion from day 1

Code Repository

Complete API example:

GitHub: techyowls/techyowls-io-blog-public/rest-api-best-practices


Further Reading


Build APIs developers love to use. Follow TechyOwls for more practical guides.

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.