Back to Blog

Spring Boot REST API Best Practices: A Comprehensive Guide

26 min read
Spring BootREST APIJavaBackend

Spring Boot REST API Best Practices: A Comprehensive Guide

Building a robust REST API with Spring Boot involves more than just writing code that works. It requires thoughtful design, proper error handling, comprehensive documentation, and rigorous testing. This guide covers best practices for creating production-ready REST APIs with Spring Boot.

API Design Principles

Resource-Oriented Design

REST APIs should be designed around resources, with clear naming conventions:

// Good: Resource-oriented endpoints
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    @GetMapping
    public List<ProductDTO> getAllProducts() {
        // Implementation
    }
    
    @GetMapping("/{id}")
    public ProductDTO getProduct(@PathVariable Long id) {
        // Implementation
    }
    
    @PostMapping
    public ResponseEntity<ProductDTO> createProduct(@RequestBody ProductDTO product) {
        // Implementation
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<ProductDTO> updateProduct(@PathVariable Long id, @RequestBody ProductDTO product) {
        // Implementation
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        // Implementation
    }
}

Consistent Response Structure

Create a standard response format for all API endpoints:

@Getter
@Setter
@AllArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private List<String> errors;
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "Success", data, null);
    }
    
    public static <T> ApiResponse<T> failure(String message, List<String> errors) {
        return new ApiResponse<>(false, message, null, errors);
    }
}

// Using the standard response
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ProductDTO>> getProduct(@PathVariable Long id) {
    ProductDTO product = productService.findById(id);
    return ResponseEntity.ok(ApiResponse.success(product));
}

Proper HTTP Status Codes

Use appropriate HTTP status codes to indicate the outcome of requests:

@PostMapping
public ResponseEntity<ApiResponse<ProductDTO>> createProduct(@Valid @RequestBody ProductDTO productDTO) {
    ProductDTO created = productService.create(productDTO);
    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(ApiResponse.success(created));
}

@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ProductDTO>> getProduct(@PathVariable Long id) {
    try {
        ProductDTO product = productService.findById(id);
        return ResponseEntity.ok(ApiResponse.success(product));
    } catch (ResourceNotFoundException e) {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ApiResponse.failure("Product not found", List.of(e.getMessage())));
    }
}

Request Validation

Input Validation with Bean Validation

Use Bean Validation annotations to validate request data:

@Data
public class ProductDTO {
    private Long id;
    
    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 100, message = "Product name must be between 2 and 100 characters")
    private String name;
    
    @NotNull(message = "Price is required")
    @Positive(message = "Price must be positive")
    private BigDecimal price;
    
    @Min(value = 0, message = "Stock cannot be negative")
    private Integer stock;
    
    @NotBlank(message = "Category is required")
    private String category;
}

@PostMapping
public ResponseEntity<ApiResponse<ProductDTO>> createProduct(@Valid @RequestBody ProductDTO productDTO) {
    // The @Valid annotation triggers validation
    ProductDTO created = productService.create(productDTO);
    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(ApiResponse.success(created));
}

Custom Validation

For complex validation rules, create custom validators:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CategoryValidator.class)
public @interface ValidCategory {
    String message() default "Invalid category";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class CategoryValidator implements ConstraintValidator<ValidCategory, String> {
    
    private final CategoryService categoryService;
    
    public CategoryValidator(CategoryService categoryService) {
        this.categoryService = categoryService;
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true; // Let @NotBlank handle this
        }
        return categoryService.existsByName(value);
    }
}

// Using the custom validator
@Data
public class ProductDTO {
    // Other fields
    
    @NotBlank(message = "Category is required")
    @ValidCategory(message = "Category does not exist")
    private String category;
}

Error Handling

Global Exception Handler

Create a global exception handler to manage all API exceptions:

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleResourceNotFoundException(ResourceNotFoundException ex) {
        ApiResponse<Void> response = ApiResponse.failure(
            "Resource not found",
            List.of(ex.getMessage())
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getAllErrors()
            .stream()
            .map(error -> {
                if (error instanceof FieldError) {
                    return ((FieldError) error).getField() + ": " + error.getDefaultMessage();
                }
                return error.getDefaultMessage();
            })
            .collect(Collectors.toList());
        
        ApiResponse<Void> response = ApiResponse.failure("Validation failed", errors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
        ApiResponse<Void> response = ApiResponse.failure(
            "An unexpected error occurred",
            List.of(ex.getMessage())
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

Custom Exceptions

Define custom exceptions for different error scenarios:

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
    
    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue));
    }
}

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

Security Best Practices

JWT Authentication

Implement JWT-based authentication:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeHttpRequests()
            .requestMatchers("/api/v1/auth/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
            
        return http.build();
    }
}

Role-Based Access Control

Implement role-based access control for API endpoints:

@RestController
@RequestMapping("/api/v1/admin")
public class AdminController {
    
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/users")
    public ResponseEntity<ApiResponse<List<UserDTO>>> getAllUsers() {
        // Implementation
    }
    
    @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
    @GetMapping("/reports")
    public ResponseEntity<ApiResponse<List<ReportDTO>>> getReports() {
        // Implementation
    }
}

Input Sanitization

Sanitize input to prevent injection attacks:

@Component
public class InputSanitizer {
    
    private final PolicyFactory policy;
    
    public InputSanitizer() {
        policy = new HtmlPolicyBuilder()
            .allowElements("b", "i", "u", "strong", "em")
            .toFactory();
    }
    
    public String sanitize(String input) {
        if (input == null) {
            return null;
        }
        return policy.sanitize(input);
    }
}

@Service
public class ProductService {
    
    private final InputSanitizer sanitizer;
    private final ProductRepository productRepository;
    
    // Constructor
    
    public ProductDTO create(ProductDTO productDTO) {
        // Sanitize text fields
        productDTO.setName(sanitizer.sanitize(productDTO.getName()));
        productDTO.setDescription(sanitizer.sanitize(productDTO.getDescription()));
        
        // Continue with creation
        Product product = mapToEntity(productDTO);
        Product saved = productRepository.save(product);
        return mapToDTO(saved);
    }
}

API Documentation

Springdoc OpenAPI Integration

Use Springdoc OpenAPI to generate API documentation:

// Add dependencies
// implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Product Management API")
                .version("1.0")
                .description("API for managing products")
                .contact(new Contact()
                    .name("API Support")
                    .email("support@example.com")
                    .url("https://example.com/support")))
            .externalDocs(new ExternalDocumentation()
                .description("API Documentation")
                .url("https://example.com/docs"));
    }
}

// Document API endpoints
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Product Management", description = "APIs for managing products")
public class ProductController {
    
    @Operation(
        summary = "Get all products",
        description = "Retrieves a list of all products with optional filtering"
    )
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Successfully retrieved products"),
        @ApiResponse(responseCode = "401", description = "Unauthorized"),
        @ApiResponse(responseCode = "500", description = "Internal server error")
    })
    @GetMapping
    public ResponseEntity<ApiResponse<List<ProductDTO>>> getAllProducts(
            @Parameter(description = "Filter by category")
            @RequestParam(required = false) String category) {
        // Implementation
    }
    
    // Other endpoints
}

Pagination and Filtering

Implementing Pagination

Use Spring Data's pagination support:

@GetMapping
public ResponseEntity<ApiResponse<Page<ProductDTO>>> getAllProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "id") String sortBy,
        @RequestParam(defaultValue = "asc") String direction) {
    
    Sort.Direction sortDirection = direction.equalsIgnoreCase("desc") ? 
        Sort.Direction.DESC : Sort.Direction.ASC;
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
    Page<ProductDTO> products = productService.findAll(pageable);
    
    return ResponseEntity.ok(ApiResponse.success(products));
}

@Service
public class ProductService {
    
    private final ProductRepository productRepository;
    private final ProductMapper productMapper;
    
    public Page<ProductDTO> findAll(Pageable pageable) {
        Page<Product> productPage = productRepository.findAll(pageable);
        return productPage.map(productMapper::toDTO);
    }
}

Advanced Filtering

Implement specification-based filtering:

@GetMapping
public ResponseEntity<ApiResponse<Page<ProductDTO>>> getAllProducts(
        @RequestParam(required = false) String name,
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "id") String sortBy,
        @RequestParam(defaultValue = "asc") String direction) {
    
    ProductFilter filter = new ProductFilter(name, category, minPrice, maxPrice);
    
    Sort.Direction sortDirection = direction.equalsIgnoreCase("desc") ? 
        Sort.Direction.DESC : Sort.Direction.ASC;
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
    Page<ProductDTO> products = productService.findAllWithFilter(filter, pageable);
    
    return ResponseEntity.ok(ApiResponse.success(products));
}

@Service
public class ProductService {
    
    public Page<ProductDTO> findAllWithFilter(ProductFilter filter, Pageable pageable) {
        Specification<Product> spec = Specification.where(null);
        
        if (filter.getName() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.like(cb.lower(root.get("name")), "%" + filter.getName().toLowerCase() + "%"));
        }
        
        if (filter.getCategory() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.equal(root.get("category"), filter.getCategory()));
        }
        
        if (filter.getMinPrice() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.greaterThanOrEqualTo(root.get("price"), filter.getMinPrice()));
        }
        
        if (filter.getMaxPrice() != null) {
            spec = spec.and((root, query, cb) -> 
                cb.lessThanOrEqualTo(root.get("price"), filter.getMaxPrice()));
        }
        
        Page<Product> productPage = productRepository.findAll(spec, pageable);
        return productPage.map(productMapper::toDTO);
    }
}

Testing

Unit Testing Controllers

Test controllers with MockMvc:

@WebMvcTest(ProductController.class)
class ProductControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private ProductService productService;
    
    @Test
    void shouldReturnProductWhenProductExists() throws Exception {
        // Given
        ProductDTO product = new ProductDTO();
        product.setId(1L);
        product.setName("Test Product");
        product.setPrice(new BigDecimal("19.99"));
        
        given(productService.findById(1L)).willReturn(product);
        
        // When & Then
        mockMvc.perform(get("/api/v1/products/1")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.id").value(1))
                .andExpect(jsonPath("$.data.name").value("Test Product"))
                .andExpect(jsonPath("$.data.price").value(19.99));
    }
    
    @Test
    void shouldReturn404WhenProductDoesNotExist() throws Exception {
        // Given
        given(productService.findById(1L)).willThrow(
            new ResourceNotFoundException("Product", "id", 1L));
        
        // When & Then
        mockMvc.perform(get("/api/v1/products/1")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.message").value("Resource not found"));
    }
}

Integration Testing

Test the complete request flow:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class ProductIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @BeforeEach
    void setup() {
        productRepository.deleteAll();
    }
    
    @Test
    void shouldCreateAndRetrieveProduct() throws Exception {
        // Create product
        ProductDTO productDTO = new ProductDTO();
        productDTO.setName("Integration Test Product");
        productDTO.setPrice(new BigDecimal("29.99"));
        productDTO.setCategory("Electronics");
        
        String productJson = objectMapper.writeValueAsString(productDTO);
        
        MvcResult result = mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(productJson))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.name").value("Integration Test Product"))
                .andReturn();
        
        // Extract ID from response
        String responseJson = result.getResponse().getContentAsString();
        ApiResponse response = objectMapper.readValue(responseJson, ApiResponse.class);
        Map<String, Object> data = (Map<String, Object>) response.getData();
        Integer id = (Integer) data.get("id");
        
        // Retrieve product
        mockMvc.perform(get("/api/v1/products/" + id)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.name").value("Integration Test Product"))
                .andExpect(jsonPath("$.data.price").value(29.99));
    }
}

Performance Optimization

Caching

Implement caching for frequently accessed data:

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(Arrays.asList(
            new ConcurrentMapCache("products"),
            new ConcurrentMapCache("categories")
        ));
        return cacheManager;
    }
}

@Service
public class ProductService {
    
    @Cacheable(value = "products", key = "#id")
    public ProductDTO findById(Long id) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product", "id", id));
        return productMapper.toDTO(product);
    }
    
    @CacheEvict(value = "products", key = "#id")
    public void deleteById(Long id) {
        productRepository.deleteById(id);
    }
    
    @CachePut(value = "products", key = "#result.id")
    public ProductDTO update(Long id, ProductDTO productDTO) {
        // Implementation
    }
}

Response Compression

Enable response compression:

# application.yml
server:
  compression:
    enabled: true
    mime-types: application/json,application/xml,text/html,text/plain
    min-response-size: 1024

Asynchronous Processing

Use asynchronous processing for time-consuming operations:

@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
    
    private final ReportService reportService;
    
    @PostMapping("/generate")
    public ResponseEntity<ApiResponse<String>> generateReport(@RequestBody ReportRequest request) {
        String reportId = reportService.scheduleReportGeneration(request);
        
        return ResponseEntity
            .accepted()
            .body(ApiResponse.success(reportId));
    }
    
    @GetMapping("/{id}/status")
    public ResponseEntity<ApiResponse<ReportStatus>> getReportStatus(@PathVariable String id) {
        ReportStatus status = reportService.getReportStatus(id);
        return ResponseEntity.ok(ApiResponse.success(status));
    }
    
    @GetMapping("/{id}/download")
    public ResponseEntity<Resource> downloadReport(@PathVariable String id) {
        Resource report = reportService.getReportFile(id);
        
        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"")
            .body(report);
    }
}

@Service
public class ReportService {
    
    private final Map<String, ReportStatus> reportStatuses = new ConcurrentHashMap<>();
    private final Map<String, Resource> reportFiles = new ConcurrentHashMap<>();
    
    @Async
    public String scheduleReportGeneration(ReportRequest request) {
        String reportId = UUID.randomUUID().toString();
        reportStatuses.put(reportId, ReportStatus.PROCESSING);
        
        CompletableFuture.runAsync(() -> {
            try {
                // Generate report (time-consuming operation)
                Resource reportFile = generateReport(request);
                reportFiles.put(reportId, reportFile);
                reportStatuses.put(reportId, ReportStatus.COMPLETED);
            } catch (Exception e) {
                reportStatuses.put(reportId, ReportStatus.FAILED);
            }
        });
        
        return reportId;
    }
    
    public ReportStatus getReportStatus(String reportId) {
        return reportStatuses.getOrDefault(reportId, ReportStatus.NOT_FOUND);
    }
    
    public Resource getReportFile(String reportId) {
        if (!ReportStatus.COMPLETED.equals(reportStatuses.get(reportId))) {
            throw new ReportNotReadyException("Report is not ready for download");
        }
        
        return reportFiles.get(reportId);
    }
    
    private Resource generateReport(ReportRequest request) {
        // Implementation of report generation
        // This is a time-consuming operation
    }
}

Versioning

URI Versioning

Version your API through the URI:

@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 {
    // V1 implementation
}

@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 {
    // V2 implementation with new features
}

Header Versioning

Version your API through custom headers:

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    private final ProductServiceV1 productServiceV1;
    private final ProductServiceV2 productServiceV2;
    
    @GetMapping(headers = "X-API-VERSION=1")
    public ResponseEntity<ApiResponse<List<ProductDTOV1>>> getAllProductsV1() {
        List<ProductDTOV1> products = productServiceV1.findAll();
        return ResponseEntity.ok(ApiResponse.success(products));
    }
    
    @GetMapping(headers = "X-API-VERSION=2")
    public ResponseEntity<ApiResponse<List<ProductDTOV2>>> getAllProductsV2() {
        List<ProductDTOV2> products = productServiceV2.findAll();
        return ResponseEntity.ok(ApiResponse.success(products));
    }
}

Monitoring and Logging

Actuator Integration

Enable Spring Boot Actuator for monitoring:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when_authorized

Structured Logging

Implement structured logging for better analysis:

@Aspect
@Component
public class LoggingAspect {
    
    private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);
    
    @Around("execution(* com.example.api.controller.*.*(..))")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        String className = joinPoint.getSignature().getDeclaringTypeName();
        String methodName = joinPoint.getSignature().getName();
        
        MDC.put("class", className);
        MDC.put("method", methodName);
        
        try {
            log.info("Entering method");
            Object result = joinPoint.proceed();
            long executionTime = System.currentTimeMillis() - start;
            
            MDC.put("executionTime", String.valueOf(executionTime));
            log.info("Method executed successfully in {}ms", executionTime);
            
            return result;
        } catch (Exception e) {
            MDC.put("exception", e.getClass().getName());
            log.error("Exception in method execution: {}", e.getMessage(), e);
            throw e;
        } finally {
            MDC.clear();
        }
    }
}

Conclusion

Building a production-ready REST API with Spring Boot requires attention to many aspects beyond basic functionality. By following these best practices, you can create APIs that are robust, secure, performant, and maintainable.

Remember that good API design is an iterative process. Start with a solid foundation, gather feedback from consumers, monitor performance, and continuously improve your implementation. The practices outlined in this guide will help you build APIs that stand the test of time and provide an excellent developer experience for your API consumers.