Backend

REST API Design Best Practices: Building APIs Developers Love

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

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