Voltar para o Blog

Building RESTful APIs with Spring Boot: Best Practices Guide

10 min de leitura
REST APISpring BootAPI DesignBest Practices

APIs bem projetadas são a fundação de aplicações modernas. Aprenda como criar APIs RESTful que são intuitivas, escaláveis e fáceis de manter.

Princípios de Design de API REST

Naming Conventions

// ✅ Use substantivos no plural para resources
GET    /api/users           // Lista todos os usuários
GET    /api/users/123       // Busca usuário específico
POST   /api/users           // Cria novo usuário
PUT    /api/users/123       // Atualiza usuário completo
PATCH  /api/users/123       // Atualiza parcialmente
DELETE /api/users/123       // Remove usuário

// ✅ Resources aninhados
GET    /api/users/123/orders        // Pedidos do usuário
GET    /api/users/123/orders/456    // Pedido específico do usuário

// ❌ Evite verbos nas URLs
POST   /api/createUser      // Ruim
POST   /api/users           // Bom

// ❌ Evite aninhamento profundo
GET    /api/users/123/orders/456/items/789/reviews  // Muito profundo
GET    /api/reviews?orderId=456&itemId=789          // Melhor

HTTP Status Codes Corretos

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    // 200 OK - Sucesso
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
        UserDTO user = userService.findById(id);
        return ResponseEntity.ok(user);
    }
    
    // 201 Created - Recurso criado
    @PostMapping
    public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDTO user = userService.createUser(request);
        URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(user.getId())
            .toUri();
        return ResponseEntity.created(location).body(user);
    }
    
    // 204 No Content - Sucesso sem retorno
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
    
    // 400 Bad Request - Dados inválidos
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationError(
            MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .toList();
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("Validation failed", errors));
    }
    
    // 404 Not Found - Recurso não existe
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(ex.getMessage()));
    }
    
    // 409 Conflict - Conflito de estado
    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleConflict(
            DuplicateResourceException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorResponse(ex.getMessage()));
    }
}

Validação de Input

Bean Validation

public record CreateUserRequest(
    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
    String username,
    
    @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 = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
        message = "Password must contain uppercase, lowercase, and number"
    )
    String password,
    
    @NotNull(message = "Age is required")
    @Min(value = 18, message = "Must be at least 18 years old")
    @Max(value = 120, message = "Age must be realistic")
    Integer age
) {}

// Controller
@PostMapping
public ResponseEntity<UserDTO> createUser(
        @Valid @RequestBody CreateUserRequest request) {
    // Validação automática antes de executar método
    UserDTO user = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

// Custom validator
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
    String message() default "Username already exists";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class UniqueUsernameValidator 
        implements ConstraintValidator<UniqueUsername, String> {
    
    @Autowired
    private UserRepository userRepository;
    
    @Override
    public boolean isValid(String username, ConstraintValidatorContext context) {
        return username != null && !userRepository.existsByUsername(username);
    }
}

Tratamento Global de Erros

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(
            ResourceNotFoundException ex,
            WebRequest request) {
        ApiError error = ApiError.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.NOT_FOUND.value())
            .error("Not Found")
            .message(ex.getMessage())
            .path(request.getDescription(false).replace("uri=", ""))
            .build();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidationError(
            MethodArgumentNotValidException ex,
            WebRequest request) {
        
        Map<String, String> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                error -> error.getDefaultMessage() != null 
                    ? error.getDefaultMessage() 
                    : "Invalid value"
            ));
        
        ApiError error = ApiError.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.BAD_REQUEST.value())
            .error("Validation Failed")
            .message("Input validation failed")
            .path(request.getDescription(false).replace("uri=", ""))
            .fieldErrors(fieldErrors)
            .build();
            
        return ResponseEntity.badRequest().body(error);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleGlobalException(
            Exception ex,
            WebRequest request) {
        log.error("Unexpected error", ex);
        
        ApiError error = ApiError.builder()
            .timestamp(LocalDateTime.now())
            .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
            .error("Internal Server Error")
            .message("An unexpected error occurred")
            .path(request.getDescription(false).replace("uri=", ""))
            .build();
            
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

@Builder
public record ApiError(
    LocalDateTime timestamp,
    int status,
    String error,
    String message,
    String path,
    Map<String, String> fieldErrors
) {}

Paginação e Ordenação

Implementação com Spring Data

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping
    public ResponseEntity<PagedResponse<UserDTO>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sortBy,
            @RequestParam(defaultValue = "desc") String direction) {
        
        // Validar tamanho máximo
        if (size > 100) {
            size = 100;
        }
        
        Sort.Direction sortDirection = direction.equalsIgnoreCase("asc") 
            ? Sort.Direction.ASC 
            : Sort.Direction.DESC;
        
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
        
        Page<UserDTO> usersPage = userService.getUsers(pageable);
        
        PagedResponse<UserDTO> response = PagedResponse.<UserDTO>builder()
            .content(usersPage.getContent())
            .page(usersPage.getNumber())
            .size(usersPage.getSize())
            .totalElements(usersPage.getTotalElements())
            .totalPages(usersPage.getTotalPages())
            .last(usersPage.isLast())
            .build();
        
        return ResponseEntity.ok(response);
    }
}

@Builder
public record PagedResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean last
) {}

Versionamento de API

URI Versioning

// V1
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDTOV1> getUser(@PathVariable Long id) {
        // Versão antiga da API
        return ResponseEntity.ok(userService.getUserV1(id));
    }
}

// V2 com mudanças breaking
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDTOV2> getUser(@PathVariable Long id) {
        // Nova versão com estrutura diferente
        return ResponseEntity.ok(userService.getUserV2(id));
    }
}

Header Versioning

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping(value = "/{id}", headers = "API-Version=1")
    public ResponseEntity<UserDTOV1> getUserV1(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserV1(id));
    }
    
    @GetMapping(value = "/{id}", headers = "API-Version=2")
    public ResponseEntity<UserDTOV2> getUserV2(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserV2(id));
    }
}

Documentação com OpenAPI

Configuração Swagger/OpenAPI

// build.gradle
dependencies {
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
}

// Configuration
@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("User Management API")
                .version("1.0.0")
                .description("API for managing users and related operations")
                .contact(new Contact()
                    .name("Lucas Lamounier")
                    .email("contato@falconapps.org")
                    .url("https://falconapps.org"))
                .license(new License()
                    .name("Apache 2.0")
                    .url("https://www.apache.org/licenses/LICENSE-2.0")))
            .externalDocs(new ExternalDocumentation()
                .description("Full Documentation")
                .url("https://docs.falconapps.org"))
            .addSecurityItem(new SecurityRequirement().addList("bearer-jwt"))
            .components(new Components()
                .addSecuritySchemes("bearer-jwt", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")));
    }
}

// Controller com anotações
@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "Operations for managing users")
public class UserController {
    
    @Operation(
        summary = "Get user by ID",
        description = "Returns a single user by their unique identifier"
    )
    @ApiResponses({
        @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 = ApiError.class))
        )
    })
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(
            @Parameter(description = "User ID", required = true)
            @PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }
}

// Acesse: http://localhost:8080/swagger-ui.html

Filtering e Searching

Query Parameters para Filtros

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/search")
    public ResponseEntity<List<UserDTO>> searchUsers(
            @RequestParam(required = false) String username,
            @RequestParam(required = false) String email,
            @RequestParam(required = false) Boolean active,
            @RequestParam(required = false) LocalDate createdAfter,
            @RequestParam(required = false) String role) {
        
        UserSearchCriteria criteria = UserSearchCriteria.builder()
            .username(username)
            .email(email)
            .active(active)
            .createdAfter(createdAfter)
            .role(role)
            .build();
        
        List<UserDTO> users = userService.search(criteria);
        return ResponseEntity.ok(users);
    }
}

// Exemplo de uso:
// GET /api/users/search?username=john&active=true&role=ADMIN

HATEOAS

Hypermedia Links

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-hateoas'
}

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/{id}")
    public ResponseEntity<EntityModel<UserDTO>> getUser(@PathVariable Long id) {
        UserDTO user = userService.findById(id);
        
        EntityModel<UserDTO> resource = EntityModel.of(user);
        
        // Add links
        resource.add(linkTo(methodOn(UserController.class).getUser(id)).withSelfRel());
        resource.add(linkTo(methodOn(UserController.class).getOrders(id)).withRel("orders"));
        resource.add(linkTo(methodOn(UserController.class).updateUser(id, null)).withRel("update"));
        resource.add(linkTo(methodOn(UserController.class).deleteUser(id)).withRel("delete"));
        
        return ResponseEntity.ok(resource);
    }
}

// Response JSON:
// {
//   "id": 1,
//   "username": "john",
//   "_links": {
//     "self": {"href": "http://localhost:8080/api/users/1"},
//     "orders": {"href": "http://localhost:8080/api/users/1/orders"},
//     "update": {"href": "http://localhost:8080/api/users/1"},
//     "delete": {"href": "http://localhost:8080/api/users/1"}
//   }
// }

Melhores Práticas

  • Use substantivos para resources, não verbos
  • Versione sua API desde o início
  • Retorne status codes apropriados
  • Implemente paginação em listas grandes
  • Documente com OpenAPI (Swagger)
  • Valide todos os inputs com Bean Validation
  • Use DTOs em vez de expor entities
  • Implemente rate limiting para prevenir abuso
  • Adicione CORS configuração segura
  • Use HTTPS em produção sempre
  • Log requisições para auditoria
  • Implemente filtros e busca quando necessário

Conclusão

APIs bem projetadas são cruciais para o sucesso de aplicações modernas. Seguindo estas práticas, você criará APIs que são intuitivas, escaláveis e fáceis de integrar.

Lembre-se: consistência é chave. Uma vez que você escolhe um padrão, siga-o em toda a API.