Skip to content

Java 16 Records: Killing Boilerplate for Good

If you have ever written an immutable data class in Java the old fashioned way, you already know the pain. You declare your fields. You write a constructor. You write getters. You override equals, hashCode, and toString. And by the time you are done, you have sixty lines of code just to carry a name and an age around. Java 16 introduced the record keyword specifically to fix this, and once you see how it works you will wonder how you ever lived without it.

The Problem: A Hand Written Immutable POJO

To appreciate records you need to feel the weight of what you used to write. Suppose you need a simple, immutable User class that holds a name and an age. The requirements for a proper immutable class are:

  1. The class itself must be final so nobody can subclass it and sneak in mutable behaviour.
  2. Every field must be private final so no code outside the constructor can ever change it.
  3. A constructor must set every field at creation time because there are no setters.
  4. You expose only getter methods, no setter methods at all.
  5. You override equals and hashCode so objects with the same data compare and hash correctly, which matters the moment you drop them into a Map or a Set.
  6. You override toString so logging and debugging give you something readable.

Here is what that looks like written by hand:

java
public final class User {

    private final String name;
    private final int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return age == user.age && name.equals(user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "User[name=" + name + ", age=" + age + "]";
    }
}

Usage looks like this:

java
User user = new User("Alice", 30);
System.out.println(user.getName()); // Alice
System.out.println(user);           // User[name=Alice, age=30]

There is nothing wrong with that code. It is correct. But it is a lot of mechanical ceremony for something conceptually tiny: a bundle of two values. This is boilerplate in its purest form, and boilerplate invites copy and paste mistakes.

Enter Records

Java 16 gives you a single line that replaces the entire class above:

java
public record User(String name, int age) {}

That is it. The compiler reads that declaration and automatically generates everything you just saw: the private final fields, the constructor that takes all fields in order, the accessor methods, equals, hashCode, and toString. You create and use a User record exactly the way you used the hand written version:

java
User user = new User("Alice", 30);
System.out.println(user.name()); // Alice
System.out.println(user);        // User[name=Alice, age=30]

The syntax follows this shape:

<access> record <Name>(<field1>, <field2>, ...) { }

The fields you list in the parentheses are called record components. Everything else flows from them.

What the Compiler Actually Generates

When you compile a record, the .class file reveals what Java secretly writes for you. Going through it piece by piece is the best way to build a solid mental model.

The Record Keyword Equals a Final Class

The record keyword is shorthand for final class. You cannot subclass a record. Trying to extend User from another class will give you a compile error before you even get to run anything.

Every Record Implicitly Extends java.lang.Record

Under the hood, every record you write automatically extends java.lang.Record. The Java runtime checks getClass().getSuperclass() == Record.class to know whether it is dealing with a record or an ordinary class, and it applies record specific rules accordingly. This implicit extension is also precisely why you cannot explicitly extend another class. Java does not support multiple class inheritance, and the java.lang.Record slot is already taken.

You can, however, implement any number of interfaces. That is perfectly legal:

java
public record User(String name, int age) implements Printable, Serializable {
    // interface method implementations go here
}

Record Components Become Private Final Fields

The compiler takes each component and creates a corresponding private final field with the same name and type:

java
private final String name;
private final int age;

You never write these yourself. You never see them in your source file. They are there in the bytecode.

The Canonical Constructor

The compiler generates a constructor that takes all the record components in order, in exactly the order you declared them, and assigns each one to the corresponding field. This is called the canonical constructor. You can think of it as the single source of truth for how a record gets initialized.

java
// what the compiler generates for you
public User(String name, int age) {
    this.name = name;
    this.age = age;
}

Accessor Methods, Not Getters

For each component the compiler generates a public accessor method whose name is exactly the component name, not getName() or getAge() but name() and age(). This is a deliberate stylistic choice that separates records from ordinary JavaBeans:

java
user.name(); // not user.getName()
user.age();  // not user.getAge()

No setter methods are generated. The compiler will not let you add them either. The record is immutable by design, and the compiler enforces that.

equals, hashCode, and toString

The compiler generates all three correctly, based on the record components. equals returns true if and only if both objects are the same type and every component is equal. hashCode combines all components into a hash. toString produces a readable representation like User[name=Alice, age=30]. You get all of this for free, written correctly, without touching a single line.

You Cannot Add Extra Instance Fields

A record is described as a transparent data carrier. Just by reading the record declaration you know exactly what data it holds. That contract would break if you could hide extra instance fields inside the body. The compiler enforces it: you cannot declare additional instance fields inside a record body.

java
public record User(String name, int age) {
    private String secret; // compile error: instance fields not allowed
}

If you need another piece of data, add it to the component list:

java
public record User(String name, int age, String email) {}

Static fields are allowed, because a static field belongs to the class, not to any particular instance. Your individual User objects are still immutable. The class just happens to share some class level state:

java
public record User(String name, int age) {
    static final int MINIMUM_AGE = 0; // this is fine
}

Static fields belong to the class, not to any instance, so each individual object you create is still completely immutable. The transparency guarantee holds because the record components still define everything each instance carries.

Overriding the Canonical Constructor

Sometimes you need validation logic. Maybe age cannot be negative. You can override the canonical constructor yourself, but you must honour two rules: keep all the parameters in the same order as the components, and initialize every field.

java
public record User(String name, int age) {

    public User(String name, int age) {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        this.name = name;
        this.age = age;
    }
}

The Compact Constructor: A Shorthand for Validation

Records offer a special shorthand called the compact constructor. You skip the parameter list entirely because the compiler knows the parameters from the component list. You skip the field assignments at the end because the compiler adds them automatically. You only write the body logic you actually care about:

java
public record User(String name, int age) {

    public User {
        // no parameter list, no field assignments at the end
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        // compiler automatically appends: this.name = name; this.age = age;
    }
}

This is the compact constructor and it only works in this form. The parameter assignments at the end are injected by the compiler automatically. You cannot use this shorthand for a regular custom constructor with different parameters.

Adding Extra Constructors

You can write additional constructors with different parameter lists if you need them. The restriction is that every custom constructor must call the canonical constructor as its first statement. This guarantees that no field is ever left uninitialized:

java
public record User(String name, int age) {

    // extra constructor: only age, name defaults to "Unknown"
    public User(int age) {
        this("Unknown", age); // must delegate to canonical constructor
    }
}

If you try to skip the delegation, the compiler will not allow it. This rule exists to make sure the canonical constructor always runs and every component always gets assigned.

Access Level Rules for the Canonical Constructor

If your record is declared public, the generated canonical constructor is also public. If you override it yourself, you cannot reduce that access level. You can increase it (from package private to public, for example) but you cannot make it more restrictive than the record declaration itself:

java
// record is public, so canonical constructor cannot be package private
public record User(String name, int age) {
    User(String name, int age) { // compile error: cannot restrict access
        this.name = name;
        this.age = age;
    }
}

The rule is straightforward: you may widen access, never narrow it.

Defensive Copying for Mutable Fields

Here is a subtlety that trips people up. Making a field private final makes the reference final, not the object it points to. If a component is a mutable object like a List, someone can still modify the list contents even though they cannot reassign the reference:

java
public record User(String name, List<String> hobbies) {}

User user = new User("Alice", new ArrayList<>(List.of("reading")));
user.hobbies().add("gaming"); // this works! the list is still mutable

If you want genuine immutability, override the canonical constructor and make a defensive copy:

java
public record User(String name, List<String> hobbies) {

    public User(String name, List<String> hobbies) {
        this.name = name;
        this.hobbies = List.copyOf(hobbies); // immutable copy
    }
}

Now user.hobbies().add(...) will throw UnsupportedOperationException. The record is truly immutable from every angle. This is just something to be aware of: private final makes the reference immutable, not the object behind it.

Overriding Accessor Methods, equals, hashCode, and toString

You are not locked into what the compiler generates. You can override any of them:

java
public record User(String name, int age) {

    // custom accessor with extra logic
    public int age() {
        return Math.max(age, 0);
    }

    @Override
    public String toString() {
        return "User(" + name + ", age " + age + ")";
    }
}

This is handy when you need to adjust formatting, add logging, or alter the equality logic for your specific domain. Everything the compiler provides is a default you can replace.

Nested Records

Records support nesting, very much like nested classes, with one important rule: a nested record is always static. There is no concept of a nonstatic nested record in Java.

java
public record User(String name, int age) {

    record Address(String city, String zip) {
        void display() {
            System.out.println(city + " " + zip);
        }
    }
}

Even though you did not write static before record Address, it is static by default. You access it like a static nested class:

java
User.Address address = new User.Address("Mumbai", "400001");
address.display();

The reason only static nesting is allowed comes back to the transparent data carrier idea. A nonstatic nested record would hold an implicit reference to its enclosing record instance. That hidden reference would not appear in the nested record's component list, which would violate the transparency guarantee. Making nesting static eliminates that hidden reference, so the nested record carries exactly and only what its components declare.

To see nesting in a fuller example, here is a record sitting alongside both a static and a nonstatic nested class:

java
public record User(String name, int age) {

    // nested record: always static
    record Address() {
        void display() { System.out.println("Address record"); }
    }

    // static nested class: explicitly static
    static class NestedStaticClass {
        void display() { System.out.println("Static nested class"); }
    }

    // nonstatic nested class: tied to a User instance
    class NestedClass {
        void display() { System.out.println("Nonstatic nested class"); }
    }
}

You access the nested record and the static nested class via the class name directly. You access the nonstatic nested class through a User instance because it needs the enclosing object to exist:

java
User.Address addr = new User.Address();
addr.display();

User.NestedStaticClass ns = new User.NestedStaticClass();
ns.display();

User userObj = new User("Alice", 30);
User.NestedClass nc = userObj.new NestedClass();
nc.display();

If you already know how nested classes work, nested records follow the same rules with the single addition that records always default to static.

Local Records

You can declare a record inside a method or any code block, just like a local class. These are called local records and they follow the same scoping rules as local classes.

A local record cannot be declared public, private, or protected. Its scope is limited to the block it lives in, so those access modifiers are meaningless there. A local record cannot be instantiated outside the block where it is declared. A local record cannot be static either, because static implies class level scope, and a local record only lives as long as its enclosing block. Declaring it static would contradict its own scoping.

java
public class ReportPrinter {

    public void printAddress() {
        record Address(String city, String zip) {
            void display() {
                System.out.println(city + " " + zip);
            }
        }

        Address address = new Address("Pune", "411001");
        address.display();
    }
}

The Address record is entirely scoped to printAddress. It cannot be used anywhere else. This is useful when you need a small, structured value type that only makes sense inside one method. Outside that method you call printAddress normally by creating a ReportPrinter instance, and it internally creates and uses Address without any visibility to the caller.

The concept here is nothing exotic. If you understand local classes, local records feel identical. A record is really just a shorthand for writing an immutable class. All the same scoping and accessibility rules that govern local classes apply equally to local records.

Records vs Lombok

Before records arrived, many Java projects used Lombok to eliminate boilerplate. Lombok annotations like @Getter, @EqualsAndHashCode, @ToString, and @Value do much of what records do. So why did Java add records?

Lombok is an external library. You add it to pom.xml or build.gradle and it works through annotation processing at compile time. Records are a first class Java language feature. You do not add any dependency. Any JVM running Java 16 or later understands them natively.

Lombok cannot enforce immutability. Lombok generates getters and removes the need to write them, but if you also write a setter method, Lombok will not stop you. Records will. The compiler refuses to let you add setters to a record. The immutability guarantee is baked into the language, not bolted on through tooling.

Records integrate with the rest of the Java ecosystem. Because records are a genuine Java feature, other parts of the language can reason about them. Pattern matching in instanceof, switch expressions, sealed classes and interfaces, all of these connect with records in ways that Lombok can never match because Lombok operates purely at the source level while the JVM never sees it. Records are the foundation that future Java features are built on top of.

Interview Questions This Topic Raises

Working through records tends to surface several questions that come up in technical discussions and interviews. Here are the key ones with clear answers.

Can a record extend another class? No. Every record implicitly extends java.lang.Record, and Java does not allow a class to extend more than one class. You cannot add a second extends clause.

Can a record implement interfaces? Yes. You can implement as many interfaces as you want, just like an ordinary class.

Why are records implicitly final? Because a record is a transparent data carrier. If you could subclass it, a subclass could add mutable state, breaking the immutability guarantee that records promise.

What is the canonical constructor? The constructor that takes all the record components in the exact order they are declared. The compiler generates it automatically, but you can override it.

What is a compact constructor? A shorthand form of the canonical constructor where you omit the parameter list and the field assignments. You only write your validation or preprocessing logic. The compiler supplies the parameters and the assignments around your code.

Can a custom constructor in a record skip calling the canonical constructor? No. Any additional constructor you write must delegate to the canonical constructor as its first statement, ensuring every field gets initialized.

Can you add instance fields inside a record body? No. All instance fields must be declared as record components in the header. This is what makes the record a transparent data carrier. Static fields are allowed because they belong to the class, not to any instance.

Are nested records static or nonstatic? Always static, by default and by rule. Nonstatic nested records are not allowed because they would carry a hidden reference to the enclosing instance, violating the transparency principle.

Is private final List&lt;String&gt; in a record truly immutable? Only the reference is final. The list contents can still be modified unless you make a defensive copy in the canonical constructor using List.copyOf.

How do record accessor methods differ from JavaBean getters? A record accessor for a field called name is simply name(), not getName(). There is no get prefix.

Putting It All Together

Records are not a replacement for every class. They are the right tool for a specific job: representing a fixed set of values, carrying data from one part of a program to another, and doing it with zero ceremony and zero risk of accidental mutation. Anywhere you previously reached for a POJO, a DTO, or a value object, a record is almost certainly simpler, safer, and easier to read.

The moment you internalize that a record declaration is just a condensed, compiler enforced version of all that boilerplate you used to type by hand, everything else follows naturally. The immutability rules, the canonical constructor, the accessor naming, the restrictions on inheritance and extra instance fields: they all flow from the single idea that a record is a transparent carrier of a fixed set of values.

The next time you find yourself writing private final, this.x = x, and return name; five times in a row, remember that one line does the same job:

java
public record User(String name, int age) {}

That is the whole point. Write less. Mean more. Let the compiler do the mechanical work.