Validation
The Kora validation module checks models, method arguments, and method results using annotations.
For models, Kora generates a Validator<T> at compile time, and for methods it applies the @Validate aspect that calls the required checks before or after method execution.
Validation works without using Reflection at application runtime: object structure, nested fields, method signatures, and available validators are checked by annotation processors during the build.
Validation errors are returned as a list of Violation or thrown as ViolationException.
For a step-by-step walkthrough before the reference details, see Validation.
Dependency¶
Dependency in build.gradle:
annotationProcessor "io.koraframework:annotation-processors" //(1)!
implementation "io.koraframework:validation-module"
- The annotation processor generates the
Validator<T>implementations and the@Validateaspect at compile time. Without it no validator is created and the graph build fails with a missingValidatordependency.
Module:
Dependency in build.gradle.kts:
ksp("io.koraframework:symbol-processors:2.0.0.RC1") //(1)!
implementation("io.koraframework:validation-module")
- The
KSPprocessor generates theValidator<T>implementations and the@Validateaspect at compile time. Without it no validator is created and the graph build fails with a missingValidatordependency.
Module:
The framework ships two mixin interfaces, and you pick one depending on whether the application serves HTTP:
| Module | Type | Artifact | Provides | Use when |
|---|---|---|---|---|
ValidatorModule |
io.koraframework.validation.common.constraint.ValidatorModule |
validation-common |
Every built-in constraint factory and the element validators Validator<List<T>>, Validator<Set<T>>, Validator<Collection<T>> |
Libraries, CLI tools and non-HTTP applications, or when you handle ViolationException yourself |
ValidationModule |
io.koraframework.validation.module.ValidationModule |
validation-module |
Everything from ValidatorModule plus the ValidationHttpServerInterceptor that maps ViolationException to an HTTP 400 response |
HTTP services that should return 400 to clients automatically |
ValidationModule extends ValidatorModule, so wiring ValidationModule also gives you everything the base module provides.
Generated Validator<T> components do not come from either mixin — they are contributed by the annotation processor for every type marked with @Valid and can be injected without wiring anything else.
Applications without an HTTP server
validation-module declares http-server-common as a compile-only dependency, and ValidationModule contributes a ValidationHttpServerInterceptor whose signature is written in terms of HttpServerRequest and HttpServerResponse.
An application that has no HTTP server therefore either has to put http-server-common on the classpath explicitly, or — the better option — depend on validation-common and wire ValidatorModule instead.
That is exactly what a client-only or batch application should do.
Validation Annotations¶
Validation annotations tell Kora what to check on a field, method argument, or method result.
They can be applied directly, or nested validation can be triggered through @Valid when the type has a generated or manually provided Validator.
Kora validation is not Jakarta Bean Validation
Kora validation is not Jakarta Bean Validation (JSR-380).
All Kora constraint annotations live in the io.koraframework.validation.common.annotation package, are ordinary declaration annotations (not TYPE_USE), and are processed at compile time.
Names overlap with the Jakarta ones on purpose, but the semantics are Kora's own — importing jakarta.validation.constraints.* by mistake produces a type that Kora simply ignores.
In particular, Kora ships no @NotNull constraint annotation: a value is required by default, and to make it optional you mark it with any @Nullable annotation (see Optional Fields).
Kora does recognize an explicit not-null marker — any annotation whose simple name is Nonnull, NotNull, or NonNull — which matters mainly for JsonNullable fields.
The structural annotations that drive validation:
@Valid- on a class,recordorsealedinterface generates aValidator<T>for that type; on a field, argument, or method result triggers nested validation through theValidatorof the corresponding type. Applicable to types, fields, parameters, and methods.@Validate- marks a method whose arguments and/or result should be validated by the aspect; thefailFastparameter controls stopping on the first error (default:false). Applicable to methods only.@ValidatedBy- links a custom constraint annotation with aValidatorFactorythat builds itsValidator(see Custom Validation Annotations). Applicable to annotation types only.
Kora ships 22 built-in constraints. Every one of them is itself annotated with @ValidatedBy, so they use the same extension mechanism a custom constraint uses:
| Annotation | Supported types | Attributes (defaults) | Check |
|---|---|---|---|
@NotBlank |
String, CharSequence |
— | Value is not null and contains at least one non-whitespace character. |
@NotEmpty |
String, CharSequence, Iterable<T>, Collection<T>, List<T>, Set<T>, Map<K, V> |
— | Value is not null and its length or size is greater than zero. |
@Pattern |
String, CharSequence |
value (required, no default), flags (default: 0) |
Value fully matches the value regular expression; flags maps to java.util.regex.Pattern flags. |
@Size |
String, CharSequence, Collection<V>, List<V>, Set<V>, Map<K, V> |
min (default: 0), max (required, no default) |
Length or size of the value lies within [min, max], both bounds inclusive. |
@OneOf |
String, CharSequence |
value (String[], required, no default) |
Value is toString()-equal to one of the listed strings. |
@UUID |
String, CharSequence |
— | Value parses with java.util.UUID.fromString(...). |
@Uri |
String, CharSequence |
— | Value parses as a java.net.URI. |
@Url |
String, CharSequence |
— | Value parses as a java.net.URI and has both a scheme and a host, i.e. it is an absolute URL. |
@Range |
Short, Integer, Long, Float, Double, BigInteger, BigDecimal |
from (double, required, no default), to (double, required, no default), boundary (default: INCLUSIVE_INCLUSIVE) |
Number lies between from and to; boundary decides whether each bound is inclusive. |
@Min |
Short, Integer, Long, Float, Double, BigInteger, BigDecimal |
value (long, required, no default) |
Number is greater than or equal to value. |
@Max |
Short, Integer, Long, Float, Double, BigInteger, BigDecimal |
value (long, required, no default) |
Number is less than or equal to value. |
@Positive |
any Number |
— | Number is strictly greater than zero. |
@PositiveOrZero |
any Number |
— | Number is greater than or equal to zero. |
@Negative |
any Number |
— | Number is strictly less than zero. |
@NegativeOrZero |
any Number |
— | Number is less than or equal to zero. |
@Digits |
Short, Integer, Long, Float, Double, BigInteger, BigDecimal, String, CharSequence |
integer (int, required, no default), fraction (int, required, no default) |
After trailing zeros are stripped, the integer part has at most integer digits and the fraction part at most fraction digits. |
@Past |
LocalDate, LocalDateTime, Instant, OffsetDateTime, ZonedDateTime |
— | Value is strictly before the current moment. |
@PastOrPresent |
LocalDate, LocalDateTime, Instant, OffsetDateTime, ZonedDateTime |
— | Value is before or equal to the current moment. |
@Future |
LocalDate, LocalDateTime, Instant, OffsetDateTime, ZonedDateTime |
— | Value is strictly after the current moment. |
@FutureOrPresent |
LocalDate, LocalDateTime, Instant, OffsetDateTime, ZonedDateTime |
— | Value is after or equal to the current moment. |
@AssertTrue |
Boolean |
— | Value is true. |
@AssertFalse |
Boolean |
— | Value is false. |
Note
Every constraint reports a violation for a null value on its own, in addition to the required-value check that Kora generates for a non-optional field or argument.
That means a required String field annotated with @NotBlank produces two violations when it is null in the default Full mode.
Applying a constraint to a type it has no factory for is a build error: there is no Validator for that combination, and the graph fails with a missing dependency rather than silently skipping the check.
Text constraints¶
@NotBlank, @NotEmpty, @Pattern, @Size, @OneOf, @UUID, @Uri, and @Url all work on String and CharSequence:
@Valid
public record Account(@NotBlank String owner, //(1)!
@NotEmpty String reference, //(2)!
@Size(min = 3, max = 64) String title, //(3)!
@Pattern("ACC\\d{10}") String number, //(4)!
@OneOf({"NEW", "ACTIVE", "CLOSED"}) String status, //(5)!
@UUID String correlationId, //(6)!
@Url String callback, //(7)!
@Uri String resource) { } //(8)!
- Rejects
null, an empty string, and a string of whitespace only. - Rejects
nulland an empty string; a string of spaces passes. - Length must be between
3and64, both inclusive. Pattern.matchessemantics — the whole value must match, no partial match.- Exactly one of the listed strings.
- Must parse with
java.util.UUID.fromString(...). - Must be an absolute
URL, i.e. have a scheme and a host. - Must parse as a
URI; a relative reference such as/orders/1is accepted.
@Valid
data class Account(@field:NotBlank val owner: String, //(1)!
@field:NotEmpty val reference: String, //(2)!
@field:Size(min = 3, max = 64) val title: String, //(3)!
@field:Pattern("ACC\\d{10}") val number: String, //(4)!
@field:OneOf("NEW", "ACTIVE", "CLOSED") val status: String, //(5)!
@field:UUID val correlationId: String, //(6)!
@field:Url val callback: String, //(7)!
@field:Uri val resource: String) //(8)!
- Rejects
null, an empty string, and a string of whitespace only. - Rejects
nulland an empty string; a string of spaces passes. - Length must be between
3and64, both inclusive. Pattern.matchessemantics — the whole value must match, no partial match.- Exactly one of the listed strings.
- Must parse with
java.util.UUID.fromString(...). - Must be an absolute
URL, i.e. have a scheme and a host. - Must parse as a
URI; a relative reference such as/orders/1is accepted.
Note
The Kora annotation is named UUID, which collides with java.util.UUID when both are star-imported.
Import the constraint explicitly as io.koraframework.validation.common.annotation.UUID, or qualify java.util.UUID at its use site.
Numeric constraints¶
@Range, @Min, @Max, @Positive, @PositiveOrZero, @Negative, @NegativeOrZero, and @Digits work on numbers:
@Valid
public record Order(@Range(from = 1, to = 900) int weight, //(1)!
@Range(from = 0, to = 1, boundary = Range.Boundary.INCLUSIVE_EXCLUSIVE) double share, //(2)!
@Min(1) long quantity, //(3)!
@Max(100) int discount, //(4)!
@Positive BigDecimal amount, //(5)!
@PositiveOrZero int retries, //(6)!
@Negative int correction, //(7)!
@NegativeOrZero int balanceDelta, //(8)!
@Digits(integer = 10, fraction = 2) BigDecimal price) { } //(9)!
- Both bounds inclusive by default.
[0, 1)— the lower bound is included, the upper bound is not.quantity >= 1.discount <= 100.- Strictly greater than zero.
- Greater than or equal to zero.
- Strictly less than zero.
- Less than or equal to zero.
- At most 10 digits before the decimal point and 2 after it.
@Valid
data class Order(@field:Range(from = 1.0, to = 900.0) val weight: Int, //(1)!
@field:Range(from = 0.0, to = 1.0, boundary = Range.Boundary.INCLUSIVE_EXCLUSIVE) val share: Double, //(2)!
@field:Min(1) val quantity: Long, //(3)!
@field:Max(100) val discount: Int, //(4)!
@field:Positive val amount: BigDecimal, //(5)!
@field:PositiveOrZero val retries: Int, //(6)!
@field:Negative val correction: Int, //(7)!
@field:NegativeOrZero val balanceDelta: Int, //(8)!
@field:Digits(integer = 10, fraction = 2) val price: BigDecimal) //(9)!
- Both bounds inclusive by default.
[0, 1)— the lower bound is included, the upper bound is not.quantity >= 1.discount <= 100.- Strictly greater than zero.
- Greater than or equal to zero.
- Strictly less than zero.
- Less than or equal to zero.
- At most 10 digits before the decimal point and 2 after it.
Range.Boundary has four variants — EXCLUSIVE_EXCLUSIVE, INCLUSIVE_EXCLUSIVE, EXCLUSIVE_INCLUSIVE, and INCLUSIVE_INCLUSIVE — and the default is INCLUSIVE_INCLUSIVE.
Note
@Range.from and @Range.to are declared as double, and the runtime narrows them to the field type: to long for Short/Integer/Long, to BigInteger/BigDecimal for the big types, and to double for Float/Double.
A bound larger than 253 therefore cannot be expressed exactly through @Range — use @Min and @Max, whose attribute is a long.
@Range also rejects an inverted range at construction time: to must be greater than or equal to from, and the same rule holds for @Size, which additionally requires min >= 0.
Temporal constraints¶
@Past, @PastOrPresent, @Future, and @FutureOrPresent compare the value with the current moment of the matching type — LocalDate.now() for LocalDate, Instant.now() for Instant, and so on:
@Valid
public record Contract(@Past LocalDate signedAt, //(1)!
@PastOrPresent Instant createdAt, //(2)!
@Future OffsetDateTime expiresAt, //(3)!
@FutureOrPresent ZonedDateTime activeFrom) { } //(4)!
- Strictly in the past.
- In the past or exactly now.
- Strictly in the future.
- In the future or exactly now.
@Valid
data class Contract(@field:Past val signedAt: LocalDate, //(1)!
@field:PastOrPresent val createdAt: Instant, //(2)!
@field:Future val expiresAt: OffsetDateTime, //(3)!
@field:FutureOrPresent val activeFrom: ZonedDateTime) //(4)!
- Strictly in the past.
- In the past or exactly now.
- Strictly in the future.
- In the future or exactly now.
The supported types are exactly LocalDate, LocalDateTime, Instant, OffsetDateTime, and ZonedDateTime.
For any other temporal type — LocalTime, Year, java.util.Date — declare a custom constraint.
Boolean constraints¶
@AssertTrue and @AssertFalse apply to Boolean:
Collection constraints¶
@NotEmpty and @Size also work on collections and maps, where they check the number of elements rather than a string length:
These constraints look only at the container. To also validate every element, combine them with @Valid — see Collection Validation.
Violation Messages¶
Every built-in constraint produces an English message that names the rule and the actual value, so the default HTTP 400 body is already diagnosable without any extra wiring:
| Constraint | Message |
|---|---|
@NotBlank |
Should be not blank, but was null / ... but was empty / ... but was blank |
@NotEmpty |
Should be not empty, but was null / ... but was empty |
@Pattern |
Should match RegEx ACC\d{10} but was: ACC1 |
@Size on a String |
Length should be in range from '3' to '64', but was smaller: 2 |
@Size on a collection or map |
Size should be in range from '1' to '10', but was greater: 11 |
@OneOf |
Should be one of [NEW, ACTIVE, CLOSED], but was: DRAFT |
@UUID / @Uri / @Url |
Should be valid UUID, but was: abc (and the URI / URL variants) |
@Range |
Should be in range from '1' to '900', but was greater: 1000 |
@Min / @Max |
Should be greater than or equal to '1', but was: 0 / Should be less than or equal to '100', but was: 101 |
@Positive and friends |
Should be positive, but was: -1 |
@Digits |
Should have digits with integer part up to '10' and fraction part up to '2', but was: 1.234 |
@Past and friends |
Should be in the past, but was: 2999-01-01 |
@AssertTrue / @AssertFalse |
Should be true, but was: false |
| generated required check on a field | Must be non null, but was null |
| generated required check on an argument | Parameter 'code' must be non null, but was null |
| generated required check on a result | Result must be non null, but was null |
Each Violation also carries a path(). The path is built as the object is walked: a nested field appends .field, and a collection element appends .[index].
A violation on the number field of the second element of a bars list therefore reports bars.[1].number, and Violation.path().full() returns exactly that string.
Class Validation¶
The @Valid annotation on a class or record tells Kora to create a Validator<T> for that type.
The generated validator becomes a regular dependency graph component and can be injected by the Validator<Type> signature.
A validator for this class will then be available in the dependency container:
Generated validators can be injected as dependencies into any component.
In the example above, the validator for User is injected by the Validator<User> signature and can be used manually.
The validate(...) method returns a list of Violation.
You can process this list yourself or call validateAndThrow(...), which throws ViolationException if there are violations.
See Manual Validation for the full imperative API.
Field Validation¶
Field validation uses the set of annotations provided by the module.
An object marked for validation looks like this:
For a record, fields are accessed through the methods of the record itself.
For Foo and the number field, the generated Validator will use the number() method.
For a regular class, the JavaBeans syntax is used: for example, the getId() method will be used for the id field.
This method must have at least package-private visibility.
static fields are skipped, so a constant next to the validated data is never picked up.
Properties are read directly, so both a data class and a plain class with var properties work.
const and @JvmStatic members are skipped, so a constant in a companion object next to the validated data is never picked up.
The constraint can be written either with the @field: use-site target or without it — for a constructor property Kora also reads the annotations of the matching primary-constructor parameter.
For a property declared in the class body, put the annotation on the property.
Required Fields¶
All fields are considered required by default, so null checks are created for them.
Optional Fields¶
To mark a field as optional, annotate it with any @Nullable annotation.
For such a field, a null check will not be created:
- Kora is built on JSpecify, so
org.jspecify.annotations.Nullableis the recommended annotation; any annotation whose simple name isNullableis accepted.JSpecify@Nullableis a type-use annotation, so its position matters for qualified and generic types:List<@Nullable String>,Outer.@Nullable Inner.
To mark a field as optional, use Kotlin Nullability syntax and add ? to the field type.
For such a field, a null check will not be created:
Kotlin carries no nullability annotation of its own — T? is the whole declaration.
A constraint still runs on an optional field when the value is present, so @Nullable @Size(min = 1, max = 10) String status means "may be absent, but if present its length is between 1 and 10".
Nested Fields¶
Use @Valid to validate nested objects that have generated or manually provided validators.
In the example above, Validator<Bar> will be created for Bar, and Validator<Foo> will be created for Foo.
When Validator<Foo> is called, it will call Validator<Bar> internally, and a violation inside Bar is reported at the path bar.<field>.
Collection Validation¶
@Valid on a List, Set, or Collection field validates every element through the element's Validator.
The ValidatorModule provides these element validators out of the box (Validator<List<T>>, Validator<Set<T>>, Validator<Collection<T>>), so no extra wiring is needed.
Each Bar in the list is validated, and the violation path is indexed by element position, for example bars.[0].number.
Constraints such as @Size can be combined with @Valid on the same collection to check both the collection size and each element, as above.
Note
Element validation itself is silent about a null collection — the required check for the field is what reports it.
A Map has no element validator out of the box: @Valid on a Map field needs a Validator<Map<K, V>> supplied by the application.
Sealed Hierarchies¶
Kora can create a Validator for sealed hierarchies.
If @Valid is placed on a sealed type, the generated validator determines the actual subtype and calls the validator for the matching final implementation, so every permitted subtype must be annotated with @Valid too.
Only sealed interfaces are dispatched this way, and only final permitted subtypes are collected.
JsonNullable¶
For JsonNullable<T>, Kora validates the T value inside the container:
undefined— the field was absent from the payload; the constraints are not executed.null— the field was present with anullvalue; the constraints run againstnulland normally report a violation.- present — the constraints run against the value.
To reject both undefined and null outright, add an explicit not-null marker (any annotation whose simple name is Nonnull, NotNull, or NonNull) next to the JsonNullable field.
This is the only place where such a marker changes validation: everywhere else "required" is simply the absence of @Nullable.
Unsupported Targets¶
@Valid needs a type that exposes fields or properties to check, so the processor rejects two shapes with a build error:
- an
enum— put the constraints on the class that holds the enum value, or write a custom constraint for it; - a non-
sealedinterface that is not a configuration interface — annotate the implementation instead.
Validation Options¶
There are two validation modes, selected through the ValidationContext passed to validate(...):
Full- all marked fields are checked, all possible validation errors are collected, and only then a list of violations is returned or an exception is thrown. This is the default behavior.FailFast- validation stops on the first found error.
A ValidationContext can be built in several equivalent ways:
ValidationContext.builder().build()- defaultFullcontext (same as callingvalidate(value)without a context).ValidationContext.full()- explicitFullcontext.ValidationContext.failFast()-FailFastcontext.ValidationContext.builder().failFast(true).build()- builder form ofFailFast.
Example of FailFast validation:
Configuration Validation¶
@Valid also applies to a configuration interface annotated with @ConfigSource or @ConfigMapper.
The accessor methods are treated as the fields to check, and the generated configuration mapper calls validateAndThrow(...) right after the configuration object is built — so a wrong value fails the application on startup instead of at the first use.
This is the one case where @Valid on an interface is allowed, and it is described together with the rest of the configuration rules in Configuration.
Manual Validation¶
A generated Validator<T> is an ordinary component, so it can be injected and called directly — for example in a service that is not an HTTP controller, or when you want to inspect violations instead of throwing.
@Component
public final class UserService {
private final Validator<User> validator;
public UserService(Validator<User> validator) {
this.validator = validator;
}
public void process(User user) {
List<Violation> violations = validator.validate(user); //(1)!
if (!violations.isEmpty()) {
Violation first = violations.getFirst();
throw new IllegalStateException(first.path().full() + ": " + first.message()); //(2)!
}
}
}
validate(value)collects all violations; usevalidate(value, context)to pass validation options.- Each
Violationexposespath()andmessage().
@Component
class UserService(private val validator: Validator<User>) {
fun process(user: User) {
val violations = validator.validate(user) //(1)!
if (violations.isNotEmpty()) {
val first = violations.first()
throw IllegalStateException("${first.path().full()}: ${first.message()}") //(2)!
}
}
}
validate(value)collects all violations; usevalidate(value, context)to pass validation options.- Each
Violationexposespath()andmessage().
The Validator<T> contract offers the following methods:
validate(value)/validate(value, context)- return aList<Violation>that is empty when the value is valid.validateAndThrow(value)/validateAndThrow(value, context)- throwViolationExceptionwhen any violation occurs, and do nothing otherwise.
Passing null to any of them is not a shortcut for "nothing to check": a generated validator reports a single violation for the null input.
When a ViolationException is caught, getViolations() returns the aggregated List<Violation>, and getMessage() returns a preformatted multi-line summary:
Validation failed with 2 violations:
1) Path 'name' violation: Length should be in range from '3' to '6', but was smaller: 2
2) Path 'bars.[0].number' violation: Should be not blank, but was blank
Method Validation¶
Method argument and result validation uses the @Validate aspect and the set of annotations provided by the module.
Kora generates aspect code at compile time, so a class with such methods must support aspect application.
Argument Validation¶
To validate method arguments, use the @Validate annotation on the method and annotate the arguments with the required constraints.
Arguments can be validated by constraint annotations directly, or by @Valid when the argument type has its own Validator:
@Component
public class ArgumentValidator {
@Valid
public record User(@NotBlank String id,
@Size(min = 3, max = 6) String name,
@Nullable String status) { }
@Validate
public int calculate(@Valid User user, //(1)!
@Range(from = 1, to = 900) int weight, //(2)!
@Pattern("ME\\d+") String code) { //(3)!
return Integer.parseInt(code.substring(2));
}
}
- Nested validation through
Validator<User>. - Numeric range constraint applied directly to the argument.
- Regular expression constraint applied directly to the argument.
@Component
open class ArgumentValidator {
@Valid
data class User(@field:NotBlank val id: String,
@field:Size(min = 3, max = 6) val name: String,
val status: String?)
@Validate
open fun calculate(@Valid user: User, //(1)!
@Range(from = 1.0, to = 900.0) weight: Int, //(2)!
@Pattern("ME\\d+") code: String): Int { //(3)!
return code.substring(2).toInt()
}
}
- Nested validation through
Validator<User>. - Numeric range constraint applied directly to the argument.
- Regular expression constraint applied directly to the argument.
If any argument fails validation, the aspect throws ViolationException before the method body runs.
Argument violations are reported at the path of the parameter name, for example code or user.name.
Required Arguments¶
All arguments are considered required by default, so null checks are created for them.
Primitive arguments are never null-checked — there is nothing to check.
Optional Arguments¶
To mark an argument as optional, annotate it with any @Nullable annotation.
For such an argument, a null check will not be created:
@Component
public class SomeService {
@Validate
public int validate(@Nullable String argument) { //(1)!
return 1;
}
}
org.jspecify.annotations.Nullableis the annotation Kora itself is built on; any annotation whose simple name isNullableis accepted.
To mark an argument as optional, use Kotlin Nullability syntax and add ? to the argument type.
For such an argument, a null check will not be created:
Nested Arguments¶
Use @Valid to validate nested arguments that have generated or manually provided validators.
In the example above, Validator<Foo> will be created for Foo.
When the method is called, the @Validate aspect will call this validator for the argument argument.
Result Validation¶
To validate a method result, use the @Validate annotation on the method and annotate the result with the corresponding annotations.
Place @Valid on the method to run nested validation through the return type's Validator.
To require that the result is not null, use any @Nonnull or @NotNull annotation.
@Component
public class ResultValidator {
@Valid
public record User(@NotBlank String id,
@Size(min = 3, max = 6) String name,
@Nullable @Size(min = 1, max = 10) String status) { } //(1)!
@Valid //(3)!
@Validate //(2)!
public User create(String name, String status) {
return new User(UUID.randomUUID().toString(), name, status);
}
}
- Constraints can be stacked:
statusis optional (@Nullable), but when present its length must be within@Size. - Indicates that the method requires validation.
- Indicates that the result should be validated through the
Validatorof the return type.
@Component
open class ResultValidator {
@Valid
data class User(@field:NotBlank val id: String,
@field:Size(min = 3, max = 6) val name: String,
@field:Size(min = 1, max = 10) val status: String?) //(1)!
@Valid //(3)!
@Validate //(2)!
open fun create(name: String, status: String?): User {
return User(UUID.randomUUID().toString(), name, status)
}
}
- Constraints can be stacked:
statusis optional (nullable), but when present its length must be within@Size. - Indicates that the method requires validation.
- Indicates that the result should be validated through the
Validatorof the return type.
The result validation runs after the method body, on its return value; if it fails, the aspect throws ViolationException instead of returning the value.
Constraints can also be applied to the result container itself. For example, a collection result can be size-checked and its elements validated at the same time:
@Valid
public record Foo(@Valid Bar bar) { }
@Component
public class SomeService {
@Size(min = 1, max = 3) //(3)!
@Valid //(2)!
@Validate //(1)!
public List<Foo> validate() {
// do something
}
}
- Indicates that the method requires validation.
- Indicates that the result should be validated through the
Validatorof the return type. - Standard validation annotation.
@Component
open class SomeService {
@Size(min = 1, max = 3) //(3)!
@Valid //(2)!
@Validate //(1)!
open fun validate(): List<Foo> {
// do something
}
}
- Indicates that the method requires validation.
- Indicates that the result should be validated through the
Validatorof the return type. - Standard validation annotation.
A method that returns nothing can still validate its arguments, but result validation on a void / Unit return is a build error — there is no value to check.
Validation Options¶
There are two validation modes:
Full- all marked arguments and the result are checked, all possible validation errors are collected, and only then an exception is thrown. This is the default behavior.FailFast- an exception is thrown on the first found error.
Example of FailFast validation:
Arguments and the result are two separate stages: with the default Full mode all argument violations are collected and thrown together, and the result is checked only if the arguments passed.
Validation HTTP Response¶
When a Kora HTTP service uses the ValidationModule (from the validation-module artifact), a failed validation can be turned into an HTTP 400 response automatically instead of an uncaught error.
This is handled by the ValidationHttpServerInterceptor — an HTTP server interceptor that catches the ViolationException thrown by the @Validate aspect and produces the response.
By default it returns status 400 with the ViolationException message as a text/plain body; a custom response mapper can replace that.
ValidationModule contributes the interceptor untagged, while the HTTP server collects global interceptors under the @Tag(HttpServer.class) tag (see Interceptors).
Override the module method and add that tag to apply the interceptor to every route:
@KoraApp
public interface Application extends
ValidationModule, //(1)!
UndertowPublicHttpServerModule,
JsonModule {
@Tag(HttpServer.class) //(2)!
default ValidationHttpServerInterceptor validationHttpServerInterceptor(@Nullable ViolationExceptionHttpServerResponseMapper mapper) {
return new ValidationHttpServerInterceptor(mapper); //(3)!
}
}
ValidationModuleextendsValidatorModuleand declares theValidationHttpServerInterceptordefault.- Registers the interceptor as a global HTTP server interceptor;
HttpServerisio.koraframework.http.server.common.HttpServer. - A
nullmapper keeps the default400plain-text response.
@KoraApp
interface Application : ValidationModule, //(1)!
UndertowPublicHttpServerModule,
JsonModule {
@Tag(HttpServer::class) //(2)!
override fun validationHttpServerInterceptor(mapper: ViolationExceptionHttpServerResponseMapper?): ValidationHttpServerInterceptor {
return ValidationHttpServerInterceptor(mapper) //(3)!
}
}
ValidationModuleextendsValidatorModuleand declares theValidationHttpServerInterceptordefault.- Registers the interceptor as a global HTTP server interceptor;
HttpServerisio.koraframework.http.server.common.HttpServer. - The parameter is declared
@Nullablein the Kora contract, so theKotlinoverride must acceptViolationExceptionHttpServerResponseMapper?; anullmapper keeps the default400plain-text response.
A @Validate-annotated controller method then produces a 400 for the client whenever its arguments or result fail validation, with no per-controller wiring:
@Json
@Valid
public record UserRequest(@NotBlank @Size(min = 2, max = 100) String name,
@NotBlank @Pattern("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") String email) { }
@Component
@HttpController
public final class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Validate //(1)!
@Json
public UserResponse createUser(@Valid @Json UserRequest request) { //(2)!
// request is already validated here
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Validate
@Json
public UserResponse getUser(@Path @NotBlank @Pattern("^\\d+$") String userId) { //(3)!
// userId is already validated here
}
}
- Enables argument (and result) validation for this route.
- Nested validation of the request body; a violation yields
HTTP400before the body runs. - Constraints work on any bound parameter —
@Path,@Query,@Header,@Cookie— not only on theJSONbody.
@Json
@Valid
data class UserRequest(@field:NotBlank @field:Size(min = 2, max = 100) val name: String,
@field:NotBlank @field:Pattern("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") val email: String)
@Component
@HttpController
open class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Validate //(1)!
@Json
open fun createUser(@Valid @Json request: UserRequest): UserResponse { //(2)!
// request is already validated here
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Validate
@Json
open fun getUser(@Path @NotBlank @Pattern("^\\d+$") userId: String): UserResponse { //(3)!
// userId is already validated here
}
}
- Enables argument (and result) validation for this route.
- Nested validation of the request body; a violation yields
HTTP400before the body runs. - Constraints work on any bound parameter —
@Path,@Query,@Header,@Cookie— not only on theJSONbody.
Custom Response¶
To control the status, headers, or body of the response — for example, to return a structured JSON error instead of the default plain text — provide a ViolationExceptionHttpServerResponseMapper component.
Its apply(request, exception) method returns the HttpServerResponse to send; returning null falls back to the default 400 plain-text response.
@Json //(1)!
public record ValidationErrorResponse(String code, String message, List<ValidationErrorDetails> errors) { }
@Json
public record ValidationErrorDetails(String field, String message) { }
@KoraApp
public interface Application extends
ValidationModule,
UndertowPublicHttpServerModule,
JsonModule {
default ViolationExceptionHttpServerResponseMapper violationExceptionMapper(JsonWriter<ValidationErrorResponse> writer) {
return (request, exception) -> {
var errors = exception.getViolations().stream() //(2)!
.map(v -> new ValidationErrorDetails(v.path().full(), v.message()))
.toList();
var body = new ValidationErrorResponse("VALIDATION_ERROR", "Validation failed", errors);
return HttpServerResponse.of(400, HttpBody.json(writer.toByteArray(body))); //(3)!
};
}
@Tag(HttpServer.class)
default ValidationHttpServerInterceptor validationHttpServerInterceptor(ViolationExceptionHttpServerResponseMapper mapper) {
return new ValidationHttpServerInterceptor(mapper);
}
}
- Serialized with the JSON module.
ViolationException.getViolations()returns everyViolation;path().full()is the dotted path (e.g.customer.address.city).- Any
HttpServerResponsemay be returned; returningnullwould fall back to the default400.
@Json //(1)!
data class ValidationErrorResponse(val code: String, val message: String, val errors: List<ValidationErrorDetails>)
@Json
data class ValidationErrorDetails(val field: String, val message: String)
@KoraApp
interface Application : ValidationModule,
UndertowPublicHttpServerModule,
JsonModule {
fun violationExceptionMapper(writer: JsonWriter<ValidationErrorResponse>): ViolationExceptionHttpServerResponseMapper {
return ViolationExceptionHttpServerResponseMapper { _, exception ->
val errors = exception.violations.map { //(2)!
ValidationErrorDetails(it.path().full(), it.message())
}
val body = ValidationErrorResponse("VALIDATION_ERROR", "Validation failed", errors)
HttpServerResponse.of(400, HttpBody.json(writer.toByteArray(body))) //(3)!
}
}
@Tag(HttpServer::class)
override fun validationHttpServerInterceptor(mapper: ViolationExceptionHttpServerResponseMapper?): ValidationHttpServerInterceptor {
return ValidationHttpServerInterceptor(mapper)
}
}
- Serialized with the JSON module.
ViolationException.getViolations()returns everyViolation;path().full()is the dotted path (e.g.customer.address.city).- Any
HttpServerResponsemay be returned; returningnullwould fall back to the default400.
Custom Validation Annotations¶
A custom validation annotation is needed when the standard checks are not enough.
It connects an annotation with a ValidatorFactory, and the factory creates a Validator for a specific value type.
To create a custom annotation:
- Create a
Validatorimplementation:
final class MyValidStringValidator implements Validator<String> {
@Override
public List<Violation> validate(@Nullable String value, ValidationContext context) {
if (value == null) {
return List.of(context.violates("Should be not empty, but was null"));
} else if (value.isEmpty()) {
return List.of(context.violates("Should be not empty, but was empty"));
}
return Collections.emptyList();
}
}
class MyValidStringValidator : Validator<String?> {
override fun validate(value: String?, context: ValidationContext): List<Violation> {
if (value == null) {
return listOf(context.violates("Should be not empty, but was null"))
} else if (value.isEmpty()) {
return listOf(context.violates("Should be not empty, but was empty"))
}
return listOf()
}
}
- Create a
ValidatorFactorysubtype:
- Register the
ValidatorFactoryas a component:
- Create a validation annotation and mark it with
@ValidatedByusing the previously createdValidatorFactorysubtype:
- Mark a field, argument, or result with the new annotation:
Note
The ValidatorFactory is looked up in the dependency graph by the factory subtype you declared, parameterized with the annotated value type — MyValidValidatorFactory<String> for a String field.
Register one factory component per value type the constraint should support; that is exactly how the built-in constraints cover String and CharSequence separately.
Parameterized Constraints¶
A custom constraint annotation may declare parameters.
When it does, its ValidatorFactory subtype must declare a create(...) method whose parameter list matches the annotation attributes (the same number of parameters, in declaration order).
Kora reads the annotation values (with defaults applied) at compile time and passes them into that create(...) method; if no matching create(...) overload exists, the build fails with Expected <Factory>#create() method with N parameters, but was didn't find such.
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@ValidatedBy(PrefixedValidatorFactory.class)
public @interface Prefixed {
String value(); //(1)!
}
public interface PrefixedValidatorFactory extends ValidatorFactory<String> {
@Override
default Validator<String> create() { //(2)!
throw new UnsupportedOperationException("Prefix is required");
}
Validator<String> create(String prefix); //(3)!
}
- A single annotation attribute.
- The inherited no-argument factory method is not usable for this constraint.
- Matching single-parameter
create(...); Kora passesvalue()intoprefix.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
@ValidatedBy(PrefixedValidatorFactory::class)
annotation class Prefixed(val value: String) //(1)!
interface PrefixedValidatorFactory : ValidatorFactory<String> {
override fun create(): Validator<String> = //(2)!
throw UnsupportedOperationException("Prefix is required")
fun create(prefix: String): Validator<String> //(3)!
}
- A single annotation attribute.
- The inherited no-argument factory method is not usable for this constraint.
- Matching single-parameter
create(...); Kora passesvalueintoprefix.
The factory is registered as a component exactly like the parameterless case (step 3 above). This is the same mechanism the built-in constraints use, and their public factory interfaces expose reusable overloads that a custom factory can delegate to:
| Factory | Overloads |
|---|---|
RangeValidatorFactory |
create(double from, double to), create(double from, double to, Range.Boundary boundary) |
SizeValidatorFactory |
create(int to), create(int from, int to) |
PatternValidatorFactory |
create(String pattern), create(String pattern, int flags) — the inherited create() throws, a pattern is mandatory |
MinValidatorFactory / MaxValidatorFactory |
create(long value) |
DigitsValidatorFactory |
create(int integer, int fraction) |
OneOfValidatorFactory |
create(String[] value) |
NotEmptyValidatorFactory, NotBlankValidatorFactory, UuidValidatorFactory, UriValidatorFactory, UrlValidatorFactory, AssertTrueValidatorFactory, AssertFalseValidatorFactory, PositiveValidatorFactory, PositiveOrZeroValidatorFactory, NegativeValidatorFactory, NegativeOrZeroValidatorFactory, PastValidatorFactory, PastOrPresentValidatorFactory, FutureValidatorFactory, FutureOrPresentValidatorFactory |
the parameterless create() |
Because these are ordinary graph components declared with @DefaultComponent, providing your own factory for the same type replaces the built-in behaviour of that constraint.
Signatures¶
Method signatures supported by the @Validate aspect out of the box:
The class must not be final for aspects to work.
T means the return value type.
T myMethod()void myMethod()(arguments only — a result constraint onvoidis a build error)CompletionStage<T> myMethod()CompletionStageCompletableFuture<T> myMethod()
Publisher, Mono, Flux, and a bare Future<T> are not supported and fail the build with an explicit message.
The class must be open for aspects to work.
T means the return value type, T?, or Unit.
myMethod(): TmyMethod(): Unit(arguments only — a result constraint onUnitis a build error)suspend myMethod(): TKotlin Coroutine (requires dependency asimplementation)myMethod(): Flow<T>Kotlin Coroutine (requires dependency asimplementation)
For a Flow<T>, arguments are validated when the flow is collected and the result constraints are applied to each emitted element.
CompletionStage, Future, Mono, and Flux are not supported and fail the build with an explicit message.