Java Programming Hub

Advanced Java development tutorials and guides

Spring BootREST APIWeb DevelopmentAPI DesignOpenAPI

Building RESTful APIs with Spring Boot: Best Practices and Advanced Techniques

January 8, 202520 min readWeb Development
# Building RESTful APIs with Spring Boot: Best Practices and Advanced Techniques RESTful APIs are the backbone of modern web applications. Spring Boot provides excellent support for building robust, scalable APIs. This guide covers best practices, advanced techniques, and production-ready patterns for API development. ## RESTful API Design Principles ### Resource-Based URLs Design URLs around resources, not actions: ```java // Good - Resource-based GET /api/users // Get all users GET /api/users/123 // Get user by ID POST /api/users // Create new user PUT /api/users/123 // Update user DELETE /api/users/123 // Delete user // Nested resources GET /api/users/123/orders // Get orders for user 123 POST /api/users/123/orders // Create order for user 123 // Bad - Action-based GET /api/getUsers POST /api/createUser POST /api/deleteUser/123 ``` ### HTTP Status Codes Use appropriate HTTP status codes: ```java @RestController @RequestMapping("/api/users") @Validated public class UserController { @Autowired private UserService userService; @GetMapping public ResponseEntity<Page<UserDto>> getAllUsers( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "id") String sortBy, @RequestParam(defaultValue = "asc") String sortDir) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.fromString(sortDir), sortBy)); Page<UserDto> users = userService.getAllUsers(pageable); return ResponseEntity.ok(users); // 200 OK } @GetMapping("/{id}") public ResponseEntity<UserDto> getUserById(@PathVariable Long id) { Optional<UserDto> user = userService.getUserById(id); return user.map(u -> ResponseEntity.ok(u)) // 200 OK .orElse(ResponseEntity.notFound().build()); // 404 Not Found } @PostMapping public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) { UserDto createdUser = userService.createUser(request); URI location = ServletUriComponentsBuilder .fromCurrentRequest() .path("/{id}") .buildAndExpand(createdUser.getId()) .toUri(); return ResponseEntity.created(location).body(createdUser); // 201 Created } @PutMapping("/{id}") public ResponseEntity<UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) { try { UserDto updatedUser = userService.updateUser(id, request); return ResponseEntity.ok(updatedUser); // 200 OK } catch (UserNotFoundException e) { return ResponseEntity.notFound().build(); // 404 Not Found } } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable Long id) { try { userService.deleteUser(id); return ResponseEntity.noContent().build(); // 204 No Content } catch (UserNotFoundException e) { return ResponseEntity.notFound().build(); // 404 Not Found } } @PatchMapping("/{id}/status") public ResponseEntity<UserDto> updateUserStatus(@PathVariable Long id, @RequestBody Map<String, Object> updates) { try { UserDto updatedUser = userService.updateUserPartially(id, updates); return ResponseEntity.ok(updatedUser); // 200 OK } catch (UserNotFoundException e) { return ResponseEntity.notFound().build(); // 404 Not Found } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().build(); // 400 Bad Request } } } ``` ## Request and Response Handling ### DTOs and Mapping Use DTOs to control API contracts: ```java // Request DTOs @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateUserRequest { @NotBlank(message = "First name is required") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") private String firstName; @NotBlank(message = "Last name is required") @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; @NotBlank(message = "Email is required") @Email(message = "Email should be valid") private String email; @NotNull(message = "Age is required") @Min(value = 18, message = "Age must be at least 18") @Max(value = 120, message = "Age must be less than 120") private Integer age; @NotBlank(message = "Department is required") private String department; @Valid private AddressDto address; @Size(max = 5, message = "Maximum 5 skills allowed") private List<@NotBlank String> skills = new ArrayList<>(); } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateUserRequest { @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") private String firstName; @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; @Email(message = "Email should be valid") private String email; @Min(value = 18, message = "Age must be at least 18") @Max(value = 120, message = "Age must be less than 120") private Integer age; private String department; @Valid private AddressDto address; @Size(max = 5, message = "Maximum 5 skills allowed") private List<String> skills; } // Response DTOs @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UserDto { private Long id; private String firstName; private String lastName; private String email; private Integer age; private String department; private AddressDto address; private List<String> skills; private LocalDateTime createdDate; private LocalDateTime lastModifiedDate; @JsonInclude(JsonInclude.Include.NON_NULL) private String status; @JsonProperty("fullName") public String getFullName() { return firstName + " " + lastName; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AddressDto { @NotBlank(message = "Street is required") private String street; @NotBlank(message = "City is required") private String city; @NotBlank(message = "State is required") private String state; @NotBlank(message = "Zip code is required") @Pattern(regexp = "\d{5}(-\d{4})?", message = "Invalid zip code format") private String zipCode; private String country = "USA"; } // Mapper @Mapper(componentModel = "spring") public interface UserMapper { UserDto toDto(User user); List<UserDto> toDtoList(List<User> users); @Mapping(target = "id", ignore = true) @Mapping(target = "createdDate", ignore = true) @Mapping(target = "lastModifiedDate", ignore = true) User toEntity(CreateUserRequest request); @Mapping(target = "id", ignore = true) @Mapping(target = "createdDate", ignore = true) @Mapping(target = "lastModifiedDate", ignore = true) void updateEntityFromDto(UpdateUserRequest request, @MappingTarget User user); } ``` ### Content Negotiation Support multiple content types: ```java @RestController @RequestMapping("/api/users") public class UserController { @GetMapping(value = "/{id}", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE }) public ResponseEntity<UserDto> getUserById(@PathVariable Long id, @RequestHeader("Accept") String acceptHeader) { UserDto user = userService.getUserById(id); if (acceptHeader.contains(MediaType.APPLICATION_XML_VALUE)) { return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(user); } return ResponseEntity.ok() .contentType(MediaType.APPLICATION_JSON) .body(user); } @PostMapping(consumes = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE }) public ResponseEntity<UserDto> createUser(@RequestBody CreateUserRequest request, @RequestHeader("Content-Type") String contentType) { UserDto createdUser = userService.createUser(request); return ResponseEntity.status(HttpStatus.CREATED).body(createdUser); } } ``` ## Validation and Error Handling ### Comprehensive Validation ```java // Custom validation annotations @Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = UniqueEmailValidator.class) @Documented 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> { @Autowired private UserRepository userRepository; @Override public boolean isValid(String email, ConstraintValidatorContext context) { if (email == null) { return true; // Let @NotNull handle null validation } return !userRepository.existsByEmail(email); } } // Validation groups public interface CreateValidation {} public interface UpdateValidation {} @Data public class UserRequest { @NotNull(groups = UpdateValidation.class) private Long id; @NotBlank(groups = {CreateValidation.class, UpdateValidation.class}) @UniqueEmail(groups = CreateValidation.class) private String email; @NotBlank(groups = CreateValidation.class) @Size(min = 8, groups = {CreateValidation.class}) private String password; } @RestController public class UserController { @PostMapping("/users") public ResponseEntity<UserDto> createUser( @Validated(CreateValidation.class) @RequestBody UserRequest request) { // Implementation } @PutMapping("/users/{id}") public ResponseEntity<UserDto> updateUser(@PathVariable Long id, @Validated(UpdateValidation.class) @RequestBody UserRequest request) { // Implementation } } ``` ### Global Exception Handling ```java @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage())); ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.BAD_REQUEST.value()) .error("Validation Failed") .message("Invalid input parameters") .path(getCurrentPath()) .validationErrors(errors) .build(); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) { Map<String, String> errors = new HashMap<>(); ex.getConstraintViolations().forEach(violation -> { String fieldName = violation.getPropertyPath().toString(); String message = violation.getMessage(); errors.put(fieldName, message); }); ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.BAD_REQUEST.value()) .error("Constraint Violation") .message("Validation constraints violated") .path(getCurrentPath()) .validationErrors(errors) .build(); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) { ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.NOT_FOUND.value()) .error("Resource Not Found") .message(ex.getMessage()) .path(getCurrentPath()) .build(); return ResponseEntity.notFound().build(); } @ExceptionHandler(DuplicateResourceException.class) public ResponseEntity<ErrorResponse> handleDuplicateResource(DuplicateResourceException ex) { ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.CONFLICT.value()) .error("Resource Conflict") .message(ex.getMessage()) .path(getCurrentPath()) .build(); return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) { ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.FORBIDDEN.value()) .error("Access Denied") .message("You don't have permission to access this resource") .path(getCurrentPath()) .build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) { log.error("Unexpected error occurred", ex); ErrorResponse errorResponse = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) .error("Internal Server Error") .message("An unexpected error occurred") .path(getCurrentPath()) .build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } private String getCurrentPath() { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); return request.getRequestURI(); } return "unknown"; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ErrorResponse { private LocalDateTime timestamp; private int status; private String error; private String message; private String path; private Map<String, String> validationErrors; } ``` ## API Documentation with OpenAPI ### OpenAPI Configuration ```java @Configuration @OpenAPIDefinition( info = @Info( title = "User Management API", version = "v1.0", description = "API for managing users in the system", contact = @Contact( name = "API Support", email = "support@example.com", url = "https://example.com/support" ), license = @License( name = "MIT License", url = "https://opensource.org/licenses/MIT" ) ), servers = { @Server(url = "http://localhost:8080", description = "Development server"), @Server(url = "https://api.example.com", description = "Production server") } ) @SecurityScheme( name = "bearerAuth", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer" ) public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .components(new Components() .addSecuritySchemes("bearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")); } } ``` ### Documented Controller ```java @RestController @RequestMapping("/api/users") @Tag(name = "User Management", description = "Operations related to user management") @SecurityRequirement(name = "bearerAuth") public class UserController { @Operation( summary = "Get all users", description = "Retrieve a paginated list of all users in the system", responses = { @ApiResponse( responseCode = "200", description = "Successfully retrieved users", content = @Content( mediaType = "application/json", schema = @Schema(implementation = PagedUserResponse.class) ) ), @ApiResponse( responseCode = "401", description = "Unauthorized - Invalid or missing authentication token" ), @ApiResponse( responseCode = "403", description = "Forbidden - Insufficient permissions" ) } ) @GetMapping public ResponseEntity<Page<UserDto>> getAllUsers( @Parameter(description = "Page number (0-based)", example = "0") @RequestParam(defaultValue = "0") int page, @Parameter(description = "Number of items per page", example = "20") @RequestParam(defaultValue = "20") int size, @Parameter(description = "Field to sort by", example = "lastName") @RequestParam(defaultValue = "id") String sortBy, @Parameter(description = "Sort direction", example = "asc") @RequestParam(defaultValue = "asc") String sortDir) { // Implementation } @Operation( summary = "Get user by ID", description = "Retrieve a specific user by their unique identifier" ) @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "User found", content = @Content(schema = @Schema(implementation = UserDto.class)) ), @ApiResponse( responseCode = "404", description = "User not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ) }) @GetMapping("/{id}") public ResponseEntity<UserDto> getUserById( @Parameter(description = "User ID", required = true, example = "123") @PathVariable Long id) { // Implementation } @Operation( summary = "Create new user", description = "Create a new user in the system" ) @ApiResponses(value = { @ApiResponse( responseCode = "201", description = "User created successfully", content = @Content(schema = @Schema(implementation = UserDto.class)) ), @ApiResponse( responseCode = "400", description = "Invalid input data", content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ), @ApiResponse( responseCode = "409", description = "User with email already exists", content = @Content(schema = @Schema(implementation = ErrorResponse.class)) ) }) @PostMapping public ResponseEntity<UserDto> createUser( @Parameter(description = "User creation data", required = true) @Valid @RequestBody CreateUserRequest request) { // Implementation } } // Schema documentation for DTOs @Schema(description = "User data transfer object") @Data public class UserDto { @Schema(description = "Unique identifier of the user", example = "123", accessMode = Schema.AccessMode.READ_ONLY) private Long id; @Schema(description = "User's first name", example = "John", required = true) private String firstName; @Schema(description = "User's last name", example = "Doe", required = true) private String lastName; @Schema(description = "User's email address", example = "john.doe@example.com", required = true) private String email; @Schema(description = "User's age", example = "30", minimum = "18", maximum = "120") private Integer age; @Schema(description = "User's department", example = "Engineering") private String department; @Schema(description = "User creation timestamp", accessMode = Schema.AccessMode.READ_ONLY) private LocalDateTime createdDate; } ``` ## API Versioning ### URL Path Versioning ```java @RestController @RequestMapping("/api/v1/users") public class UserControllerV1 { @GetMapping("/{id}") public ResponseEntity<UserDtoV1> getUserById(@PathVariable Long id) { // V1 implementation } } @RestController @RequestMapping("/api/v2/users") public class UserControllerV2 { @GetMapping("/{id}") public ResponseEntity<UserDtoV2> getUserById(@PathVariable Long id) { // V2 implementation with additional fields } } ``` ### Header-Based Versioning ```java @RestController @RequestMapping("/api/users") public class UserController { @GetMapping(value = "/{id}", headers = "API-Version=1") public ResponseEntity<UserDtoV1> getUserByIdV1(@PathVariable Long id) { // V1 implementation } @GetMapping(value = "/{id}", headers = "API-Version=2") public ResponseEntity<UserDtoV2> getUserByIdV2(@PathVariable Long id) { // V2 implementation } @GetMapping("/{id}") public ResponseEntity<UserDtoV2> getUserByIdDefault(@PathVariable Long id) { // Default to latest version } } ``` ### Custom Versioning with Annotations ```java @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiVersion { String value(); } @Component public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping { @Override protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) { RequestMappingInfo info = super.getMappingForMethod(method, handlerType); if (info == null) { return null; } ApiVersion methodVersion = AnnotationUtils.findAnnotation(method, ApiVersion.class); ApiVersion typeVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class); if (methodVersion != null || typeVersion != null) { String version = methodVersion != null ? methodVersion.value() : typeVersion.value(); RequestMappingInfo versionInfo = RequestMappingInfo .paths("/api/v" + version) .build(); info = versionInfo.combine(info); } return info; } } @RestController @ApiVersion("1") public class UserControllerV1 { @GetMapping("/users/{id}") public ResponseEntity<UserDtoV1> getUserById(@PathVariable Long id) { // V1 implementation } } @RestController @ApiVersion("2") public class UserControllerV2 { @GetMapping("/users/{id}") public ResponseEntity<UserDtoV2> getUserById(@PathVariable Long id) { // V2 implementation } } ``` Building robust RESTful APIs requires careful attention to design principles, proper error handling, comprehensive documentation, and thoughtful versioning strategies. Spring Boot provides excellent tools to implement these patterns effectively.