ArchitectMind
  1. Home
  2. Tutorials
  3. Book 1 · Core Java & Modern Java
  4. Volume V
  5. Record Fundamentals
Book 1 · Volume V — Java Platform Evolution & Advanced Language Features · Topic 2.1

Record Fundamentals

Declaration, components, accessors, canonical and compact constructors

10 min readStage 1 · Foundations11 interview questions
Prerequisites
B1V1 classes, constructors · Chapter 8 (equals/hashCode) helpful
Covers
Record declaration · Components · Accessors · Canonical constructor · Compact constructor
Related
2.2 Semantics · 2.3 Design · 4.3 Record Patterns · B1V4 4.1 (record serialization)
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally
  5. 5. APIs / Features / Syntax
  6. 6. Practical Examples
  7. 7. Common Problems & Mistakes
  8. 8. Performance & Production Considerations
  9. 9. Architecture & Design Considerations
  10. 10. Interview Questions & Answers

1Concept / Definition

A record is a special kind of class declared as a transparent carrier for a fixed set of values, its components. From the header record Money(BigDecimal amount, Currency currency) {} the compiler derives a private final field and a public accessor for each component, a canonical constructor, and value-based equals, hashCode and toString. Records are implicitly final and extend java.lang.Record. The design principle is that a record's API is exactly its state description: no hidden state, and state that can be fully reconstructed from the accessors.

One declaration instead of a hand-written value class
Figure 1 — One declaration instead of a hand-written value class

2Why It Exists

Java value classes required constructors, getters, equals, hashCode and toString — easily 40 lines, frequently wrong (a field forgotten in equals), and hidden behind IDE generation or Lombok. Records (JEP 395, Java 16) make "plain data" a first-class language concept, which also enables features that depend on knowing a type's state: record patterns for deconstruction, safe serialization through the canonical constructor, and compact data-oriented modelling with sealed types.

3Core Concepts

Record declaration

A record declaration has a name, optional type parameters, a header listing components, optional implements clauses and a body. It can be top-level, nested (implicitly static) or local to a method — useful for intermediate results in stream pipelines. The body may contain static fields and methods, static initialisers, nested types, extra instance methods and constructors, but no instance fields beyond the components and no instance initialisers. Records cannot extend other classes, cannot be abstract, and are implicitly final.

What records can and cannot do
Figure 2 — What records can and cannot do

Components

Each component in the header produces a private final field of the same name and type, a constructor parameter, and an accessor. Components may be annotated; the annotation propagates to the field, accessor, parameter or record component according to its @Target. Components can be of any type including arrays and generics — though arrays break value semantics (2.2). Reflection exposes them in order through Class.getRecordComponents().

From component to generated members
Figure 3 — From component to generated members
Members generated for every record
Figure 4 — Members generated for every record

Accessors

Accessors have the component's name and no get prefix: money.amount(). They may be declared explicitly — for example to return a defensive copy — but must be public, have the same return type and no throws clause. Frameworks recognise record accessors: Jackson serializes them as properties, Spring binds configuration to them, and Bean Validation validates annotated components.

Canonical constructor

The canonical constructor has exactly the components as parameters, in order. The implicit one simply assigns all fields. An explicit canonical constructor must assign every field and be at least as accessible as the record. Additional (non-canonical) constructors are allowed but must delegate to another constructor with this(...), ultimately reaching the canonical one — so every instance passes through it.

Compact constructor

The compact constructor is a canonical constructor written without the parameter list: Range { if (lo > hi) throw …; }. Its parameters are implicit and, after the body runs, the compiler assigns them to the fields. The body may validate arguments, normalise them by reassigning the parameters (name = name.strip();) and make defensive copies (lines = List.copyOf(lines);) — but it may not assign the fields directly. It is the idiomatic place for record invariants.

Explicit canonical and compact constructors
Figure 5 — Explicit canonical and compact constructors
The sequence inside a compact constructor
Figure 6 — The sequence inside a compact constructor

4How It Works Internally

A record compiles to a final class extending java.lang.Record with a Record attribute listing its components. equals, hashCode and toString are not generated as ordinary bytecode but via an invokedynamic call to java.lang.runtime.ObjectMethods.bootstrap, which builds optimised implementations from method handles at first use — keeping class files small and letting the JDK improve the implementations later. Serialization treats records specially: it reads component values and calls the canonical constructor, so validation always runs.

5APIs / Features / Syntax

JAVAA realistic value record, a generic record and a local record
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
    public static final Money ZERO_EUR = new Money(BigDecimal.ZERO, Currency.getInstance("EUR"));

    public Money {  // compact canonical constructor
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        // normalise
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }

    // non-canonical: must delegate
    public Money(String amount, String currency) {
        this(new BigDecimal(amount), Currency.getInstance(currency));
    }

    // static factory
    public static Money eur(String amount) { return new Money(amount, "EUR"); }

    public Money plus(Money other) {  // behaviour is fine
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        // "withers" return new instances
        return new Money(amount.add(other.amount), currency);
    }

    @Override public int compareTo(Money o) { return amount.compareTo(o.amount); }
}

// Generic and local records
record Pair<A, B>(A first, B second) {}

List<String> topCustomers(List<Order> orders) {
    record Total(String customer, BigDecimal sum) {}           // local record
    return orders.stream()
        .collect(Collectors.groupingBy(Order::customer, Collectors.reducing(BigDecimal.ZERO, Order::total, BigDecimal::add)))
        .entrySet().stream().map(e -> new Total(e.getKey(), e.getValue()))
        .sorted(Comparator.comparing(Total::sum).reversed())
        .limit(10).map(Total::customer).toList();
}

6Practical Examples

JAVARecords in everyday application code
// Validation with Bean Validation annotations on components
public record CreateCustomerRequest(@NotBlank String name, @Email String email, @Past LocalDate birthDate) {}

// Configuration binding (Spring Boot)
@ConfigurationProperties("payments")
public record PaymentProperties(URI endpoint, Duration timeout, int maxRetries) {
    public PaymentProperties {
        if (maxRetries < 0) throw new IllegalArgumentException("maxRetries must be >= 0");
        // default via normalisation
        if (timeout == null) timeout = Duration.ofSeconds(5);
    }
}

// Explicit accessor returning a defensive copy of an array component
public record Checksum(byte[] bytes) {
    public Checksum { bytes = bytes.clone(); }
    @Override public byte[] bytes() { return bytes.clone(); }
    // equals/hashCode/toString must also be overridden for arrays — see 2.2
}

// Map keys and composite identifiers
record SkuWarehouse(String sku, String warehouse) {}
Map<SkuWarehouse, Integer> stock = new HashMap<>();
stock.merge(new SkuWarehouse("SKU-1", "BER"), 5, Integer::sum);

7Common Problems & Mistakes

MistakeConsequenceFix
Assigning this.x in a compact constructorCompile errorReassign the parameter instead
Mutable components (List, arrays)Record can change after creationDefensive copies (List.copyOf, clone)
Using records for JPA entitiesNo no-arg constructor, final fields, no proxiesKeep entities as classes
Expecting getX() accessor namesFrameworks/EL expressions failUse x(); configure libraries
Adding many derived or cached fieldsNot allowed: instance fieldsCompute in methods or use a class
Non-canonical constructors that skip validationImpossible — they must delegatePut invariants in the canonical/compact ctor

8Performance & Production Considerations

  • Records are ordinary objects: the same allocation cost as a final class with the same fields; the JIT's escape analysis often removes short-lived ones.
  • The invokedynamic-based equals/hashCode are as fast as hand-written code after warm-up.
  • Records are candidates for value classes (Project Valhalla) in future Java versions, which could remove identity and flatten them in memory.
  • Heavy use of records for intermediate stream results is fine; prefer them over Map<String,Object> or Object[] tuples.

9Architecture & Design Considerations

Records make the distinction between data and behaviour-with-identity explicit. Value objects in domain-driven design (Money, Address, DateRange), messages and events, DTOs, query projections, configuration and composite keys are natural records; entities with lifecycle and identity, services and mutable builders are not. Combined with sealed interfaces (Chapter 3) and patterns (Chapter 4), records support an algebraic, data-oriented style that complements object orientation.

10Interview Questions & Answers

Q1KnowledgeConcept

What is a record?

A record is a special kind of final class, introduced as a standard feature in Java 16, that acts as a transparent carrier for a fixed set of values called components. From a header such as record Money(BigDecimal amount, Currency currency), the compiler generates a private final field and a public accessor for each component, a canonical constructor, and equals, hashCode and toString based on all components. Every record extends java.lang.Record and cannot extend anything else. The defining idea is that its API is exactly its state: there is no hidden state, so a record is shallowly immutable and can be rebuilt completely from its accessors.

Q2KnowledgeTricky

What are the accessor names of a record?

Accessors use the component name itself, with no get or is prefix: point.x(), money.amount(), flag.enabled(). That is a deliberate break from the JavaBeans convention, because a record is not a bean, and it trips up older tools. Expression languages, some templating engines and hand-written reflection that look for getAmount() may not find the value, although current versions of Jackson, Spring, Hibernate Validator and MapStruct all recognise record accessors. You may declare an accessor explicitly, for example to return a defensive copy of an array, but it must be public, return exactly the component type and declare no checked exceptions, or the record will not compile.

Q3UnderstandingConcept

What is the difference between a canonical and a compact constructor?

Both are the canonical constructor, the one whose parameters match the components in order, and every record has exactly one. The explicit canonical form lists the parameters and must assign every field itself with this.amount = amount. The compact form omits the parameter list: the parameters are implicit, the body runs first, and the compiler then assigns each parameter to its field automatically. Inside a compact constructor you validate, normalise by reassigning the parameter, or make defensive copies, but you may not assign the fields directly; this.amount = x is a compile error. The compact form is preferred because it focuses on invariants and cannot forget a field assignment.

Q4UnderstandingConcept

Can a record extend another class or declare extra instance fields?

No to both. A record implicitly extends java.lang.Record, and Java has single inheritance, so it cannot extend another class; it is also implicitly final, so nothing can extend it. It may not declare instance fields beyond its components, nor instance initialiser blocks, because any hidden state would break the guarantee that a record is fully described by its header. What it can do is implement interfaces, often a sealed interface, and declare static fields, static methods, nested types, extra constructors and instance methods. Derived values such as a total or a full name should be computed in a method rather than cached in a field.

Q5ApplicationCode

How do you validate and normalise record data?

Put the logic in the compact canonical constructor, because every instance, whether created by your code, Jackson, a mapper or deserialization, must pass through it. Validate with Objects.requireNonNull and explicit checks that throw IllegalArgumentException with a clear message. Normalise by reassigning the implicit parameter, for example name = name.strip() or amount = amount.setScale(2, RoundingMode.HALF_EVEN). Make defensive copies of mutable inputs with List.copyOf or clone, since a record is only shallowly immutable. The compiler assigns the fields after the body runs. Bean Validation annotations on components complement this for input-level rules, but core invariants belong in the constructor so that no invalid record can exist.

Q6ApplicationCode

What rules apply to additional constructors?

A record may declare additional, non-canonical constructors with different parameter lists, but each must begin by delegating with this(...) to another constructor, and the chain must end at the canonical constructor. That rule guarantees that every instance passes through the canonical or compact constructor, so its validation and normalisation can never be bypassed. A typical use is convenience parsing: Money(String amount, String currency) calls this(new BigDecimal(amount), Currency.getInstance(currency)). Additional constructors cannot assign fields themselves. Many teams prefer static factory methods such as Money.eur("10.00") instead, because they have descriptive names, can return cached instances and avoid ambiguity between overloaded constructors.

Q7Senior EngineerInternals

How are equals, hashCode and toString implemented for records?

They are not generated as ordinary bytecode. javac emits a small method whose body is an invokedynamic instruction bootstrapped by java.lang.runtime.ObjectMethods.bootstrap, passing the record class, the component names and getters for the fields. On first call, the bootstrap builds method handles that compare or hash each component in order, using equals for references and the primitive comparisons for primitives, and toString produces Money[amount=10.00, currency=EUR]. After linking, the JIT inlines the result, so it performs like hand-written code. The advantage is smaller class files and the JDK's freedom to improve the implementation without recompiling records. You can still override any of the three explicitly.

Q8Senior EngineerInternals

Why is record deserialization safer than for ordinary classes?

Because records are deserialized through their canonical constructor. For an ordinary Serializable class, the JVM allocates the object without running its constructor and then writes field values straight from the stream, so a crafted stream can produce an object that violates every invariant, a well-known attack vector. For a record, the serialization mechanism reads the component values from the stream, matches them by name and calls the canonical constructor, so the compact constructor's validation and defensive copying run. Records also ignore custom readObject and writeObject hooks. This makes them a good choice for serialized messages, and the same principle applies when Jackson binds JSON to records.

Q9Technical ArchitectDesign

Where do records fit in a layered Spring application?

Records fit everywhere the application handles data rather than identity. Typical uses are request and response DTOs in controllers, validated with Bean Validation annotations on the components; commands and domain events; query projections returned by Spring Data, including interface-free DTO projections from JPQL; @ConfigurationProperties classes, with defaults applied in the compact constructor; and value objects such as Money or DateRange inside the domain. They are a poor fit for JPA entities, which need a no-argument constructor, mutable state and proxies for lazy loading, and for Spring beans such as services, which have dependencies and behaviour rather than value semantics. Embeddables can be records since Hibernate 6.2.

Q10Technical ArchitectScenario

How would you migrate Lombok @Value classes to records?

Migrate incrementally, starting with simple immutable @Value classes that have no inheritance. Replace each with a record, move constructor validation into a compact constructor, and update callers from getX() to x(), which an IDE refactoring or OpenRewrite recipe can automate. Check framework support for each class: Jackson, MapStruct, Bean Validation and Spring binding all handle records in current versions, but older expression languages or reflection utilities may not. Keep Lombok or a plain class where you need inheritance, mutability, a large builder, or lazy fields. Records have no built-in builder or wither, so classes with many optional fields may stay as they are or use a small handwritten builder.

Q11Solution ArchitectDesign

How do records support data-oriented programming?

Data-oriented programming models data as plain, immutable values and keeps operations separate from them, which suits pipelines, messaging and domain alternatives. Records are the product types: a record holds a fixed combination of values, is immutable and transparent, and can be deconstructed with record patterns. Sealed interfaces are the sum types: a PaymentResult is exactly one of Approved, Declined or Pending. Pattern matching in switch then processes these alternatives exhaustively, so adding a new variant produces compile errors wherever it is not handled. The result is explicit domain modelling with compiler-checked completeness, a good complement to object orientation for boundaries, events and rules engines rather than a replacement for it.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.