Spring Boot REST API Best Practices: A Comprehensive Guide
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.