backend

15 min read

Top 10 Spring Boot REST API Best Practices (With Code Examples)

Explore the top 10 Spring Boot REST API best practices for creating clean, secure, and scalable APIs. Enhance your API development skills with practical code examples and expert tips for professional-grade Spring Boot applications.

Top 10 Spring Boot REST API Best Practices (With Code Examples) thumbnail

Published By: Nelson Djalo | Date: May 5, 2025

Introduction

Spring Boot makes it easy to build REST APIs quickly, but writing clean, secure, and scalable APIs still requires following best practices. In this comprehensive guide, you'll learn the top 10 Spring Boot REST API best practices that will help you write professional-grade APIs ready for production.

Whether you're building internal tools or public APIs, these tips will help you avoid common mistakes, improve maintainability, and impress your team. The practices outlined in this guide are based on years of experience building production APIs and have been proven to work at scale.

Building a REST API is more than just creating endpoints that return data. It's about designing an interface that's intuitive, secure, performant, and maintainable. The best practices in this guide will help you create APIs that developers love to use and that can scale with your business needs.

Table of Contents

🚀 1. Use Consistent and RESTful Resource Naming

Always use plural nouns and avoid action words in your URLs. This is one of the fundamental principles of REST API design and helps create intuitive, predictable endpoints.

❌ Bad

@GetMapping("/getAllUsers")
@PostMapping("/createUser")
@PutMapping("/updateUserById")

✅ Good

@GetMapping("api/v1/users")
@PostMapping("api/v1/users")
@PutMapping("api/v1/users/{id}")

Keep endpoints clean and meaningful:

  • api/v1/users – get all users
  • api/v1/users/{id} – get user by ID
  • api/v1/users (POST) – create new user
  • api/v1/users/{id} (PUT/PATCH) – update user
  • api/v1/users/{id} (DELETE) – delete user

Advanced Naming Conventions:

For nested resources, use a hierarchical structure:

// Get all posts by a specific user
@GetMapping("api/v1/users/{userId}/posts")

// Get a specific post by a specific user
@GetMapping("api/v1/users/{userId}/posts/{postId}")

// Create a new post for a specific user
@PostMapping("api/v1/users/{userId}/posts")

Benefits of RESTful Naming:

  • Predictability: Developers can guess endpoint URLs
  • Consistency: All endpoints follow the same pattern
  • Scalability: Easy to add new resources following the same pattern
  • Documentation: Self-documenting API structure

2. Return the Correct HTTP Status Codes

Always return status codes that reflect what happened on the server. This provides clear feedback to API consumers about the success or failure of their requests.

Creating Resources:

@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserRequest request) {
    UserDto created = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

Updating Resources:

@PutMapping("/users/{id}")
public ResponseEntity<UserDto> updateUser(@PathVariable Long id, @RequestBody @Valid UserRequest request) {
    UserDto updated = userService.updateUser(id, request);
    return ResponseEntity.ok(updated);
}

Deleting Resources:

@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
    return ResponseEntity.noContent().build();
}

Common HTTP Status Codes:

  • 200 OK – Success (GET, PUT, PATCH)
  • 201 Created – New resource created (POST)
  • 204 No Content – Successfully deleted (DELETE)
  • 400 Bad Request – Validation failed or malformed request
  • 401 Unauthorized – Authentication required
  • 403 Forbidden – Authenticated but not authorized
  • 404 Not Found – Resource not found
  • 409 Conflict – Resource conflict (e.g., duplicate email)
  • 422 Unprocessable Entity – Valid request but cannot be processed
  • 500 Internal Server Error – Something went wrong on the server

Custom Status Codes for Business Logic:

@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserRequest request) {
    try {
        UserDto created = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    } catch (DuplicateEmailException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).build();
    }
}

3. Use DTOs Instead of Entities

Never expose your database entities directly. Use Data Transfer Objects (DTOs) to control what data is exposed to clients and protect your internal data structure.

❌ Bad:

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    User user = userRepository.findById(id).orElseThrow(() -> new UserNotFoundException("User not found"));
    return ResponseEntity.ok(user); // Exposes all entity fields including sensitive data
}

✅ Good:

@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException("User not found"));
    return ResponseEntity.ok(UserResponse.from(user));
}

DTO Implementation:

public record UserResponse(
    Long id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getCreatedAt()
        );
    }
}

Benefits of Using DTOs:

  • Security: Hide sensitive fields like passwords, internal IDs
  • Flexibility: Change internal structure without affecting API contract
  • Performance: Only transfer necessary data
  • Versioning: Easier to evolve API without breaking changes

Advanced DTO Patterns:

// Request DTO for creating users
public record CreateUserRequest(
    @NotBlank String name,
    @Email String email,
    @NotBlank @Size(min = 8) String password
) {}

// Response DTO for user details
public record UserDetailResponse(
    Long id,
    String name,
    String email,
    List<PostSummary> posts,
    LocalDateTime createdAt
) {}

// Summary DTO for list views
public record UserSummaryResponse(
    Long id,
    String name,
    String email
) {}

4. Use Bean Validation for Request Bodies

Avoid manual if-checks and use Jakarta Bean Validation annotations for comprehensive input validation.

❌ Bad:

@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody UserRequest request) {
    if (request.getName() == null || request.getName().isBlank()) {
        throw new IllegalArgumentException("Name is required");
    }
    if (request.getEmail() == null || !request.getEmail().contains("@")) {
        throw new IllegalArgumentException("Valid email is required");
    }
    // More manual validation...
}

✅ Good:

public record UserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,
    
    @NotBlank(message = "Email is required")
    @Email(message = "Email must be valid")
    String email,
    
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=]).*$", 
             message = "Password must contain at least one digit, lowercase, uppercase, and special character")
    String password
) {}

@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody @Valid UserRequest request) {
    // Validation is automatically handled by Spring
    UserDto created = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

Custom Validation Annotations:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
    String message() default "Email already exists";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
    private final UserRepository userRepository;
    
    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        return email == null || !userRepository.existsByEmail(email);
    }
}

Validation Error Handling:

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.toList());
        
        ValidationErrorResponse response = new ValidationErrorResponse(
            "VALIDATION_ERROR",
            "Validation failed",
            errors
        );
        
        return ResponseEntity.badRequest().body(response);
    }
}

5. Apply Separation of Concerns

Structure your code using the Controller-Service-Repository pattern to maintain clean separation of concerns and improve testability.

Controller Layer (Handles HTTP requests):

@RestController
@RequestMapping("/api/v1/users")
@Validated
public class UserController {
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping
    public ResponseEntity<Page<UserSummaryResponse>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "name") String sort) {
        
        Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
        Page<UserSummaryResponse> users = userService.getUsers(pageable);
        return ResponseEntity.ok(users);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
        UserDetailResponse user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }
    
    @PostMapping
    public ResponseEntity<UserDetailResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
        UserDetailResponse created = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<UserDetailResponse> updateUser(
            @PathVariable Long id, 
            @RequestBody @Valid UpdateUserRequest request) {
        UserDetailResponse updated = userService.updateUser(id, request);
        return ResponseEntity.ok(updated);
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}

Service Layer (Business Logic):

@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }
    
    public Page<UserSummaryResponse> getUsers(Pageable pageable) {
        return userRepository.findAll(pageable)
            .map(UserSummaryResponse::from);
    }
    
    public UserDetailResponse getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
        return UserDetailResponse.from(user);
    }
    
    public UserDetailResponse createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateEmailException("Email already exists: " + request.email());
        }
        
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));
        user.setCreatedAt(LocalDateTime.now());
        
        User savedUser = userRepository.save(user);
        return UserDetailResponse.from(savedUser);
    }
    
    public UserDetailResponse updateUser(Long id, UpdateUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
        
        user.setName(request.name());
        user.setEmail(request.email());
        user.setUpdatedAt(LocalDateTime.now());
        
        User updatedUser = userRepository.save(user);
        return UserDetailResponse.from(updatedUser);
    }
    
    public void deleteUser(Long id) {
        if (!userRepository.existsById(id)) {
            throw new UserNotFoundException("User not found with id: " + id);
        }
        userRepository.deleteById(id);
    }
}

Repository Layer (Data Access):

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    
    @Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
    List<User> findByNameContaining(@Param("name") String name);
}

6. Implement Pagination and Limit Results

Never return thousands of records at once. Use pagination to improve performance and user experience.

Controller Implementation:

@GetMapping
public ResponseEntity<Page<UserSummaryResponse>> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "name") String sort,
        @RequestParam(defaultValue = "asc") String direction) {
    
    Sort.Direction sortDirection = Sort.Direction.fromString(direction.toUpperCase());
    Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort));
    
    Page<UserSummaryResponse> users = userService.getUsers(pageable);
    return ResponseEntity.ok(users);
}

Service Implementation:

public Page<UserSummaryResponse> getUsers(Pageable pageable) {
    // Validate page size to prevent abuse
    if (pageable.getPageSize() > 100) {
        pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort());
    }
    
    return userRepository.findAll(pageable)
        .map(UserSummaryResponse::from);
}

Custom Pagination Response:

public record PaginatedResponse<T>(
    List<T> data,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean hasNext,
    boolean hasPrevious
) {
    public static <T> PaginatedResponse<T> from(Page<T> page) {
        return new PaginatedResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.hasNext(),
            page.hasPrevious()
        );
    }
}

Advanced Pagination with Filtering:

@GetMapping("/search")
public ResponseEntity<PaginatedResponse<UserSummaryResponse>> searchUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String name,
        @RequestParam(required = false) String email,
        @RequestParam(defaultValue = "name") String sort) {
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
    Page<UserSummaryResponse> users = userService.searchUsers(name, email, pageable);
    
    return ResponseEntity.ok(PaginatedResponse.from(users));
}

7. Use Global Exception Handling

Handle errors in one place using @ControllerAdvice to provide consistent error responses across your API.

Global Exception Handler:

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.toList());
        
        ErrorResponse response = new ErrorResponse(
            "VALIDATION_ERROR",
            "Validation failed",
            errors
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
        ErrorResponse response = new ErrorResponse(
            "USER_NOT_FOUND",
            ex.getMessage()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
    
    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<ErrorResponse> handleDuplicateEmail(DuplicateEmailException ex) {
        ErrorResponse response = new ErrorResponse(
            "DUPLICATE_EMAIL",
            ex.getMessage()
        );
        
        return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse response = new ErrorResponse(
            "INTERNAL_ERROR",
            "An unexpected error occurred"
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

Error Response DTO:

public record ErrorResponse(
    String code,
    String message,
    List<String> details
) {
    public ErrorResponse(String code, String message) {
        this(code, message, null);
    }
}

Custom Exceptions:

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

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

8. Secure Your APIs

Secure your endpoints using Spring Security with proper authentication and authorization.

Security Configuration:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/public/**").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                .accessDeniedHandler(new JwtAccessDeniedHandler())
            );
        
        return http.build();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

JWT Authentication Filter:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtTokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        String token = getJwtFromRequest(request);
        
        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            String username = tokenProvider.getUsernameFromToken(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

Method-Level Security:

@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('USER') or #id == authentication.principal.id")
    public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
        // Only users can access their own data, or admins can access any user
        UserDetailResponse user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }
    
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        // Only admins can delete users
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}

9. Version Your API

Keep old versions alive by versioning your endpoints to avoid breaking changes for existing consumers.

URL Versioning:

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV1> getUser(@PathVariable Long id) {
        // V1 implementation
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> getUser(@PathVariable Long id) {
        // V2 implementation with additional fields
    }
}

Header Versioning:

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            @RequestHeader(value = "API-Version", defaultValue = "1") String version) {
        
        if ("2".equals(version)) {
            return ResponseEntity.ok(userService.getUserV2(id));
        } else {
            return ResponseEntity.ok(userService.getUserV1(id));
        }
    }
}

Content Negotiation:

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping(value = "/{id}", produces = {
        "application/vnd.company.app-v1+json",
        "application/vnd.company.app-v2+json"
    })
    public ResponseEntity<?> getUser(
            @PathVariable Long id,
            HttpServletRequest request) {
        
        String acceptHeader = request.getHeader("Accept");
        if (acceptHeader.contains("v2")) {
            return ResponseEntity.ok(userService.getUserV2(id));
        } else {
            return ResponseEntity.ok(userService.getUserV1(id));
        }
    }
}

Versioning Strategy Benefits:

  • Backward Compatibility: Existing clients continue to work
  • Gradual Migration: Clients can migrate to new versions at their own pace
  • Feature Evolution: Add new features without breaking existing functionality
  • Deprecation Management: Clear timeline for removing old versions

10. Document Your API with Swagger / OpenAPI

Use SpringDoc OpenAPI or Swagger UI for interactive documentation that helps developers understand and test your API.

Dependencies:

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

OpenAPI Configuration:

@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("User Management API")
                .version("1.0")
                .description("API for managing users in the system")
                .contact(new Contact()
                    .name("API Support")
                    .email("support@company.com"))
                .license(new License()
                    .name("MIT")
                    .url("https://opensource.org/licenses/MIT")))
            .servers(List.of(
                new Server().url("http://localhost:8080").description("Development server"),
                new Server().url("https://api.company.com").description("Production server")
            ));
    }
}

Controller Documentation:

@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "User Management", description = "APIs for managing users")
public class UserController {
    
    @Operation(
        summary = "Get user by ID",
        description = "Retrieves a user by their unique identifier"
    )
    @ApiResponses(value = {
        @ApiResponse(
            responseCode = "200",
            description = "User found",
            content = @Content(schema = @Schema(implementation = UserDetailResponse.class))
        ),
        @ApiResponse(
            responseCode = "404",
            description = "User not found",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class))
        )
    })
    @GetMapping("/{id}")
    public ResponseEntity<UserDetailResponse> getUser(
            @Parameter(description = "User ID", example = "1")
            @PathVariable Long id) {
        UserDetailResponse user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }
    
    @Operation(
        summary = "Create new user",
        description = "Creates a new user with the provided information"
    )
    @PostMapping
    public ResponseEntity<UserDetailResponse> createUser(
            @RequestBody @Valid CreateUserRequest request) {
        UserDetailResponse created = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

DTO Documentation:

@Schema(description = "Request to create a new user")
public record CreateUserRequest(
    @Schema(description = "User's full name", example = "John Doe")
    @NotBlank(message = "Name is required")
    String name,
    
    @Schema(description = "User's email address", example = "john.doe@example.com")
    @NotBlank(message = "Email is required")
    @Email(message = "Email must be valid")
    String email,
    
    @Schema(description = "User's password (min 8 characters)", example = "SecurePass123!")
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password
) {}

Access Documentation:

  • Swagger UI: http://localhost:8080/swagger-ui.html
  • OpenAPI JSON: http://localhost:8080/v3/api-docs
  • OpenAPI YAML: http://localhost:8080/v3/api-docs.yaml

Advanced Best Practices

Beyond the top 10 practices, consider these advanced techniques for production-ready APIs:

Rate Limiting

Implement rate limiting to prevent API abuse and ensure fair usage:

@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    
    private final RateLimiter rateLimiter;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) throws Exception {
        
        String clientId = getClientId(request);
        if (!rateLimiter.tryAcquire(clientId)) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.getWriter().write("Rate limit exceeded");
            return false;
        }
        
        return true;
    }
}

Caching

Implement caching to improve performance:

@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    
    @Cacheable(value = "users", key = "#id")
    @GetMapping("/{id}")
    public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
        UserDetailResponse user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }
    
    @CacheEvict(value = "users", key = "#id")
    @PutMapping("/{id}")
    public ResponseEntity<UserDetailResponse> updateUser(
            @PathVariable Long id, 
            @RequestBody @Valid UpdateUserRequest request) {
        UserDetailResponse updated = userService.updateUser(id, request);
        return ResponseEntity.ok(updated);
    }
}

Request/Response Logging

Implement comprehensive logging for debugging and monitoring:

@Component
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
    
    private static final Logger logger = LoggerFactory.getLogger(RequestResponseLoggingFilter.class);
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        long startTime = System.currentTimeMillis();
        
        // Log request
        logger.info("Request: {} {}", request.getMethod(), request.getRequestURI());
        
        filterChain.doFilter(request, response);
        
        // Log response
        long duration = System.currentTimeMillis() - startTime;
        logger.info("Response: {} {} - {}ms", 
                   request.getMethod(), 
                   request.getRequestURI(), 
                   duration);
    }
}

Conclusion

By following these best practices, you'll write cleaner, more maintainable, and secure REST APIs with Spring Boot. These practices are not just theoretical concepts—they're proven techniques used by successful companies to build scalable, reliable APIs.

Key Takeaways:

  • Follow RESTful naming conventions for intuitive APIs
  • Use appropriate HTTP status codes for clear communication
  • Protect your domain with DTOs and proper validation
  • Handle exceptions consistently across your application
  • Structure your code for scalability and maintainability
  • Secure your APIs with proper authentication and authorization
  • Version your APIs to maintain backward compatibility
  • Document your APIs for better developer experience

Remember that building great APIs is an iterative process. Start with these fundamentals, gather feedback from your users, and continuously improve based on real-world usage patterns.

Video Tutorial

Ready to put these practices into action? Check out our comprehensive Building APIs with Spring Boot course for hands-on experience with all these techniques and more advanced API development patterns.

Your Career Transformation Starts Now

Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.