REST API Design Best Practices: Building APIs Developers Love
Learn how to design clean, consistent, and intuitive REST APIs that are a joy to use. Covers naming conventions, versioning, error handling, and more.
This article is also available in Portuguese
Why API Design Matters
I’ve used bad APIs and I know how frustrating it is. When you can’t understand how to use an API, you get lost and waste time. That’s why I always try to think about the developer who will use my API.
A well-made API is intuitive, consistent, and easy to understand. I’ll share what I’ve learned over the years.
RESTful Resource Naming
Use Nouns, Not Verbs
# ❌ Bad - verbs in URLs
GET /getUsers
POST /createUser
PUT /updateUser
# ✅ Good - nouns with HTTP verbs
GET /users
POST /users
PUT /users/:id
Use Plural Nouns
# ❌ Inconsistent
GET /user/:id
GET /products
# ✅ Consistent
GET /users/:id
GET /products/:id
Nested Resources
When a resource belongs to another, nest it in the URL:
GET /users/:userId/posts
GET /users/:userId/posts/:postId
Tip: Limit to 2 levels of nesting. More than that gets confusing.
HTTP Methods
Use the correct HTTP methods for each operation:
@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {
// GET - Retrieve resources
@GetMapping("/{id}")
fun getUser(@PathVariable id: String): ResponseEntity<User> {
val user = userService.findById(id)
?: return ResponseEntity.status(404)
.body(mapOf("error" to "User not found"))
return ResponseEntity.ok(user)
}
// POST - Create new resource
@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 - Full update (replace entire resource)
@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 - Partial update
@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 - Remove resource
@DeleteMapping("/{id}")
fun deleteUser(@PathVariable id: String): ResponseEntity<Void> {
userService.deleteById(id)
return ResponseEntity.noContent().build()
}
}
HTTP Status Codes
Use the correct codes for each situation:
- 200 OK - Everything’s fine (GET, PUT, PATCH)
- 201 Created - Resource created successfully (POST)
- 204 No Content - Deleted successfully (DELETE)
- 400 Bad Request - Invalid data sent
- 401 Unauthorized - Need to log in
- 403 Forbidden - Logged in but no permission
- 404 Not Found - Resource doesn’t exist
- 409 Conflict - Resource already exists (e.g., duplicate email)
- 422 Unprocessable Entity - Validation errors
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Error - Something went wrong on the server
- 503 Service Unavailable - Service temporarily down
Error Handling
Always return errors in a consistent format. This really helps whoever is integrating:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": [
{
"field": "email",
"message": "Email is required"
},
{
"field": "age",
"message": "Age must be greater than 0"
}
],
"timestamp": "2026-02-14T10:30:00Z",
"path": "/api/users"
}
}
How to Implement
// Custom error class
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
)
// Custom exception
class ApiException(
val statusCode: HttpStatus,
val code: String,
message: String,
val details: List<ErrorDetail> = emptyList()
) : RuntimeException(message)
// Global exception handler
@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 ?: "Internal error",
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 ?: "Internal server error",
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"
}
API Versioning
When you need to change the API without breaking existing users, you need to version. I prefer versioning in the URL because it’s simpler:
GET /v1/users
GET /v2/users
Other options are versioning by header or query parameter, but I find those more complicated. In the URL it’s explicit and easy to understand.
Filtering, Sorting, and Pagination
Filtering
Allow filtering by query parameters:
GET /users?status=active&role=admin
GET /orders?created_after=2026-01-01
GET /users?search=john
Sorting
Use the sort parameter:
GET /users?sort=created_at # ascending
GET /users?sort=-created_at # descending (prefix with -)
GET /users?sort=role,-created_at # multiple fields
Pagination
For large lists, always use pagination. I use offset because it’s simpler:
GET /users?page=2&limit=20
Response:
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 100,
"pages": 5
}
}
For very large volumes, cursor is better, but offset works well in most cases.
Field Selection
Sometimes the client only needs a few fields. Allow this:
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)
}
Rate Limiting
Always implement rate limiting to protect your API from abuse:
// Add to 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)) {
// Add rate limit headers
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": "Too many requests, please try again later"
}
}
""".trimIndent())
}
}
private fun createBucket(): Bucket {
val limit = Bandwidth.simple(100, Duration.ofMinutes(15))
return Bucket.builder()
.addLimit(limit)
.build()
}
}
}
}
Response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 50
X-RateLimit-Reset: 1644840000
Authentication & Security
Use JWT for authentication:
GET /users
Headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Security Checklist
- ✅ Always HTTPS in production
- ✅ Validate all input (never trust the client)
- ✅ Configure CORS correctly
- ✅ Use rate limiting
- ✅ Keep dependencies updated
- ✅ Log access for auditing
Documentation
Document your API! Use Swagger/OpenAPI which comes integrated with Spring Boot. It’s very easy and really helps whoever will use your API.
I use Swagger UI because it’s interactive - you can test directly in the documentation.
HATEOAS (Optional)
You can include links to related resources in the response. It’s useful, but not required. If your API is simple, you can skip this part.
Quick Checklist
Before publishing your API, check:
- Consistent URLs (plural nouns)
- Correct HTTP methods
- Appropriate status codes
- Consistent error format
- Pagination on lists
- Rate limiting
- Authentication working
- Documentation (Swagger)
- HTTPS in production
- Health check endpoint
Conclusion
At the end of the day, a good API is one that’s easy to use. Think about the developer who will integrate: can they understand it without reading documentation? Are errors clear? Does the structure make sense?
If you follow these practices, your API will be much easier to use and maintain. And that makes all the difference! 🚀