RizTech Academy logo
RizTech Academy
Object-Oriented JavaLesson 6 of 825 min

Records: the modern way to hold data

Most classes in a real codebase do not have interesting behaviour. They hold three values, hand them back, and compare equal when their values match. Before Java 16 that took forty-five lines, most of which the IDE generated and nobody read.

record Subscriber(String name, String pincode, int tiffins) { }

That line is a complete, correct, immutable class. This lesson is about what it generates, what it refuses to do, and the one way it will still surprise you.

What you get

Subscriber a = new Subscriber("Priya Deshmukh", "411207", 26);
Subscriber b = new Subscriber("Priya Deshmukh", "411207", 26);

System.out.println(a);
System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());
System.out.println(a.name());
Subscriber[name=Priya Deshmukh, pincode=411207, tiffins=26]
true
true
Priya Deshmukh

From that one line, the compiler generates:

Generated Detail
private final fields One per component, in order
A canonical constructor Taking all components, in order
An accessor per component name(), not getName()
equals Compares every component
hashCode Consistent with equals
toString Subscriber[name=..., pincode=..., tiffins=26]

Two of those are the real prize. The equals/hashCode pair is generated correctly and consistently, which is the contract module 5 devotes a whole lesson to breaking. And toString means your logs are readable by default rather than saying Subscriber@799f10e1.

Records are final, cannot extend anything, and their fields cannot be reassigned. That is not a limitation to work around — it is the deal. A record says "I am nothing but my values".

Validation: the compact constructor

You almost always want to reject nonsense. A record gives you a special form with no parameter list and no assignments:

record Subscriber(String name, String pincode, int tiffins) {
    Subscriber {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name must not be blank, got [" + name + "]");
        }
        if (!pincode.matches("\\d{6}")) {
            throw new IllegalArgumentException("pincode must be six digits, got [" + pincode + "]");
        }
        if (tiffins < 0 || tiffins > 62) {
            throw new IllegalArgumentException("tiffins must be between 0 and 62, got " + tiffins);
        }
        name = name.strip();
    }
}
threw: tiffins must be between 0 and 62, got -1
threw: name must not be blank, got [ ]
normalised: [Priya]

Note the last line. Inside a compact constructor, assigning to a parameter changes what gets stored — name = name.strip() normalises the value on the way in. There are no this.name = name lines; the compiler adds them after your code.

This is where the encapsulation lesson's promise gets kept for one line of class. An invalid Subscriber cannot exist.

Adding to a record

A record is a class. It can have methods, static factories, constants and interfaces:

record Subscriber(String name, String pincode, int tiffins) implements Billable {
    static final int PRICE_PER_TIFFIN_PAISE = 8_235;

    static Subscriber standard(String name, String pincode) {
        return new Subscriber(name, pincode, 26);
    }

    long billPaise() {
        return (long) tiffins * PRICE_PER_TIFFIN_PAISE;
    }

    Subscriber withTiffins(int newTiffins) {
        return new Subscriber(name, pincode, newTiffins);
    }

    @Override
    public long amountPaise() {
        return billPaise();
    }
}
214110
Subscriber[name=Kavita, pincode=411014, tiffins=26]
Subscriber[name=Priya Deshmukh, pincode=411207, tiffins=30]

What it cannot have is additional instance fields. Every piece of state must be a component in the header — which is what makes equals trustworthy.

withTiffins is the immutable way to "change" something: return a new instance. The with prefix is the convention, and you will see it constantly in modern Java.

The trap: records are shallowly immutable

This is the one that catches people.

record Route(String area, List<String> stops) { }

List<String> stops = new ArrayList<>(List.of("Kesnand"));
Route leaky = new Route("Wagholi", stops);

stops.add("added from outside");
System.out.println(leaky);

leaky.stops().add("added through the accessor");
System.out.println(leaky);
Route[area=Wagholi, stops=[Kesnand, added from outside]]
Route[area=Wagholi, stops=[Kesnand, added from outside, added through the accessor]]

The record's field cannot be reassigned. The List it points at can be modified all day — both by whoever handed it in and by anybody who calls the accessor. It is the leaking-getter bug from the encapsulation lesson, wearing a record's clothes.

The fix is one line in the compact constructor:

record SafeRoute(String area, List<String> stops) {
    SafeRoute {
        stops = List.copyOf(stops);
    }
}
safe record : SafeRoute[area=Wagholi, stops=[Kesnand]]
accessor refused modification

List.copyOf both copies (so the caller's later add is ignored) and returns an immutable list (so the accessor's caller cannot modify it either). One line, both holes.

Any record component that is a collection, an array or a mutable object needs copying in the compact constructor. record on its own buys you an immutable reference, not an immutable object.

When a record is the wrong choice

Being honest about this matters, because records are pleasant enough to over-apply.

  • When the thing has identity rather than values. Two subscribers with the same name and pincode are probably the same person, not two equal things. A record's equals says otherwise. Anything with a database ID usually wants a class.
  • When it must be mutable. An Account whose balance changes is a class.
  • When it needs to be extended. Records are final. Use a sealed interface with record implementations instead, which the next lesson but one covers.
  • JPA and Hibernate entities. They need a no-argument constructor and mutable fields. Records are excellent as the DTOs around such an entity.
  • When there are ten components. That is a signal the thing wants splitting up, whatever form you write it in.

Where records are exactly right: DTOs, API request and response bodies, coordinates, money amounts, parsed CSV rows, method return values that carry more than one thing, and the cases of a sealed hierarchy.

That last one is the payoff. Hold the thought until the pattern matching lesson.

Records against the alternatives

Record Class Enum
Values fixed at construction Yes Optional Yes
equals/hashCode/toString Generated You write them Identity-based
Can be extended No Yes No
Instances Unlimited Unlimited A fixed, named set
Best for Data Behaviour and state A closed set of options

Check your work

What does record Subscriber(String name, String pincode, int tiffins) {} generate? Private final fields, a canonical constructor, an accessor per component named after it, and correct equals, hashCode and toString.

What is an accessor called? name(), not getName(). Records do not follow the JavaBean convention.

What is a compact constructor for, and what is unusual about it? Validating and normalising. It has no parameter list, and assigning to a parameter changes what is stored, because the compiler appends the field assignments after your code.

Can a record have extra instance fields? No. All state must be declared as components in the header. Static fields and methods are fine.

Why did leaky.stops().add(...) work on an "immutable" record? Records are shallowly immutable: the field cannot be reassigned, but the object it points at can be modified. Copy mutable components in the compact constructor.

What does List.copyOf fix, exactly? Two things at once — it takes a snapshot, so the caller's later changes do not reach you, and it returns an immutable list, so the accessor's caller cannot modify it either.

Name two cases where a record is the wrong choice. Anything with identity rather than value equality (a database entity), anything mutable, anything that needs subclassing, and JPA entities.

Practice 2, the shrinkage. The hand-written Subscriber with three fields, a constructor, three getters, equals, hashCode and toString is around 45 lines. The record with the same validation is about 12, and the 33 lines that disappeared are the ones most likely to contain a bug — a field forgotten in equals, or a hashCode that does not match it.

Practice 4, the leak. Before: Route[area=Wagholi, stops=[Kesnand, added from outside, added through the accessor]]. After adding stops = List.copyOf(stops); to the compact constructor: the outside add is ignored and the accessor's add throws UnsupportedOperationException. Both holes, one line.

Practice 5, what a record cannot express. An Account with a changing balance cannot be a record, because every component is final — withBalance would return a new account, and "the same account with a different balance" is precisely the thing an identity-bearing object needs to express. That is the difference between a value and an entity, and it is the most useful distinction in this whole module.

Practice

  1. Convert a class to a record. Take the Subscriber you wrote in the classes lesson and rewrite it as a record with the same behaviour. Count the lines before and after.

  2. Confirm what you got for free. Create two records with identical values. Print both, compare with equals, compare their hash codes, and put them in a Set — confirm the set holds one, not two. The hand-written version would have held two unless you wrote equals and hashCode correctly.

  3. Add validation. A compact constructor rejecting a blank name, a non-six-digit pincode, and a tiffin count outside 0 to 62, each with a message naming the bad value. Then normalise the name with strip() and prove it worked.

  4. Reproduce the mutable-component leak. Write a Route record whose second component is a list of stops, then modify it both from outside and through the accessor. Then fix it with List.copyOf in the compact constructor and confirm both holes are now closed.

  5. Find the case it cannot express. Try to write Account with a changing balance as a record. Write down what goes wrong and what that tells you about when to use one.

  6. Harder — a parsed row. Write record DeliveryRow(LocalDate date, String customer, int tiffins) and a static factory parse(String line) that takes 2026-09-27,Priya,2 and returns one, throwing a clear IllegalArgumentException naming the offending field for a bad line. Then parse a list of lines, collect the failures rather than stopping at the first, and print a report of good rows and bad ones. This is the shape of nearly all real data loading, and the capstone does exactly this.

Next: enums — for when the set of values is fixed and a typo should not compile.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship