Request Validation in Spring Boot with Kotlin: A Complete Guide

December 30, 2024 4 min read Intermediate

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:.

Latest Articles

Error Handling Best Practices in Spring Boot with Kotlin

Error Handling Best Practices in Spring Boot with Kotlin

4 min read rest · Spring Basics

Master Spring Boot error handling in Kotlin with comprehensive examples: learn to implement global exception handlers, custom error responses, and production-ready error handling strategies

Role-Based Access Control (RBAC) in Spring Security with Kotlin

Role-Based Access Control (RBAC) in Spring Security with Kotlin

4 min read Security · rest

Master Role-Based Access Control (RBAC) in Spring Boot applications using Kotlin with practical examples, from basic setup to advanced configurations with method-level security

Implementing API Key Authentication in Spring Boot with Kotlin: A Complete Guide (2024)

Implementing API Key Authentication in Spring Boot with Kotlin: A Complete Guide (2024)

4 min read Security · rest

Learn how to implement secure API key authentication in Spring Boot with Kotlin, including rate limiting, monitoring, and production-ready best practices - complete with practical examples and ready-to-use code