KValid frente a Jakarta Validation¶
KValid toma prestado de Jakarta el vocabulario —los nombres y la semántica de los constraints— pero no implementa la especificación, y tampoco lee sus anotaciones. Esta página dice exactamente qué coincide, qué no, y por qué.
En una frase
Si vienes de Hibernate Validator, tus anotaciones se llaman igual y hacen lo mismo. Lo que
cambia es cuándo se resuelven (compilación, no runtime) y qué no está (groups).
1 · Por qué no se implementa la especificación¶
No es una cuestión de esfuerzo: son dos incompatibilidades de diseño.
jakarta.validation-api es un JAR de JVM. Publica un único artefacto Java: no hay klib ni
variantes de Kotlin/Native. Si los constraints de KValid fueran jakarta.validation.constraints.*,
commonMain tendría que depender de él y se acabaría el soporte multiplataforma — que es
justamente la razón de ser de la librería.
La SPI de la especificación está moldeada alrededor de la reflexión. Validator.validate(Object)
recibe Object; ConstraintValidator se instancia por una factoría; y la API de metadatos
(getConstraintsForClass, BeanDescriptor) es introspección pura. Implementarla de verdad
significa implementar reflexión, que es exactamente lo que KValid evita.
Una conformidad parcial anunciada como total sería peor que no tenerla, así que no se anuncia.
2 · Por qué tampoco se leen las anotaciones jakarta.validation en Java¶
Sería tentador: los proyectos Java ya tienen sus DTOs anotados. Pero el resultado sería que la misma anotación significaría cosas distintas según quién la procesara, que es el peor fallo posible en una librería de validación. Los desajustes concretos:
| Detalle | Jakarta / Hibernate | KValid |
|---|---|---|
groups y payload |
En todas las anotaciones | No existen (ver §4) |
| Cascada | @Valid sobre el campo |
Implícita: el tipo lleva @Validated |
| Mensajes | Plantillas EL {clave} + ValidationMessages.properties |
code + params, resueltos por kvalid-i18n |
@Pattern |
Tiene flags |
No los tiene |
@DecimalMin/@DecimalMax |
Tienen inclusive |
Siempre inclusivos |
@Email |
Regex propia de Hibernate | Regex propia, más estricta en unos casos y más laxa en otros |
Leer la anotación e ignorar en silencio groups, flags o inclusive cambiaría el
comportamiento del proyecto sin avisar. Y respetarlos exigiría reimplementar media
especificación. Ninguna de las dos merece la pena, así que KValid solo procesa sus propias
anotaciones, y una clase con anotaciones de Jakarta simplemente no genera nada.
Puedes usar los dos a la vez: el starter de Spring compone su Validator con el de Hibernate
si está en el classpath, así que las clases con Jakarta las sigue validando Hibernate.
3 · Tabla de equivalencias¶
Los 22 constraints de Jakarta Validation 3.0:
| Jakarta | KValid | Notas |
|---|---|---|
@NotNull |
✅ @NotNull |
|
@Null |
✅ @Null |
Error de compilación si el tipo no admite null |
@NotBlank |
✅ @NotBlank |
|
@NotEmpty |
✅ @NotEmpty |
String o colección |
@Size |
✅ @Size |
|
@Pattern |
⚠️ @Pattern |
Sin flags |
@Email |
⚠️ @Email |
Regex distinta |
@Min / @Max |
✅ @Min / @Max |
|
@DecimalMin / @DecimalMax |
⚠️ @DecimalMin / @DecimalMax |
Sin inclusive (siempre inclusivos) |
@Digits |
⚠️ @Digits |
No admite Double/Float (ver §5) |
@Positive / @Negative |
✅ @Positive / @Negative |
|
@PositiveOrZero / @NegativeOrZero |
✅ @PositiveOrZero / @NegativeOrZero |
|
@AssertTrue / @AssertFalse |
✅ @AssertTrue / @AssertFalse |
|
@Past / @Future |
✅ @Past / @Future |
Instant (kotlinx o java.time) |
@PastOrPresent / @FutureOrPresent |
✅ @PastOrPresent / @FutureOrPresent |
idem |
Además, fuera del estándar: @Range y @Url (ambas existen en Hibernate como extensiones
de proveedor) y @OneOf (propia de KValid).
4 · groups: no está, y es a propósito¶
Es la diferencia que más se nota al migrar, así que conviene el razonamiento completo.
Invierte el acoplamiento. groups = {OnCreate.class} en un campo hace que el modelo sepa
qué casos de uso existen: el DTO pasa a depender de la operación, cuando debería ser al revés.
Y los grupos son interfaces marcadoras vacías, metadatos disfrazados de tipos.
Y sobre todo: falla en silencio. Si olvidas pasar el grupo, los constraints de ese grupo no se ejecutan — y un constraint que no corre es indistinguible de uno que pasó. Ese es justo el fallo que KValid existe para eliminar: no tiene sentido prometer que un error se detecta al compilar y a la vez ofrecer una validación que desaparece según un parámetro de runtime.
La alternativa: un tipo por caso de uso.
// En vez de una clase con grupos…
@Validated
data class CreateUser(
@NotBlank @Size(max = 40) val name: String,
@Email val email: String,
)
@Validated
data class UpdateUser(
@NotNull val id: Long?,
@Size(max = 40) val name: String?, // opcional al actualizar
)
Cada endpoint recibe el tipo que le corresponde, el compilador te impide mezclarlos y no hay ningún parámetro que se pueda olvidar. En Kotlin una data class cuesta tres líneas.
payload tampoco existe, por lo mismo: su uso habitual —clasificar la severidad— se cubre
mejor con un code propio y un @Constraint a medida.
5 · Diferencias de comportamiento que conviene conocer¶
@Digits no admite Double ni Float¶
Igual que la especificación, que solo lo define para enteros, BigDecimal, BigInteger y
CharSequence: el binario de coma flotante no tiene un número exacto de dígitos decimales, así
que contarlos daría resultados dependientes del redondeo. KValid lo rechaza en compilación
con un mensaje que lo explica, en vez de dar un resultado dudoso en runtime.
@Digits cuenta dígitos enteros significativos¶
0.5 tiene 0 dígitos enteros (Hibernate, que cuenta precision - scale, da lo mismo).
La única divergencia es el valor entero 0: KValid cuenta 0 dígitos y Hibernate 1, así que
@Digits(integer = 0, ...) los trata distinto. Es un caso degenerado, pero queda dicho.
Los ceros a la derecha de los decimales sí cuentan (1.50 son dos decimales), como en
Jakarta.
@DecimalMin/@DecimalMax comparan según el tipo¶
A diferencia de Hibernate, que convierte todo a BigDecimal en runtime, KValid elige la
comparación al generar:
| Tipo de la propiedad | Se emite | Por qué |
|---|---|---|
Int Long Short Byte |
v.toLong() < 1L |
La cota se redondea en compilación: para un entero, v < 0.5 es exactamente v < 1 |
Double Float |
v.toDouble() < 0.01 |
Comparar un valor aproximado con precisión exacta sería falsa exactitud |
BigDecimal BigInteger |
v.compareTo(DEC_0_01) < 0 |
Exacto; la cota se construye una vez por archivo |
Consecuencia práctica: solo se emite BigDecimal cuando la propiedad ya es de un tipo que
únicamente existe en la JVM, así que estos constraints funcionan en commonMain con targets
nativos, JS y Wasm. Y no se asigna ningún objeto por validación.
La comparación con BigDecimal usa compareTo, no equals, así que ignora la escala:
1.00 y 1.0 son el mismo valor.
Los mensajes no son plantillas EL¶
Jakarta interpola {jakarta.validation.constraints.Size.message} con un motor de expresiones.
KValid emite un code estable ("size.max") y un mapa de params ({"max": 40}), y la
traducción es cosa de kvalid-i18n o de tu propia capa. Es lo que permite que el mismo
ValidationResult se renderice en el servidor y en el cliente, cada uno en su idioma.
6 · Migrar desde Hibernate Validator¶
- Cambia los imports de
jakarta.validation.constraints.*adev.kvalid.annotations.*. - Añade
@Validateda las clases (Jakarta no lo necesita porque descubre por reflexión). - Sustituye
@Validsobre campos anidados por@Validateden el tipo anidado. - Si usabas
groups, parte la clase en un tipo por caso de uso (§4). - Compila. Lo que no encaje será un error con su explicación, no un fallo en runtime.
En Spring no hace falta tocar los controllers: @Valid sigue funcionando igual — ver
Spring Boot.