Most REST APIs I’ve encountered have the same handful of problems: inconsistent error responses, missing validation, endpoints that return entities directly, and pagination bolted on as an afterthought. These aren’t hard problems—they just need deliberate design decisions made early.

Here’s what I’ve settled on after building Spring Boot APIs for several years. These aren’t theoretical guidelines; they’re practices that have saved me debugging time and client complaints.

HTTP Status Codes Done Right

The most basic signal your API sends is the HTTP status code. Getting this right means clients can handle responses generically without parsing your response body first.

The ones I use most:

  • 200 OK — successful GET, PUT, or PATCH
  • 201 Created — successful POST that creates a resource
  • 204 No Content — successful DELETE or PUT with no body
  • 400 Bad Request — client sent invalid data
  • 401 Unauthorized — missing or invalid authentication
  • 403 Forbidden — authenticated but lacks permission
  • 404 Not Found — resource doesn’t exist
  • 409 Conflict — state conflict (duplicate email, optimistic lock failure)
  • 422 Unprocessable Entity — valid syntax but business rule violation
  • 500 Internal Server Error — something broke on your side

In Spring Boot, you specify status codes on controller methods using @ResponseStatus or by returning ResponseEntity:

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

Using ResponseEntity is more verbose but gives you full control when the status code depends on the result (like the 404 example above).

Exception Handling with @ControllerAdvice

Nothing is worse than an API that returns a stack trace when something goes wrong. Centralized exception handling using @ControllerAdvice keeps your error responses consistent and your controllers clean.

First, define a standard error response structure:

public record ApiError(
    int status,
    String message,
    String path,
    Instant timestamp,
    List<FieldError> errors
) {
    public record FieldError(String field, String message) {}
}

Then create the global exception handler:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
        return new ApiError(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            request.getRequestURI(),
            Instant.now(),
            null
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        List<ApiError.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(e -> new ApiError.FieldError(e.getField(), e.getDefaultMessage()))
            .toList();

        return new ApiError(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed",
            request.getRequestURI(),
            Instant.now(),
            fieldErrors
        );
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError handleGeneric(Exception ex, HttpServletRequest request) {
        // Log the actual exception but don't expose internals
        log.error("Unhandled exception", ex);
        return new ApiError(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "An unexpected error occurred",
            request.getRequestURI(),
            Instant.now(),
            null
        );
    }
}

Define your custom exceptions to communicate intent:

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resourceName, Long id) {
        super(String.format("%s with id %d not found", resourceName, id));
    }
}

public class ConflictException extends RuntimeException {
    public ConflictException(String message) {
        super(message);
    }
}

The result is a consistent error format every client can depend on:

{
  "status": 400,
  "message": "Validation failed",
  "path": "/api/v1/users",
  "timestamp": "2026-03-04T10:15:30Z",
  "errors": [
    { "field": "email", "message": "must be a valid email address" },
    { "field": "age", "message": "must be greater than or equal to 18" }
  ]
}

Request Validation with @Valid

Spring Boot integrates with Jakarta Validation (formerly Bean Validation), so you get constraint annotations out of the box. The key is using @Valid on your controller method parameters to trigger validation before execution.

Define constraints on your request DTOs:

public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "must be a valid email address")
    String email,

    @NotNull(message = "Age is required")
    @Min(value = 18, message = "must be greater than or equal to 18")
    Integer age,

    @Valid  // Cascade validation to nested objects
    @NotNull
    AddressRequest address
) {}

public record AddressRequest(
    @NotBlank String street,
    @NotBlank String city,
    @Pattern(regexp = "^[0-9]{5}$", message = "must be a 5-digit postal code")
    String postalCode
) {}

Use @Valid in the controller:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

For custom business rule validation (checking uniqueness in the database, for example), do that in the service layer and throw a ConflictException rather than bending the constraint annotations to do it.

DTOs vs Entities: Always Use DTOs at the API Layer

One of the most common mistakes I see is returning JPA entities directly from controllers. Don’t do it.

The problems are real and tend to surface in production:

  • You expose internal database structure to API consumers
  • Lazy-loaded relationships cause LazyInitializationException or trigger unintended N+1 queries
  • Adding a field to your entity automatically exposes it in your API (security risk)
  • You can’t easily evolve your API independently of your database schema

Define separate DTOs for request and response:

// Request DTO — what clients send
public record CreateUserRequest(
    @NotBlank String name,
    @Email String email,
    @NotNull @Min(18) Integer age
) {}

// Response DTO — what clients receive
public record UserResponse(
    Long id,
    String name,
    String email,
    String status,
    Instant createdAt
) {}

// Entity — your internal database representation
@Entity
@Table(name = "users")
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;
    private Integer age;
    private UserStatus status;
    private String passwordHash;  // Never expose this
    private Instant createdAt;
    private Instant updatedAt;
}

The mapping between entity and DTO can live in a mapper class or in the service:

@Service
public class UserService {

    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        user.setAge(request.age());
        user.setStatus(UserStatus.ACTIVE);
        user.setCreatedAt(Instant.now());
        user = userRepository.save(user);
        return toResponse(user);
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getStatus().name(),
            user.getCreatedAt()
        );
    }
}

MapStruct is worth adding if you have many entities to map. It generates the boilerplate at compile time with no runtime overhead.

Pagination

Any endpoint that can return more than a few dozen records needs pagination. Spring Data makes this easy with Pageable, but there are a few things worth getting right.

Use Spring Data’s Pageable for query parameters:

@GetMapping
public Page<UserResponse> listUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "createdAt,desc") String sort) {

    // Limit page size to prevent abuse
    size = Math.min(size, 100);

    Sort sortSpec = parseSort(sort);
    Pageable pageable = PageRequest.of(page, size, sortSpec);

    return userRepository.findAll(pageable)
        .map(this::toResponse);
}

Alternatively, use @PageableDefault to set defaults:

@GetMapping
public Page<UserResponse> listUsers(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {
    return userRepository.findAll(pageable).map(this::toResponse);
}

The Page<T> response includes pagination metadata automatically:

{
  "content": [...],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20
  },
  "totalElements": 150,
  "totalPages": 8,
  "last": false,
  "first": true
}

One caution: Page triggers a count query on every request. For large datasets, this gets expensive. Consider using Slice<T> instead—it only fetches one extra element to determine if there’s a next page, without counting the total.

API Versioning

APIs change. How you version them determines how painful those changes are for your clients.

URI versioning is the most pragmatic approach—clients can see the version in the URL, it’s easy to test in a browser, and routing is straightforward:

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

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

Header-based versioning keeps URLs clean but is harder to test and cache:

@GetMapping(headers = "API-Version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }

@GetMapping(headers = "API-Version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

For most teams, URI versioning is the right call. The “ugly URL” argument against it isn’t worth the operational complexity of header-based alternatives.

Keep old versions running for a defined deprecation window (I use 6 months minimum) and communicate the sunset date in response headers:

@GetMapping
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
    UserResponse user = userService.findById(id);
    return ResponseEntity.ok()
        .header("Deprecation", "true")
        .header("Sunset", "Sat, 04 Sep 2026 00:00:00 GMT")
        .body(user);
}

HATEOAS

HATEOAS (Hypermedia as the Engine of Application State) lets clients navigate your API by following links in responses rather than constructing URLs themselves. It reduces coupling between client and server.

Spring provides spring-boot-starter-hateoas for this:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

Extend RepresentationModel in your response class:

public class UserResponse extends RepresentationModel<UserResponse> {
    private Long id;
    private String name;
    private String email;
    private String status;
    // getters/setters
}

Add links in the controller:

@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
    User user = userService.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User", id));

    UserResponse response = toResponse(user);

    // Add self link
    response.add(linkTo(methodOn(UserController.class).getUser(id)).withSelfRel());

    // Add related resource links
    response.add(linkTo(methodOn(UserController.class).listUsers(null)).withRel("users"));
    response.add(linkTo(methodOn(OrderController.class).listUserOrders(id, null)).withRel("orders"));

    return response;
}

The response now includes navigable links:

{
  "id": 42,
  "name": "Alice Smith",
  "email": "alice@example.com",
  "_links": {
    "self": { "href": "/api/v1/users/42" },
    "users": { "href": "/api/v1/users" },
    "orders": { "href": "/api/v1/users/42/orders" }
  }
}

HATEOAS is more useful for public APIs where clients you don’t control need to discover capabilities. For internal APIs with tight coupling between client and server teams, the overhead may not be worth it.

OpenAPI/Swagger Documentation

Good API documentation is part of the API. springdoc-openapi generates it automatically from your code:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.3.0</version>
</dependency>

The Swagger UI is available at /swagger-ui.html with zero configuration. To make the documentation actually useful, annotate your controllers and DTOs:

@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {

    @Operation(
        summary = "Create a new user",
        description = "Creates a user account. Email must be unique."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "User created successfully"),
        @ApiResponse(responseCode = "400", description = "Validation error",
            content = @Content(schema = @Schema(implementation = ApiError.class))),
        @ApiResponse(responseCode = "409", description = "Email already exists")
    })
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }
}

Document your DTOs:

public record CreateUserRequest(
    @Schema(description = "User's full name", example = "Alice Smith")
    @NotBlank String name,

    @Schema(description = "Email address, used for login", example = "alice@example.com")
    @Email String email,

    @Schema(description = "User's age in years", example = "25", minimum = "18")
    @Min(18) Integer age
) {}

Configure the API metadata:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("User Service API")
                .version("1.0")
                .description("User management REST API"))
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")));
    }
}

Expose the OpenAPI spec at a stable URL for client code generation:

springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
  show-actuator: false

Frequently Asked Questions

Should I return the entity directly from a Spring Boot REST controller?

No—return DTOs (Data Transfer Objects), not entities. Entities are tied to your database schema and may expose sensitive fields, cause lazy-loading exceptions, or include internal implementation details you don’t want in your API contract. DTOs let you design the API response independently of the database model, add computed fields, and change your schema without breaking clients. Use MapStruct or manual mapping to convert between entities and DTOs.

How should I handle exceptions globally in a Spring Boot REST API?

Use @ControllerAdvice with @ExceptionHandler methods to handle exceptions in a single place. Create a consistent error response format (e.g., with fields for status, message, timestamp, and errors) and return it for all exceptions. Handle specific exceptions like MethodArgumentNotValidException for validation errors, EntityNotFoundException for 404s, and a catch-all Exception handler for unexpected errors. This ensures every error response follows the same structure, making client error handling predictable.

What HTTP status codes should I use for different REST API scenarios?

Use 200 OK for successful reads and updates that return data, 201 Created for successful resource creation (with a Location header pointing to the new resource), 204 No Content for deletes or updates that return nothing, 400 Bad Request for validation errors and malformed input, 401 Unauthorized when authentication is required, 403 Forbidden when the user is authenticated but lacks permission, 404 Not Found when a resource doesn’t exist, 409 Conflict for duplicate resource creation, and 500 Internal Server Error for unexpected failures.

How do I implement pagination in a Spring Boot REST API?

Use Spring Data’s Pageable parameter in your repository and controller methods. Accept page and size as query parameters (e.g., GET /users?page=0&size=20), and return a response that includes the data plus pagination metadata: current page, total pages, total elements, and whether there are next/previous pages. Never return an unbounded list—any collection that can grow should be paginated from day one. Spring Data’s Page<T> type handles most of this automatically.

What is the best approach to API versioning in Spring Boot?

URL path versioning (e.g., /api/v1/users, /api/v2/users) is the most common and practical approach for most teams—it’s explicit, easy to route, and simple to document. Header-based versioning (using an Accept or custom header) is cleaner from a REST purist perspective but harder to test and share URLs for. Query parameter versioning (?version=1) works but mixes versioning with filtering concerns. Whichever you choose, pick one and apply it consistently from the start. Version when you have breaking changes, not speculatively.

Putting It Together

These practices work together rather than independently. A well-designed Spring Boot REST API:

  1. Returns the right HTTP status for every outcome
  2. Sends consistent, parseable error responses from a single @ControllerAdvice
  3. Validates inputs at the controller boundary and rejects bad requests before service logic runs
  4. Uses DTOs to decouple your API contract from your database schema
  5. Paginates any collection that can grow unbounded
  6. Versions the API so you can evolve it without breaking existing clients
  7. Optionally uses HATEOAS for public APIs where discoverability matters
  8. Documents everything with OpenAPI so clients don’t need to read your source code

None of these are particularly complicated in isolation. The discipline is applying them consistently across every endpoint, not just the ones where it seems obviously necessary.