Records

Declare immutable data carriers in one line and know where records fit in a service: DTOs, keys, results, but not JPA entities.

Syntaxrecord Name(Type first, Type second) { // optional: static fields, methods, extra constructors }

A record is a class whose whole job is to carry a fixed set of values. You list the components in the header and the compiler writes the rest: a private final field per component, a constructor that takes them all (the canonical constructor), an accessor per component, and equals, hashCode and toString based on every component.

record User(long id, String email) {}

That one line replaces around forty lines of a hand-written class. Accessors are named after the component, so you call user.email(), not getEmail(). toString prints User[id=1, [email protected]], and two records with equal components are equal, which makes them safe as map keys and easy to compare in tests.

What a record cannot do

  • It is implicitly final and already extends java.lang.Record, so it cannot extend another class or be extended.
  • It cannot declare extra instance fields. Static fields, static and instance methods, nested types and extra constructors are all allowed.
  • It can implement interfaces, which is how records join a sealed hierarchy later in this stage.

Where records fit in a back end

Use them for data that crosses a boundary: request and response bodies, rows read from a query, messages on a queue, configuration, composite cache keys, and methods that need to return two or three values together. Jackson has supported records since 2.12 and maps JSON properties to components by name. A record can also be declared locally inside a method, which is handy for an intermediate result in a stream pipeline.

They are not a fit for JPA entities. Jakarta Persistence needs a no-argument constructor and a non-final class it can subclass for lazy-loading proxies, which a final, immutable record cannot offer. Keep entities as classes and map them to records at the edge.

Mistakes people make

Records are only shallowly immutable. The field holding a List cannot be reassigned, but if the caller passed an ArrayList they can still add to it after the record is built. Copy collections in a compact constructor (next lesson). Arrays are worse: the generated equals compares array references, not contents, so two records holding equal byte[] values are not equal. Prefer a List, or override equals and hashCode yourself.

A record also exposes every component through its accessor and its toString. Do not put a password hash or an API token in a record that may end up in a log line, or override toString to mask it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Model a JSON request body such as CreateUserRequest(String email, String name) that Jackson binds by component name.
  • Return a row from a query as a read-only DTO instead of exposing a mutable entity to the web layer.
  • Use a record of tenant and user id as the key for an in-memory cache.

More examples

Static factories, methods and a wither

Records can carry behaviour as long as it only reads the components. Because the fields are final, every change returns a new record, so the original price is untouched after plus and withPence.

Example · java
record Money(long pence, String currency) {
    static Money gbp(long pence) {
        return new Money(pence, "GBP");
    }

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

    // A "wither" returns a copy with one component changed.
    Money withPence(long newPence) {
        return new Money(newPence, currency);
    }

    String display() {
        return "%s %d.%02d".formatted(currency, pence / 100, pence % 100);
    }
}

void main() {
    Money price = Money.gbp(1999);
    Money total = price.plus(Money.gbp(501));
    IO.println(total.display());
    IO.println(price.withPence(0));
    IO.println(price.display());
}

// Output:
// GBP 25.00
// Money[pence=0, currency=GBP]
// GBP 19.99

A composite cache key

The generated equals and hashCode use every component, so a record is a correct map key with no extra code. Concatenating strings such as tenant + ":" + id works too, but is easy to get subtly wrong when a value contains the separator.

Example · java
record CacheKey(String tenant, long userId) {}

void main() {
    Map<CacheKey, String> cache = new HashMap<>();
    cache.put(new CacheKey("acme", 42), "Ada Lovelace");

    // A new instance with the same components finds the same entry.
    IO.println(cache.get(new CacheKey("acme", 42)));
    IO.println(cache.get(new CacheKey("globex", 42)));
    IO.println(cache.containsKey(new CacheKey("acme", 42)));
}

// Output:
// Ada Lovelace
// null
// true

Shallow immutability and arrays

Arrays use reference equality, so records holding them break equality and map lookups. The Tags record changed after construction because it kept the caller's mutable list; a compact constructor with List.copyOf fixes that.

Example · java
record Payload(byte[] data) {}
record Tags(List<String> values) {}

void main() {
    var a = new Payload(new byte[] {1, 2, 3});
    var b = new Payload(new byte[] {1, 2, 3});
    IO.println("payloads equal: " + a.equals(b));
    IO.println("contents equal: " + Arrays.equals(a.data(), b.data()));

    List<String> source = new ArrayList<>(List.of("java"));
    var tags = new Tags(source);
    source.add("sql");
    IO.println(tags.values());
}

// Output:
// payloads equal: false
// contents equal: true
// [java, sql]

Discussion

  • Be the first to comment on this lesson.