DEV Community

Alexey Gavrilov
Alexey Gavrilov

Posted on • Edited on

Java Records Constructor Validation: Beyond the Boilerplate

Java Records, finalized in Java 16, provide a concise way to create immutable data carriers. But what happens when your clean Record needs to validate its data? Suddenly, that elegant one-liner becomes a validation challenge.

Let me show you three approaches to Record validation I've encountered in production code, and why I ended up building a new solution.

The Problem: Real-World Records Need Validation

Consider this simple user registration scenario:

public record User(String name, String email, int age) {}
Enter fullscreen mode Exit fullscreen mode

Clean, right? But in production, you need validation:

  • Name: 2-50 characters, not null or empty
  • Email: valid format, not null
  • Age: 0-120, reasonable range

Suddenly, your elegant Record isn't so simple anymore.

Approach 1: Manual Validation (The Verbose Way)

The most straightforward approach is manual validation in the compact constructor:

public record User(String name, String email, int age) {
    public User {
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("Name cannot be null or empty");
        }
        if (name.length() < 2 || name.length() > 50) {
            throw new IllegalArgumentException("Name must be between 2 and 50 characters");
        }
        if (email == null || email.trim().isEmpty()) {
            throw new IllegalArgumentException("Email cannot be null or empty");
        }
        if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("Invalid email format");
        }
        if (age < 0 || age > 120) {
            throw new IllegalArgumentException("Age must be between 0 and 120");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • 15+ lines of boilerplate for simple validation
  • Repetitive null/empty checks
  • Hard to read the actual business rules
  • Error messages are inconsistent
  • Stops at first error (no way to collect all validation issues)

Approach 2: Bean Validation Annotations (The Framework Way)

Let's try Jakarta Bean Validation (formerly JSR 303):

public record User(
    @NotBlank @Size(min = 2, max = 50) String name,
    @NotBlank @Email String email,
    @Min(0) @Max(120) int age
) {
    public User {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<User>> violations = validator.validate(this);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Wait, there's a problem! This code looks clean but doesn't actually work. In the compact constructor, when you call validator.validate(this), the Record instance hasn't been fully constructed yet—the fields are not initialized with the parameter values. The validator sees default values (null, 0) instead of your actual parameters.

To make Bean Validation work properly, you'd need to move validation outside the constructor:

public record User(
    @NotBlank @Size(min = 2, max = 50) String name,
    @NotBlank @Email String email,
    @Min(0) @Max(120) int age
) {
    // No validation in constructor!
    public static User createValidated(String name, String email, int age) {
        // Create instance first
        User user = new User(name, email, age);
        // Then validate it
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<User>> violations = validator.validate(user);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
        return user;
    }
}
Enter fullscreen mode Exit fullscreen mode

But now you have a fundamental problem: you can still create invalid User records directly with new User("", "invalid", -5) and bypass validation entirely. The constructor is public and unvalidated!

Problems:

  • Complex setup for simple validation
  • Heavy dependencies (2+ MB of JARs)
  • Doesn't work with simple validate(this) approach
  • Either move validation outside constructor (breaks immutability guarantee) or complex workarounds
  • Reflection-based (performance overhead)
  • Overkill for basic parameter checking

Advanced Bean Validation Solution

There is a way to make Bean Validation work properly with Records, but it requires significant complexity. Gunnar Morling's approach uses ByteBuddy for compile-time byte code enhancement:

  • Write a custom ByteBuddy plugin
  • Configure Maven build-time enhancement
  • Implement validation interceptors
  • Set up proper constructor parameter validation

While this works, it adds substantial complexity to your build process and requires deep knowledge of byte code manipulation. For most teams, this level of complexity isn't justified for basic parameter validation.

Approach 3: Lightweight Fluent Validation (The Pragmatic Way)

After wrestling with both approaches in different projects, I wanted something that was:

  • Explicit and readable
  • Zero dependencies
  • Fast (no reflection)
  • Designed for constructor validation
  • Capable of collecting all errors or failing fast

This led me to create ValidCheck:

public record User(String name, String email, int age) {
    public User {
        ValidCheck.require()
            .notNullOrEmpty(name, "name")
            .hasLength(name, 2, 50, "name")
            .matches(email, "(?i)^[\\w._%+-]+@[\\w.-]+\\.[A-Z]{2,}$", "email")
            .inRange(age, 0, 120, "age");
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

// Valid user - no exceptions
User user = new User("John", "john@example.com", 25);

// Invalid user - clear error message
User invalid = new User("", "invalid", -5);
// Throws: ValidationException: 'name' must not be empty
Enter fullscreen mode Exit fullscreen mode

For collecting all validation errors:

public record CreateUserRequest(String name, String email, Integer age, String phone) {
    public CreateUserRequest {
        ValidCheck.check()
            .notNull(name, "name")
            .hasLength(name, 1, 100, "name")
            .matches(email, "(?i)^[\\w._%+-]+@[\\w.-]+\\.[A-Z]{2,}$", "email")
            .inRange(age, 0, 120, "age")
            .when(phone != null,
                v -> v.matches(phone, "\\d{10}", "phone", "must be 10 digits"))
            .validate(); // Throws with all errors if any validation failed
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: API DTOs

Here's how ValidCheck looks with a realistic API request object:

public record CreateOrderRequest(
    String customerId,
    List<OrderItem> items,
    String shippingAddress,
    PaymentMethod paymentMethod
) {
    public CreateOrderRequest {
        ValidCheck.require()
            .notNullOrEmpty(customerId, "customerId")
            .matches(customerId, "[A-Z0-9]{8}", "customerId")
            .notNullOrEmpty(items, "items")
            .hasLength(shippingAddress, 10, 200, "shippingAddress")
            .notNull(paymentMethod, "paymentMethod");
    }
}
Enter fullscreen mode Exit fullscreen mode

The validation reads like documentation of your business rules.

IDE-First Design

ValidCheck's API excels at IDE integration, making validation code practically write itself through intelligent autocomplete. As you type ValidCheck.require()., your IDE suggests only relevant validation methods - starting with notNull(), then flowing naturally to type-specific options like hasLength() for strings or inRange() for numbers. After selecting a method, the first parameter is always the value you want to validate, so you naturally type the variable name you're checking - no mental mapping required.

This guided discovery means you rarely need to memorize method names or check documentation. The method chaining reads like natural language, making complex validation both discoverable and maintainable while eliminating the cognitive overhead of remembering syntax.

Conclusion

Java Records provide a clean way to create immutable data carriers, but validation can quickly turn elegant code into verbose boilerplate.

Choose manual validation when you have simple rules and want maximum control.

Choose Bean Validation when you're already invested in the Jakarta ecosystem or need complex validation features.

Consider a lightweight alternative like ValidCheck when you want explicit, readable validation without the framework overhead.

The goal isn't to have the most features—it's to have code that's easy to read, maintain, and debug when things go wrong.

What's your experience with Record validation? Have you found other approaches that work well? I'd love to hear about them in the comments.


About ValidCheck: It's a zero-dependency Java validation library designed specifically for constructor and method parameter validation. You can find it on GitHub or add it to your Maven project:

    <dependency>
      <groupId>io.github.ag-libs.validcheck</groupId>
      <artifactId>validcheck</artifactId>
      <version>0.9.7</version>
    </dependency>
Enter fullscreen mode Exit fullscreen mode

This blog post was written with AI assistance for editing and structure.

Top comments (0)