REST API Design in Spring Boot 4 — Validation, Error Handling & DTOs
Design production-ready REST APIs in Spring Boot 4 — request validation with Bean Validation, structured error responses, DTO patterns, and API best practices.
The Spring Boot 4 Starter post got you a running API. This post makes it production-ready — proper validation, structured errors, and clean DTO separation.
Request validation with Bean Validation
Add the validation starter:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
}
Validating request bodies
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateUserRequest(
@field:NotBlank(message = "Name is required")
@field:Size(min = 2, max = 50, message = "Name must be 2-50 characters")
val name: String,
@field:NotBlank(message = "Email is required")
@field:Email(message = "Invalid email format")
val email: String,
@field:Size(min = 8, message = "Password must be at least 8 characters")
val password: String
)
Apply with @Valid:
@PostMapping
fun createUser(@Valid @RequestBody request: CreateUserRequest): ResponseEntity<UserResponse> {
val user = userService.create(request)
return ResponseEntity.status(HttpStatus.CREATED).body(user.toResponse())
}
If validation fails, Spring throws MethodArgumentNotValidException. Handle it in the global exception handler.
Validating path variables and query parameters
@Validated
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {
@GetMapping
fun getUsers(
@RequestParam(defaultValue = "0") @Min(0) page: Int,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) size: Int
): PagedResponse<UserResponse> {
return userService.getAll(page, size)
}
}
@Validated on the class enables parameter-level validation.
Structured error responses
Every error should return a consistent JSON structure:
data class ApiError(
val status: Int,
val error: String,
val message: String,
val timestamp: String = Instant.now().toString(),
val path: String? = null,
val fieldErrors: List<FieldError>? = null
)
data class FieldError(
val field: String,
val message: String,
val rejectedValue: Any? = null
)
Global exception handler
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(
e: MethodArgumentNotValidException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
val fieldErrors = e.bindingResult.fieldErrors.map { error ->
FieldError(
field = error.field,
message = error.defaultMessage ?: "Invalid value",
rejectedValue = error.rejectedValue
)
}
return ResponseEntity.badRequest().body(
ApiError(
status = 400,
error = "Validation Failed",
message = "Request contains ${fieldErrors.size} validation error(s)",
path = request.requestURI,
fieldErrors = fieldErrors
)
)
}
@ExceptionHandler(NotFoundException::class)
fun handleNotFound(
e: NotFoundException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(404).body(
ApiError(
status = 404,
error = "Not Found",
message = e.message ?: "Resource not found",
path = request.requestURI
)
)
}
@ExceptionHandler(ConflictException::class)
fun handleConflict(
e: ConflictException,
request: HttpServletRequest
): ResponseEntity<ApiError> {
return ResponseEntity.status(409).body(
ApiError(
status = 409,
error = "Conflict",
message = e.message ?: "Resource conflict",
path = request.requestURI
)
)
}
@ExceptionHandler(Exception::class)
fun handleGeneral(
e: Exception,
request: HttpServletRequest
): ResponseEntity<ApiError> {
logger.error("Unhandled exception at ${request.requestURI}", e)
return ResponseEntity.status(500).body(
ApiError(
status = 500,
error = "Internal Server Error",
message = "An unexpected error occurred",
path = request.requestURI
)
)
}
companion object {
private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
}
}
Error response example
{
"status": 400,
"error": "Validation Failed",
"message": "Request contains 2 validation error(s)",
"timestamp": "2025-12-15T10:30:00Z",
"path": "/api/v1/users",
"fieldErrors": [
{
"field": "name",
"message": "Name is required",
"rejectedValue": ""
},
{
"field": "email",
"message": "Invalid email format",
"rejectedValue": "not-an-email"
}
]
}
DTO patterns
Separate request and response DTOs
// Request — what the client sends
data class CreateUserRequest(
@field:NotBlank val name: String,
@field:Email val email: String,
val password: String
)
// Response — what the client receives
data class UserResponse(
val id: String,
val name: String,
val email: String,
val role: String,
val createdAt: String
)
// Update — partial fields
data class UpdateUserRequest(
@field:Size(min = 2, max = 50) val name: String?,
@field:Email val email: String?
)
Never expose your entity directly. The User entity might have passwordHash, internalNotes, or deletedAt that clients shouldn’t see.
Mapper extension functions
fun User.toResponse() = UserResponse(
id = id,
name = name,
email = email,
role = role.name,
createdAt = createdAt.toString()
)
fun CreateUserRequest.toUser(passwordEncoder: PasswordEncoder) = User(
name = name,
email = email,
passwordHash = passwordEncoder.encode(password)
)
Paginated responses
data class PagedResponse<T>(
val content: List<T>,
val page: Int,
val size: Int,
val totalElements: Long,
val totalPages: Int,
val hasNext: Boolean
)
fun <T, R> Page<T>.toPagedResponse(mapper: (T) -> R) = PagedResponse(
content = content.map(mapper),
page = number,
size = size,
totalElements = totalElements,
totalPages = totalPages,
hasNext = hasNext()
)
Usage:
@GetMapping
fun getUsers(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): PagedResponse<UserResponse> {
return userRepository.findAll(PageRequest.of(page, size))
.toPagedResponse { it.toResponse() }
}
API versioning
Spring Boot 4 adds built-in API versioning support:
@RestController
@RequestMapping("/api/v1/users")
class UserControllerV1(private val userService: UserService) {
@GetMapping
fun getUsers(): List<UserResponseV1> {
return userService.getAll().map { it.toResponseV1() }
}
}
@RestController
@RequestMapping("/api/v2/users")
class UserControllerV2(private val userService: UserService) {
@GetMapping
fun getUsers(): PagedResponse<UserResponseV2> {
// V2 returns paginated, includes new fields
return userService.getAll().toPagedResponse { it.toResponseV2() }
}
}
URL-based versioning (/api/v1/, /api/v2/) is the simplest and most visible approach.
HTTP status codes
Use the right status codes:
| Code | When |
|---|---|
| 200 OK | Successful GET, PUT |
| 201 Created | Successful POST that creates a resource |
| 204 No Content | Successful DELETE |
| 400 Bad Request | Validation failure, malformed request |
| 401 Unauthorized | Missing or invalid authentication |
| 403 Forbidden | Authenticated but not authorized |
| 404 Not Found | Resource doesn’t exist |
| 409 Conflict | Duplicate resource (e.g., duplicate email) |
| 422 Unprocessable Entity | Valid JSON but semantically wrong |
| 429 Too Many Requests | Rate limit exceeded |
| 500 Internal Server Error | Unexpected server failure |
Summary
Production-ready REST APIs need:
- Request validation with Bean Validation annotations and
@Valid - Structured error responses with field-level error details
- DTO separation — never expose entities directly
- Pagination for list endpoints
- Proper HTTP status codes for each scenario
- Global exception handler for consistent error formatting
- API versioning for backward compatibility
These patterns scale from simple CRUD APIs to complex microservice architectures. Apply them consistently across all endpoints.