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 build.gradle:
Module:
Dependency build.gradle.kts:
Module:
The module ships two mixin interfaces, and you pick one depending on whether the application serves HTTP:
| Module | Artifact | Provides | Use when |
|---|---|---|---|
ValidatorModule |
validation-common |
Generated Validator<T> beans, all built-in constraint factories, and element validators (Validator<List<T>>, Validator<Set<T>>, Validator<Collection<T>>) |
Libraries and non-HTTP applications, or when you handle ViolationException yourself |
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.
The dependency shown above (validation-module) is the right choice for an HTTP service; a library that only needs to generate validators can depend on validation-common and wire ValidatorModule instead.
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 ru.tinkoff.kora.validation.common.annotation package and are processed at compile time.
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 a standard @Nonnull / @NotNull marker (from javax.annotation, jakarta.annotation, and similar packages) as an explicit not-null requirement, which matters mainly for JsonNullable fields.
The structural annotations that drive validation:
@Valid- on a class orrecordgenerates 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.
The built-in constraint annotations and their parameters:
| Annotation | Supported types | Parameters (defaults) | Description |
|---|---|---|---|
@NotBlank |
String, CharSequence |
— | Value is not null and contains at least one non-whitespace character. |
@NotEmpty |
String, CharSequence, Iterable, Collection, List, Set, Map |
— | Value is not null and not empty. |
@Pattern |
String, CharSequence |
value (required, no default), flags (default: 0) |
Value matches the value regular expression; flags maps to java.util.regex.Pattern flags. |
@Range |
Short, Integer, Long, Float, Double, BigInteger, BigDecimal |
from (required, no default), to (required, no default), boundary (default: INCLUSIVE_INCLUSIVE) |
Number lies within [from, to]; boundary controls whether the bounds are inclusive. |
@Size |
String, CharSequence, Collection, List, Set, Map |
min (default: 0), max (required, no default) |
Size (length) of the value is within min and max. |
Note
Watch the required parameters: @Size.max has no default, so omitting it is a compile error; @Range.from and @Range.to are both required and are declared as double.
The @Range.boundary value is a Range.Boundary enum with the variants EXCLUSIVE_EXCLUSIVE, INCLUSIVE_EXCLUSIVE, EXCLUSIVE_INCLUSIVE, and INCLUSIVE_INCLUSIVE.
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.
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:
- Any
@Nullableannotation is suitable, for examplejavax.annotation.Nullable,jakarta.annotation.Nullable, ororg.jetbrains.annotations.Nullable.
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:
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.
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.
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.
JsonNullable¶
For JsonNullable<T>, Kora validates the T value inside the container.
If JsonNullable is in the undefined state, regular value checks are not performed.
Use @NotNull or @Nonnull to disallow undefined or null.
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:
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.get(0);
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 (anullvalue fails with a violation).validateAndThrow(value)/validateAndThrow(value, context)- throwViolationExceptionwhen any violation occurs, and do nothing otherwise.
When a ViolationException is caught, getViolations() returns the aggregated List<Violation>, and getMessage() returns a preformatted multi-line summary of every violation path and message.
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
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.
Required Arguments¶
All arguments are considered required by default, so null checks are created for them.
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;
}
}
- Any
@Nullableannotation is suitable, for examplejavax.annotation.Nullable,jakarta.annotation.Nullable, ororg.jetbrains.annotations.Nullable.
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)!
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)!
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.
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:
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 ViolationException thrown by the @Validate aspect (including an exception wrapped in CompletionException for asynchronous signatures) and produces the response.
By default it returns status 400 with the ViolationException message as a plain-text body; a custom response mapper can replace that.
Global interceptors are collected by the @Tag(HttpServerModule.class) tag (see Interceptors), so the interceptor must be provided with that tag to apply to every route:
@KoraApp
public interface Application extends
ValidationModule, //(1)!
UndertowHttpServerModule,
JsonModule {
@Tag(HttpServerModule.class) //(2)!
default ValidationHttpServerInterceptor validationHttpServerInterceptor(@Nullable ViolationExceptionHttpServerResponseMapper mapper) {
return new ValidationHttpServerInterceptor(mapper); //(3)!
}
}
ValidationModuleextendsValidatorModuleand provides theValidationHttpServerInterceptorandViolationExceptionHttpServerResponseMapperwiring.- Registers the interceptor as a global HTTP server interceptor.
- Passing
nullas the mapper keeps the default400plain-text response.
@KoraApp
interface Application : ValidationModule, //(1)!
UndertowHttpServerModule,
JsonModule {
@Tag(HttpServerModule::class) //(2)!
fun validationInterceptor(mapper: ViolationExceptionHttpServerResponseMapper?): ValidationHttpServerInterceptor {
return ValidationHttpServerInterceptor(mapper) //(3)!
}
}
ValidationModuleextendsValidatorModuleand provides theValidationHttpServerInterceptorandViolationExceptionHttpServerResponseMapperwiring.- Registers the interceptor as a global HTTP server interceptor.
- Passing
nullas the mapper 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
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
}
}
- Enables argument (and result) validation for this route.
- Nested validation of the request body; a violation yields
HTTP400before the body runs.
@Json
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
class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Validate //(1)!
@Json
fun createUser(@Valid @Json request: UserRequest): UserResponse {
// request 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.
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,
UndertowHttpServerModule,
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.toByteArrayUnchecked(body))); //(3)!
};
}
@Tag(HttpServerModule.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,
UndertowHttpServerModule,
JsonModule {
fun violationExceptionMapper(writer: JsonWriter<ValidationErrorResponse>): ViolationExceptionHttpServerResponseMapper {
return ViolationExceptionHttpServerResponseMapper { request, 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.toByteArrayUnchecked(body))) //(3)!
}
}
@Tag(HttpServerModule::class)
fun validationInterceptor(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> {
@Nonnull
@Override
public List<Violation> validate(String value, @Nonnull 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:
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.
@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:
RangeValidatorFactory-create(double from, double to)andcreate(double from, double to, Range.Boundary boundary).SizeValidatorFactory-create(int to)andcreate(int from, int to).PatternValidatorFactory-create(String pattern)andcreate(String pattern, int flags).NotEmptyValidatorFactoryandNotBlankValidatorFactory- the parameterlesscreate().
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()Optional<T> myMethod()CompletionStage<T> myMethod()CompletionStageMono<T> myMethod()Project Reactor (requires dependency)Flux<T> myMethod()Project Reactor (requires dependency)
The class must be open for aspects to work.
T means the return value type, T?, or Unit.
myMethod(): Tsuspend myMethod(): TKotlin Coroutine (requires dependency asimplementation)myMethod(): Flow<T>Kotlin Coroutine (requires dependency asimplementation)