DEV Community

Cover image for KVerify: A Two-Year Journey to Get Validation Right
tessoir
tessoir

Posted on

KVerify: A Two-Year Journey to Get Validation Right

KVerify: A Two-Year Journey to Get Validation Right

In December 2023, I wrote a small article about a utility I called ValidationBuilder. The idea was simple: a DSL where you'd call validation rules as extension functions on property references, collect all violations in one pass, and get a result back. Ktor-specific, but the concept was portable.

I published it and moved on.

Except I didn't.


The Problem

I came to Kotlin without a Java background. Spring was my first serious framework, and I didn't really understand it.

My issue was specific: I was reaching for Kotlin object declarations where Spring wanted managed beans. I was hardcoding configuration values that should have been injected, not because I didn't know configuration existed, but because I couldn't figure out how to bridge Spring's dependency injection with Kotlin's object. I was working around my own knowledge gaps without realizing that's what I was doing. Everything ran, so I assumed everything was fine.

Eventually I moved to Ktor. Deliberately — I wanted less magic, more control. What I got instead was a different kind of overwhelming. Ktor gives you primitives and expects you to build the structure yourself. No built-in validation, no standard error handling, no guardrails. You figure it out or you don't.

That turned out to be one of the better things that happened to me as a developer. Without a framework making decisions for me, I had to actually learn why certain patterns exist. Architecture, separation of concerns, what makes code maintainable — I learned it the hard way, by building things from scratch and getting them wrong.

But the validation problem stayed unsolved. Hand-written if checks everywhere. Error messages as hardcoded strings. No reuse, no structure. I looked at Konform and Valiktor. Valiktor was already abandoned. Konform was actively maintained and had a reasonable DSL — but I looked at it, decided it would take too long to learn properly, and moved on. Which, in hindsight, is a funny reason to spend the next two years building my own library from scratch.

I didn't have precise words for what was wrong. I just knew the answer wasn't there.

So in December 2023, I wrote my own.


The Library

Seven months later, in July 2024, I started a proper repository. Not because I had a clear vision of what the library should be — I just wanted to stop copying the same validation code between projects. A few lines of dependency instead of manually replicating the ValidationBuilder every time.

That was the entire plan.


NamedValue

The library started with a concept called NamedValue — a simple wrapper: a value and a name, travelling together.

data class NamedValue<T>(val name: String, val value: T)
Enter fullscreen mode Exit fullscreen mode

The reason was practical. Validation error messages need to reference the field that failed — "password must not be blank", "age must be at least 18". Without NamedValue, you'd have to pass the field name to every rule call manually. With it, the name was always there, carried alongside the value, available to any rule that needed it. Rules were just extension functions on NamedValue<T>:

password.named("password").notBlank().ofLength(8..255)
Enter fullscreen mode Exit fullscreen mode

It felt clean. The infix named read naturally, the rules chained, and the name showed up in every error message automatically.

The problem was that it had quietly made a decision I hadn't noticed yet — it tied the field name to the value itself, rather than to the validation context. That distinction wouldn't matter for a while. And then it would matter enormously.


The Iterations

The 1.x releases came quickly. November, December 2024 — versions shipping, rules working, NamedValue doing its job. On paper, the library was functional.

But something kept feeling off. The ValidationContext had quietly accumulated weight:

fun validate(message: String, predicate: Predicate)
fun validate(rule: Rule<Unit>)
fun <T> T.validate(vararg rules: Rule<T>): T
fun <T> NamedValue<T>.validate(message: MessageCallback<T>, predicate: ValuePredicate<T>): NamedValue<T>
fun <T> NamedValue<T>.validate(ruleCallbackWithMessage: NamedValueRuleCallback<T>): NamedValue<T>
Enter fullscreen mode Exit fullscreen mode

Five ways to validate. Type aliases for every possible callback shape. Each one added to solve a real problem — and together they made the interface feel like it was trying to be everything at once.

The fix was to stop and ask: what does a validation context actually need to do? The answer was one thing: decide what happens when a rule fails.

interface ValidationContext {
    fun onFailure(violation: Violation)
}
Enter fullscreen mode Exit fullscreen mode

That's it. Everything else was the rule's problem, not the context's.

This pattern repeated itself throughout 2025. Build something, feel the weight accumulate, find the one thing it actually needed to do, cut everything else. The library kept getting smaller. Each simplification felt right for a week, then revealed the next thing that was still wrong.

And through all of it, NamedValue stayed. Untouched. Seemingly fine.


5 AM

This was the state of things through most of 2025. Iterating. Releasing. Something always slightly wrong.

There were nights where I'd wake up at five — suddenly, with something already forming in my head. Not a diagram or a grand insight. Just code. An API shape. A specific thing that had been bothering me that now had an answer.

On July 14, 2025, that happened at 5:29 AM. I made two commits — a package restructuring I had been putting off for days — and started my day four hours early. I napped later and went to bed earlier that night.

The sleep during those periods wasn't great. Not quite fever dreams, but something adjacent — restless, with the problem still running somewhere in the background. I wasn't suffering dramatically. I was just genuinely stuck on something, and my brain apparently didn't get the memo that work was supposed to stop.


Spring, Revisited

In January 2026, I decided to learn Spring again. Not to go back to it — just to understand it better. And since I had a library now, I figured I'd try using it alongside Spring's own validation.

The code I wrote looked like this:

fun validate(context: ValidationContext) =
    with(context) {
        ::title.toNamed().verifyWith(
            provider.namedNotBlank(),
            provider.namedLengthBetween(3..255) { violation("Title must be between 3 and 255 characters long") }
        )
    }
Enter fullscreen mode Exit fullscreen mode

Spring's version, sitting right above it in the same file:

@NotBlank
@Length(min = 3, max = 255)
val title: String
Enter fullscreen mode Exit fullscreen mode

I wasn't trying to beat Spring at its own game — annotations are a different tool for a different philosophy. But looking at both side by side, something became impossible to ignore. The toNamed() call on every property. The provider object. The fact that adding a custom reason meant constructing a violation manually and losing the field name in the process. The constant risk of accidentally applying a regular rule to a named value or vice versa.

I had been living inside this DSL for over a year. I had stopped seeing it. Comparing it to anything — even Spring, which I had originally left — made the friction visible again.

NamedValue wasn't a small ergonomics issue. It was a load-bearing flaw. And it had been there from the beginning.


The Decision

By January 2026, the problem had a name.

Not NamedValue specifically — the concept behind it. Rules and naming metadata were coupled. A rule for a named value was a different type than a rule for a plain value. That meant two rule sets, two providers, two of everything. Users had to constantly think about which kind of rule to reach for. The maintenance cost was real and growing.

I knew these two things shouldn't coexist. What I didn't know was how to separate them.

I had asked AI assistants about this problem before. Multiple times. Nothing useful came back — or maybe it did and I wasn't ready to hear it yet. But one day in January 2026, I described the problem again, and the answer that came back was something I had been circling for two years without landing on: put the metadata in the context, not in the rule or the value. Rules validate values. Context carries where those values live.

I knew it was right immediately. Not because the AI said so — but because two years of the wrong model had made the right one recognizable the moment I saw it.

NamedValue was removed. Not in a single dramatic commit — it was simply no longer needed. The path would live in the context. Property references would put it there automatically. Rules would stay pure.

But "the context" now meant something different. The old ValidationContext — the interface that decided what to do on failure — was renamed to ValidationScope. A new ValidationContext took its place: a pure, immutable carrier of metadata. Same name, completely different responsibility. The scope executes. The context carries.

What followed was three months of more progress than the previous two years combined.


The New Context

Once NamedValue was gone, a new question appeared immediately: if the path lives in the context, how does the context store it?

The first attempt was a linked list — each context node held one path segment and a reference to its parent. Clean in theory, recursive in practice. It worked but felt fragile for deep nesting.

Two days later, I replaced it with something more ambitious — a design modeled directly after Kotlin's CoroutineContext. Elements stored by key, retrievable by type, composable with +. It looked elegant:

interface ValidationContext {
    operator fun <E : Element> get(key: Key<E>): E?
    operator fun plus(context: ValidationContext): ValidationContext

    interface Key<E : Element>
    interface Element : ValidationContext {
        val key: Key<*>
    }
}
Enter fullscreen mode Exit fullscreen mode

It lasted twenty-two days.

The flaw was fundamental. CoroutineContext uses key-based replacement — if you add an element with a key that already exists, it replaces the old one. That's the right behavior for something like a coroutine dispatcher, where you want exactly one. But a validation path needs multiple NamePathElements to coexist. A path like user.friends[0].user requires the same element type to appear multiple times — key replacement silently destroyed them.

The next attempt dropped the key system entirely. Elements became a plain list, retrieved by type filtering. Simpler, but every + operation allocated a new list. Fine for shallow contexts, wasteful for deep ones.

Then fold replaced the list — a binary tree of CombinedContext(left, right) nodes, traversed lazily. No intermediate allocations. Better.

Then, two weeks later, one more simplification: replace fold with Iterable. Not because fold was wrong — but because Iterable is something every Kotlin developer already understands. filter, filterIsInstance, for loops — all of it works immediately, with no new protocol to learn. The context became something you could hand to any standard library function without explanation.

interface ValidationContext : Iterable<ValidationContext.Element>
Enter fullscreen mode Exit fullscreen mode

That was the final form. Six weeks, five implementations, one interface.


The Rules Problem

There was one more problem to solve.

The scope controlled what happened on failure — collect, throw, or anything else. But "anything else" was the interesting part. What if you wanted to stop after the first violation and skip all remaining rules? The scope needed to intercept rule execution to do that.

So rules came back as an explicit abstraction. A rule received the context and the value, ran a check, and returned a violation or null:

interface Rule<in T> {
    fun check(context: ValidationContext, value: T): Violation?
}
Enter fullscreen mode Exit fullscreen mode

The scope would call it, inspect the result, and decide what to do next:

fun <T> enforce(rule: Rule<T>, value: T) {
    val violation = rule.check(validationContext, value) ?: return
    onFailure(violation)
}
Enter fullscreen mode Exit fullscreen mode

This worked — until you tried to extend a scope with additional context. Kotlin's by delegation copies the implementation from the delegated object, not the receiver. So when ContextExtendedValidationScope delegated to the original scope, enforce ran with the original scope's validationContext — not the overridden one. The extended context, the one with the path segment you just added, was completely invisible to the rule.

internal class ContextExtendedValidationScope<out T : ValidationScope>(
    val originalValidationScope: T,
    val additionalContext: ValidationContext,
) : ValidationScope by originalValidationScope {
    override val validationContext: ValidationContext
        get() = originalValidationScope.validationContext + additionalContext
}
Enter fullscreen mode Exit fullscreen mode

The overridden validationContext was there. The enforce implementation just never called it.

I tried to fix the delegation chain. There was no clean way.

The solution was to stop passing context and value to the rule entirely. A rule is just a check — it closes over whatever it needs from the surrounding code. By the time enforce is called, the closure already has the right scope, the right context, the right value:

fun interface Rule {
    fun check(): Violation?
}

scope.enforce {
    if (value.isBlank()) NotBlankViolation(
        validationPath = scope.validationContext.validationPath(),
        reason = "must not be blank",
    ) else null
}
Enter fullscreen mode Exit fullscreen mode

value and scope are already in the closure. The rule doesn't need parameters. The delegation problem disappeared — because there was nothing left to delegate except the outcome.


Polishing

By late February 2026, the architecture was right. What remained was a different kind of work — not building, but removing.

The guiding principle was simple: leave only the shape. Strip everything down to the minimal core, then test it, then add back only what proved it deserved to exist. Any helper function that wasn't load-bearing — gone. Any abstraction that existed for convenience rather than necessity — gone.

The commit messages tell the story plainly. reduce public API surface to minimal stable core. replace class-based Rule hierarchy with extension functions. Rule composition operators — and, or, ! — removed. The Verification interface removed, kept as a concrete class. FirstViolationValidationScope removed entirely.

It wasn't painful. Overshipping and dealing with it after a release felt far worse than cutting now. A public API is a promise — every method you expose is something you have to maintain, something users will depend on, something you can't easily take back. The smaller the surface, the more deliberate every addition has to be.

What remained after the cuts was small enough to hold in your head. Clean enough to document properly. Stable enough to release.

On March 23, 2026 — two years and nine months after the first commit — KVerify 2.0.0 shipped.


The Comparison

This is the core of KVerify on its first commit, July 13, 2024:

// Collect all violations
inline fun collectViolations(block: CollectAllContext.() -> Unit): ValidationResult

// Fail on first violation
inline fun failFast(block: FailFastContext.() -> Unit): ValidationResult

// Rules as extension functions
fun NamedString.notBlank(message: String = "$name must not be blank"): NamedString =
    validate(message) { it.isNotBlank() }

// Violations collected into a result
val isValid get() = this is ValidationResult.Valid
inline fun ValidationResult.onInvalid(block: (List<ValidationException>) -> Unit): ValidationResult
inline fun ValidationResult.onValid(block: () -> Unit): ValidationResult
Enter fullscreen mode Exit fullscreen mode

And this is KVerify 2.0.0:

// Collect all violations
inline fun validateCollecting(block: CollectingValidationScope.() -> Unit): ValidationResult

// Fail on first violation
inline fun validateThrowing(block: ThrowingValidationScope.() -> T): T

// Rules as extension functions
fun Verification<String>.notBlank(reason: String? = null): Verification<String> =
    apply {
        scope.enforce {
            if (value.isBlank()) NotBlankViolation(
                validationPath = scope.validationContext.validationPath(),
                reason = reason ?: "Value must not be blank",
            ) else null
        }
    }

// Violations collected into a result
val isValid: Boolean get() = violations.isEmpty()
inline fun ValidationResult.onInvalid(block: (List<Violation>) -> Unit)
inline fun ValidationResult.onValid(block: () -> Unit)
Enter fullscreen mode Exit fullscreen mode

The user-facing API changed completely. The underlying shape did not.

Two strategies — collect or throw. Rules as extension functions. A result you can branch on. onValid, onInvalid. These were all there on day one. Not because the first version was well-designed — it wasn't. But because the instinct pointing toward them was right.

What two years bought was the understanding of why they were right. The NamedValue coupling, the overengineered contexts, the delegation trap, the CoroutineContext detour — none of it was wasted. Each wrong turn made the correct shape more recognizable. By the time the right architecture appeared, it was obvious. Not because it was simple — because everything else had already been tried.

Experience isn't knowing the answer. It's having been wrong enough times to recognize it when you finally see it.


What it looks like today

data class Address(val street: String, val city: String, val postalCode: String)
data class RegisterRequest(val username: String, val email: String, val age: Int, val address: Address)

fun ValidationScope.validate(request: RegisterRequest) {
    verify(request::username).notBlank().minLength(3).maxLength(20)
    verify(request::email).notBlank()
    verify(request::age).atLeast(18)

    pathName("address") {
        verify(request.address::street).notBlank()
        verify(request.address::city).notBlank()
        verify(request.address::postalCode).exactLength(5)
    }
}

val result = validateCollecting { validate(request) }

result.violations
    .filterIsInstance<PathAwareViolation>()
    .forEach { println("${it.validationPath}: ${it.reason}") }
Enter fullscreen mode Exit fullscreen mode

If any of this resonated — the frustration with existing tools, the design problems, or just the library itself — KVerify is on GitHub. A star goes a long way for a solo project. And if you try it and something feels wrong, open an issue. Two years of iteration taught me that the best feedback comes from someone actually using it.

Top comments (0)