Akkurate Validation Library

repository·main·Indexed 19 days ago

https://github.com/nesk/akkurate

A 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.

Tokens
14.6K
Snippets
47
Records
60
Agent score
64%

What's inside Akkurate

  1. Overview of Akkurate validation library

    main
    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.
  2. Core features of Akkurate

    main

    Akkurate 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.
  3. Key features of Akkurate

    main

    Akkurate 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.
  4. Use contextual validation to access external sources

    main

    Akkurate 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)
  5. How scope control affects DSL usage in v0.7.0+

    main

    Since version 0.7.0, Akkurate uses scope control (DSL markers) to prevent implicit references to outer receivers. When nesting validation blocks (e.g., using each inside 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()
            }
        }
    }
  6. Compare values using `Validatable` equality

    main

    When comparing two values (e.g., checking if a property matches a field in the parent object), you can use the == operator directly on Validatable<T> objects. Validatable<T> implements equals and hashCode as 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" }
        }
    }
  7. Bind validation results to Raise computation

    main

    When working inside an Arrow either { ... } block (a Raise computation), you can use the bind() function to seamlessly integrate Akkurate validations.

    Instead of manually converting results to Either and handling errors with when or throw, 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")
    }
  8. How `ConstraintViolationSet.equals` behaves in v0.9.0+

    main

    Starting from version 0.9.0, the equals implementation for ConstraintViolationSet is symmetric. This means that comparing a ConstraintViolationSet to a standard Kotlin Set<ConstraintViolation> will return the same result regardless of which object is on the left-hand side of the equals call. 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
    }
  9. Handle nullable types in the DSL

    main

    The 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 author is Author?, then author.user becomes Validatable<User?> and author.user.emailAddress becomes Validatable<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 the isNotNull() 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()
        }
    }
  10. Apply conditional constraints based on values or previous results

    main

    You can use standard Kotlin control flow (if statements) 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 .satisfied property 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().satisfied

    Validator<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" }
        }
    }
  11. Reuse validators through composition

    main

    To 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 (using each).

    @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) }
    }
  12. How Akkurate validation works

    main

    Akkurate validation relies on two primary components:

    1. @Validate annotation: Applied to a data class to mark it for the KSP compiler plugin. This triggers the generation of validatable accessors.
    2. Validator<T> interface: Used to define the actual validation rules via a DSL.

    When you annotate a class with @Validate and build the project (e.g., ./gradlew build), the compiler generates accessors that act as a bridge between the Validatable<T> DSL and your class properties. These accessors allow you to reference properties (like title or releaseDate) directly inside the Validator lambda.