PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 1 min read

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:

CodeWhen
200 OKSuccessful GET, PUT
201 CreatedSuccessful POST that creates a resource
204 No ContentSuccessful DELETE
400 Bad RequestValidation failure, malformed request
401 UnauthorizedMissing or invalid authentication
403 ForbiddenAuthenticated but not authorized
404 Not FoundResource doesn’t exist
409 ConflictDuplicate resource (e.g., duplicate email)
422 Unprocessable EntityValid JSON but semantically wrong
429 Too Many RequestsRate limit exceeded
500 Internal Server ErrorUnexpected 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.