Core Java

Records and Compact Constructors: Where Validation Logic Actually Belongs

Records hand you equals, hashCode, and toString for free. They don’t hand you immutability for free the moment an array shows up as a component, and the compact constructor is where that gap needs to be closed.

Java records are sold on brevity: declare the components once, and the compiler writes the constructor, accessors, equals, hashCode, and toString for you. That pitch is mostly true, but it quietly assumes every component is either primitive or itself immutable. The moment a record wraps an array, that assumption breaks, and the fix has to live in exactly one place: the compact constructor.

What a Compact Constructor Actually Is

A compact constructor is a shorthand form of the canonical constructor that a record generates automatically. Instead of writing out every parameter and every this.field = field assignment, you write only the validation or normalization logic, using the component names directly as if they were local parameters. The compiler still performs the field assignments afterward, automatically, using whatever values those parameter names hold at the end of the compact constructor body.

That last detail is the part worth sitting with, because it’s also where the first pitfall hides.

Pitfall One: Forgetting to Reassign the Parameter

Inside a compact constructor, the component names behave like ordinary parameters, not fields. If you compute a normalized or trimmed value, you have to assign it back to that parameter name, or the normalization silently never happens. The final field assignment always uses whatever the parameter holds at the end of the block, not whatever local variable you happened to compute along the way.

public class SilentMistake {

    record Email(String address) {
        Email {
            // Computes a trimmed, lowercased value but never assigns it back.
            String normalized = address.trim().toLowerCase();
            // Missing: address = normalized;
        }
    }

    record EmailFixed(String address) {
        EmailFixed {
            address = address.trim().toLowerCase(); // reassigns the implicit parameter
        }
    }

    public static void main(String[] args) {
        Email e = new Email("  User@Example.COM  ");
        System.out.println("[buggy]  stored value: '" + e.address() + "'");

        EmailFixed f = new EmailFixed("  User@Example.COM  ");
        System.out.println("[fixed]  stored value: '" + f.address() + "'");
    }
}

Output[buggy] stored value: ‘ User@Example.COM ‘ [fixed] stored value: ‘user@example.com’

Nothing throws an exception here. The record compiles cleanly, tests that only check for a non-null value pass, and the bug simply sits there until someone notices two “different” email addresses are actually the same person.

Pitfall Two: Array Components and the Illusion of Immutability

This is the pitfall that causes the most damage in practice, because it looks completely safe on the surface. A record with an array-typed component still stores only a reference to that array, exactly like any other class would. If you don’t copy the array on the way in, the caller’s original array and the record’s internal state are the same object in memory.

import java.util.Arrays;

public class Broken {

    record Team(String name, String[] members) {
        // No compact constructor - array is stored by reference.
    }

    public static void main(String[] args) {
        String[] roster = {"Ana", "Ben", "Cid"};
        Team team = new Team("Falcons", roster);

        System.out.println("Before external mutation: " + Arrays.toString(team.members()));

        // The caller still holds the original array reference.
        roster[0] = "HACKED";

        System.out.println("After external mutation:  " + Arrays.toString(team.members()));

        // The accessor also leaks the live internal array.
        team.members()[1] = "ALSO HACKED";
        System.out.println("After accessor mutation:  " + Arrays.toString(team.members()));
    }
}

OutputBefore external mutation: [Ana, Ben, Cid] After external mutation: [HACKED, Ben, Cid] After accessor mutation: [HACKED, ALSO HACKED, Cid]

Notice there are two separate leaks here, not one. The first happens because the constructor never copied the incoming array. The second happens because the generated accessor hands back the exact same array reference it stored internally, so even a caller who never touched the original array can still reach in and mutate the record’s state directly through the getter.

The Fix: Defensive Copying on the Way In and the Way Out

Closing this gap takes two separate steps, and skipping either one leaves a hole. The compact constructor needs to clone the incoming array before storing it, and the accessor needs to be overridden to clone the array again before returning it.

import java.util.Arrays;

public class Fixed {

    record Team(String name, String[] members) {

        // Compact constructor: validates and defensively copies.
        Team {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name must not be blank");
            }
            if (members == null) {
                throw new IllegalArgumentException("members must not be null");
            }
            members = members.clone(); // copy on the way IN
        }

        // Overridden accessor: copy on the way OUT too.
        @Override
        public String[] members() {
            return members.clone();
        }
    }

    public static void main(String[] args) {
        String[] roster = {"Ana", "Ben", "Cid"};
        Team team = new Team("Falcons", roster);

        System.out.println("Before external mutation: " + Arrays.toString(team.members()));

        roster[0] = "HACKED";
        System.out.println("After external mutation:  " + Arrays.toString(team.members()));

        team.members()[1] = "ALSO HACKED";
        System.out.println("After accessor mutation:  " + Arrays.toString(team.members()));

        try {
            new Team("", roster);
        } catch (IllegalArgumentException e) {
            System.out.println("Validation caught: " + e.getMessage());
        }
    }
}

OutputBefore external mutation: [Ana, Ben, Cid] After external mutation: [Ana, Ben, Cid] After accessor mutation: [Ana, Ben, Cid] Validation caught: name must not be blank

With both copies in place, the record’s internal array is a private detail again. Neither the original caller’s array nor the value returned by the accessor can reach back into the record’s real state. The validation logic sits comfortably in the same block, since a compact constructor is allowed to both validate and transform in one pass.

Where Each Kind of Logic Belongs

Records support more than one place to attach behavior, and it’s easy to reach for the wrong one out of habit. The table below is a quick reference for what actually belongs where.

Logic TypeCorrect LocationWhy
Null and range validationCompact constructorRuns on every construction path, including deserialization frameworks that call the canonical constructor
Normalization (trimming, case folding)Compact constructorMust reassign the parameter so the generated field assignment picks up the new value
Defensive copy of mutable componentsCompact constructor AND accessor overrideThe constructor protects against external mutation on the way in; the accessor protects against it on the way out
Derived, non-stored valuesA separate instance methodCompact constructors can only affect stored components, not add new derived fields

Quick Checklist: Compact Constructor Review

  • Does every normalization step reassign the component name, not just a local variable?
  • Does every array, List, Set, Map, or Date-typed component get defensively copied on the way in?
  • Is the accessor for each mutable-typed component overridden to copy on the way out as well?
  • Are null checks placed in the compact constructor rather than scattered across call sites?
  • Would a reflection-based framework calling the canonical constructor directly still trigger this validation?

What We Learned

Compact constructors are the one place guaranteed to run on every construction path, which makes them the natural home for validation and normalization. But that guarantee only protects the fields themselves, not whatever mutable objects those fields might point to. Array-typed components are the clearest example: without an explicit clone on the way in and an overridden accessor on the way out, a record that looks immutable on paper can still be silently rewritten from outside. Records remove a lot of boilerplate, but they don’t remove the need to think about who else is holding a reference to your data.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button