Compact Constructors and Validation

Use compact constructors to validate, normalise and defensively copy record components so an invalid record can never exist.

Syntaxrecord Name(Type a, Type b) { Name { // validate or reassign a and b; fields are assigned after this block } }

Every record has a canonical constructor that takes all of its components. You can write it out in full, but usually you want the compact form: the record's name with no parameter list. Your code runs first, then the parameters are assigned to the fields for you.

record Email(String value) {
    Email {
        Objects.requireNonNull(value, "email is required");
        value = value.strip().toLowerCase(Locale.ROOT);
    }
}

Inside a compact constructor the parameters are ordinary local variables. You can check them, throw, or reassign them to normalise the value, and whatever they hold at the end is what lands in the fields. You cannot write this.value = ... there; the compiler rejects it because the assignment happens automatically.

What belongs in it

  • Null checks with Objects.requireNonNull(value, "email is required").
  • Range and format checks that throw IllegalArgumentException.
  • Normalisation: trimming, lower-casing with Locale.ROOT, clamping a page size.
  • Defensive copies: items = List.copyOf(items); makes the record truly immutable and also rejects null elements.
  • Rules across fields, such as a start date that must not be after the end date.

Other constructors must delegate

You may add constructors for defaults, but each must call another one with this(...), ending at the canonical constructor. Since Java 25, statements that do not touch this, such as parsing a raw argument, may come before that call; the call itself is still required. Every instance therefore passes through your checks. A static factory such as Email.of(raw) is often clearer than an extra constructor because it has a name.

Invariants versus request validation

A compact constructor guards invariants: once an Email exists, every method can trust it. That is not the same as validating a form. A constructor stops at the first problem, while an API client wants every bad field reported in one 400 response. Check the raw request first and collect the errors (or use Bean Validation in a framework), then build the record. The constructor check stays as a safety net for paths that skip the web layer, such as a batch import or a message consumer.

When Jackson deserialises JSON into a record it calls the canonical constructor, so your checks run there too. An exception thrown inside it arrives wrapped in a Jackson exception, which your error handler should turn into a 400, not a 500.

Common mistakes

Calling toLowerCase() without a locale gives different results on a server whose default locale is Turkish, where uppercase I does not become a plain i. Forgetting the defensive copy leaves a hole that tests rarely catch. Slow work, such as a database lookup to check that an email is unique, does not belong in a constructor; keep it to checks on the values themselves.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Normalise an email address to lower case and trim it once, so lookups and unique constraints behave consistently.
  • Clamp a requested page size to a maximum so a client cannot ask the database for a million rows.
  • Copy the list of line items in an Order record so later changes to the request object cannot alter a placed order.

More examples

Defensive copy of a list

Clearing the caller's cart no longer affects the order, and nobody can add to the record's list. The emptiness check runs on the copy, so it sees exactly what the record will hold.

Example · java
record Order(String id, List<String> skus) {
    Order {
        Objects.requireNonNull(id, "id is required");
        skus = List.copyOf(skus);
        if (skus.isEmpty()) {
            throw new IllegalArgumentException("an order needs at least one item");
        }
    }
}

void main() {
    List<String> cart = new ArrayList<>(List.of("BOOK-1", "PEN-7"));
    var order = new Order("o-100", cart);
    cart.clear();
    IO.println(order.skus());

    try {
        order.skus().add("MUG-3");
    } catch (UnsupportedOperationException e) {
        IO.println("skus cannot be changed");
    }

    try {
        new Order("o-101", List.of());
    } catch (IllegalArgumentException e) {
        IO.println(e.getMessage());
    }
}

// Output:
// [BOOK-1, PEN-7]
// skus cannot be changed
// an order needs at least one item

Clamping values and a default constructor

A client asking for 500 rows quietly gets 100, which protects the database from huge queries. The no-argument constructor must delegate with this(...), so the defaults pass through the same checks.

Example · java
record PageRequest(int page, int size) {
    static final int MAX_SIZE = 100;

    PageRequest {
        if (page < 1) {
            throw new IllegalArgumentException("page must be 1 or more, got " + page);
        }
        size = Math.clamp(size, 1, MAX_SIZE);
    }

    PageRequest() {
        this(1, 20);
    }

    int offset() {
        return (page - 1) * size;
    }
}

void main() {
    IO.println(new PageRequest());
    IO.println(new PageRequest(3, 500));
    IO.println(new PageRequest(3, 500).offset());
    try {
        new PageRequest(0, 10);
    } catch (IllegalArgumentException e) {
        IO.println(e.getMessage());
    }
}

// Output:
// PageRequest[page=1, size=20]
// PageRequest[page=3, size=100]
// 200
// page must be 1 or more, got 0

Collect every error before constructing

The problems method reports both bad fields at once, which is what a 400 response body should contain. The compact constructor still guards the same rules for any code that builds a SignUp directly.

Example · java
record SignUp(String email, int age) {
    SignUp {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("invalid email");
        }
        if (age < 13) {
            throw new IllegalArgumentException("age must be 13 or over");
        }
    }

    static List<String> problems(Map<String, String> form) {
        List<String> errors = new ArrayList<>();
        String email = form.getOrDefault("email", "");
        if (!email.contains("@")) {
            errors.add("email: must contain @");
        }
        String age = form.getOrDefault("age", "");
        if (!age.matches("\\d{1,3}")) {
            errors.add("age: must be a whole number");
        } else if (Integer.parseInt(age) < 13) {
            errors.add("age: must be 13 or over");
        }
        return errors;
    }
}

void main() {
    var bad = Map.of("email", "ada.example.com", "age", "ten");
    IO.println(SignUp.problems(bad));

    var good = Map.of("email", "[email protected]", "age", "36");
    if (SignUp.problems(good).isEmpty()) {
        IO.println(new SignUp(good.get("email"), Integer.parseInt(good.get("age"))));
    }
}

// Output:
// [email: must contain @, age: must be a whole number]
// SignUp[[email protected], age=36]

Discussion

  • Be the first to comment on this lesson.