Chapter 10: Validation and Exception Handling
- Chapter 10: Validation and Exception Handling
Building robust applications requires more than just functional code—it demands comprehensive validation and exceptional exception handling. In this chapter, we’ll explore how to implement effective validation strategies using Kotlin with Spring Boot and Hibernate Validator. We’ll cover everything from basic bean validation to sophisticated custom validators, and we’ll dive deep into creating a robust exception handling framework that provides meaningful feedback to API consumers.
Validation in Kotlin presents unique opportunities and challenges. Kotlin’s null safety, data classes, and extension functions allow us to create more expressive and type-safe validation code than traditional Java approaches. We’ll explore how to leverage these language features while working within the Spring Boot ecosystem.
Exception handling is equally critical. A well-designed exception handling strategy not only prevents crashes but also provides valuable debugging information and user-friendly error messages. We’ll build a comprehensive exception handling framework that handles everything from validation errors to database constraints, service failures, and external API errors.
10.1 Validation Strategies
Effective validation is multi-layered, occurring at different points in your application stack. Let’s explore the various validation strategies and when to apply each one.
Input Validation Strategy
Input validation is your first line of defense against invalid data. In Spring Boot applications, this typically happens at the controller layer:
// Comprehensive input validation strategy
@RestController
@RequestMapping("/api/v1/users")
@Validated // Enable method-level validation
class UserController(
private val userService: UserService
) {
@PostMapping
fun createUser(
@Valid @RequestBody createUserRequest: CreateUserRequest
): ResponseEntity<UserResponse> {
val user = userService.createUser(createUserRequest)
return ResponseEntity.status(HttpStatus.CREATED).body(user)
}
@PutMapping("/{id}")
fun updateUser(
@PathVariable @Positive id: Long,
@Valid @RequestBody updateUserRequest: UpdateUserRequest
): ResponseEntity<UserResponse> {
val user = userService.updateUser(id, updateUserRequest)
return ResponseEntity.ok(user)
}
@GetMapping
fun getUsers(
@RequestParam(defaultValue = "0") @Min(0) page: Int,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) size: Int,
@RequestParam @Pattern(regexp = "^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", message = "Invalid email format")
email: String?
): ResponseEntity<Page<UserResponse>> {
val users = userService.getUsers(page, size, email)
return ResponseEntity.ok(users)
}
}
// Data Transfer Objects with comprehensive validation
data class CreateUserRequest(
@field:NotBlank(message = "Username is required")
@field:Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@field:Pattern(
regexp = "^[a-zA-Z0-9_-]+$",
message = "Username can only contain letters, numbers, underscores, and hyphens"
)
val username: String,
@field:NotBlank(message = "Email is required")
@field:Email(message = "Email must be valid")
val email: String,
@field:NotBlank(message = "Password is required")
@field:Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
@field:Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character"
)
val password: String,
@field:NotBlank(message = "First name is required")
@field:Size(max = 50, message = "First name cannot exceed 50 characters")
val firstName: String,
@field:NotBlank(message = "Last name is required")
@field:Size(max = 50, message = "Last name cannot exceed 50 characters")
val lastName: String,
@field:Valid
val profile: UserProfileRequest?,
@field:Size(max = 10, message = "Cannot have more than 10 roles")
val roles: Set<@NotBlank @Size(max = 20) String> = emptySet()
)
data class UserProfileRequest(
@field:Past(message = "Birth date must be in the past")
val birthDate: LocalDate?,
@field:Size(max = 200, message = "Bio cannot exceed 200 characters")
val bio: String?,
@field:URL(message = "Website must be a valid URL")
val website: String?,
@field:Pattern(
regexp = "^\\+?[1-9]\\d{1,14}$",
message = "Phone number must be in international format"
)
val phoneNumber: String?,
@field:Valid
val address: AddressRequest?
)
data class AddressRequest(
@field:NotBlank(message = "Street is required")
@field:Size(max = 100, message = "Street cannot exceed 100 characters")
val street: String,
@field:NotBlank(message = "City is required")
@field:Size(max = 50, message = "City cannot exceed 50 characters")
val city: String,
@field:NotBlank(message = "State is required")
@field:Size(min = 2, max = 50, message = "State must be between 2 and 50 characters")
val state: String,
@field:NotBlank(message = "Postal code is required")
@field:Pattern(
regexp = "^\\d{5}(-\\d{4})?$",
message = "Postal code must be in format 12345 or 12345-6789"
)
val postalCode: String,
@field:NotBlank(message = "Country is required")
@field:Size(min = 2, max = 2, message = "Country must be 2-letter ISO code")
val country: String
)
// Update request with partial validation
data class UpdateUserRequest(
@field:Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@field:Pattern(
regexp = "^[a-zA-Z0-9_-]+$",
message = "Username can only contain letters, numbers, underscores, and hyphens"
)
val username: String? = null,
@field:Email(message = "Email must be valid")
val email: String? = null,
@field:Size(max = 50, message = "First name cannot exceed 50 characters")
val firstName: String? = null,
@field:Size(max = 50, message = "Last name cannot exceed 50 characters")
val lastName: String? = null,
@field:Valid
val profile: UserProfileRequest? = null
) {
// Validation method to ensure at least one field is provided for update
fun hasAtLeastOneField(): Boolean {
return username != null || email != null || firstName != null ||
lastName != null || profile != null
}
}
Business Logic Validation Strategy
While input validation handles format and basic constraints, business logic validation ensures that operations make sense within your domain:
// Business logic validation service
@Service
@Transactional(readOnly = true)
class UserValidationService(
private val userRepository: UserRepository,
private val roleService: RoleService
) {
/**
* Validates business rules for user creation.
* This goes beyond simple format validation to check business constraints.
*/
fun validateUserCreation(request: CreateUserRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check username uniqueness
if (userRepository.existsByUsername(request.username)) {
errors.add(ValidationError(
field = "username",
message = "Username '${request.username}' is already taken",
code = "USERNAME_ALREADY_EXISTS"
))
}
// Check email uniqueness
if (userRepository.existsByEmail(request.email)) {
errors.add(ValidationError(
field = "email",
message = "Email '${request.email}' is already registered",
code = "EMAIL_ALREADY_EXISTS"
))
}
// Validate roles exist and user has permission to assign them
request.roles.forEach { roleName ->
if (!roleService.roleExists(roleName)) {
errors.add(ValidationError(
field = "roles",
message = "Role '$roleName' does not exist",
code = "INVALID_ROLE"
))
}
}
// Business rule: Age validation if birthdate provided
request.profile?.birthDate?.let { birthDate ->
val age = Period.between(birthDate, LocalDate.now()).years
if (age < 13) {
errors.add(ValidationError(
field = "profile.birthDate",
message = "User must be at least 13 years old",
code = "MINIMUM_AGE_REQUIRED"
))
}
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
/**
* Validates business rules for user updates.
*/
fun validateUserUpdate(userId: Long, request: UpdateUserRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
val existingUser = userRepository.findById(userId).orElse(null)
?: return ValidationResult(
isValid = false,
errors = listOf(ValidationError(
field = "id",
message = "User with ID $userId not found",
code = "USER_NOT_FOUND"
))
)
// Validate at least one field is provided
if (!request.hasAtLeastOneField()) {
errors.add(ValidationError(
field = "request",
message = "At least one field must be provided for update",
code = "NO_FIELDS_TO_UPDATE"
))
}
// Check username uniqueness (if changing)
request.username?.let { newUsername ->
if (newUsername != existingUser.username && userRepository.existsByUsername(newUsername)) {
errors.add(ValidationError(
field = "username",
message = "Username '$newUsername' is already taken",
code = "USERNAME_ALREADY_EXISTS"
))
}
}
// Check email uniqueness (if changing)
request.email?.let { newEmail ->
if (newEmail != existingUser.email && userRepository.existsByEmail(newEmail)) {
errors.add(ValidationError(
field = "email",
message = "Email '$newEmail' is already registered",
code = "EMAIL_ALREADY_EXISTS"
))
}
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
/**
* Validates that a user can be deleted.
*/
fun validateUserDeletion(userId: Long): ValidationResult {
val errors = mutableListOf<ValidationError>()
val user = userRepository.findById(userId).orElse(null)
?: return ValidationResult(
isValid = false,
errors = listOf(ValidationError(
field = "id",
message = "User with ID $userId not found",
code = "USER_NOT_FOUND"
))
)
// Business rule: Cannot delete users with active orders
if (userRepository.hasActiveOrders(userId)) {
errors.add(ValidationError(
field = "id",
message = "Cannot delete user with active orders",
code = "USER_HAS_ACTIVE_ORDERS"
))
}
// Business rule: Cannot delete admin users if they're the last admin
if (user.hasRole("ADMIN") && userRepository.countAdminUsers() <= 1) {
errors.add(ValidationError(
field = "id",
message = "Cannot delete the last admin user",
code = "LAST_ADMIN_USER"
))
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
}
// Validation result classes
data class ValidationResult(
val isValid: Boolean,
val errors: List<ValidationError>
) {
fun throwIfInvalid() {
if (!isValid) {
throw BusinessValidationException(errors)
}
}
}
data class ValidationError(
val field: String,
val message: String,
val code: String,
val rejectedValue: Any? = null
)
// Custom exceptions for validation failures
class BusinessValidationException(
val validationErrors: List<ValidationError>
) : RuntimeException("Business validation failed") {
override val message: String
get() = validationErrors.joinToString("; ") { "${it.field}: ${it.message}" }
}
Database Constraint Validation Strategy
Database constraints provide the final layer of validation and ensure data integrity even if application-level validation fails:
// Entity with comprehensive database constraints
@Entity
@Table(
name = "users",
uniqueConstraints = [
UniqueConstraint(name = "uk_users_username", columnNames = ["username"]),
UniqueConstraint(name = "uk_users_email", columnNames = ["email"])
],
indexes = [
Index(name = "idx_users_email", columnList = "email"),
Index(name = "idx_users_created_at", columnList = "created_at")
]
)
class User : BaseEntity() {
@Column(name = "username", nullable = false, length = 50)
var username: String = ""
protected set
@Column(name = "email", nullable = false, length = 255)
var email: String = ""
protected set
@Column(name = "password_hash", nullable = false, length = 255)
var passwordHash: String = ""
protected set
@Column(name = "first_name", nullable = false, length = 50)
var firstName: String = ""
protected set
@Column(name = "last_name", nullable = false, length = 50)
var lastName: String = ""
protected set
@Column(name = "active", nullable = false)
var active: Boolean = true
protected set
@Column(name = "email_verified", nullable = false)
var emailVerified: Boolean = false
protected set
@Column(name = "last_login")
var lastLogin: LocalDateTime? = null
protected set
// Relationships with proper constraints
@OneToOne(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "profile_id", referencedColumnName = "id")
var profile: UserProfile? = null
protected set
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = [JoinColumn(name = "user_id")],
inverseJoinColumns = [JoinColumn(name = "role_id")]
)
var roles: MutableSet<Role> = mutableSetOf()
protected set
// Factory method with validation
companion object {
fun create(
username: String,
email: String,
passwordHash: String,
firstName: String,
lastName: String
): User {
require(username.isNotBlank()) { "Username cannot be blank" }
require(email.isNotBlank()) { "Email cannot be blank" }
require(passwordHash.isNotBlank()) { "Password hash cannot be blank" }
require(firstName.isNotBlank()) { "First name cannot be blank" }
require(lastName.isNotBlank()) { "Last name cannot be blank" }
return User().apply {
this.username = username
this.email = email
this.passwordHash = passwordHash
this.firstName = firstName
this.lastName = lastName
}
}
}
// Business methods with validation
fun updateEmail(newEmail: String) {
require(newEmail.isNotBlank()) { "Email cannot be blank" }
require(newEmail.contains("@")) { "Email must be valid" }
this.email = newEmail
this.emailVerified = false // Reset verification when email changes
}
fun addRole(role: Role) {
roles.add(role)
}
fun removeRole(role: Role) {
roles.remove(role)
}
fun hasRole(roleName: String): Boolean {
return roles.any { it.name == roleName }
}
}
// Database constraint exception handler
@Component
class DatabaseConstraintExceptionHandler {
fun handleConstraintViolation(ex: DataIntegrityViolationException): ValidationResult {
val errors = mutableListOf<ValidationError>()
val rootCause = ex.rootCause
if (rootCause is SQLIntegrityConstraintViolationException) {
val constraintName = extractConstraintName(rootCause.message ?: "")
when {
constraintName.contains("uk_users_username") -> {
errors.add(ValidationError(
field = "username",
message = "Username is already taken",
code = "USERNAME_ALREADY_EXISTS"
))
}
constraintName.contains("uk_users_email") -> {
errors.add(ValidationError(
field = "email",
message = "Email is already registered",
code = "EMAIL_ALREADY_EXISTS"
))
}
else -> {
errors.add(ValidationError(
field = "database",
message = "Database constraint violation",
code = "CONSTRAINT_VIOLATION"
))
}
}
}
return ValidationResult(
isValid = false,
errors = errors
)
}
private fun extractConstraintName(message: String): String {
// Extract constraint name from database error message
// This is database-specific implementation
return when {
message.contains("Duplicate entry") && message.contains("uk_users_username") -> "uk_users_username"
message.contains("Duplicate entry") && message.contains("uk_users_email") -> "uk_users_email"
else -> message
}
}
}
10.2 Hibernate Validator in Kotlin
Hibernate Validator is the reference implementation of Bean Validation and integrates seamlessly with Spring Boot. Let’s explore how to use it effectively in Kotlin applications.
Basic Hibernate Validator Setup
First, let’s ensure proper configuration and dependencies:
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
// This includes hibernate-validator automatically
}
// Validation configuration
@Configuration
class ValidationConfig {
@Bean
fun validator(): Validator {
return ValidatorBuilder.create()
.with(KotlinValidatorConfiguration())
.buildValidatorFactory()
.validator
}
@Bean
fun methodValidationPostProcessor(): MethodValidationPostProcessor {
val processor = MethodValidationPostProcessor()
processor.setValidator(validator())
return processor
}
}
// Kotlin-specific validator configuration
class KotlinValidatorConfiguration : ValidatorConfiguration<KotlinValidatorConfiguration> {
override fun addMapping(stream: InputStream?): KotlinValidatorConfiguration = this
override fun addProperty(name: String?, value: String?): KotlinValidatorConfiguration = this
override fun messageInterpolator(interpolator: MessageInterpolator?): KotlinValidatorConfiguration = this
override fun traversableResolver(resolver: TraversableResolver?): KotlinValidatorConfiguration = this
override fun constraintValidatorFactory(constraintValidatorFactory: ConstraintValidatorFactory?): KotlinValidatorConfiguration = this
override fun parameterNameProvider(parameterNameProvider: ParameterNameProvider?): KotlinValidatorConfiguration = this
override fun clockProvider(clockProvider: ClockProvider?): KotlinValidatorConfiguration = this
override fun addValueExtractor(extractor: ValueExtractor<*>?): KotlinValidatorConfiguration = this
override fun buildValidatorFactory(): ValidatorFactory {
return Validation.byProvider(HibernateValidator::class.java)
.configure()
.parameterNameProvider(ReflectionParameterNameProvider())
.buildValidatorFactory()
}
}
Advanced Validation Annotations in Kotlin
Kotlin’s type system and language features work excellently with Hibernate Validator:
// Advanced validation examples leveraging Kotlin features
data class ProductRequest(
@field:NotBlank(message = "Product name is required")
@field:Size(min = 2, max = 100, message = "Product name must be between 2 and 100 characters")
val name: String,
@field:NotBlank(message = "Description is required")
@field:Size(min = 10, max = 1000, message = "Description must be between 10 and 1000 characters")
val description: String,
@field:NotNull(message = "Price is required")
@field:DecimalMin(value = "0.01", message = "Price must be greater than 0")
@field:DecimalMax(value = "999999.99", message = "Price must be less than 1,000,000")
@field:Digits(integer = 6, fraction = 2, message = "Price must have at most 6 digits and 2 decimal places")
val price: BigDecimal,
@field:NotEmpty(message = "At least one category is required")
@field:Size(max = 5, message = "Cannot have more than 5 categories")
val categories: List<@NotBlank @Size(max = 50) String>,
@field:Valid
@field:NotNull(message = "Dimensions are required")
val dimensions: ProductDimensions,
@field:Valid
val images: List<ProductImage>? = null,
@field:Future(message = "Launch date must be in the future")
val launchDate: LocalDateTime? = null,
// Using Kotlin's sealed class for type-safe status
val status: ProductStatus = ProductStatus.DRAFT
)
data class ProductDimensions(
@field:Positive(message = "Length must be positive")
@field:DecimalMax(value = "1000.0", message = "Length cannot exceed 1000 cm")
val length: BigDecimal,
@field:Positive(message = "Width must be positive")
@field:DecimalMax(value = "1000.0", message = "Width cannot exceed 1000 cm")
val width: BigDecimal,
@field:Positive(message = "Height must be positive")
@field:DecimalMax(value = "1000.0", message = "Height cannot exceed 1000 cm")
val height: BigDecimal,
@field:NotNull(message = "Unit is required")
val unit: DimensionUnit
) {
// Computed property for volume
val volume: BigDecimal
get() = length * width * height
// Validation method
@AssertTrue(message = "Product dimensions result in impractical volume")
fun isVolumeReasonable(): Boolean {
return volume <= BigDecimal("1000000") // 1 cubic meter in cubic cm
}
}
data class ProductImage(
@field:NotBlank(message = "Image URL is required")
@field:URL(message = "Image URL must be valid")
val url: String,
@field:NotBlank(message = "Alt text is required for accessibility")
@field:Size(max = 200, message = "Alt text cannot exceed 200 characters")
val altText: String,
@field:PositiveOrZero(message = "Display order cannot be negative")
val displayOrder: Int = 0
)
// Kotlin sealed class for type-safe status
sealed class ProductStatus {
object DRAFT : ProductStatus()
object PENDING_REVIEW : ProductStatus()
object APPROVED : ProductStatus()
object PUBLISHED : ProductStatus()
object DISCONTINUED : ProductStatus()
override fun toString(): String = this::class.simpleName ?: "UNKNOWN"
}
enum class DimensionUnit {
CM, INCH, M, FT
}
// Group validation for different validation scenarios
interface BasicValidation
interface DetailedValidation : BasicValidation
interface PublishingValidation : DetailedValidation
data class ProductPublishRequest(
@field:Valid
val product: ProductRequest,
@field:NotBlank(message = "Publishing reason is required", groups = [PublishingValidation::class])
@field:Size(max = 500, message = "Publishing reason cannot exceed 500 characters", groups = [PublishingValidation::class])
val publishingReason: String? = null,
@field:NotNull(message = "Reviewer is required for publishing", groups = [PublishingValidation::class])
val reviewedBy: Long? = null,
@field:AssertTrue(message = "Product must be approved before publishing", groups = [PublishingValidation::class])
fun isProductApproved(): Boolean = product.status == ProductStatus.APPROVED
)
Programmatic Validation in Services
Sometimes you need to perform validation programmatically in your service layer:
@Service
@Transactional
class ProductValidationService(
private val validator: Validator,
private val productRepository: ProductRepository
) {
/**
* Validates a product using different validation groups based on the operation.
*/
fun validateProduct(request: ProductRequest, operation: ValidationOperation): ValidationResult {
val groups = when (operation) {
ValidationOperation.CREATE -> arrayOf(BasicValidation::class.java)
ValidationOperation.UPDATE -> arrayOf(DetailedValidation::class.java)
ValidationOperation.PUBLISH -> arrayOf(PublishingValidation::class.java)
}
val violations = validator.validate(request, *groups)
val errors = violations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "VALIDATION_ERROR",
rejectedValue = violation.invalidValue
)
}.toMutableList()
// Add custom business logic validation
errors.addAll(validateBusinessRules(request, operation))
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
/**
* Validates individual fields programmatically.
*/
fun validateField(obj: Any, fieldName: String): List<ValidationError> {
val violations = validator.validateProperty(obj, fieldName)
return violations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "VALIDATION_ERROR",
rejectedValue = violation.invalidValue
)
}
}
/**
* Validates a value against specific constraints.
*/
fun <T> validateValue(beanType: Class<T>, fieldName: String, value: Any?): List<ValidationError> {
val violations = validator.validateValue(beanType, fieldName, value)
return violations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "VALIDATION_ERROR",
rejectedValue = violation.invalidValue
)
}
}
private fun validateBusinessRules(request: ProductRequest, operation: ValidationOperation): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
when (operation) {
ValidationOperation.CREATE -> {
// Check for duplicate product names
if (productRepository.existsByName(request.name)) {
errors.add(ValidationError(
field = "name",
message = "Product with name '${request.name}' already exists",
code = "DUPLICATE_PRODUCT_NAME"
))
}
}
ValidationOperation.PUBLISH -> {
// Additional validation for publishing
if (request.images.isNullOrEmpty()) {
errors.add(ValidationError(
field = "images",
message = "At least one product image is required for publishing",
code = "IMAGES_REQUIRED_FOR_PUBLISHING"
))
}
if (request.launchDate == null) {
errors.add(ValidationError(
field = "launchDate",
message = "Launch date is required for publishing",
code = "LAUNCH_DATE_REQUIRED_FOR_PUBLISHING"
))
}
}
else -> { /* No additional validation */ }
}
return errors
}
}
enum class ValidationOperation {
CREATE, UPDATE, PUBLISH
}
// Extension function for easy validation
fun <T> Validator.validateKotlin(obj: T, vararg groups: Class<*>): ValidationResult {
val violations = this.validate(obj, *groups)
val errors = violations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "VALIDATION_ERROR",
rejectedValue = violation.invalidValue
)
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
10.3 Spring Boot Integration with Validation
Spring Boot provides excellent integration with validation frameworks. Let’s explore how to configure and customize this integration effectively.
Global Validation Configuration
@Configuration
@EnableConfigurationProperties(ValidationProperties::class)
class ValidationConfiguration(
private val validationProperties: ValidationProperties
) {
@Bean
@Primary
fun validator(messageSource: MessageSource): LocalValidatorFactoryBean {
val validator = LocalValidatorFactoryBean()
validator.setValidationMessageSource(messageSource)
validator.setParameterNameDiscoverer(DefaultParameterNameDiscoverer())
// Configure Hibernate Validator specific settings
validator.setConfigurationInitializer { configuration ->
configuration
.messageInterpolator(
ResourceBundleMessageInterpolator(
PlatformResourceBundleLocator("ValidationMessages")
)
)
.clockProvider(DefaultClockProvider.INSTANCE)
.addValueExtractor(OptionalValueExtractor())
}
return validator
}
@Bean
fun methodValidationPostProcessor(validator: LocalValidatorFactoryBean): MethodValidationPostProcessor {
val processor = MethodValidationPostProcessor()
processor.setValidator(validator)
processor.setProxyTargetClass(true)
return processor
}
@Bean
@ConditionalOnMissingBean
fun validationService(validator: Validator): ValidationService {
return ValidationService(validator)
}
}
// Custom validation properties
@ConfigurationProperties(prefix = "app.validation")
data class ValidationProperties(
val failFast: Boolean = false,
val maxViolations: Int = 100,
val enableMethodValidation: Boolean = true,
val customMessages: Map<String, String> = emptyMap()
)
// Custom value extractor for Kotlin's nullable types
class OptionalValueExtractor : ValueExtractor<Optional<*>> {
override fun extractValues(originalValue: Optional<*>, receiver: ValueExtractor.ValueReceiver) {
if (originalValue.isPresent) {
receiver.value(null, originalValue.get())
}
}
}
Controller-Level Validation Integration
// Enhanced controller with comprehensive validation
@RestController
@RequestMapping("/api/v1/products")
@Validated
class ProductController(
private val productService: ProductService,
private val validationService: ValidationService
) {
@PostMapping
fun createProduct(
@Valid @RequestBody request: ProductRequest,
bindingResult: BindingResult
): ResponseEntity<*> {
// Manual validation for complex scenarios
val validationResult = validationService.validate(request, ValidationOperation.CREATE)
if (!validationResult.isValid) {
return ResponseEntity.badRequest().body(
ErrorResponse(
message = "Validation failed",
errors = validationResult.errors
)
)
}
val product = productService.createProduct(request)
return ResponseEntity.status(HttpStatus.CREATED).body(product)
}
@PutMapping("/{id}")
fun updateProduct(
@PathVariable @Positive id: Long,
@Valid @RequestBody request: ProductRequest
): ResponseEntity<ProductResponse> {
val product = productService.updateProduct(id, request)
return ResponseEntity.ok(product)
}
// Method-level validation for complex parameters
@GetMapping("/search")
fun searchProducts(
@RequestParam(required = false)
@Size(min = 2, max = 100, message = "Search term must be between 2 and 100 characters")
query: String?,
@RequestParam(defaultValue = "0")
@Min(value = 0, message = "Page number cannot be negative")
page: Int,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "Page size must be at least 1")
@Max(value = 100, message = "Page size cannot exceed 100")
size: Int,
@RequestParam(required = false)
@DecimalMin(value = "0.01", message = "Minimum price must be greater than 0")
minPrice: BigDecimal?,
@RequestParam(required = false)
@DecimalMax(value = "999999.99", message = "Maximum price must be less than 1,000,000")
maxPrice: BigDecimal?,
@RequestParam(required = false)
categories: List<@NotBlank @Size(max = 50) String>?
): ResponseEntity<Page<ProductResponse>> {
// Custom validation for parameter relationships
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw IllegalArgumentException("Minimum price cannot be greater than maximum price")
}
val products = productService.searchProducts(query, page, size, minPrice, maxPrice, categories)
return ResponseEntity.ok(products)
}
// Batch operations with validation
@PostMapping("/batch")
fun createProductsBatch(
@Valid @RequestBody requests: List<@Valid ProductRequest>
): ResponseEntity<BatchResponse<ProductResponse>> {
if (requests.size > 100) {
throw IllegalArgumentException("Cannot process more than 100 products in a single batch")
}
val results = productService.createProductsBatch(requests)
return ResponseEntity.ok(results)
}
// Publishing with group validation
@PostMapping("/{id}/publish")
fun publishProduct(
@PathVariable @Positive id: Long,
@RequestBody @Validated(PublishingValidation::class) request: ProductPublishRequest
): ResponseEntity<ProductResponse> {
val product = productService.publishProduct(id, request)
return ResponseEntity.ok(product)
}
}
// Batch response for bulk operations
data class BatchResponse<T>(
val successful: List<T>,
val failed: List<BatchError>,
val totalProcessed: Int,
val successCount: Int,
val failureCount: Int
) {
companion object {
fun <T> create(successful: List<T>, failed: List<BatchError>): BatchResponse<T> {
return BatchResponse(
successful = successful,
failed = failed,
totalProcessed = successful.size + failed.size,
successCount = successful.size,
failureCount = failed.size
)
}
}
}
data class BatchError(
val index: Int,
val errors: List<ValidationError>,
val originalRequest: Any
)
Service-Level Validation Integration
@Service
@Transactional
class ProductService(
private val productRepository: ProductRepository,
private val validationService: ValidationService,
private val productMapper: ProductMapper
) {
/**
* Creates a product with comprehensive validation.
*/
fun createProduct(request: ProductRequest): ProductResponse {
// Validate using service-level validation
val validationResult = validationService.validate(request, ValidationOperation.CREATE)
validationResult.throwIfInvalid()
val product = productMapper.toEntity(request)
val savedProduct = productRepository.save(product)
return productMapper.toResponse(savedProduct)
}
/**
* Updates a product with partial validation.
*/
fun updateProduct(id: Long, request: ProductRequest): ProductResponse {
val existingProduct = productRepository.findById(id).orElse(null)
?: throw ProductNotFoundException("Product not found with ID: $id")
// Validate update operation
val validationResult = validationService.validate(request, ValidationOperation.UPDATE)
validationResult.throwIfInvalid()
val updatedProduct = productMapper.updateEntity(existingProduct, request)
val savedProduct = productRepository.save(updatedProduct)
return productMapper.toResponse(savedProduct)
}
/**
* Batch creation with individual validation tracking.
*/
fun createProductsBatch(requests: List<ProductRequest>): BatchResponse<ProductResponse> {
val successful = mutableListOf<ProductResponse>()
val failed = mutableListOf<BatchError>()
requests.forEachIndexed { index, request ->
try {
val validationResult = validationService.validate(request, ValidationOperation.CREATE)
if (validationResult.isValid) {
val product = createProduct(request)
successful.add(product)
} else {
failed.add(BatchError(
index = index,
errors = validationResult.errors,
originalRequest = request
))
}
} catch (ex: Exception) {
failed.add(BatchError(
index = index,
errors = listOf(ValidationError(
field = "general",
message = ex.message ?: "Unknown error occurred",
code = "PROCESSING_ERROR"
)),
originalRequest = request
))
}
}
return BatchResponse.create(successful, failed)
}
/**
* Publishes a product with strict validation.
*/
fun publishProduct(id: Long, request: ProductPublishRequest): ProductResponse {
val product = productRepository.findById(id).orElse(null)
?: throw ProductNotFoundException("Product not found with ID: $id")
// Validate publishing requirements
val validationResult = validationService.validate(
request.product,
ValidationOperation.PUBLISH
)
validationResult.throwIfInvalid()
// Additional business validation for publishing
if (product.status != ProductStatus.APPROVED) {
throw IllegalStateException("Product must be approved before publishing")
}
product.status = ProductStatus.PUBLISHED
product.publishedAt = LocalDateTime.now()
product.publishedBy = request.reviewedBy
val savedProduct = productRepository.save(product)
return productMapper.toResponse(savedProduct)
}
}
// Validation service wrapper
@Component
class ValidationService(private val validator: Validator) {
fun <T> validate(obj: T, operation: ValidationOperation? = null): ValidationResult {
val groups = when (operation) {
ValidationOperation.CREATE -> arrayOf(BasicValidation::class.java)
ValidationOperation.UPDATE -> arrayOf(DetailedValidation::class.java)
ValidationOperation.PUBLISH -> arrayOf(PublishingValidation::class.java)
null -> arrayOf<Class<*>>()
}
return validator.validateKotlin(obj, *groups)
}
fun <T> validateProperty(obj: T, propertyName: String): ValidationResult {
val violations = validator.validateProperty(obj, propertyName)
val errors = violations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "VALIDATION_ERROR",
rejectedValue = violation.invalidValue
)
}
return ValidationResult(
isValid = errors.isEmpty(),
errors = errors
)
}
}
10.4 Custom Validation
Sometimes built-in validation annotations aren’t sufficient for your business requirements. Let’s explore how to create custom validation annotations and validators in Kotlin.
Creating Custom Validation Annotations
// Custom validation annotation for password strength
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [PasswordStrengthValidator::class])
@MustBeDocumented
annotation class ValidPassword(
val message: String = "Password does not meet strength requirements",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val minLength: Int = 8,
val requireUppercase: Boolean = true,
val requireLowercase: Boolean = true,
val requireDigit: Boolean = true,
val requireSpecialChar: Boolean = true,
val maxRepeatingChars: Int = 3
)
// Password strength validator
class PasswordStrengthValidator : ConstraintValidator<ValidPassword, String> {
private var minLength: Int = 8
private var requireUppercase: Boolean = true
private var requireLowercase: Boolean = true
private var requireDigit: Boolean = true
private var requireSpecialChar: Boolean = true
private var maxRepeatingChars: Int = 3
override fun initialize(annotation: ValidPassword) {
minLength = annotation.minLength
requireUppercase = annotation.requireUppercase
requireLowercase = annotation.requireLowercase
requireDigit = annotation.requireDigit
requireSpecialChar = annotation.requireSpecialChar
maxRepeatingChars = annotation.maxRepeatingChars
}
override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
if (value == null) return false
val violations = mutableListOf<String>()
// Check minimum length
if (value.length < minLength) {
violations.add("must be at least $minLength characters long")
}
// Check character requirements
if (requireUppercase && !value.any { it.isUpperCase() }) {
violations.add("must contain at least one uppercase letter")
}
if (requireLowercase && !value.any { it.isLowerCase() }) {
violations.add("must contain at least one lowercase letter")
}
if (requireDigit && !value.any { it.isDigit() }) {
violations.add("must contain at least one digit")
}
if (requireSpecialChar && !value.any { !it.isLetterOrDigit() }) {
violations.add("must contain at least one special character")
}
// Check for too many repeating characters
if (hasExcessiveRepeating(value, maxRepeatingChars)) {
violations.add("cannot have more than $maxRepeatingChars consecutive repeating characters")
}
// If there are violations, customize the error message
if (violations.isNotEmpty()) {
context.disableDefaultConstraintViolation()
context.buildConstraintViolationWithTemplate(
"Password ${violations.joinToString(", ")}"
).addConstraintViolation()
return false
}
return true
}
private fun hasExcessiveRepeating(value: String, maxRepeating: Int): Boolean {
var count = 1
for (i in 1 until value.length) {
if (value[i] == value[i - 1]) {
count++
if (count > maxRepeating) return true
} else {
count = 1
}
}
return false
}
}
// Custom validation for date ranges
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [DateRangeValidator::class])
@MustBeDocumented
annotation class ValidDateRange(
val message: String = "End date must be after start date",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val startDateField: String = "startDate",
val endDateField: String = "endDate",
val allowSameDate: Boolean = false
)
// Date range validator
class DateRangeValidator : ConstraintValidator<ValidDateRange, Any> {
private var startDateField: String = ""
private var endDateField: String = ""
private var allowSameDate: Boolean = false
override fun initialize(annotation: ValidDateRange) {
startDateField = annotation.startDateField
endDateField = annotation.endDateField
allowSameDate = annotation.allowSameDate
}
override fun isValid(obj: Any?, context: ConstraintValidatorContext): Boolean {
if (obj == null) return true
try {
val startDate = getFieldValue(obj, startDateField) as? LocalDate
val endDate = getFieldValue(obj, endDateField) as? LocalDate
if (startDate == null || endDate == null) return true
return if (allowSameDate) {
!endDate.isBefore(startDate)
} else {
endDate.isAfter(startDate)
}
} catch (ex: Exception) {
return false
}
}
private fun getFieldValue(obj: Any, fieldName: String): Any? {
val field = obj.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(obj)
}
}
// Custom validation for unique collection elements
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [UniqueElementsValidator::class])
@MustBeDocumented
annotation class UniqueElements(
val message: String = "Collection must contain unique elements",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val property: String = "" // Property name to check uniqueness on
)
// Unique elements validator
class UniqueElementsValidator : ConstraintValidator<UniqueElements, Collection<*>> {
private var property: String = ""
override fun initialize(annotation: UniqueElements) {
property = annotation.property
}
override fun isValid(collection: Collection<*>?, context: ConstraintValidatorContext): Boolean {
if (collection.isNullOrEmpty()) return true
return if (property.isBlank()) {
// Check uniqueness of elements themselves
collection.size == collection.toSet().size
} else {
// Check uniqueness of specified property
val propertyValues = collection.mapNotNull { element ->
getPropertyValue(element, property)
}
propertyValues.size == propertyValues.toSet().size
}
}
private fun getPropertyValue(obj: Any?, propertyName: String): Any? {
if (obj == null) return null
try {
val field = obj.javaClass.getDeclaredField(propertyName)
field.isAccessible = true
return field.get(obj)
} catch (ex: Exception) {
return null
}
}
}
Using Custom Validation Annotations
// Data classes using custom validation annotations
data class CreateAccountRequest(
@field:NotBlank(message = "Username is required")
@field:Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters")
val username: String,
@field:NotBlank(message = "Email is required")
@field:Email(message = "Email must be valid")
val email: String,
@field:NotBlank(message = "Password is required")
@field:ValidPassword(
minLength = 10,
requireUppercase = true,
requireLowercase = true,
requireDigit = true,
requireSpecialChar = true,
maxRepeatingChars = 2
)
val password: String,
@field:NotBlank(message = "Password confirmation is required")
val passwordConfirmation: String
) {
// Class-level validation to ensure passwords match
@AssertTrue(message = "Password and confirmation must match")
fun isPasswordConfirmed(): Boolean {
return password == passwordConfirmation
}
}
@ValidDateRange(
startDateField = "startDate",
endDateField = "endDate",
allowSameDate = false,
message = "End date must be after start date"
)
data class EventRequest(
@field:NotBlank(message = "Event name is required")
val name: String,
@field:NotNull(message = "Start date is required")
@field:Future(message = "Start date must be in the future")
val startDate: LocalDate,
@field:NotNull(message = "End date is required")
val endDate: LocalDate,
@field:UniqueElements(
property = "email",
message = "Each attendee email must be unique"
)
val attendees: List<AttendeeRequest> = emptyList()
)
data class AttendeeRequest(
@field:NotBlank(message = "Attendee name is required")
val name: String,
@field:NotBlank(message = "Attendee email is required")
@field:Email(message = "Attendee email must be valid")
val email: String
)
// Product with multiple custom validations
data class AdvancedProductRequest(
@field:NotBlank(message = "Product name is required")
val name: String,
@field:NotNull(message = "Price is required")
@field:Positive(message = "Price must be positive")
val price: BigDecimal,
@field:UniqueElements(message = "Product tags must be unique")
val tags: List<@NotBlank @Size(max = 20) String> = emptyList(),
@field:Valid
val variants: List<ProductVariant> = emptyList()
)
data class ProductVariant(
@field:NotBlank(message = "Variant name is required")
val name: String,
@field:NotNull(message = "Variant price is required")
@field:Positive(message = "Variant price must be positive")
val price: BigDecimal,
@field:PositiveOrZero(message = "Stock quantity cannot be negative")
val stockQuantity: Int = 0
)
Complex Custom Validators
For more complex validation scenarios, you might need validators that interact with the application context:
// Context-aware validation annotation
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [UniqueUsernameValidator::class])
@MustBeDocumented
annotation class UniqueUsername(
val message: String = "Username is already taken",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val ignoreCase: Boolean = true
)
// Context-aware validator that uses repository
@Component
class UniqueUsernameValidator(
private val userRepository: UserRepository
) : ConstraintValidator<UniqueUsername, String> {
private var ignoreCase: Boolean = true
override fun initialize(annotation: UniqueUsername) {
ignoreCase = annotation.ignoreCase
}
override fun isValid(username: String?, context: ConstraintValidatorContext): Boolean {
if (username.isNullOrBlank()) return true
return if (ignoreCase) {
!userRepository.existsByUsernameIgnoreCase(username)
} else {
!userRepository.existsByUsername(username)
}
}
}
// Complex business rule validator
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [OrderValidationValidator::class])
@MustBeDocumented
annotation class ValidOrder(
val message: String = "Order validation failed",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
@Component
class OrderValidationValidator(
private val productService: ProductService,
private val inventoryService: InventoryService,
private val userService: UserService
) : ConstraintValidator<ValidOrder, OrderRequest> {
override fun isValid(order: OrderRequest?, context: ConstraintValidatorContext): Boolean {
if (order == null) return true
context.disableDefaultConstraintViolation()
var isValid = true
// Validate customer exists and is active
if (!userService.isActiveUser(order.customerId)) {
context.buildConstraintViolationWithTemplate(
"Customer with ID ${order.customerId} is not active"
).addPropertyNode("customerId").addConstraintViolation()
isValid = false
}
// Validate each order item
order.items.forEachIndexed { index, item ->
// Check product exists
if (!productService.productExists(item.productId)) {
context.buildConstraintViolationWithTemplate(
"Product with ID ${item.productId} does not exist"
).addPropertyNode("items[$index].productId").addConstraintViolation()
isValid = false
}
// Check inventory availability
if (!inventoryService.isAvailable(item.productId, item.quantity)) {
context.buildConstraintViolationWithTemplate(
"Insufficient inventory for product ${item.productId}"
).addPropertyNode("items[$index].quantity").addConstraintViolation()
isValid = false
}
}
// Validate total amount
val calculatedTotal = order.items.sumOf { item ->
productService.getPrice(item.productId) * item.quantity.toBigDecimal()
}
if (order.totalAmount != calculatedTotal) {
context.buildConstraintViolationWithTemplate(
"Total amount ${order.totalAmount} does not match calculated total $calculatedTotal"
).addPropertyNode("totalAmount").addConstraintViolation()
isValid = false
}
return isValid
}
}
@ValidOrder
data class OrderRequest(
@field:NotNull(message = "Customer ID is required")
@field:Positive(message = "Customer ID must be positive")
val customerId: Long,
@field:NotEmpty(message = "Order must contain at least one item")
@field:Size(max = 50, message = "Order cannot contain more than 50 items")
val items: List<@Valid OrderItemRequest>,
@field:NotNull(message = "Total amount is required")
@field:Positive(message = "Total amount must be positive")
val totalAmount: BigDecimal,
@field:Valid
val shippingAddress: AddressRequest,
@field:Valid
val billingAddress: AddressRequest? = null
)
data class OrderItemRequest(
@field:NotNull(message = "Product ID is required")
@field:Positive(message = "Product ID must be positive")
val productId: Long,
@field:NotNull(message = "Quantity is required")
@field:Min(value = 1, message = "Quantity must be at least 1")
@field:Max(value = 100, message = "Quantity cannot exceed 100")
val quantity: Int
)
10.5 Exception Handling and Custom Exceptions
Robust exception handling is crucial for providing meaningful feedback to API consumers and maintaining application stability. Let’s build a comprehensive exception handling framework.
Custom Exception Hierarchy
// Base exception classes
abstract class ApplicationException(
message: String,
cause: Throwable? = null,
val errorCode: String,
val httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR
) : RuntimeException(message, cause)
// Domain-specific exceptions
class ValidationException(
message: String = "Validation failed",
val errors: List<ValidationError>,
cause: Throwable? = null
) : ApplicationException(
message = message,
cause = cause,
errorCode = "VALIDATION_ERROR",
httpStatus = HttpStatus.BAD_REQUEST
)
class ResourceNotFoundException(
resource: String,
identifier: Any,
cause: Throwable? = null
) : ApplicationException(
message = "$resource not found with identifier: $identifier",
cause = cause,
errorCode = "RESOURCE_NOT_FOUND",
httpStatus = HttpStatus.NOT_FOUND
)
class BusinessRuleException(
message: String,
val ruleCode: String,
cause: Throwable? = null
) : ApplicationException(
message = message,
cause = cause,
errorCode = ruleCode,
httpStatus = HttpStatus.UNPROCESSABLE_ENTITY
)
class DuplicateResourceException(
resource: String,
field: String,
value: Any,
cause: Throwable? = null
) : ApplicationException(
message = "$resource with $field '$value' already exists",
cause = cause,
errorCode = "DUPLICATE_RESOURCE",
httpStatus = HttpStatus.CONFLICT
)
class InsufficientPermissionException(
action: String,
resource: String,
cause: Throwable? = null
) : ApplicationException(
message = "Insufficient permission to $action $resource",
cause = cause,
errorCode = "INSUFFICIENT_PERMISSION",
httpStatus = HttpStatus.FORBIDDEN
)
class ExternalServiceException(
service: String,
operation: String,
message: String,
cause: Throwable? = null
) : ApplicationException(
message = "External service '$service' failed during '$operation': $message",
cause = cause,
errorCode = "EXTERNAL_SERVICE_ERROR",
httpStatus = HttpStatus.BAD_GATEWAY
)
// Specific domain exceptions
class UserNotFoundException(userId: Long) : ResourceNotFoundException("User", userId)
class ProductNotFoundException(productId: Long) : ResourceNotFoundException("Product", productId)
class OrderNotFoundException(orderId: Long) : ResourceNotFoundException("Order", orderId)
class UsernameAlreadyExistsException(username: String) :
DuplicateResourceException("User", "username", username)
class EmailAlreadyExistsException(email: String) :
DuplicateResourceException("User", "email", email)
class InsufficientStockException(
productId: Long,
requested: Int,
available: Int
) : BusinessRuleException(
message = "Insufficient stock for product $productId. Requested: $requested, Available: $available",
ruleCode = "INSUFFICIENT_STOCK"
)
class AccountNotActiveException(userId: Long) : BusinessRuleException(
message = "User account $userId is not active",
ruleCode = "ACCOUNT_NOT_ACTIVE"
)
Global Exception Handler
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
class GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
/**
* Handles validation exceptions from @Valid annotations.
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidationException(ex: MethodArgumentNotValidException): ErrorResponse {
logger.debug("Validation error occurred", ex)
val errors = ex.bindingResult.fieldErrors.map { fieldError ->
ValidationError(
field = fieldError.field,
message = fieldError.defaultMessage ?: "Invalid value",
code = fieldError.code ?: "VALIDATION_ERROR",
rejectedValue = fieldError.rejectedValue
)
}.plus(
ex.bindingResult.globalErrors.map { globalError ->
ValidationError(
field = globalError.objectName,
message = globalError.defaultMessage ?: "Invalid object",
code = globalError.code ?: "VALIDATION_ERROR"
)
}
)
return ErrorResponse(
message = "Validation failed",
errorCode = "VALIDATION_ERROR",
details = errors,
timestamp = Instant.now()
)
}
/**
* Handles constraint violation exceptions from method-level validation.
*/
@ExceptionHandler(ConstraintViolationException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleConstraintViolationException(ex: ConstraintViolationException): ErrorResponse {
logger.debug("Constraint violation occurred", ex)
val errors = ex.constraintViolations.map { violation ->
ValidationError(
field = violation.propertyPath.toString(),
message = violation.message,
code = violation.constraintDescriptor.annotation.annotationClass.simpleName ?: "CONSTRAINT_VIOLATION",
rejectedValue = violation.invalidValue
)
}
return ErrorResponse(
message = "Constraint violation",
errorCode = "CONSTRAINT_VIOLATION",
details = errors,
timestamp = Instant.now()
)
}
/**
* Handles custom application exceptions.
*/
@ExceptionHandler(ApplicationException::class)
fun handleApplicationException(ex: ApplicationException): ResponseEntity<ErrorResponse> {
when (ex.httpStatus.series()) {
HttpStatus.Series.CLIENT_ERROR -> logger.warn("Client error occurred: {}", ex.message, ex)
HttpStatus.Series.SERVER_ERROR -> logger.error("Server error occurred: {}", ex.message, ex)
else -> logger.info("Application error occurred: {}", ex.message, ex)
}
val errorResponse = when (ex) {
is ValidationException -> ErrorResponse(
message = ex.message,
errorCode = ex.errorCode,
details = ex.errors,
timestamp = Instant.now()
)
else -> ErrorResponse(
message = ex.message,
errorCode = ex.errorCode,
timestamp = Instant.now()
)
}
return ResponseEntity.status(ex.httpStatus).body(errorResponse)
}
/**
* Handles database constraint violations.
*/
@ExceptionHandler(DataIntegrityViolationException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun handleDataIntegrityViolationException(ex: DataIntegrityViolationException): ErrorResponse {
logger.warn("Database constraint violation occurred", ex)
val errorDetails = parseDataIntegrityViolation(ex)
return ErrorResponse(
message = errorDetails.message,
errorCode = errorDetails.code,
details = listOf(ValidationError(
field = errorDetails.field,
message = errorDetails.message,
code = errorDetails.code
)),
timestamp = Instant.now()
)
}
/**
* Handles resource access exceptions (e.g., file not found, database connection issues).
*/
@ExceptionHandler(DataAccessException::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleDataAccessException(ex: DataAccessException): ErrorResponse {
logger.error("Data access error occurred", ex)
return ErrorResponse(
message = "A database error occurred",
errorCode = "DATABASE_ERROR",
timestamp = Instant.now()
)
}
/**
* Handles HTTP message conversion errors.
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleHttpMessageNotReadableException(ex: HttpMessageNotReadableException): ErrorResponse {
logger.debug("HTTP message not readable", ex)
val message = when {
ex.message?.contains("JSON parse error") == true -> "Invalid JSON format"
ex.message?.contains("Required request body is missing") == true -> "Request body is required"
else -> "Invalid request format"
}
return ErrorResponse(
message = message,
errorCode = "INVALID_REQUEST_FORMAT",
timestamp = Instant.now()
)
}
/**
* Handles method argument type mismatches.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleTypeMismatchException(ex: MethodArgumentTypeMismatchException): ErrorResponse {
logger.debug("Method argument type mismatch", ex)
val error = ValidationError(
field = ex.name,
message = "Invalid value '${ex.value}' for parameter '${ex.name}'. Expected type: ${ex.requiredType?.simpleName}",
code = "TYPE_MISMATCH",
rejectedValue = ex.value
)
return ErrorResponse(
message = "Invalid parameter type",
errorCode = "TYPE_MISMATCH",
details = listOf(error),
timestamp = Instant.now()
)
}
/**
* Handles missing required parameters.
*/
@ExceptionHandler(MissingServletRequestParameterException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleMissingParameterException(ex: MissingServletRequestParameterException): ErrorResponse {
logger.debug("Missing required parameter", ex)
val error = ValidationError(
field = ex.parameterName,
message = "Required parameter '${ex.parameterName}' is missing",
code = "MISSING_PARAMETER"
)
return ErrorResponse(
message = "Missing required parameter",
errorCode = "MISSING_PARAMETER",
details = listOf(error),
timestamp = Instant.now()
)
}
/**
* Handles authentication exceptions.
*/
@ExceptionHandler(AuthenticationException::class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
fun handleAuthenticationException(ex: AuthenticationException): ErrorResponse {
logger.warn("Authentication failed: {}", ex.message)
return ErrorResponse(
message = "Authentication failed",
errorCode = "AUTHENTICATION_FAILED",
timestamp = Instant.now()
)
}
/**
* Handles access denied exceptions.
*/
@ExceptionHandler(AccessDeniedException::class)
@ResponseStatus(HttpStatus.FORBIDDEN)
fun handleAccessDeniedException(ex: AccessDeniedException): ErrorResponse {
logger.warn("Access denied: {}", ex.message)
return ErrorResponse(
message = "Access denied",
errorCode = "ACCESS_DENIED",
timestamp = Instant.now()
)
}
/**
* Handles all other unexpected exceptions.
*/
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleGenericException(ex: Exception): ErrorResponse {
logger.error("Unexpected error occurred", ex)
return ErrorResponse(
message = "An unexpected error occurred",
errorCode = "INTERNAL_SERVER_ERROR",
timestamp = Instant.now()
)
}
private fun parseDataIntegrityViolation(ex: DataIntegrityViolationException): ErrorDetails {
val message = ex.rootCause?.message ?: ex.message ?: "Database constraint violation"
return when {
message.contains("uk_users_username") -> ErrorDetails(
field = "username",
message = "Username is already taken",
code = "USERNAME_ALREADY_EXISTS"
)
message.contains("uk_users_email") -> ErrorDetails(
field = "email",
message = "Email is already registered",
code = "EMAIL_ALREADY_EXISTS"
)
message.contains("foreign key constraint") -> ErrorDetails(
field = "reference",
message = "Referenced entity does not exist",
code = "INVALID_REFERENCE"
)
else -> ErrorDetails(
field = "database",
message = "Database constraint violation",
code = "CONSTRAINT_VIOLATION"
)
}
}
private data class ErrorDetails(
val field: String,
val message: String,
val code: String
)
}
// Error response model
data class ErrorResponse(
val message: String,
val errorCode: String,
val details: List<ValidationError>? = null,
val timestamp: Instant,
val path: String? = null
) {
companion object {
fun simple(message: String, errorCode: String): ErrorResponse {
return ErrorResponse(
message = message,
errorCode = errorCode,
timestamp = Instant.now()
)
}
}
}
Service-Level Exception Handling
@Service
@Transactional
class UserService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val validationService: ValidationService
) {
private val logger = LoggerFactory.getLogger(UserService::class.java)
/**
* Creates a user with comprehensive error handling.
*/
fun createUser(request: CreateUserRequest): UserResponse {
logger.info("Creating user with username: {}", request.username)
try {
// Validate request
val validationResult = validationService.validate(request, ValidationOperation.CREATE)
if (!validationResult.isValid) {
throw ValidationException("User creation validation failed", validationResult.errors)
}
// Check for existing username
if (userRepository.existsByUsername(request.username)) {
throw UsernameAlreadyExistsException(request.username)
}
// Check for existing email
if (userRepository.existsByEmail(request.email)) {
throw EmailAlreadyExistsException(request.email)
}
// Create user entity
val user = User.create(
username = request.username,
email = request.email,
passwordHash = passwordEncoder.encode(request.password),
firstName = request.firstName,
lastName = request.lastName
)
// Add profile if provided
request.profile?.let { profileRequest ->
user.profile = UserProfile.create(
user = user,
birthDate = profileRequest.birthDate,
bio = profileRequest.bio,
website = profileRequest.website,
phoneNumber = profileRequest.phoneNumber
)
}
val savedUser = userRepository.save(user)
logger.info("User created successfully with ID: {}", savedUser.id)
return UserResponse.from(savedUser)
} catch (ex: DataIntegrityViolationException) {
logger.warn("Database constraint violation during user creation", ex)
// Convert database exceptions to application exceptions
when {
ex.message?.contains("uk_users_username") == true ->
throw UsernameAlreadyExistsException(request.username)
ex.message?.contains("uk_users_email") == true ->
throw EmailAlreadyExistsException(request.email)
else -> throw BusinessRuleException("User creation failed due to database constraint", "DATABASE_CONSTRAINT", ex)
}
} catch (ex: ApplicationException) {
// Re-throw application exceptions
throw ex
} catch (ex: Exception) {
logger.error("Unexpected error during user creation", ex)
throw ApplicationException(
message = "User creation failed due to unexpected error",
cause = ex,
errorCode = "USER_CREATION_FAILED",
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR
)
}
}
/**
* Updates a user with proper error handling.
*/
fun updateUser(userId: Long, request: UpdateUserRequest): UserResponse {
logger.info("Updating user with ID: {}", userId)
try {
val existingUser = userRepository.findById(userId).orElse(null)
?: throw UserNotFoundException(userId)
// Validate update request
val validationResult = validationService.validateUserUpdate(userId, request)
if (!validationResult.isValid) {
throw ValidationException("User update validation failed", validationResult.errors)
}
// Apply updates
request.username?.let { username ->
if (username != existingUser.username && userRepository.existsByUsername(username)) {
throw UsernameAlreadyExistsException(username)
}
existingUser.username = username
}
request.email?.let { email ->
if (email != existingUser.email) {
if (userRepository.existsByEmail(email)) {
throw EmailAlreadyExistsException(email)
}
existingUser.updateEmail(email)
}
}
request.firstName?.let { existingUser.firstName = it }
request.lastName?.let { existingUser.lastName = it }
val savedUser = userRepository.save(existingUser)
logger.info("User updated successfully: {}", savedUser.id)
return UserResponse.from(savedUser)
} catch (ex: ApplicationException) {
throw ex
} catch (ex: Exception) {
logger.error("Unexpected error during user update", ex)
throw ApplicationException(
message = "User update failed due to unexpected error",
cause = ex,
errorCode = "USER_UPDATE_FAILED",
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR
)
}
}
/**
* Deletes a user with business rule validation.
*/
fun deleteUser(userId: Long) {
logger.info("Deleting user with ID: {}", userId)
try {
val user = userRepository.findById(userId).orElse(null)
?: throw UserNotFoundException(userId)
// Validate deletion is allowed
val validationResult = validationService.validateUserDeletion(userId)
if (!validationResult.isValid) {
throw ValidationException("User deletion validation failed", validationResult.errors)
}
userRepository.delete(user)
logger.info("User deleted successfully: {}", userId)
} catch (ex: ApplicationException) {
throw ex
} catch (ex: Exception) {
logger.error("Unexpected error during user deletion", ex)
throw ApplicationException(
message = "User deletion failed due to unexpected error",
cause = ex,
errorCode = "USER_DELETION_FAILED",
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR
)
}
}
}
// Response model
data class UserResponse(
val id: Long,
val username: String,
val email: String,
val firstName: String,
val lastName: String,
val active: Boolean,
val emailVerified: Boolean,
val profile: UserProfileResponse?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
fun from(user: User): UserResponse {
return UserResponse(
id = user.id,
username = user.username,
email = user.email,
firstName = user.firstName,
lastName = user.lastName,
active = user.active,
emailVerified = user.emailVerified,
profile = user.profile?.let { UserProfileResponse.from(it) },
createdAt = user.createdAt!!,
updatedAt = user.updatedAt!!
)
}
}
}
data class UserProfileResponse(
val birthDate: LocalDate?,
val bio: String?,
val website: String?,
val phoneNumber: String?
) {
companion object {
fun from(profile: UserProfile): UserProfileResponse {
return UserProfileResponse(
birthDate = profile.birthDate,
bio = profile.bio,
website = profile.website,
phoneNumber = profile.phoneNumber
)
}
}
}
10.6 Summary
In this comprehensive chapter, we’ve built a robust validation and exception handling framework that leverages Kotlin’s language features while integrating seamlessly with Spring Boot and Hibernate Validator.
Validation Strategies form the foundation of data integrity in our applications. We explored three key layers:
- Input validation at the controller level using standard annotations
- Business logic validation in the service layer for domain-specific rules
- Database constraint validation as the final safety net
Each layer serves a specific purpose and together they provide comprehensive protection against invalid data entering your system.
Hibernate Validator integration with Kotlin shows how to effectively use Bean Validation in a type-safe manner. We covered:
- Proper configuration for Kotlin projects
- Advanced validation scenarios using groups and conditional validation
- Programmatic validation for complex business logic scenarios
- Leveraging Kotlin’s language features like data classes and null safety
Spring Boot’s validation integration provides seamless framework support through:
- Automatic validation of request bodies and parameters
- Method-level validation for service boundaries
- Customizable validation behavior and error messages
- Batch validation for bulk operations
Custom validation capabilities enable domain-specific validation rules:
- Creating custom annotation-based validators
- Context-aware validation using Spring beans
- Complex multi-field validation scenarios
- Property-based validation for collections and nested objects
Exception handling provides the critical safety net that ensures graceful failure handling:
- Hierarchical custom exception design that reflects domain concepts
- Comprehensive global exception handler covering all error scenarios
- Meaningful error responses that help API consumers understand and fix issues
- Proper logging and monitoring integration
The patterns and techniques demonstrated in this chapter create several key benefits:
- Type Safety: Kotlin’s null safety and type system prevent many validation errors at compile time
- Expressiveness: Custom validators and exception types make business rules explicit in code
- Consistency: Standardized error responses and validation patterns across the entire application
- Maintainability: Clear separation between validation layers and comprehensive error handling
- Developer Experience: Meaningful error messages and proper HTTP status codes
Key principles to remember when implementing validation and exception handling:
- Validate early and often - catch errors as close to the source as possible
- Be specific in error messages - help users understand exactly what went wrong
- Use appropriate HTTP status codes - follow REST conventions for error responses
- Log appropriately - log errors for debugging but don’t expose sensitive information
- Fail fast - don’t continue processing when validation fails
The validation and exception handling framework we’ve built provides a solid foundation for building robust, production-ready applications. In the next chapter, we’ll explore Spring Boot Actuator, which builds upon these error handling concepts to provide comprehensive application monitoring and health checks.
The investment in comprehensive validation and exception handling pays dividends in application reliability, maintainability, and user experience. Well-designed validation prevents data corruption, while thoughtful exception handling ensures graceful degradation and meaningful feedback when things go wrong.