StackWise
Modern JavaModern Java (17–21)

Records

Concise immutable data carriers — compact constructors, validation, custom methods, local records and where records fit (and don't).

beginner 4 min readLesson 18 of 26

The boilerplate problem

A simple immutable Point class used to need a constructor, getters, equals, hashCode and toString — ~40 lines that IDEs generated and humans had to review. A record (Java 16) declares the same thing in one line:

public record Point(int x, int y) {}

The compiler generates:

  • private final fields x and y
  • a canonical constructor Point(int x, int y)
  • accessor methods x() and y() (no get prefix)
  • equals and hashCode based on all components
  • toString() → Point[x=1, y=2]

Records are implicitly final and extend java.lang.Record, so they can’t extend other classes — but they can implement interfaces.

Compact constructors: validation and normalisation

Records.javaJava 21
import java.math.BigDecimal;
import java.util.*;

record Money(BigDecimal amount, String currency) {
    // Compact constructor: no parameter list, fields assigned automatically afterwards
    Money {
        Objects.requireNonNull(amount, "amount");
        if (amount.signum() < 0) throw new IllegalArgumentException("amount must be >= 0");
        if (!currency.matches("[A-Z]{3}")) throw new IllegalArgumentException("ISO currency expected: " + currency);
        amount = amount.setScale(2, java.math.RoundingMode.HALF_EVEN);   // normalise before assignment
    }

    // Additional constructor must delegate to the canonical one
    Money(String amount, String currency) { this(new BigDecimal(amount), currency); }

    // Static factory and instance methods are allowed
    static Money gbp(String amount) { return new Money(amount, "GBP"); }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(amount.add(other.amount), currency);
    }
}

record Team(String name, List<String> members) {
    Team {
        members = List.copyOf(members);            // defensive copy: records are only shallowly immutable
    }
}

class Main {
    public static void main(String[] args) {
        Money a = Money.gbp("19.999");
        Money b = new Money("5", "GBP");
        System.out.println(a + " + " + b + " = " + a.plus(b));
        System.out.println("equal by value? " + new Money("5.00", "GBP").equals(b));

        try { new Money("-1", "GBP"); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        try { new Money("1", "pounds"); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }

        var members = new ArrayList<>(List.of("ada", "bob"));
        var team = new Team("core", members);
        members.add("mallory");                       // doesn't leak into the record
        System.out.println(team);

        // Local records: great for intermediate results inside a method
        record Score(String player, int points) {}
        var best = List.of(new Score("ada", 42), new Score("bob", 57), new Score("cy", 33)).stream()
            .max(Comparator.comparingInt(Score::points)).orElseThrow();
        System.out.println("winner: " + best.player());

        // Records in sets and maps just work
        Set<Money> wallet = new HashSet<>(List.of(Money.gbp("1"), Money.gbp("1.00"), Money.gbp("2")));
        System.out.println("distinct coins: " + wallet.size());
    }
}

What records can and can’t have

✓ Allowed ✗ Not allowed
Static fields and methods Additional instance fields
Instance methods Extending another class
Implementing interfaces Non-final / mutable component fields
Nested types, annotations, generics Overriding accessors with different return types
Custom accessors (same signature) Instance initializer blocks

Where records shine

  • DTOs for REST APIs — Jackson and Spring MVC handle records out of the box
  • Value objects — Money, EmailAddress, OrderId
  • Projection types for Spring Data queries and @ConfigurationProperties
  • Map keys and composite keys — correct equals/hashCode for free
  • Algebraic data types with sealed interfaces (next lesson)
  • Multiple return values via local records
@ConfigurationProperties("payments")
public record PaymentProperties(URI baseUrl, Duration timeout, int maxRetries) {}

@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable long id) { ... }    // OrderResponse is a record

Record patterns (Java 21)

Records can be deconstructed in instanceof and switch:

if (shape instanceof Point(int x, int y) && x == y) { ... }

String describe(Object o) {
    return switch (o) {
        case Point(var x, var y) when x == 0 && y == 0 -> "origin";
        case Point(var x, var y) -> "point at " + x + "," + y;
        default -> "something else";
    };
}
Finished this lesson?

Mark it complete to track your progress through Modern Java.