Simplifying Data Validation in Ktor with ValidationBuilder
As a developer transitioning from Spring + Kotlin to Ktor, I encountered a challenge that many of you might relate to. When I was using…
Simplifying Data Validation in Ktor with ValidationBuilder
As a developer transitioning from Spring + Kotlin to Ktor, I encountered a challenge that many of you might relate to. When I was using Spring, I loved the power of validation annotations. They were intuitive, easy to use, and greatly reduced boilerplate code. However, when I switched to Ktor, I found that the validation approach was quite different.
In Ktor, validation isn’t handled by annotations like in Spring. Instead, it requires writing separate functions and performing manual checks for each piece of data.
This difference in validation approaches led me to reflect on my own experience and the challenges it presented. I realized that while Ktor’s approach gave me the freedom I craved, it also introduced complexity and verbosity. I saw an opportunity to simplify this process and decided to create my own solution.
Introducing ValidationBuilder, a utility designed to streamline data validation in Ktor. With ValidationBuilder, you can collect all violations and use them in the Validation plugin. It offers a developer-friendly interface that can be replicated anywhere.
Here’s a basic usage example:
// The data class we want to validate
data class EventDto(
val title: String,
val description: String,
@get:JsonProperty("upload_date_time")
val uploadDateTime: LocalDateTime,
@get:JsonProperty("photo_url")
val photoUrl: String,
@get:JsonProperty("video_url")
val videoUrl: String,
) {
fun validate(): ValidationResult = validateAll {
::title.length(3L..100L)
::description.length(0L..1000L)
::uploadDateTime.after(LocalDateTime.now())
::photoUrl.validUrl()
::videoUrl.validUrl()
}
}
// Registering validation with Ktor's Validation plugin:
fun Application.configureValidation() {
install(RequestValidation) {
validate<EventDto> { it.validate() }
}
}
// usage
fun Route.eventRoutes() {
route("/events") {
post {
createEvent(call.receive<EventDto>()).let { call.respond(it) }
}
}
}
In this example, EventDto defines a validate method that checks the length of the title field. If the length is not within the specified range, a violation is added to the violations list. The same process repeats for every specified field.
Source code:
class ValidationBuilder {
val violations = mutableListOf<String>()
inline fun violation(block: () -> String) = violations.add(block())
fun <T : Any> KProperty<T>.validate(
message: String? = null,
predicate: T.() -> Boolean,
): KProperty<T> = apply {
val value = runCatching { getter.call() }.getOrElse {
throw RuntimeException("Could not get value of $name. Please use this::$name")
}
if (!value.predicate()) {
violation { message ?: "$jsonName is invalid" }
}
}
fun KProperty<String>.notBlank(message: String? = null): KProperty<String> =
validate((message ?: "$jsonName cannot be blank")) { isNotBlank() }
fun KProperty<String>.length(range: LongRange, message: String? = null): KProperty<String> =
validate(message ?: "$jsonName must be between ${range.first} and ${range.last} characters long") {
length in range
}
fun KProperty<String>.length(max: Long, message: String? = null): KProperty<String> =
validate(message ?: "$jsonName must be between 0 and $max characters long") {
length in 0..max
}
fun KProperty<String>.validUrl(message: String? = null): KProperty<String> =
validate(message ?: "$jsonName must be a valid URL: https://example.com") { isValidUrl() }
fun KProperty<String>.validUUID(message: String? = null): KProperty<String> =
validate(message ?: "$jsonName must be a valid UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") {
toUUID().isSuccess
}
fun <T : Comparable<T>> KProperty<T>.range(range: ClosedRange<T>, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be between ${range.start} and ${range.endInclusive}") {
this in range
}
fun <T : Comparable<T>> KProperty<T>.max(max: T, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be less than $max") { this <= max }
fun <T : Comparable<T>> KProperty<T>.min(min: T, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be greater than $min") { this >= min }
fun <T : Temporal> KProperty<T>.before(before: T, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be before $before") {
Duration.between(before, this).let { it.isPositive || it.isZero }
}
fun <T : Temporal> KProperty<T>.after(after: T, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be after $after") {
Duration.between(after, this).let { it.isNegative || it.isZero }
}
fun <T : Temporal> KProperty<T>.between(from: T, to: T, message: String? = null): KProperty<T> =
validate(message ?: "$jsonName must be between from $from and to $to") {
Duration.between(from, this).let { it.isPositive || it.isZero }
&& Duration.between(to, this).let { it.isNegative || it.isZero }
}
}
private val Duration.isPositive
get() = !isNegative
private val KProperty<*>.jsonName
get() = /*getter.findAnnotation<JsonProperty>()?.value*/ // if you use jackson
/*?: getter.findAnnotation<SerialName>()?.value*/ // if you use kotlinx serialization
/*?:*/ name // if you use none of above
inline fun validateAll(block: ValidationBuilder.() -> Unit): ValidationResult {
val violations = ValidationBuilder().apply(block).violations
return if (violations.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(violations)
}
Breakdown of how the ValidationBuilder code works:
The ValidationBuilder class holds a list of validation violations and provides a set of helper methods for adding violations.
The key methods are:
- violation() — Adds a violation string to the violations list
- validate() — Validates a property by calling its getter, running a predicate function on it, and adding a violation if the predicate fails
The rest of the methods like notBlank(), length(), etc. are convenience wrappers around validate(). They provide common validations for properties like Strings, Numbers, Dates etc.
For example:
- length() validates a String is within a long range
- min() validates a Comparable is greater than a minimum value
- before() validates a Temporal is before a specific date
The validateAll() function is used to run validations and collect all violations. It takes a lambda where you can call the validation methods on properties.
It returns a ValidationResult sealed class that indicates whether validation succeeded or failed with a list of violations.
The key things that ValidationBuilder provides:
- A simple domain specific language for validating properties
- Common reusable validations for common data types
- Collecting all violations in one place
- Integration with Ktor’s request validation
So in summary, it removes a lot of boilerplate by handling collecting violations, running predicates, and integrating with Ktor. The developer just focuses on declaring the validation rules.
Even more examples:
enum class Category {
SPORTS,
POLITICS,
TECH
}
validateAll {
::category.inValues(Category.entries)
}
validateall {
::tags.all { it.length(3..15) }
::tags.maxSize(5)
}
validateAll {
::password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$")
}
Why ::prop?
ValidationBuilder relies on using property references like ::title rather than just passing in property values. This is because ValidationBuilder needs to access the KProperty metadata for each property in order to:
- Get the property name to use in error messages
- Find any JSON alias annotations like @JsonProperty
- Call the property getter to access the actual value for validation
For example:
::title.length(3..100)
Behind the scenes, ValidationBuilder does:
- Calls
titlegetter to get the real String value - Checks if value length is in the range
- If invalid, constructs an error using the
titlename:
"title must be between 3 and 100 characters long"
To further reduce boilerplate, a jsonName extension property finds the JSON alias annotation (if present) and uses it in error messages:
private val KProperty<*>.jsonName
get() = /*getter.findAnnotation<JsonProperty>()?.value*/ // if you use jackson
/*?: getter.findAnnotation<SerialName>()?.value*/ // if you use kotlinx serialization
/*?:*/ name // if you use none of above
This way you can annotate the property once and get automatic aliasing in messages:
@get:JsonProperty("event_title")
val title: String
::title.length(3..100) // uses "event_title" in message
Notice the @get: target on the annotation - this is crucial for ValidationBuilder to function properly.
Without @get:, ValidationBuilder would not be able to find the JSON alias annotation on the property getter method. It relies on annotations being present on the getter specifically.
So in other words, always use @get: when adding JSON or serialization annotations to properties validated by ValidationBuilder. Otherwise, the automatic aliasing of property names in error messages will not work.
Conclusion
ValidationBuilder provides a cleaner and more cohesive way to handle validations in Ktor. By centralizing logic into a builder pattern, it avoids spreading validation code across functions.
Its validation DSL and automatic error messaging reduce verbosity. Reusable validations handle common data types. And it integrates smoothly with Ktor’s request validation.
For anyone looking to simplify their validation approach in Ktor, ValidationBuilder is worth trying out. The concepts could also be applied to other frameworks beyond Ktor. Validation is a key aspect of application stability, and solutions like this help make it uncomplicated.
메타데이터
- post_id
- 6c00aac15d71
- slug
- simplifying-data-validation-in-ktor-with-validationbuilder-6c00aac15d71
- url
- https://medium.com/@tessoir/simplifying-data-validation-in-ktor-with-validationbuilder-6c00aac15d71
- canonical_url
- https://medium.com/@tessoir/simplifying-data-validation-in-ktor-with-validationbuilder-6c00aac15d71
- author_url
- https://medium.com/@tessoir
- status
- ok
- fetched_at
- 2026-06-16 19:09:56