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 // MelhorHTTP 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.htmlFiltering 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=ADMINHATEOAS
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.