Spring Boot

Building REST APIs

A complete guide to building a production-ready REST API with Spring Boot 3: controller design, HTTP methods and status codes, request validation, DTOs, a consistent JSON error format that does not turn client mistakes into 500s, pagination with caps, CORS, API versioning, OpenAPI docs and tests. Includes the mistakes that break real APIs, such as unvalidated sort parameters and catch-all exception handlers.

REST API Foundations in Spring Boot

A REST API in Spring Boot is built around three core annotations: @RestController marks the class as a REST endpoint handler, @RequestMapping defines the base URL path for the controller, and the HTTP method annotations (@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping) map specific HTTP methods and paths to handler methods. Spring Boot's embedded Tomcat handles the HTTP layer: parsing request bodies, serializing response objects to JSON through Jackson, setting headers, and managing status codes. You write plain Java methods that take and return Java objects, and Spring does the translation. A JSON request body annotated with @RequestBody becomes a Java object, and an object returned from a @RestController method becomes JSON. The examples in this entry target Spring Boot 3, which uses the jakarta.validation packages and Java records for DTOs. The layering is the same throughout: controllers handle HTTP only, services hold business rules and transactions, repositories talk to the database, and DTOs form the contract with clients.

HTTP Methods and Status Codes

Design URLs around nouns in the plural (/api/v1/products), and let the HTTP method express the action. A consistent use of methods and status codes lets clients and tools behave predictably. - GET reads a resource or a collection. It is safe and idempotent. Returns 200, or 404 when the resource does not exist. - POST creates a resource. It is not idempotent. Returns 201 Created with a Location header pointing at the new resource. - PUT replaces a resource entirely. It is idempotent: sending it twice leaves the same state. Returns 200 with the updated body, or 204 with none. - PATCH changes some fields. Send only what changes. Returns 200 with the updated resource. - DELETE removes a resource. It is idempotent. Returns 204 No Content. Common error statuses: 400 for malformed or invalid input, 401 when the caller is not authenticated, 403 when authenticated but not allowed, 404 for a missing resource, 409 for a conflict such as a duplicate, and 500 only for genuine server faults. Client mistakes must never surface as 500, and the exception handling section below shows how to guarantee that.

Controller Structure and HTTP Method Mapping

A complete controller covering all five methods. Three design decisions are worth noticing. List endpoints take a Pageable (with a default and a cap, see the pagination section) instead of hand-rolled page, size and sortBy parameters, because an unchecked sortBy string reaches the database layer and a huge size can load the whole table. Missing resources throw ResourceNotFoundException, so every 404 returns the same JSON error body instead of an empty one. And POST returns 201 with a Location header.
Java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Slf4j
public class ProductController {

    private final ProductService productService;

    // GET /api/v1/products?page=0&size=20&sort=name,asc — paginated list:
    @GetMapping
    public PagedResponse<ProductResponse> getAllProducts(
            @PageableDefault(size = 20, sort = "name") Pageable pageable) {
        return PagedResponse.from(productService.findAll(pageable));
    }

    // GET /api/v1/products/42 — get one by ID (404 JSON error if missing):
    @GetMapping("/{id}")
    public ProductResponse getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }

    // GET /api/v1/products/search?query=laptop&category=electronics — search:
    @GetMapping("/search")
    public List<ProductResponse> searchProducts(
            @RequestParam String query,
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "10") int limit) {
        return productService.search(query, category, limit);
    }

    // POST /api/v1/products — create, respond 201 with a Location header:
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid CreateProductRequest request) {
        log.info("Creating product: {}", request.name());
        ProductResponse created = productService.create(request);

        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.id())
            .toUri();
        return ResponseEntity.created(location).body(created);
    }

    // PUT /api/v1/products/42 — full replacement:
    @PutMapping("/{id}")
    public ProductResponse updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid UpdateProductRequest request) {
        return productService.update(id, request);
    }

    // PATCH /api/v1/products/42 — partial update:
    @PatchMapping("/{id}")
    public ProductResponse patchProduct(
            @PathVariable Long id,
            @RequestBody @Valid PatchProductRequest request) {
        return productService.patch(id, request);
    }

    // DELETE /api/v1/products/42 — delete:
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteProduct(@PathVariable Long id) {
        productService.delete(id);
    }
}

Request DTOs — Input Validation

Request DTOs define what the API accepts. They carry Bean Validation annotations enforced by @Valid, so invalid input is rejected with HTTP 400 before reaching any business logic. This needs spring-boot-starter-validation on the classpath; without it the annotations are silently ignored. Two details prevent subtle bugs. Use Integer, not int, for a field that must be provided: a missing primitive in JSON silently becomes 0 and passes @Min(0). And a class-level constraint, like the price range check below, produces a global error, not a field error, so the exception handler must read both kinds (it does in the next section).
Java
// Create request — all required fields:
public record CreateProductRequest(

    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 200, message = "Name must be 2-200 characters")
    String name,

    @NotBlank(message = "Description is required")
    @Size(max = 2000, message = "Description cannot exceed 2000 characters")
    String description,

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be greater than 0")
    @Digits(integer = 8, fraction = 2, message = "Invalid price format")
    BigDecimal price,

    @NotNull(message = "Category is required")
    Long categoryId,

    @NotNull(message = "Stock quantity is required")   // Integer, not int, so "missing" is detectable
    @Min(value = 0, message = "Stock cannot be negative")
    Integer stockQuantity,

    @NotNull
    @Size(max = 10, message = "Maximum 10 tags allowed")
    List<@NotBlank @Size(max = 50) String> tags

) { }

// Update request — all fields required (PUT = full replacement):
public record UpdateProductRequest(

    @NotBlank @Size(min = 2, max = 200)
    String name,

    @NotBlank @Size(max = 2000)
    String description,

    @NotNull @DecimalMin("0.01") @Digits(integer = 8, fraction = 2)
    BigDecimal price,

    @NotNull
    Long categoryId,

    @NotNull @Min(0)
    Integer stockQuantity,

    @NotNull @Size(max = 10)
    List<@NotBlank @Size(max = 50) String> tags

) { }

// Patch request — all fields optional (PATCH = partial update):
public record PatchProductRequest(

    @Size(min = 2, max = 200)
    String name,                   // null = don't update

    @Size(max = 2000)
    String description,            // null = don't update

    @DecimalMin("0.01") @Digits(integer = 8, fraction = 2)
    BigDecimal price,              // null = don't update

    Long categoryId,               // null = don't update

    @Min(0)
    Integer stockQuantity,         // null = don't update (Integer, not int)

    @Size(max = 10)
    List<@NotBlank @Size(max = 50) String> tags   // null = don't update

) { }

// Custom cross-field validation:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PriceRangeValidator.class)
public @interface ValidPriceRange {
    String message() default "Sale price must be less than regular price";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@ValidPriceRange
public record CreateSaleRequest(
    @NotNull BigDecimal regularPrice,
    @NotNull BigDecimal salePrice
) { }

public class PriceRangeValidator
        implements ConstraintValidator<ValidPriceRange, CreateSaleRequest> {
    @Override
    public boolean isValid(CreateSaleRequest req, ConstraintValidatorContext ctx) {
        if (req.regularPrice() == null || req.salePrice() == null) return true;
        return req.salePrice().compareTo(req.regularPrice()) < 0;
    }
}

Response DTOs and ResponseEntity

Response DTOs expose exactly the fields clients need. Never return JPA entities directly: you leak internal columns, tie your API to your schema, and risk lazy-loading errors during serialization. For timestamps in an API, prefer Instant or OffsetDateTime over LocalDateTime, because LocalDateTime carries no time zone and clients cannot interpret it unambiguously. Return a PagedResponse instead of a raw Page. Serializing Spring Data's PageImpl directly is discouraged in recent Spring Data versions (its JSON structure is not a stable contract), and a wrapper record gives clients a shape you control. The record is defined once here and reused everywhere. One performance warning: mapping an entity to ProductResponse touches the category and the tags, which triggers one extra query per row on list endpoints (the N+1 problem). Fetch the category with an @EntityGraph on the repository method, and set spring.jpa.properties.hibernate.default_batch_fetch_size to something like 50 so lazy collections such as tags load in batches.
Java
// Response DTO — never expose JPA entities directly:
public record ProductResponse(
    Long id,
    String name,
    String description,
    BigDecimal price,
    String category,
    int stockQuantity,
    boolean inStock,
    List<String> tags,
    Instant createdAt,
    Instant updatedAt
) {
    // Factory method — converts entity to DTO (call it inside a transaction):
    public static ProductResponse from(Product product) {
        return new ProductResponse(
            product.getId(),
            product.getName(),
            product.getDescription(),
            product.getPrice(),
            product.getCategory().getName(),
            product.getStockQuantity(),
            product.getStockQuantity() > 0,
            product.getTags().stream().map(Tag::getName).sorted().toList(),
            product.getCreatedAt(),
            product.getUpdatedAt()
        );
    }
}

// Paginated response — a stable shape you control (defined once, reused everywhere):
public record PagedResponse<T>(
    List<T> content,
    int pageNumber,
    int pageSize,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last,
    boolean empty
) {
    public static <T> PagedResponse<T> from(Page<T> page) {
        return new PagedResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isFirst(),
            page.isLast(),
            page.isEmpty()
        );
    }
}

// ResponseEntity — use it when you need control over status or headers:
@GetMapping("/{id}/summary")
public ResponseEntity<ProductResponse> getProductSummary(@PathVariable Long id) {
    ProductResponse dto = productService.findById(id);

    // 200 OK with caching headers:
    return ResponseEntity.ok()
        .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic())
        .header("X-Product-Version", "2")
        .body(dto);

    // Other examples:
    // return ResponseEntity.status(HttpStatus.ACCEPTED).body(dto);
    // return ResponseEntity.created(location).body(dto);
    // return ResponseEntity.noContent().build();
}

Global Exception Handling

@RestControllerAdvice centralizes error handling so controllers contain no try-catch blocks and every failure returns the same JSON shape. The handler below extends ResponseEntityExceptionHandler, and this matters. A bare @ExceptionHandler(Exception.class) catch-all intercepts every exception, including Spring MVC's own: a missing required parameter, an unsupported media type, or a request to an unknown URL would all be turned into 500 errors. By extending ResponseEntityExceptionHandler, Spring MVC exceptions keep their proper 4xx status, and the handleExceptionInternal override puts them into your error format. The catch-all then only receives genuinely unexpected exceptions. The remaining handlers cover typical cases. Validation errors include both field errors and global (class-level) errors. A DataIntegrityViolationException returns 409, which catches the race where two requests pass the existsByName check at the same moment and the database unique constraint stops the second one. A PropertyReferenceException (an unknown sort property from ?sort=banana) becomes a 400 instead of a 500. Server-side messages are never echoed for unexpected errors. The record is named ErrorResponse here, which is also the name of an interface in org.springframework.web. Keep your own class in your own package and be careful which one your IDE imports.
Java
// Standard error response structure:
public record ErrorResponse(
    int status,
    String code,
    String message,
    List<FieldError> errors,
    Instant timestamp,
    String path
) {
    public record FieldError(String field, String message) { }

    public static ErrorResponse of(int status, String code, String message, String path) {
        return new ErrorResponse(status, code, message, List.of(), Instant.now(), path);
    }
}

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // 404 — resource not found:
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex,
                                                        HttpServletRequest request) {
        return build(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", ex.getMessage(), request);
    }

    // 409 — duplicate found by the service:
    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateResourceException ex,
                                                         HttpServletRequest request) {
        return build(HttpStatus.CONFLICT, "DUPLICATE_RESOURCE", ex.getMessage(), request);
    }

    // 409 — database constraint hit (e.g. two concurrent creates with the same name).
    // The SQL message is deliberately not returned to the client.
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException ex,
                                                             HttpServletRequest request) {
        log.warn("Data integrity violation at {}: {}", request.getRequestURI(),
            ex.getMostSpecificCause().getMessage());
        return build(HttpStatus.CONFLICT, "DATA_CONFLICT",
            "The request conflicts with existing data", request);
    }

    // 400 — unknown sort property, e.g. ?sort=banana,asc
    @ExceptionHandler(PropertyReferenceException.class)
    public ResponseEntity<ErrorResponse> handleBadSort(PropertyReferenceException ex,
                                                       HttpServletRequest request) {
        return build(HttpStatus.BAD_REQUEST, "INVALID_SORT",
            "Cannot sort by '" + ex.getPropertyName() + "'", request);
    }

    // 400 — path variable or query param type mismatch:
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex,
                                                            HttpServletRequest request) {
        String type = ex.getRequiredType() != null
            ? ex.getRequiredType().getSimpleName() : "a valid value";
        return build(HttpStatus.BAD_REQUEST, "INVALID_PARAMETER",
            "Parameter '" + ex.getName() + "' must be of type " + type, request);
    }

    // 403 — only if you use Spring Security method security:
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex,
                                                            HttpServletRequest request) {
        return build(HttpStatus.FORBIDDEN, "ACCESS_DENIED",
            "You do not have permission to access this resource", request);
    }

    // 500 — only truly unexpected exceptions reach this:
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex, HttpServletRequest request) {
        log.error("Unexpected error at {}", request.getRequestURI(), ex);
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR",
            "An unexpected error occurred", request);
    }

    // 400 — @Valid failure: field errors AND class-level (global) errors:
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {

        List<ErrorResponse.FieldError> errors = new ArrayList<>();
        ex.getBindingResult().getFieldErrors().forEach(e ->
            errors.add(new ErrorResponse.FieldError(e.getField(), e.getDefaultMessage())));
        ex.getBindingResult().getGlobalErrors().forEach(e ->
            errors.add(new ErrorResponse.FieldError(e.getObjectName(), e.getDefaultMessage())));

        ErrorResponse body = new ErrorResponse(400, "VALIDATION_FAILED",
            "Request validation failed", errors, Instant.now(), path(request));
        return ResponseEntity.badRequest().body(body);
    }

    // Every other Spring MVC exception (404 unknown URL, 405, 406, 415, malformed JSON,
    // missing parameter...) keeps its own status and uses the same JSON shape.
    // The generic reason phrase avoids leaking parser internals to clients.
    @Override
    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex, Object body, HttpHeaders headers,
            HttpStatusCode statusCode, WebRequest request) {

        HttpStatus status = HttpStatus.valueOf(statusCode.value());
        ErrorResponse error = ErrorResponse.of(status.value(),
            status.name(), status.getReasonPhrase(), path(request));
        return super.handleExceptionInternal(ex, error, headers, statusCode, request);
    }

    private ResponseEntity<ErrorResponse> build(HttpStatus status, String code,
                                                String message, HttpServletRequest request) {
        return ResponseEntity.status(status)
            .body(ErrorResponse.of(status.value(), code, message, request.getRequestURI()));
    }

    private String path(WebRequest request) {
        return ((ServletWebRequest) request).getRequest().getRequestURI();
    }
}

// Custom exception classes:
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, Object id) {
        super(resource + " not found with id: " + id);
    }
}

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

Standard Error Format with ProblemDetail

Spring Framework 6 supports RFC 9457 Problem Details through the ProblemDetail class, sent as application/problem+json. If you prefer a standard format over a custom ErrorResponse, enable spring.mvc.problemdetails.enabled=true and Spring Boot registers a handler that renders Spring MVC's own exceptions in that format. Use that setting or your own handler class extending ResponseEntityExceptionHandler, not both. Custom fields such as a machine-readable code go in as properties.
Java
// application.properties:
// spring.mvc.problemdetails.enabled=true

@RestControllerAdvice
public class ProblemDetailsHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        problem.setProperty("code", "RESOURCE_NOT_FOUND");
        return problem;
    }
}

// Response (Content-Type: application/problem+json):
// {
//   "type": "about:blank",
//   "title": "Resource not found",
//   "status": 404,
//   "detail": "Product not found with id: 99",
//   "instance": "/api/v1/products/99",
//   "code": "RESOURCE_NOT_FOUND"
// }

Service Layer

The service layer contains business logic and transactions. Controllers delegate to services, and services never deal with HTTP. A few details keep it correct. The service throws ResourceNotFoundException so the controller stays free of Optional handling. PUT and PATCH apply every field in the request, including the category and the tags (an earlier version of this pattern validated them and then ignored them). saveAndFlush makes generated values such as updatedAt available before the response is built. The existsByName check is a friendly early error, but only a unique constraint on the column actually guarantees uniqueness, which is why the database error is also handled globally as a 409. Clamp user-supplied limits so a client cannot request an unbounded result.
Java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ProductService {

    private static final int MAX_SEARCH_LIMIT = 50;

    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;
    private final TagRepository tagRepository;

    @Transactional(readOnly = true)
    public Page<ProductResponse> findAll(Pageable pageable) {
        return productRepository.findAll(pageable).map(ProductResponse::from);
    }

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        return ProductResponse.from(getOrThrow(id));
    }

    @Transactional(readOnly = true)
    public List<ProductResponse> search(String query, String category, int limit) {
        int safeLimit = Math.min(Math.max(limit, 1), MAX_SEARCH_LIMIT);
        return productRepository
            .searchByNameAndCategory(query, category, PageRequest.of(0, safeLimit))
            .stream()
            .map(ProductResponse::from)
            .toList();
    }

    public ProductResponse create(CreateProductRequest request) {
        if (productRepository.existsByName(request.name())) {
            throw new DuplicateResourceException(
                "Product already exists with name: " + request.name());
        }

        Product product = Product.builder()
            .name(request.name())
            .description(request.description())
            .price(request.price())
            .category(getCategory(request.categoryId()))
            .stockQuantity(request.stockQuantity())
            .tags(resolveTags(request.tags()))
            .build();

        Product saved = productRepository.saveAndFlush(product);
        log.info("Product created: id={}, name={}", saved.getId(), saved.getName());
        return ProductResponse.from(saved);
    }

    public ProductResponse update(Long id, UpdateProductRequest request) {
        Product product = getOrThrow(id);
        product.setName(request.name());
        product.setDescription(request.description());
        product.setPrice(request.price());
        product.setCategory(getCategory(request.categoryId()));
        product.setStockQuantity(request.stockQuantity());
        product.setTags(resolveTags(request.tags()));
        return ProductResponse.from(productRepository.saveAndFlush(product));
    }

    public ProductResponse patch(Long id, PatchProductRequest request) {
        Product product = getOrThrow(id);
        // Only update fields that are non-null in the request:
        if (request.name() != null) product.setName(request.name());
        if (request.description() != null) product.setDescription(request.description());
        if (request.price() != null) product.setPrice(request.price());
        if (request.categoryId() != null) product.setCategory(getCategory(request.categoryId()));
        if (request.stockQuantity() != null) product.setStockQuantity(request.stockQuantity());
        if (request.tags() != null) product.setTags(resolveTags(request.tags()));
        return ProductResponse.from(productRepository.saveAndFlush(product));
    }

    public void delete(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ResourceNotFoundException("Product", id);
        }
        productRepository.deleteById(id);
        log.info("Product deleted: id={}", id);
    }

    private Product getOrThrow(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product", id));
    }

    private Category getCategory(Long id) {
        return categoryRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Category", id));
    }

    // Sketch: assumes Tag has a unique name and a constructor taking it
    private Set<Tag> resolveTags(List<String> names) {
        return names.stream()
            .distinct()
            .map(name -> tagRepository.findByName(name)
                .orElseGet(() -> tagRepository.save(new Tag(name))))
            .collect(Collectors.toSet());
    }
}

Pagination and Sorting

Spring Data resolves a Pageable parameter automatically from the page, size and sort query parameters. Treat these as untrusted input. The default maximum page size in Spring Data web support is 2000, so lower it explicitly, otherwise one request can pull thousands of rows. A client can also sort by any property name: an unknown one raises PropertyReferenceException (handled above as a 400), and a valid but unindexed one can make the query slow, so consider restricting sortable fields. Add a unique tie-breaker such as id to the sort, because sorting only by a non-unique column such as name can return the same row on two pages. Offset pagination, which this uses, gets slower on very large tables because the database must skip rows. For big or fast-changing collections, consider keyset (cursor) pagination or return a Slice to avoid the count query.
Java
// Controller — Pageable auto-resolved from request parameters:
@GetMapping
public PagedResponse<ProductResponse> getProducts(
        @PageableDefault(size = 20, sort = {"createdAt", "id"}, direction = Sort.Direction.DESC)
        Pageable pageable) {
    return PagedResponse.from(productService.findAll(pageable));
}

// Client usage:
// GET /api/v1/products                                → page 0, size 20, default sort
// GET /api/v1/products?page=2&size=10                 → page 2, 10 per page
// GET /api/v1/products?sort=price,desc                → sorted by price descending
// GET /api/v1/products?sort=name,asc&sort=price,desc  → multi-sort

// application.properties — put real limits on client-controlled paging:
// spring.data.web.pageable.default-page-size=20
// spring.data.web.pageable.max-page-size=100          (the default maximum is 2000)
// spring.data.web.pageable.one-indexed-parameters=false

// Repository with Pageable (category loaded in the same query, avoiding N+1):
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Override
    @EntityGraph(attributePaths = "category")
    Page<Product> findAll(Pageable pageable);

    Page<Product> findByCategoryId(Long categoryId, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
    Page<Product> findByPriceRange(
        @Param("min") BigDecimal min,
        @Param("max") BigDecimal max,
        Pageable pageable
    );
}

// JSON response shape:
// {
//   "content": [...],
//   "pageNumber": 0,
//   "pageSize": 20,
//   "totalElements": 150,
//   "totalPages": 8,
//   "first": true,
//   "last": false,
//   "empty": false
// }

Content Negotiation and Headers

Spring Boot serves different formats based on the Accept header and gives you full control over request and response headers. XML output needs the jackson-dataformat-xml dependency; without it an XML request returns 406. Content negotiation is covered in depth in the Content Negotiation entry. The example below focuses on headers. Never put a client-supplied value into a response header unchecked: the download endpoint maps the requested format through an allow-list and builds the Content-Disposition header with ContentDisposition, which handles quoting and encoding. Note also that a path variable needs a matching placeholder in the URL: the download mapping includes {id}.
Java
@RestController
@RequestMapping("/api/v1/reports")
@RequiredArgsConstructor
public class ReportController {

    private static final Map<String, MediaType> FORMATS = Map.of(
        "PDF", MediaType.APPLICATION_PDF,
        "CSV", new MediaType("text", "csv"));

    private final ReportService reportService;

    // Serve JSON or XML based on the Accept header:
    @GetMapping(value = "/{id}", produces = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE
    })
    public ReportResponse getReport(@PathVariable Long id) {
        return reportService.findById(id);
    }

    // Accept and return JSON only:
    @PostMapping(
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ReportResponse createReport(@RequestBody @Valid CreateReportRequest request) {
        return reportService.create(request);
    }

    // Read request headers, build response headers safely:
    @GetMapping("/{id}/download")
    public ResponseEntity<byte[]> downloadReport(
            @PathVariable Long id,
            @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String language,
            @RequestHeader(value = "X-Report-Format", defaultValue = "PDF") String format) {

        String key = format.toUpperCase(Locale.ROOT);
        MediaType type = FORMATS.get(key);
        if (type == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Unsupported report format");
        }

        byte[] data = reportService.generateReport(id, language, key);

        return ResponseEntity.ok()
            .contentType(type)
            .contentLength(data.length)
            .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment()
                    .filename("report-" + id + "." + key.toLowerCase(Locale.ROOT))
                    .build().toString())
            .header("X-Report-Id", id.toString())
            .body(data);
    }
}

CORS Configuration

Browsers block cross-origin calls unless the API sends CORS headers. Configure it globally in one place, and list exact origins rather than a wildcard, especially with allowCredentials(true), which cannot be combined with a wildcard origin. If the application uses Spring Security, CORS must also be enabled in the security filter chain, otherwise the browser's preflight OPTIONS request is rejected before Spring MVC's CORS configuration is consulted. The @CrossOrigin annotation works for a single controller, but a global configuration is easier to keep consistent.
Java
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com", "http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
            .allowedHeaders("Content-Type", "Authorization")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

// With Spring Security, also enable CORS in the filter chain so preflight requests pass:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())   // picks up the WebMvcConfigurer mapping above
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

// Single controller only (global config is usually better):
// @CrossOrigin(origins = "https://myapp.com", maxAge = 3600)

API Versioning

API versioning lets you evolve an API without breaking existing clients. There are four common strategies: the URL path, a custom header, the Accept header with a vendor media type, and a request parameter. URL versioning is the most widely used, because it is visible, bookmarkable and easy to test in a browser. The trade-off is that the version becomes part of the resource's address. With header, parameter and media-type versioning, decide what happens when the client sends no version: without a default, the request matches no handler and returns 404, and with the media-type strategy a request that accepts anything can match several handlers at once. A single handler with a defaultValue is the simplest fix. Newer Spring releases (Spring Framework 7 and Spring Boot 4) add built-in API versioning support, so check the reference documentation for your version before building your own mechanism. The Content Negotiation entry covers vendor media types in more detail.
Java
// ── STRATEGY 1: URL path versioning (most common) ────────────────────

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    @GetMapping("/{id}")
    public UserResponseV1 getUser(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) {
        return userService.findByIdV2(id);  // new response shape
    }
}

// ── STRATEGY 2: Header versioning (clean URLs, less discoverable) ────

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

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        return userService.findByIdV2(id);
    }
}
// A request without X-API-Version matches neither method and returns 404.

// ── Safer: one handler with an explicit default version ──────────────

@GetMapping("/{id}")
public ResponseEntity<?> getUser(
        @PathVariable Long id,
        @RequestHeader(value = "X-API-Version", defaultValue = "2") int version) {
    return switch (version) {
        case 1 -> ResponseEntity.ok(userService.findByIdV1(id));
        case 2 -> ResponseEntity.ok(userService.findByIdV2(id));
        default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "Unsupported API version");
    };
}

// ── STRATEGY 3: Accept header (vendor media type) ────────────────────

@GetMapping(value = "/{id}", produces = "application/vnd.myapp.v1+json")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }

@GetMapping(value = "/{id}", produces = "application/vnd.myapp.v2+json")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

// ── STRATEGY 4: Request parameter ────────────────────────────────────

@GetMapping(value = "/{id}", params = "version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }

@GetMapping(value = "/{id}", params = "version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

Documenting the API with OpenAPI

Clients need a contract they can read. The springdoc-openapi library generates an OpenAPI 3 description of your controllers and serves an interactive Swagger UI, with no extra code in simple cases. Use the 2.x line of springdoc for Spring Boot 3 and look up the current version on Maven Central. The description is available at /v3/api-docs and the UI at /swagger-ui.html. Because the generated spec is built from your controllers and DTOs, the validation annotations you wrote appear in it as constraints. Add @Operation and @ApiResponse annotations where the default description is not enough, and consider disabling the UI in production, or protecting it, if the API is not public.
XML
<!-- pom.xml -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>${springdoc.version}</version>   <!-- latest 2.x -->
</dependency>

<!-- application.properties springdoc.swagger-ui.enabled=true springdoc.api-docs.path=/v3/api-docs
-->

Testing REST APIs

Use @WebMvcTest for fast controller tests, which load only the MVC layer (including your @RestControllerAdvice) and mock the service, and @SpringBootTest for end-to-end tests. Replace beans with @MockitoBean on Spring Boot 3.4 and later (@MockBean on earlier versions); @AutoConfigureMockMvc is already included in @WebMvcTest. Two testing pitfalls to avoid. Do not count validation errors exactly: one bad field can trigger several constraints (a blank name violates both @NotBlank and @Size), so assert on which fields are reported. And do not put @Transactional on a test that calls the API over real HTTP (RANDOM_PORT): the server handles the request on another thread, so the test's transaction neither wraps nor rolls back what the server does. Clean the data in @BeforeEach instead.
Java
// Controller slice test — only loads the MVC layer, no database:
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getProduct_whenExists_returns200() throws Exception {
        ProductResponse product = new ProductResponse(
            1L, "Laptop Pro", "High-performance laptop",
            new BigDecimal("1299.99"), "Electronics",
            10, true, List.of("laptop", "premium"),
            Instant.now(), Instant.now()
        );
        when(productService.findById(1L)).thenReturn(product);

        mockMvc.perform(get("/api/v1/products/1")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Laptop Pro"))
            .andExpect(jsonPath("$.price").value(1299.99))
            .andExpect(jsonPath("$.inStock").value(true));
    }

    @Test
    void getProduct_whenNotFound_returns404WithErrorBody() throws Exception {
        when(productService.findById(99L))
            .thenThrow(new ResourceNotFoundException("Product", 99L));

        mockMvc.perform(get("/api/v1/products/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
            .andExpect(jsonPath("$.path").value("/api/v1/products/99"));
    }

    @Test
    void createProduct_withValidRequest_returns201AndLocation() throws Exception {
        CreateProductRequest request = new CreateProductRequest(
            "New Laptop", "Description", new BigDecimal("999.99"),
            1L, 5, List.of("laptop")
        );
        ProductResponse created = new ProductResponse(
            2L, "New Laptop", "Description",
            new BigDecimal("999.99"), "Electronics",
            5, true, List.of("laptop"),
            Instant.now(), Instant.now()
        );
        when(productService.create(any())).thenReturn(created);

        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(header().string("Location", containsString("/api/v1/products/2")));
    }

    @Test
    void createProduct_withInvalidRequest_returns400WithFieldErrors() throws Exception {
        CreateProductRequest invalid = new CreateProductRequest(
            "",                         // blank name: @NotBlank and @Size both fail
            "Description",
            new BigDecimal("-10.00"),   // negative price: @DecimalMin fails
            1L, 0, List.of()
        );

        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalid)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
            .andExpect(jsonPath("$.errors[?(@.field == 'name')]").exists())
            .andExpect(jsonPath("$.errors[?(@.field == 'price')]").exists());
    }

    @Test
    void getProducts_withUnknownRequiredParameter_isNot500() throws Exception {
        mockMvc.perform(get("/api/v1/products/search"))   // required "query" is missing
            .andExpect(status().isBadRequest());
    }
}

// Full integration test over real HTTP — no @Transactional here:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class ProductApiIntegrationTest {

    @Autowired private TestRestTemplate restTemplate;
    @Autowired private ProductRepository productRepository;
    @Autowired private CategoryRepository categoryRepository;

    private Long categoryId;

    @BeforeEach
    void setUp() {
        productRepository.deleteAll();
        categoryRepository.deleteAll();
        categoryId = categoryRepository
            .save(Category.builder().name("Electronics").build()).getId();
    }

    @Test
    void fullCrudFlow() {
        // Create:
        CreateProductRequest createReq = new CreateProductRequest(
            "Test Product", "Description", new BigDecimal("49.99"),
            categoryId, 100, List.of("test"));
        ResponseEntity<ProductResponse> createRes = restTemplate
            .postForEntity("/api/v1/products", createReq, ProductResponse.class);
        assertThat(createRes.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createRes.getHeaders().getLocation()).isNotNull();
        Long id = createRes.getBody().id();

        // Read:
        ResponseEntity<ProductResponse> getRes = restTemplate
            .getForEntity("/api/v1/products/" + id, ProductResponse.class);
        assertThat(getRes.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getRes.getBody().name()).isEqualTo("Test Product");

        // Delete, then confirm a JSON 404:
        restTemplate.delete("/api/v1/products/" + id);
        ResponseEntity<ErrorResponse> deleted = restTemplate
            .getForEntity("/api/v1/products/" + id, ErrorResponse.class);
        assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(deleted.getBody().code()).isEqualTo("RESOURCE_NOT_FOUND");
    }
}

Production Readiness Checklist

Before shipping a REST API, check the following. - Validation: every request DTO is @Valid, with limits on string lengths, list sizes and numeric ranges. - Errors: one consistent JSON shape, 4xx for client mistakes, no stack traces or SQL in responses, and unexpected errors logged with context. - Pagination: every list endpoint is paged, with a low maximum page size and a stable sort. - Security: authentication on everything except public routes, CORS with explicit origins, and HTTPS terminated at the edge. Authorization rules are checked on the server for every resource, not only hidden in the UI. - Request limits: a maximum body size, and timeouts on outbound calls so a slow dependency cannot hold every thread. - Idempotency: PUT and DELETE are idempotent by design; for payment-like POST endpoints, accept an idempotency key so retries do not create duplicates. - Performance: no N+1 queries on list endpoints, response compression (server.compression.enabled=true), and cache headers or ETags on read-heavy resources. - Operations: Actuator health and metrics endpoints, graceful shutdown (server.shutdown=graceful), structured logs with a request id. - Contract: an OpenAPI description, a versioning plan, and tests for success, validation failure, not found and conflict paths.

Common Mistakes

1. A catch-all @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler. Missing parameters, unsupported media types and unknown URLs become 500 errors. 2. Using Page directly as a response, or accepting unbounded size and sort parameters. Wrap pages in your own record and cap the page size. 3. Returning entities from controllers. They leak internal fields and cause lazy-loading errors. 4. Using primitive int or boolean in request DTOs. A missing field silently becomes 0 or false and passes validation. 5. Validating fields in the DTO and then not applying them in the service (for example, categoryId or tags on PUT). 6. Returning an empty 404 from some endpoints and a JSON error from others. Throw one exception type everywhere. 7. Counting validation errors in tests, or reading only field errors in the handler and dropping class-level ones. 8. Relying on an existsBy check for uniqueness. Add a unique constraint and handle the violation as a 409. 9. Forgetting spring-boot-starter-validation, so validation annotations do nothing. 10. Putting a client value straight into a response header such as Content-Disposition. 11. Using @Transactional on a RANDOM_PORT integration test. It has no effect on what the server does. 12. Configuring CORS only in Spring MVC while Spring Security is active. Preflight requests fail.

FAQ

What is the difference between @Controller and @RestController? @RestController combines @Controller and @ResponseBody, so every method's return value is written to the response body (as JSON by default) instead of being treated as a view name. Should I use PUT or PATCH for updates? Use PUT to replace a resource with a complete representation and PATCH to change only some fields. Both can return the updated resource. How do I return a 404 in Spring Boot? Throw a custom exception such as ResourceNotFoundException and handle it in a @RestControllerAdvice. That gives every endpoint the same JSON error body. Alternatively use ResponseEntity.notFound().build() for an empty body. Why does a missing request parameter return 500? A broad @ExceptionHandler(Exception.class) is catching MissingServletRequestParameterException. Extend ResponseEntityExceptionHandler so Spring MVC's exceptions keep their 4xx status. How do I validate a request body in Spring Boot? Add spring-boot-starter-validation, annotate the DTO fields with constraints such as @NotBlank and @Min, and put @Valid on the @RequestBody parameter. Failures raise MethodArgumentNotValidException, which your handler turns into a 400. How should I version a REST API? URL versioning (/api/v1/...) is the simplest and most common. Header and media-type versioning keep URLs clean but are harder to test and need a defined default version. How do I document a Spring Boot REST API? Add springdoc-openapi-starter-webmvc-ui to generate an OpenAPI spec and a Swagger UI from your controllers.