Akkurate Validation Library
repository·main·Indexed 19 days ago
https://github.com/nesk/akkurateA declarative, type-safe validation library for Kotlin that replaces annotation-based validation with an expressive DSL. Akkurate supports Kotlin Multiplatform, asynchronous external data lookups, and complex business logic. Key features include a wide range of built-in constraints, custom violation messages and paths, fail-fast validation, and integration with the Arrow functional programming library via the akkurate-arrow dependency.
What's inside Akkurate
- Akkurate is a Kotlin-based validation library designed to handle complex business logic through a declarative DSL. Instead of using numerous annotations or complex custom constraints, Akkurate allows you to write validation rules directly in Kotlin code. It is designed to be maintainable, expressive, and supports Kotlin Multiplatform for use in both front-end and back-end environments.
Core features of Akkurate
mainAkkurate provides several key capabilities for developers:
- Declarative DSL: Write clear, readable validation code using loops and conditions instead of annotations.
- Essential Constraints: Includes a wide range of built-in constraints so you only write custom logic when necessary.
- Extensibility: Custom constraints can be implemented easily as simple lambdas.
- Contextual & Asynchronous: Validation rules can query external data sources (databases, REST APIs) both synchronously and asynchronously.
- Kotlin Multiplatform: Write validation logic once and deploy it across different platforms (front-end and back-end).
- Testability: Built-in support for testing validation logic.
Key features of Akkurate
mainAkkurate provides several core capabilities for validation:
- Declarative DSL: Write clear, DRY validation code using Kotlin syntax.
- Essential Constraints: Includes a wide range of built-in constraints so you only need to write custom logic for specific business rules.
- Extensibility: Custom constraints can be implemented easily as simple lambdas.
- Contextual & Asynchronous Support: Validation can include asynchronous calls to external data sources like databases or REST APIs.
- Kotlin Multiplatform: Write validation logic once and deploy it across different platforms (front-end and back-end).
- Testability: Designed to be easily testable out of the box.
Use contextual validation to access external sources
mainAkkurate allows you to pass external dependencies (like DAOs, APIs, or files) into your validation logic using contextual validation. Instead of using global variables, you define a validator with a specific context type using
Validator<ContextType, ValueType>.The context type is specified first, followed by the value type. The context instance is provided as the first parameter of the validation lambda.
interface UserDao { fun existsByUsername(username: String): Boolean } @Validate data class UserUpdate(val username: String) // Validator<ContextType, ValueType> val validateUser = Validator<UserDao, UserUpdate> { userDao -> // 'userDao' is the context provided at runtime val (isValidUsername) = username.hasLengthGreaterThanOrEqualTo(5) if (isValidUsername) { username.constrain { !userDao.existsByUsername(it) } otherwise { "This username is already taken" } } } // Usage: Provide the context instance before the value to validate val someUserDao: UserDao = TODO() val someUserUpdate: UserUpdate = TODO() validateUser(someUserDao, someUserUpdate)How scope control affects DSL usage in v0.7.0+
mainSince version 0.7.0, Akkurate uses scope control (DSL markers) to prevent implicit references to outer receivers. When nesting validation blocks (e.g., using
eachinside a property block), you can no longer implicitly call methods belonging to the outer scope if they conflict with the current scope. If you need to apply a constraint to the property itself rather than the elements within it, you must ensure the call is correctly scoped.@Validate data class Book(val title: String, val labels: List<String>) val validateBook = Validator<Book> { title.isNotBlank() labels { // Apply constraints to the 'labels' property itself hasSizeLowerThan(10) each { // Apply constraints to each element in the list isNotBlank() } } }Compare values using `Validatable` equality
mainWhen comparing two values (e.g., checking if a property matches a field in the parent object), you can use the
==operator directly onValidatable<T>objects.Validatable<T>implementsequalsandhashCodeas pass-through methods to the underlying value, so you do not need to call.unwrap()manually for equality checks.Validator<Library> { val library = this for (book in books) { constrain { // No need for .unwrap() on both sides book.height == library.shelfHeight } otherwise { "Book height must be equal to shelf height" } } }Bind validation results to Raise computation
mainWhen working inside an Arrow
either { ... }block (a Raise computation), you can use thebind()function to seamlessly integrate Akkurate validations.Instead of manually converting results to
Eitherand handling errors withwhenorthrow,bind()allows you to work directly with the validation results. If a validation fails,bind()will automatically short-circuit the computation and return the error, allowing you to focus on the 'happy path' logic.either { // bind() automatically handles the conversion and short-circuits on failure val book = bind(validateBook(Book("The Lord of the Rings"))) val author = bind(validateAuthor(Author("J.R.R. Tolkien"))) // This code only executes if both validations succeed println("Validated book: $book") println("Validated author: $author") }How `ConstraintViolationSet.equals` behaves in v0.9.0+
mainStarting from version 0.9.0, the
equalsimplementation forConstraintViolationSetis symmetric. This means that comparing aConstraintViolationSetto a standard KotlinSet<ConstraintViolation>will return the same result regardless of which object is on the left-hand side of theequalscall. This fixes a previous bug where the comparison was not symmetric.// After v0.9.0 fun compare( standardSet: Set<ConstraintViolation>, constraintViolationSet: ConstraintViolationSet ) { standardSet.equals(constraintViolationSet) // ✅ true constraintViolationSet.equals(standardSet) // ✅ true }Handle nullable types in the DSL
mainThe Akkurate DSL handles nullability automatically during path traversal. If a property is nullable, the nullability propagates to all child properties in the path.
Nullability Propagation
If
authorisAuthor?, thenauthor.userbecomesValidatable<User?>andauthor.user.emailAddressbecomesValidatable<String?>.Constraint Behavior on Nulls
By default, if a property in the path is
null, the constraint will always succeed. To force a property to be non-null, you must explicitly use theisNotNull()constraint.Validator<Book> { author.user.emailAddress { isNotNull() // Fails if the value is null isNotEmpty() // Only checked if not null } }@Validate data class Book(val author: Author?) Validator<Book> { author.user.emailAddress { isNotNull() isNotEmpty() } }Apply conditional constraints based on values or previous results
mainYou can use standard Kotlin control flow (
ifstatements) to apply constraints only when certain conditions are met. This is useful for handling optional constraints or avoiding unnecessary operations (like database queries) when a prerequisite constraint fails.Conditional logic based on unwrapped values
To use a value in a condition, you must unwrap it first:
Validator<Library> { val (max) = maximumCapacity if (max > 0) { books.hasSizeLowerThanOrEqualTo(max) otherwise { "Too many books" } } }Conditional constraints based on previous validation results
You can check if a previous constraint was satisfied using the
.satisfiedproperty or via destructuring. This allows you to chain validations where the second only runs if the first passes.Destructuring (Recommended):
val (isOk) = property.constraint()Property access:
val isOk = property.constraint().satisfiedValidator<UserUpdate> { // Retrieve the satisfied status of the constraint via destructuring val (isValidUsername) = username.hasLengthGreaterThanOrEqualTo(5) // Run the database check only if the last constraint succeeded if (isValidUsername) { username.constrain { !userDao.existsByUsername(it) } otherwise { "This username is already taken" } } }Reuse validators through composition
mainTo avoid repeating the same validation logic across different models, you can compose validators. You can call an existing
Validator<T>instance on a property using the.validateWith()method. This works for both single objects and collections (usingeach).@Validate data class Book(val author: Person, val reviewers: Set<Person>) @Validate data class Person(val fullName: String) val validatePerson = Validator<Person> { fullName.isNotEmpty() } val validateBook = Validator<Book> { author.validateWith(validatePerson) reviewers.each { validateWith(validatePerson) } }How Akkurate validation works
mainAkkurate validation relies on two primary components:
@Validateannotation: Applied to a data class to mark it for the KSP compiler plugin. This triggers the generation of validatable accessors.Validator<T>interface: Used to define the actual validation rules via a DSL.
When you annotate a class with
@Validateand build the project (e.g.,./gradlew build), the compiler generates accessors that act as a bridge between theValidatable<T>DSL and your class properties. These accessors allow you to reference properties (liketitleorreleaseDate) directly inside theValidatorlambda.