rest 4 min read

Request Validation in Spring Boot with Kotlin: A Complete Guide

Learn how to implement robust request validation in Spring Boot with Kotlin, from basic field validation to custom validators, complete with practical examples and best practices.

Introduction

Request validation is a crucial aspect of building robust and secure APIs. In this tutorial, you'll learn how to implement comprehensive request validation in a Spring Boot application using Kotlin. You'll explore different validation techniques, from basic field validation to complex custom validation rules.

Prerequisites

To follow this tutorial, you will need:

  • Basic knowledge of Kotlin and Spring Boot
  • JDK 17 or later installed
  • An IDE (IntelliJ IDEA recommended)
  • Gradle or Maven build tool
  • Basic understanding of REST APIs

Step 1 — Setting Up the Project

First, create a new Spring Boot project using Spring Initializr. You'll need the following dependencies:

  • Spring Web
  • Spring Validation
  • Kotlin
  • Spring Boot DevTools (optional)

Add these dependencies to your build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

Step 2 — Creating the Data Model

Let's create a simple user registration model to demonstrate validation. Create a new file called UserRegistrationRequest.kt:

data class UserRegistrationRequest(
    @field:NotBlank(message = "Email is required")
    @field:Email(message = "Invalid email format")
    val email: String,

    @field:NotBlank(message = "Password is required")
    @field:Size(min = 8, message = "Password must be at least 8 characters long")
    val password: String,

    @field:NotBlank(message = "Name is required")
    @field:Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
    val name: String,

    @field:Min(value = 18, message = "Age must be at least 18")
    val age: Int
)

Step 3 — Implementing Basic Validation

Create a controller to handle user registration:

Client Request Controller Validation Business Logic Validation Errors
@RestController
@RequestMapping("/api/users")
class UserController {

    @PostMapping("/register")
    fun registerUser(@Valid @RequestBody request: UserRegistrationRequest): ResponseEntity<Any> {
        // Registration logic here
        return ResponseEntity.ok().build()
    }
}

Step 4 — Creating a Global Exception Handler

To handle validation errors gracefully, create a global exception handler:

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationErrors(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, List<String>>> {
        val errors = ex.bindingResult.fieldErrors
            .map { it.defaultMessage ?: "Validation error" }
            .sorted()

        return ResponseEntity.badRequest()
            .body(mapOf("errors" to errors))
    }
}

Step 5 — Implementing Custom Validation

Sometimes you need custom validation logic. Let's create a custom validator to check if a password is strong enough:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [StrongPasswordValidator::class])
annotation class StrongPassword(
    val message: String = "Password must contain at least one uppercase letter, one lowercase letter, and one number",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

class StrongPasswordValidator : ConstraintValidator<StrongPassword, String> {
    override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
        if (value == null) return false

        val hasUppercase = value.any { it.isUpperCase() }
        val hasLowercase = value.any { it.isLowerCase() }
        val hasNumber = value.any { it.isDigit() }

        return hasUppercase && hasLowercase && hasNumber
    }
}

Update the UserRegistrationRequest to use the custom validator:

data class UserRegistrationRequest(
    // ... other fields ...

    @field:NotBlank(message = "Password is required")
    @field:Size(min = 8, message = "Password must be at least 8 characters long")
    @field:StrongPassword
    val password: String,

    // ... other fields ...
)

Step 6 — Cross-Field Validation

Sometimes you need to validate fields in relation to each other. Let's create a custom validator for confirming passwords:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [PasswordMatchValidator::class])
annotation class PasswordMatch(
    val message: String = "Passwords do not match",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

class PasswordMatchValidator : ConstraintValidator<PasswordMatch, UserRegistrationRequest> {
    override fun isValid(value: UserRegistrationRequest, context: ConstraintValidatorContext?): Boolean {
        return value.password == value.confirmPassword
    }
}

@PasswordMatch
data class UserRegistrationRequest(
    // ... other fields ...
    val confirmPassword: String
)

Step 7 — Testing the Validation

Here's how to test your validation using @SpringBootTest:

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    fun `should return 400 when email is invalid`() {
        val request = UserRegistrationRequest(
            email = "invalid-email",
            password = "Password123",
            confirmPassword = "Password123",
            name = "John Doe",
            age = 25
        )

        mockMvc.perform(
            MockMvcRequestBuilders.post("/api/users/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(MockMvcResultMatchers.status().isBadRequest)
            .andExpect(MockMvcResultMatchers.jsonPath("$.errors").isArray)
            .andExpect(MockMvcResultMatchers.jsonPath("$.errors[0]").value("Invalid email format"))
    }
}

Best Practices and Tips

  1. Group Related Validations: Use validation groups when you need different validation rules for different scenarios.
interface RegistrationValidation
interface UpdateValidation

data class UserRequest(
    @field:NotBlank(groups = [RegistrationValidation::class])
    val password: String
)
  1. Custom Error Messages: Use message templates for more dynamic error messages:
@field:Size(
    min = 8,
    message = "Password must be at least {min} characters long"
)
  1. Validation Service Layer: For complex validations involving database checks, create a separate validation service:
@Service
class UserValidationService(
    private val userRepository: UserRepository
) {
    fun validateEmailUniqueness(email: String) {
        if (userRepository.existsByEmail(email)) {
            throw EmailAlreadyExistsException("Email already registered")
        }
    }
}

Conclusion

You've learned how to implement comprehensive request validation in a Spring Boot application using Kotlin. From basic field validation to custom validators and cross-field validation, you now have the tools to ensure your API accepts only valid data.

Next Steps

  • Learn about API versioning to maintain backward compatibility
  • Explore Spring Security for authentication and authorization
  • Implement rate limiting to protect your validated endpoints

Common Questions and Troubleshooting

Q: Why aren't my validation annotations working?
A: Make sure you've added the @Valid annotation to your controller method parameter and included the spring-boot-starter-validation dependency.

Q: How can I customize validation messages for different locales?
A: Use message source files in resources/messages_[locale].properties and reference them in your validation annotations.

Q: What's the difference between @field: and @get: prefix in Kotlin validation annotations?
A: @field: applies the validation to the backing field, while @get: applies it to the getter. For request validation, you typically want @field:.