Skip to content

ValidationScope

tessoir edited this page Mar 17, 2026 · 1 revision

ValidationScope

Collecting validation

validateCollecting runs all rules unconditionally and returns a ValidationResult containing every violation that was produced. A failed rule never prevents subsequent rules from running.

val result = validateCollecting {
    verify(user::name).notBlank().minLength(3)
    verify(user::email).notBlank()
    verify(user::age).atLeast(18)
}

ValidationResult exposes two properties:

result.isValid   // true if no violations were produced
result.isInvalid // true if at least one violation was produced

And four ways to act on it:

// Run a block if valid
result.onValid {
    println("All good!")
}

// Run a block with the violation list if invalid
result.onInvalid { violations ->
    violations.forEach { println(it.reason) }
}

// Throw a ValidationException if invalid, do nothing if valid
result.throwOnInvalid()

// Branch on both outcomes and return a value
val response = result.fold(
    onValid = { Response.ok() },
    onInvalid = { violations -> Response.badRequest(violations) }
)

Use validateCollecting when you want to report all problems at once — forms, API request validation, DTO validation.

Throwing validation

validateThrowing stops at the first failed rule and throws immediately.

validateThrowing {
    verify(order::total).atLeast(minimumAmount)
    verify(order::items).minSize(1)
}

A failed rule throws a ViolationException, which carries the single violation that caused it. ViolationException is a subtype of ValidationException, which carries a list — the subtype relationship matters when catching exceptions from code that may use either scope.

try {
    validateThrowing {
        verify(order::total).atLeast(minimumAmount)
        verify(order::items).minSize(1)
    }
} catch (e: ViolationException) {
    println(e.violation.reason)
}

If all rules pass, validateThrowing returns the value of the block normally — so it can be used inline:

val validated = validateThrowing {
    verify(user::age).atLeast(18)
    processUser(user)
}

Use validateThrowing when a single violation means the operation cannot proceed — business logic, use cases, internal invariants.

enforce and failIf

verify is the most common way to apply rules, but the scope exposes two lower-level functions that are useful when you need more control.

enforce takes a Rule — a functional interface whose single method returns a Violation on failure, or null on success. It can be passed as a lambda:

validateCollecting {
    enforce {
        if (user.name.isBlank()) violation("Name must not be blank") else null
    }
}

failIf is a convenience wrapper that reads more naturally for simple boolean checks:

validateCollecting {
    failIf({ user.age < 18 }) {
        violation("User must be at least 18 years old")
    }
}

Both behave according to the active scope — in a collecting scope they accumulate violations, in a throwing scope they throw immediately.

Custom scope implementation

ValidationScope is an interface with two members: validationContext and enforce. Implementing it is straightforward when the built-in collecting and throwing behaviors don't fit your needs — for example, a scope that logs violations as they occur:

class LoggingValidationScope(
    override val validationContext: ValidationContext,
    private val logger: Logger,
) : ValidationScope {
    override fun enforce(rule: Rule) {
        val violation = rule.check() ?: return
        logger.warn("Validation failed: ${violation.reason}")
    }
}

Custom scopes integrate with the rest of the library without any additional wiring — verify, failIf, and scope extension functions all work on any ValidationScope implementation.

Path control

pathName and pathIndex let you append named or indexed segments to the validation path manually. They are covered in depth on the ValidationContext & ValidationPath page.

Next steps

  • Built-in Rules — full reference for the rules available out of the box

Clone this wiki locally