Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

validy-io/validy

Open more actions menu

Repository files navigation

Java 24 Spring Boot 4 Jakarta EE 11 MIT License Zero Dependencies

 ██╗   ██╗ █████╗ ██╗     ██╗██████╗ ██╗   ██╗
 ██║   ██║██╔══██╗██║     ██║██╔══██╗╚██╗ ██╔╝
 ██║   ██║███████║██║     ██║██║  ██║ ╚████╔╝
 ╚██╗ ██╔╝██╔══██║██║     ██║██║  ██║  ╚██╔╝
  ╚████╔╝ ██║  ██║███████╗██║██████╔╝   ██║
   ╚═══╝  ╚═╝  ╚═╝╚══════╝╚═╝╚═════╝   ╚═╝

Elegant, composable validation for Java 24

Fluent DSL · Sealed results · Validation groups · Spring Boot 4 ready · Zero dependencies


Getting Started · DSL Reference · Spring Boot · Modules · Examples


Why Validy?

Java validation has long meant one of two things: annotation soup on your domain objects, or writing the same if (x == null) checks in every service method. Validy takes a different approach.

// Your domain object stays clean — no annotations
record User(String name, String email, int age, Address address) {}

// Rules live where they belong — in a dedicated validator
var validator = validator(User.class)
    .field("name",    User::name,    notBlank(), maxLength(100))
    .field("email",   User::email,   notBlank(), email())
    .field("age",     User::age,     between(18, 120))
    .nested("address", User::address, addressValidator())
    .build();

// Results are sealed — the compiler forces you to handle both cases
switch (validator.validate(user)) {
    case Valid   v -> proceed(user);
    case Invalid e -> respond(e.summary());
}

No reflection. No annotation processors. No classpath scanning. Plain Java 24.


✨ Highlights

🧩 Composable rules Combine with .and(), .or(), .negate()
🏷️ Validation groups Scope rules to OnCreate, OnUpdate, or any custom phase
🔗 Nested validation Embed validators for child objects — errors prefixed automatically
📋 Collection elements Validate every item in a List or Set with eachElement()
🔒 Sealed results Valid / Invalid — pattern matching, no unchecked casts
🌱 Spring Boot 4 Drop-in integration — @Validated just works, RFC 7807 responses
🪶 Zero dependencies core module is pure Java 24, nothing else
🧪 Testable by design Validators are plain objects — new them in unit tests, no context needed

📦 Modules

validy/
├── core/                     Pure Java 24 — DSL, rules, sealed result type
├── spring/                   Spring Boot 4 auto-configuration
└── samples/
    └── spring-boot-sample/   Full REST API demonstrating all features

core

The heart of the library. Zero dependencies — just Java 24 sealed types, records, and functional interfaces. Use this alone if you're not in a Spring environment.

spring

Spring Boot 4 integration. Implements Spring's Validator SPI so @Validated on @RequestBody works automatically. Provides RFC 7807 Problem Detail error responses out of the box.

samples/spring-boot-sample

A fully working Spring Boot 4 REST API showing every feature: @Validated controllers, service-layer manual validation, custom reusable rules, nested validators, and collection element validation.


🚀 Getting Started

Gradle (Kotlin DSL)

// Core only — no Spring required
implementation("io.validy:core:1.0.0")

// Spring Boot 4 integration
implementation("io.validy:spring:1.0.0")

Maven

<!-- Core only -->
<dependency>
    <groupId>io.validy</groupId>
    <artifactId>core</artifactId>
    <version>1.0.0</version>
</dependency>

<!-- Spring Boot 4 integration -->
<dependency>
    <groupId>io.validy</groupId>
    <artifactId>spring</artifactId>
    <version>1.0.0</version>
</dependency>

📖 DSL Reference

Defining a validator

import static validy.core.Valify.*;

var userValidator = validator(User.class)
    .field("name",  User::name,  notBlank(), maxLength(100))
    .field("email", User::email, notBlank(), email())
    .field("age",   User::age,   between(18, 120))
    .build();

Running validation

// Basic — runs all Default rules
ValidationResult result = userValidator.validate(user);

// With groups — runs Default + specified groups
ValidationResult result = userValidator.validate(user, OnCreate.class);

// Pattern matching on the result
switch (result) {
    case Valid   v -> System.out.println("All good!");
    case Invalid e -> System.err.println(e.summary());
}

// Callbacks
result.ifValid(() -> persist(user));
result.ifInvalid(e -> log.warn(e.summary()));

// Boolean check
if (result.isValid()) { ... }

Custom rules

Rules are plain functional interfaces — a lambda, method reference, or class all work:

// Inline
Rule<String> strongPassword = minLength(8)
    .and(matches(".*[A-Z].*").withMessage("$", "must contain an uppercase letter"))
    .and(matches(".*[0-9].*").withMessage("$", "must contain a digit"))
    .and(matches(".*[!@#$%^&*].*").withMessage("$", "must contain a special character"));

// Reusable static method
static Rule<String> ukPostcode() {
    return matches("^[A-Z]{1,2}\\d[\\dA-Z]? ?\\d[A-Z]{2}$")
        .withMessage("$", "must be a valid UK postcode");
}

// From a predicate + message
Rule<User> adultOnly = rule(u -> u.age() >= 18, "must be 18 or older");

Composing rules

// Both must pass — all errors collected
Rule<String> name = notBlank().and(maxLength(100));

// First success wins — short-circuits
Rule<String> id = uuid().or(numeric());

// Invert
Rule<String> notAnEmail = email().negate("must NOT be an email");

// Adapt to a different type
Rule<String> noSwearWords = ...;
Rule<Comment> cleanComment = noSwearWords.contramap(Comment::body);

Nested objects

var addressValidator = validator(Address.class)
    .field("street", Address::street, notBlank())
    .field("city",   Address::city,   notBlank())
    .field("zip",    Address::zip,    matches("^\\d{5}$"))
    .build();

var userValidator = validator(User.class)
    .field("name", User::name, notBlank())
    .nested("address", User::address, addressValidator)
    // child error "zip" surfaces as "address.zip"
    .build();

Collection element validation

// Validate each string element
.field("tags", Post::tags, notEmpty(), eachElement(notBlank()))

// Validate each object with its own validator
.field("addresses", User::addresses, notEmpty(), eachElement(addressValidator))

// Errors are indexed automatically:
// "[0].zip"    → first address, zip field
// "[1]"        → second element (object-level error)
// "[2].street" → third address, street field

Validation groups

// 1. Declare groups — plain interfaces, no annotations, no registration
public interface OnCreate extends ValidationGroup {}
public interface OnUpdate extends ValidationGroup {}

// 2. Assign rules to groups
var validator = validator(User.class)
    .field("name",     User::name,     notBlank())                          // Default — always
    .field("email",    User::email,    notBlank(), email())                 // Default — always
    .field("password", User::password, strongPassword()).groups(OnCreate.class) // create only
    .field("id",       User::id,       notNull()).groups(OnUpdate.class)        // update only
    .rule(u -> u.password().equals(u.confirm())
        ? valid() : invalid("confirm", "must match")).groups(OnCreate.class)
    .build();

// 3. Run with the right group
validator.validate(user);                    // Default only
validator.validate(user, OnCreate.class);   // Default + OnCreate
validator.validate(user, OnUpdate.class);   // Default + OnUpdate
validator.validate(user, OnCreate.class, OnPublish.class); // multiple groups

🛠 Built-in Rules

String

Rule Description
notBlank() Not null and not whitespace-only
notNull() Not null
minLength(n) / maxLength(n) Length bounds
length(min, max) Combined length bounds
email() Valid email address
url() Valid http/https URL
uuid() Valid UUID
matches(regex) Custom regex pattern
numeric() Digits only
alpha() Letters only
oneOf(values...) Whitelist
startsWith(s) / endsWith(s) Prefix / suffix

Number

Rule Description
min(n) Value ≥ n — works with any Comparable
max(n) Value ≤ n
between(min, max) Value in [min, max]
positive() Integer > 0
nonNegative() Integer ≥ 0

Collection

Rule Description
notEmpty() Collection is not empty
minSize(n) At least n elements
maxSize(n) At most n elements
eachElement(rule) Every element passes the rule — errors indexed as [0], [1].field

Temporal (Instant)

Rule Description
future() Strictly after now
futureOrPresent() Now or after
past() Strictly before now
after(ref) After a fixed instant
before(ref) Before a fixed instant

🌱 Spring Boot Integration

1 — Register your validators

@Configuration
class ValifyConfig {

    @Bean
    ValidatorRegistry validatorRegistry() {
        return ValidatorRegistry.builder()
            .register(CreateUserRequest.class, UserValidators.createUser())
            .register(UpdateUserRequest.class, UserValidators.updateUser())
            .build();
    }

    @Bean
    ValidationExceptionHandler validationExceptionHandler() {
        return new ValidationExceptionHandler();
    }
}

2 — Use @Validated in controllers

@RestController
@RequestMapping("/users")
class UserController {

    @PostMapping
    ResponseEntity<UserResponse> create(@RequestBody @Validated CreateUserRequest req) {
        // Validation passed — proceed
        return ResponseEntity.ok(userService.create(req));
    }
}

3 — Automatic RFC 7807 error responses

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type":   "https://validy.io/problems/validation-error",
  "title":  "Validation Failed",
  "status": 422,
  "detail": "3 constraint(s) violated",
  "errors": {
    "email":          ["must be a valid email address"],
    "name":           ["must not be blank"],
    "address.zip":    ["must be a valid US ZIP code"]
  }
}

No additional configuration — ValidationExceptionHandler handles this automatically.

Auto-configuration

The Spring module registers itself via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. It activates only when a ValidatorRegistry bean is present — zero impact if you don't define one.


💡 Examples

Reusable rule library

public final class AppRules {

    public static Rule<String> strongPassword() {
        return minLength(8)
            .and(matches(".*[A-Z].*").withMessage("$", "must contain an uppercase letter"))
            .and(matches(".*[0-9].*").withMessage("$", "must contain a digit"))
            .and(matches(".*[!@#$%^&*].*").withMessage("$", "must contain a special character"));
    }

    public static Rule<String> usZip() {
        return matches("^\\d{5}(-\\d{4})?$")
            .withMessage("$", "must be a valid US ZIP code");
    }

    public static Rule<String> slug() {
        return matches("^[a-z0-9]+(?:-[a-z0-9]+)*$")
            .withMessage("$", "must be a valid URL slug");
    }
}

Multi-step form validation

interface Step1 extends ValidationGroup {}
interface Step2 extends ValidationGroup {}
interface Step3 extends ValidationGroup {}

var signupValidator = validator(SignupForm.class)
    .field("name",  SignupForm::name,  notBlank()).groups(Step1.class)
    .field("email", SignupForm::email, email())   .groups(Step1.class)
    .field("street", SignupForm::street, notBlank()).groups(Step2.class)
    .field("zip",    SignupForm::zip,    usZip())  .groups(Step2.class)
    .field("card",   SignupForm::card,   notBlank()).groups(Step3.class)
    .build();

// Each step validates only what the user has filled in so far
signupValidator.validate(form, Step1.class);
signupValidator.validate(form, Step1.class, Step2.class);
signupValidator.validate(form, Step1.class, Step2.class, Step3.class);

Service-layer validation (no HTTP)

@Service
class OrderService {

    private final Validator<CreateOrderRequest> validator = OrderValidators.create();

    public Order createOrder(CreateOrderRequest request) {
        switch (validator.validate(request, OnCreate.class)) {
            case Valid   v -> { return persist(request); }
            case Invalid e -> throw new ValidationException(e.errors());
        }
    }
}

Unit testing validators

class UserValidatorTest {

    // No Spring context — validators are plain objects
    private final Validator<CreateUserRequest> validator = UserValidators.createUser();

    @Test
    void blank_name_fails_on_name_field() {
        var req    = new CreateUserRequest("", "a@b.com", 25, "P@ssw0rd!", "P@ssw0rd!");
        var result = validator.validate(req, OnCreate.class);

        assertThat(result.isValid()).isFalse();
        result.ifInvalid(e ->
            assertThat(e.errors()).anyMatch(err -> err.field().equals("name"))
        );
    }

    @Test
    void valid_request_passes() {
        var req = new CreateUserRequest("Alice", "alice@example.com", 30, "P@ssw0rd!", "P@ssw0rd!");
        assertThat(validator.validate(req, OnCreate.class).isValid()).isTrue();
    }
}

🗺 Roadmap

  • GroupSequence — ordered phase execution (gate expensive rules behind cheap ones)
  • LocalDate / LocalDateTime rule set
  • BigDecimal precision and scale rules
  • Fail-fast mode (stop at first error)
  • I18n / message bundle support

🤝 Contributing

Contributions are welcome. Please open an issue before submitting a pull request for significant changes.

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/my-feature
  3. Commit your changes: git commit -m 'feat: add my feature'
  4. Push to the branch: git push origin feat/my-feature
  5. Open a pull request

📄 License

Validy is released under the MIT License.


Built with ☕ and a deep dislike of annotation-driven validation

validy-io/validy

About

Elegant, composable validation library for Java 24 — fluent DSL, sealed results, zero dependencies, Spring Boot ready

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

Morty Proxy This is a proxified and sanitized view of the page, visit original site.