Backend

Boas Práticas no Design de APIs REST: Construindo APIs que Desenvolvedores Amam

✍️ Taylson Martinez
12 min read
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

Ler 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! 🚀