Boas Práticas no Design de APIs REST: Construindo APIs que Desenvolvedores Amam
Aprenda a projetar APIs REST limpas, consistentes e intuitivas que são um prazer usar. Cobre convenções de nomenclatura, versionamento, tratamento de erros e muito mais.
Este artigo também está disponível em inglês
Por Que o Design de API Importa
Já usei APIs ruins e sei como é frustrante. Quando você não consegue entender como usar uma API, fica perdido e perde tempo. Por isso, sempre tento pensar no desenvolvedor que vai usar minha API.
Uma API bem feita é intuitiva, consistente e fácil de entender. Vou compartilhar o que aprendi ao longo dos anos.
Nomenclatura de URLs
Use Substantivos, Não Verbos
O HTTP já tem verbos (GET, POST, PUT, DELETE). Não precisa repetir na URL:
# ❌ Ruim - verbos nas URLs
GET /getUsers
POST /createUser
# ✅ Bom - substantivos com verbos HTTP
GET /users
POST /users
Use Plural
Mantenha tudo no plural para ser consistente:
# ✅ Consistente
GET /users/:id
GET /products/:id
Recursos Aninhados
Quando um recurso pertence a outro, aninhe na URL:
GET /users/:userId/posts
GET /users/:userId/posts/:postId
Dica: Limite a 2 níveis de aninhamento. Mais que isso fica confuso.
Métodos HTTP
Use os métodos HTTP corretos para cada operação:
@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {
// GET - Buscar recursos
@GetMapping("/{id}")
fun getUser(@PathVariable id: String): ResponseEntity<User> {
val user = userService.findById(id)
?: return ResponseEntity.status(404)
.body(mapOf("error" to "Usuário não encontrado"))
return ResponseEntity.ok(user)
}
// POST - Criar novo recurso
@PostMapping
fun createUser(@RequestBody user: User): ResponseEntity<User> {
val created = userService.create(user)
return ResponseEntity.status(201)
.location(URI.create("/users/${created.id}"))
.body(created)
}
// PUT - Atualização completa (substitui o recurso inteiro)
@PutMapping("/{id}")
fun updateUser(
@PathVariable id: String,
@RequestBody user: User
): ResponseEntity<User> {
val updated = userService.update(id, user, overwrite = true)
return ResponseEntity.ok(updated)
}
// PATCH - Atualização parcial
@PatchMapping("/{id}")
fun patchUser(
@PathVariable id: String,
@RequestBody updates: Map<String, Any>
): ResponseEntity<User> {
val updated = userService.partialUpdate(id, updates)
return ResponseEntity.ok(updated)
}
// DELETE - Remover recurso
@DeleteMapping("/{id}")
fun deleteUser(@PathVariable id: String): ResponseEntity<Void> {
userService.deleteById(id)
return ResponseEntity.noContent().build()
}
}
Códigos de Status HTTP
Use os códigos corretos para cada situação:
- 200 OK - Tudo certo (GET, PUT, PATCH)
- 201 Criado - Recurso criado com sucesso (POST)
- 204 Sem Conteúdo - Deletado com sucesso (DELETE)
- 400 Requisição Inválida - Dados enviados estão errados
- 401 Não Autorizado - Precisa fazer login
- 403 Proibido - Logado mas sem permissão
- 404 Não Encontrado - Recurso não existe
- 409 Conflito - Recurso já existe (ex: email duplicado)
- 422 Entidade Não Processável - Erros de validação
- 429 Muitas Requisições - Rate limit excedido
- 500 Erro Interno - Algo deu errado no servidor
- 503 Serviço Indisponível - Serviço temporariamente fora do ar
Tratamento de Erros
Sempre retorne erros em um formato consistente. Isso ajuda muito quem está integrando:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Dados de entrada inválidos",
"details": [
{
"field": "email",
"message": "Email é obrigatório"
}
],
"timestamp": "2026-02-14T10:30:00Z",
"path": "/api/users"
}
}
Como Implementar
// Classe de erro personalizada
data class ApiError(
val code: String,
val message: String,
val details: List<ErrorDetail> = emptyList(),
val timestamp: String = Instant.now().toString(),
val path: String,
val stack: String? = null
)
data class ErrorDetail(
val field: String,
val message: String
)
// Exception personalizada
class ApiException(
val statusCode: HttpStatus,
val code: String,
message: String,
val details: List<ErrorDetail> = emptyList()
) : RuntimeException(message)
// Handler global de exceções
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ApiException::class)
fun handleApiException(
ex: ApiException,
request: HttpServletRequest
): ResponseEntity<Map<String, ApiError>> {
val error = ApiError(
code = ex.code,
message = ex.message ?: "Erro interno",
details = ex.details,
path = request.requestURI,
stack = if (isDevelopment()) ex.stackTraceToString() else null
)
return ResponseEntity
.status(ex.statusCode)
.body(mapOf("error" to error))
}
@ExceptionHandler(Exception::class)
fun handleGenericException(
ex: Exception,
request: HttpServletRequest
): ResponseEntity<Map<String, ApiError>> {
val error = ApiError(
code = "INTERNAL_ERROR",
message = ex.message ?: "Erro interno do servidor",
path = request.requestURI,
stack = if (isDevelopment()) ex.stackTraceToString() else null
)
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(mapOf("error" to error))
}
private fun isDevelopment() = System.getenv("ENV") == "development"
}
Versionamento de API
Quando você precisa mudar a API sem quebrar quem já está usando, precisa versionar. Eu prefiro versionar na URL porque é mais simples:
GET /v1/users
GET /v2/users
Outras opções são versionar por header ou query parameter, mas acho mais complicado. Na URL fica explícito e fácil de entender.
Filtragem, Ordenação e Paginação
Filtragem
Permita filtrar por query parameters:
GET /users?status=active&role=admin
GET /orders?created_after=2026-01-01
GET /users?search=john
Ordenação
Use o parâmetro sort:
GET /users?sort=created_at # crescente
GET /users?sort=-created_at # decrescente (prefixo -)
GET /users?sort=role,-created_at # múltiplos campos
Paginação
Para listas grandes, sempre use paginação. Eu uso offset porque é mais simples:
GET /users?page=2&limit=20
Resposta:
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 100,
"pages": 5
}
}
Para volumes muito grandes, cursor é melhor, mas offset funciona bem na maioria dos casos.
Seleção de Campos
Às vezes o cliente só precisa de alguns campos. Permita isso:
GET /users?fields=id,name
GET /users?exclude=password,ssn
@GetMapping
fun getUsers(@RequestParam(required = false) fields: String?): ResponseEntity<List<User>> {
val users = if (fields != null) {
val fieldList = fields.split(",").map { it.trim() }
userService.findAllWithFields(fieldList)
} else {
userService.findAll()
}
return ResponseEntity.ok(users)
}
Limitação de Taxa (Rate Limiting)
Sempre implemente rate limiting para proteger sua API de abuso:
// Adicione no build.gradle.kts:
// implementation("com.bucket4j:bucket4j-core:8.1.0")
@Configuration
class RateLimitConfig {
@Bean
fun rateLimitFilter(): Filter {
return object : OncePerRequestFilter() {
private val buckets = ConcurrentHashMap<String, Bucket>()
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val clientId = request.remoteAddr
val bucket = buckets.computeIfAbsent(clientId) {
createBucket()
}
if (bucket.tryConsume(1)) {
// Adiciona headers de rate limit
response.setHeader("X-RateLimit-Limit", "100")
response.setHeader("X-RateLimit-Remaining",
bucket.availableTokens.toString())
response.setHeader("X-RateLimit-Reset",
(System.currentTimeMillis() / 1000 + 900).toString())
filterChain.doFilter(request, response)
} else {
response.status = 429
response.contentType = "application/json"
response.writer.write("""
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Muitas requisições, tente novamente mais tarde"
}
}
""".trimIndent())
}
}
private fun createBucket(): Bucket {
val limit = Bandwidth.simple(100, Duration.ofMinutes(15))
return Bucket.builder()
.addLimit(limit)
.build()
}
}
}
}
Headers de resposta:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 50
X-RateLimit-Reset: 1644840000
Autenticação e Segurança
Use JWT para autenticação:
GET /users
Headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Checklist de Segurança
- ✅ Sempre HTTPS em produção
- ✅ Valide todas as entradas (nunca confie no cliente)
- ✅ Configure CORS corretamente
- ✅ Use rate limiting
- ✅ Mantenha dependências atualizadas
- ✅ Faça logging de acessos para auditoria
Documentação
Documente sua API! Use Swagger/OpenAPI que já vem integrado com Spring Boot. É muito fácil e ajuda demais quem vai usar sua API.
Eu uso Swagger UI porque é interativo - você pode testar direto na documentação.
HATEOAS (Opcional)
Você pode incluir links para recursos relacionados na resposta. É útil, mas não é obrigatório. Se sua API é simples, pode pular essa parte.
Checklist Rápido
Antes de publicar sua API, verifique:
- URLs consistentes (substantivos no plural)
- Métodos HTTP corretos
- Códigos de status apropriados
- Erros em formato consistente
- Paginação nas listas
- Rate limiting
- Autenticação funcionando
- Documentação (Swagger)
- HTTPS em produção
- Health check endpoint
Conclusão
No final das contas, uma boa API é uma que é fácil de usar. Pense no desenvolvedor que vai integrar: ele consegue entender sem ler documentação? Os erros são claros? A estrutura faz sentido?
Se você seguir essas práticas, sua API vai ser muito mais fácil de usar e manter. E isso faz toda a diferença! 🚀