Appearance
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:
- The class itself must be
finalso nobody can subclass it and sneak in mutable behaviour. - Every field must be
private finalso no code outside the constructor can ever change it. - A constructor must set every field at creation time because there are no setters.
- You expose only getter methods, no setter methods at all.
- You override
equalsandhashCodeso objects with the same data compare and hash correctly, which matters the moment you drop them into aMapor aSet. - You override
toStringso 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 (you can check with User.class.isRecord()). 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, and you cannot write a working one: the fields are final, so this.name = n in any method fails with "cannot assign a value to final variable name". 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 mutableIf 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. You also cannot write the static keyword on a local record. (Behind the scenes a local record is implicitly static: it cannot use the enclosing object's instance fields or the method's local variables.)
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 does not restrict you. It only generates code; in a Lombok class you can still write your own setter that changes a field. In a record every field is final, so a setter that changes a field will not compile. The immutability guarantee is part of the language, not added by a tool.
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. Sealed classes and interfaces and pattern matching in instanceof and switch all work with records (covered in the next chapters).
Check yourself
- What does the compiler generate for
record User(String name, int age) {}? - How do you read the name of a
User? - Can a record extend another class? Implement interfaces?
- Can you add
private String secret;inside a record body? Astaticfield? - What is a compact constructor, and what does the compiler add to it?
- What must an extra constructor like
User(int age)do first? record User(String name, List<String> hobbies): is the record fully immutable? How do you fix it?- Is a nested record static or non-static? Can a local record be
public?
Answers
- A final class extending
java.lang.Record,private finalfields, the canonical constructor, accessorsname()andage(), plusequals,hashCodeandtoString. user.name(), notgetName().- No (it already extends
java.lang.Record). Yes, any number of interfaces. - No instance fields beyond the components. Static fields are allowed.
- A canonical constructor without the parameter list; the compiler adds the field assignments at the end.
- Call the canonical constructor with
this(...). - No, the list itself can still be changed. Store
List.copyOf(hobbies)in the constructor. - Nested records are always static. No, local records cannot have access modifiers.