
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:
@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
- 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
)
- Custom Error Messages: Use message templates for more dynamic error messages:
@field:Size(
min = 8,
message = "Password must be at least {min} characters long"
)
- 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:
.