Pour nos projets Kotlin, nous avons fait le choix d’utiliser Jakarta Validation API (anciennement Bean Validation / Java validation API) avec l’implémentation Hibernate Validator, plutôt que des solutions natives Kotlin.
Cet article détaille cette décision architecturale et présente notre retour d’expérience terrain.
Validation des données en Java et Kotlin : état de l’art
Jakarta validation API : le standard de validation en Java
Jakarta Validation API (JSR 380) est le standard de facto pour la validation en Java. Basé sur des annotations, il permet de déclarer des contraintes directement sur les propriétés d’une classe :
data class User(
@field:NotBlank
@field:Email
val email: String,
@field:Size(min = 8, max = 100)
val password: String,
@field:Min(18)
val age: Int?
)
Hibernate Validator, l’implémentation de référence, enrichit la spécification avec des validateurs supplémentaires et des fonctionnalités avancées.
Les bibliothèques de validation en Kotlin
L’écosystème Kotlin propose plusieurs bibliothèques de validation natives :
- Konform : DSL fonctionnel pour définir des règles de validation
- Valiktor : Approche déclarative avec DSL
- Akkurate : Validation avec gestion fine des erreurs
Ces solutions adoptent généralement un paradigme différent :
val validateUser = Validation<User> {
User::email required {
pattern(".+@.+\\..+") hint "doit être un email valide"
}
User::password required {
minLength(8)
maxLength(100)
}
User::age ifPresent {
minimum(18)
}
}
Pourquoi utiliser Jakarta Validation avec Kotlin/Spring ?
Les arguments en faveur de Jakarta Validation
1. Synergie avec l’écosystème Spring
Notre projet repose sur Spring Framework, qui intègre nativement Jakarta Validation. Cette intégration profonde offre plusieurs avantages :
@RestController
class UserController(
private val userService: UserService
) {
@GetMapping("/users/{username}")
fun readUser(
@PathVariable
@Size(min= 3, max = 64) username: String
): UserDto {
return userService.readUser(username)
}
}
Spring gère automatiquement la validation et transforme les violations en réponses HTTP appropriées (400 Bad Request).
2. Injection de dépendances dans les validateurs
Un avantage majeur réside dans la possibilité d’injecter des dépendances depuis le contexte applicatif de Spring.
Cela permet de mettre en oeuvre des validations complexes nécessitant l’accès à des services ou à des repositories :
/** Checks that the annotated password complies to password policies */
@MustBeDocumented
@Constraint(validatedBy = [CustomPasswordValidator::class])
@Target(
AnnotationTarget.FIELD,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.FUNCTION,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.TYPE_PARAMETER
)
@Retention(AnnotationRetention.RUNTIME)
annotation class Password(
/** Default validation message */
val message: String = "Password too simple (password entropy below threshold)",
/** Validation groups */
val groups: Array<KClass<*>> = [],
/** Payload for metadata */
val payload: Array<KClass<out Payload>> = []
)
/** Example of custom validator with injected dependencies */
@Component
class CustomPasswordValidator(
private val passwordValidator: PasswordValidator,
private val passwordConfig: PasswordConfig
) : ConstraintValidator<Password, String?> {
override fun isValid(
value: String?,
context: ConstraintValidatorContext
): Boolean {
// Ignore null, @NotNull or NotEmpty the field instead
if (value == null) return true
val data = PasswordData(value)
val res = passwordValidator.validate(data)
if (res.isValid) {
// Entropy check
if (passwordConfig.entropyThreshold > .0) {
val entropy = passwordValidator.estimateEntropy(data)
return entropy > passwordConfig.entropyThreshold
}
return true
}
// Handle constraint violations ourself
context.disableDefaultConstraintViolation()
passwordValidator.getMessages(res).forEach {
val message = it.substring(0, it.length - 1) // Remove trailing dot
context.buildConstraintViolationWithTemplate(message)
.addConstraintViolation()
}
return false
}
}
3. Encapsulation et cohésion
Contrairement aux DSLs Kotlin qui définissent les règles de validation dans des classes séparées, les annotations permettent de garder les contraintes au plus près des données :
// ✅ Contraintes portées par la classe
class UserDto {
@field:Size(min= 3, max = 64)
var username: String? = null
@field:Size(min = 1, max = 128)
var firstname: String? = null
@field:Size(min = 1, max = 128)
var lastname: String? = null
[…]
}
// ❌ Avec un DSL, la validation est externalisée
// Risque de désynchronisation entre le DTO et ses règles
Cette approche améliore la maintenabilité : les contraintes sont immédiatement visibles lors de la consultation du DTO.
4. Validation contextuelle via les groupes
Les groupes de validation permettent d’appliquer différents ensembles de règles selon le contexte, des groupes sont ajoutés sur les contraintes.
Le standard apporte le group Default, qui est appliqué par défaut, sauf si on spécifie un ou plusieurs autres groupes :
// Group for constraints on Create requests
interface Create
class UserDto() {
// Password must be non-null at Creation only
@field:NotNull(groups = [Create::class])
val password: String?
}
@RestController
class UserController(
private val userService: UserService
) {
@PostMapping("/users")
fun createUser(
// Apply both group Default and Create
@Validated(Default ::class, Create::class)
@RequestBody
userDto: UserDto
): UserDto {
return userService.createUser(userDto)
}
}
5. Maturité et richesse fonctionnelle
Jakarta Validation existe depuis 2009. Cette maturité se traduit par :
- Une collection exhaustive de validateurs prédéfinis (@Email, @URL, @CreditCardNumber, @UUID, etc.)
- Une documentation extensive et une large communauté
- Des intégrations testées avec tous les frameworks majeurs
- Une stabilité éprouvée en production
6. Standardisation Jakarta
Jakarta Validation est une spécification Jakarta EE. Cette standardisation garantit une portabilité entre implémentations et une pérennité du code.
7. Documentation automatique
Les projets swagger-core et springdoc-openapi intègrent les contraintes dans le document de spec OpenAPI qu’ils génèrent, exemple :

Limites de Jakarta Validation en environnement Kotlin
1. Absence d’extensions idiomatiques Kotlin
Jakarta Validation a été conçu pour Java. Certaines particularités Kotlin ne sont pas parfaitement gérées :
class UserDto {
// Il faut préciser que l’annotation concerne le field
@field:Email
var email: String? = null
}
Les types nullables de Kotlin nécessitent également une attention particulière pour éviter les validations redondantes.
2. Validation binaire : succès ou exception
Jakarta Validation fonctionne en mode « tout ou rien » :
try {
validator.validate(user)
// Succès
} catch (e: ConstraintViolationException) {
// Échec - on perd le contrôle du flow
}
Pas de support natif pour des niveaux de sévérité (erreur, avertissement, info) ni pour une gestion fonctionnelle des erreurs de type Result<T, Violations>.
3. Messages d’erreur et sérialisation JSON
Par défaut, les messages d’erreur référencent les noms de propriétés Java, pas les noms d’attributs JSON personnalisés :
class AddressDto {
@field:JsonProperty("postal_code")
@field:Size(min = 1, max = 16)
var zipcode: String? = null
}
// Message d'erreur : "zipcode: size must be between 1 and 16"
// Attendu : " postal_code: size must be between 1 and 16"
4. Dépendance à la JVM
Jakarta Validation est intrinsèquement lié à la JVM. Pour des projets Kotlin Multiplatform visant aussi JavaScript ou Native, cette solution n’est pas portable.
5. Performance en réflexion
L’introspection des annotations via réflexion peut avoir un impact sur les performances pour des volumes de validation très élevés, bien que Hibernate Validator intègre des mécanismes de cache sophistiqués.
Fonctionnalités clés de Jakarta Validation API
Contraintes standard
La spécification fournit un ensemble riche de contraintes :
data class CompleteExample(
// Contraintes de nullité
@field:NotNull
val required: String,
// Contraintes de chaînes de caractères
@field:NotBlank
@field:Size(min = 2, max = 100)
@field:Pattern(regexp = "^[A-Za-z]+$")
val name: String,
// Contraintes numériques
@field:Min(0)
@field:Max(150)
val age: Int,
@field:DecimalMin("0.0")
@field:DecimalMax("999.99")
@field:Digits(integer = 3, fraction = 2)
val price: BigDecimal,
// Contraintes temporelles
@field:Past
val birthDate: LocalDate,
@field:Future
val appointmentDate: LocalDateTime,
// Contraintes booléennes
@field:AssertTrue
val acceptedTerms: Boolean,
// Contraintes de collections
@field:Size(min = 1, max = 5)
val tags: List<String>,
// Validation en cascade
@field:Valid
val address: Address
)
Les groupes de validation
Les groupes constituent l’une des fonctionnalités les plus puissantes de Jakarta Validation pour adapter la validation au contexte d’exécution.
Un exemple : distinguer création et mise à jour.
Le mot de passe est requis à la création, mais pas après, (pour respecter le contrat REST : on GET puis on PUT l’objet modifié, vu que le GET ne renvoie pas le mot de passe, on ne peut le demander à la mise à jour).
// Définition des marqueurs de groupes
interface Create
interface Update
class UserDto {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@field:NotNull(groups = [Create::class])
@field:Password
var password: String? = null
}
@RestController
class UserController(
private val userService: UserService
) {
@PostMapping("/users")
fun createUser(
@Validated(Default::class, Create::class)
@RequestBody
userDto: UserDto
): UserDto {
return userService.createUser(userDto)
}
}
Intégration avec le framework Spring
Grâce à spring-boot-starter-validation, la validation est intégrée et pré-configurée, il suffit d’instancier un ValidatorFactory dans le contexte applicatif puis de proprement gérer les exceptions.
Dans build.gradle.kts :
implementation("org.springframework.boot:spring-boot-starter-validation")
Ensuite dans une classe annotée @Configuration
@Bean
fun validator(): LocalValidatorFactoryBean {
return LocalValidatorFactoryBean().apply {
setProviderClass(HibernateValidator::class.java) // HibernateValidator
}
}
Et enfin, via un controller advice, on peut proprement formater les constraint violations vers notre DTO d’erreur :
class ErrorDto(
var errorName: String,
var errorMessage: String
) {
var timestamp: ZonedDateTime = ZonedDateTime.now(ZoneOffset.UTC)
}
// @Validated @RequestBody
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValidException(ex: MethodArgumentNotValidException) : ResponseEntity<ErrorDto> {
// Access the underlying BindingResult
val violations = ex.bindingResult.fieldErrors.joinToString {
"${it.field}: ${it.defaultMessage}"
}
return ResponseEntity.badRequest()
.body(ErrorDto("Validation", violations))
}
// Constraints on method parameters
@ExceptionHandler(HandlerMethodValidationException::class)
fun handleHandlerMethodValidationException(ex: HandlerMethodValidationException) : ResponseEntity<ErrorDto> {
val violations = ex.parameterValidationResults.joinToString {
val paramName = it.methodParameter.parameterName
it.resolvableErrors.joinToString { error ->
“Parameter $paramName: ${error.defaultMessage}"
}
}
return ResponseEntity.badRequest()
.body(ErrorDto("Validation", violations))
}
Solutions de contournement pour les limitations
1. Validateurs personnalisés pour les types Kotlin
Actuellement la majorité des types de la stdlib de kotlin sont compatibles avec leurs équivalents du JDK, et sont donc supportés par les validateurs standards.
Il existe bien quelques types qui ne sont pas supportés, mais ils sont expérimentaux et ne sont pas non plus supportés par Jackson.
Dans tous les cas un validateur custom comme celui du @Password présenté plus haut permettra de valider ces types.
2. Niveaux de validation via les groupes
Pour certains contextes (migration de données, imports en masse, analytics), on souhaite logger les violations sans bloquer le traitement.
// Groupe marqueur pour le mode logging
interface LogOnly
class UserDto {
@field:Pattern(regexp = "^[-+0-9]{5,16}$")
// Scenario: Stricter pattern, log only for now, to refine this pattern
@field:Pattern(
groups = [LogOnly::class],
regexp = "^\\+?\\d{1,4}?[-.\\s]?\\(?\\d{1,3}?\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}$")
var phoneNumber: String? = null
}
@Service
class UserService(
private val userRepository: UserRepository,
private val validator: Validator
) {
private fun logConstraintViolations(userDto: UserDto) {
val constraintViolations = validator.validate(userDto, LogOnly::class.java)
for (constraintViolation in constraintViolations) {
log.warn("LogOnly constraint violation: ${constraintViolation.message}")
}
}
}
3. Gestion des erreurs et récriture des Path/Query parameters
Le framework Spring encapsule les ConstraintViolations dans deux exceptions :
- MethodArgumentNotValidException pour les payload @RequestBody
- HandlerMethodValidationException pour les Query/Path parameters
Pour le premier cas, pas de problèmes particuliers, cependant pour le second, le nom du query/path param peut être différent du nom du paramètre de fonction, il nous faut donc récrire ces noms de paramètres, ce qui est faisable facilement en modifiant notre exception handler introduit plus tôt :
// Constraints on method parameters
@ExceptionHandler(HandlerMethodValidationException::class)
fun handleHandlerMethodValidationException(ex: HandlerMethodValidationException): ResponseEntity<ErrorDto> {
val violations = ex.parameterValidationResults.joinToString {
val param = it.methodParameter
// Is it a path parameter or a query parameter ?
val isPathParam = param.hasParameterAnnotation(PathVariable::class.java)
// Get name override from web annotation
val paramName =
param.getParameterAnnotation(PathVariable::class.java)?.name?.takeIf(String::isNotEmpty)
?: param.getParameterAnnotation(RequestParam::class.java)?.name?.takeIf(String::isNotEmpty)
?: param.parameterName
it.resolvableErrors.joinToString { error ->
"${if (isPathParam) "path" else "query"} parameter $paramName: ${error.defaultMessage}"
}
}
return ResponseEntity.badRequest()
.body(ErrorDto("Validation", violations))
}
4. Réécriture des chemins pour correspondre aux noms JSON
De même que les query/path parameters peuvent avoir un nom différent, les attributs JSON peuvent avoir un nom différent de leur property de class (via la naming strategy dans la configuration de Jackson ou via un @JsonProperty utilisé localement).
Il n’y a pas de solution standard proposée par la validation API, mais l’implémentation Hibernate Validateur propose une configuration basée sur le pattern Visitor : le PropertyNodeNameProvider.
@Component
class JacksonPropertyNodeNameProvider(
private val objectMapper: ObjectMapper
): PropertyNodeNameProvider {
override fun getName(property: Property): String {
return (property as? JavaBeanProperty)?.let {
val visitor = JsonPropertyNameGetter(property.name)
objectMapper.acceptJsonFormatVisitor(
property.declaringClass,
visitor
)
visitor.attributeName
} ?: property.name
}
}
private class JsonPropertyNameGetter(propertyName: String): JsonFormatVisitorWrapper.Base() {
private val visitor = JsonPropertyNameMapper(propertyName)
val attributeName: String?
get() = visitor.attributeName
override fun expectObjectFormat(type: JavaType?) = visitor
class JsonPropertyNameMapper(val propertyName: String): JsonObjectFormatVisitor.Base()
{
var attributeName: String? = null
private fun setName(writer: BeanProperty) {
if (writer.member.name.equals("get$propertyName", true)) {
attributeName = writer.name
}
}
override fun property(writer: BeanProperty) = setName(writer)
override fun optionalProperty(writer: BeanProperty) = setName(writer)
}
}
Ce provider doit être enregistré sur l’instance ValidatorFactory :
@Bean
fun validator(
propertyNodeNameProvider: JacksonPropertyNodeNameProvider
): LocalValidatorFactoryBean {
return LocalValidatorFactoryBean().apply {
setProviderClass(HibernateValidator::class.java) // HibernateValidator
setConfigurationInitializer {
val conf = it as HibernateValidatorConfiguration
conf.propertyNodeNameProvider(propertyNodeNameProvider)
}
}
}
Faut-il choisir Jakarta Validation avec Kotlin et Spring ?
Jakarta Validation API associé à Hibernate Validator représente un choix pragmatique et robuste pour les projets Kotlin basés sur Spring.
Bien que des alternatives purement Kotlin existent et offrent une syntaxe plus idiomatique, les bénéfices liés à :
- l’intégration profonde avec Spring
- la maturité de l’écosystème,
- la flexibilité des groupes de validation
compensent largement les quelques adaptations nécessaires.
Les stratégies de contournement présentées permettent d’adresser les limitations principales tout en conservant les avantages d’un standard éprouvé.
Pour des projets d’entreprise nécessitant une validation fiable, maintenable et extensible, Jakarta Validation reste une référence solide.
Pour aller plus loin
Un projet de démonstration complet illustrant tous les concepts abordés dans cet article est disponible sur GitHub :
- Un projet Spring en Kotlin
- Une API REST complète démontrant les différents patterns
- Des exemples d’utilisation de divers types de contraintes
- L’implémentation complète des groupes de validation (Create, Update, LogOnly)
- Un validateur personnalisé avec injection de dépendance
- La récriture des Query/Path parameters et des chemins vers noms d’attributs JSON
- Des tests unitaires exhaustifs
- La Swagger-UI affichant les contraintes
Ressources complémentaires
- Documentation Jakarta Validation
- Hibernate Validator Reference Guide
- Spring Validation Documentation
***
À propos de l’auteur :
Jonathan BAYLE, Lead Tech chez LetReco
Je veille à la qualité, à la maintenabilité et à la performance de l’application. J’accompagne les choix techniques et travaille avec l’équipe pour construire une solution robuste, évolutive et durable.



