Back to Advanced Topics
Q12Question 12 of 41

What are Records (Java 16), what does the compiler generate, can you add validation, and what are their constraints?

Key Concepts
  • A record is declared as `record Name(Type field, ...)` and the compiler generates private final fields, canonical constructor, accessors, equals, hashCode, and toString.
  • Records are implicitly final and cannot extend any class because they already extend java.lang.Record, but they can implement interfaces.
  • A compact constructor validates or normalizes parameters before the compiler assigns them to fields automatically.
  • Record components are final, making instances shallowly immutable; mutable components like List require defensive copying.
  • Records can have static fields, static methods, and additional instance methods, but cannot have non-static instance fields beyond the components.
  • Records pair naturally with sealed types and pattern matching for exhaustive type-safe dispatch.

Definition

A record is a compiler-generated, immutable data carrier with automatic accessors, equals, hashCode, and toString, where a compact constructor validates before fields are set.
  • Record – a transparent, immutable data carrier declared as record Name(Type field, ...) that the compiler generates boilerplate for.
  • Generated members – private final fields, a canonical constructor, public accessors named after each component (not getX), and value-based equals, hashCode, and toString.
  • Compact constructor – a constructor written without a parameter list that runs before the implicit field assignments, used for validation and normalization.
  • Constraints – implicitly final (no subclassing), cannot extend a class (already extends java.lang.Record), can implement interfaces, components are final.
  • Shallow immutability – record references are final but a mutable component like List can still be changed through its reference.

Think of a record as a printed, tamper-proof receipt at National Bank of India. Once the teller fills in the amount, sender, and receiver and hands it over, nobody can erase or change the numbers. The bank's printing machine (the compiler) automatically prints all the standard parts. The compact constructor is the teller's check before printing: is the amount positive? If not, the receipt is never printed.

Understanding the Concepts

What is a Java record?

A record is a transparent, immutable data carrier introduced as a standard feature in Java 16. You declare it by listing the components in the header, and the compiler generates the boilerplate you used to write by hand.

JAVA
package com.bank.model;

import java.math.BigDecimal;

// One line replaces ~40 lines of class boilerplate
public record TransferReceipt(
    String fromAccount,
    String toAccount,
    BigDecimal amount
) {}

What does the compiler generate?

From the record header, the compiler generates:

  • A private final field for each component.
  • A canonical constructor matching the component order.
  • A public accessor for each component named exactly like the component (not getAmount() but amount()).
  • A value-based equals that compares all components.
  • A value-based hashCode that hashes all components.
  • A toString that prints the record name and all components.
JAVA
package com.bank.service;

import com.bank.model.TransferReceipt;
import java.math.BigDecimal;

public class ReceiptDemo {
    public static void main(String[] args) {
        var receipt = new TransferReceipt(
            "RAJESH-001", "PRIYA-002", new BigDecimal("25000.00"));

        // Accessor: named after component, not getX
        System.out.println("From: " + receipt.fromAccount());
        System.out.println("Amount: " + receipt.amount());

        // Value-based equals
        var same = new TransferReceipt(
            "RAJESH-001", "PRIYA-002", new BigDecimal("25000.00"));
        System.out.println("Equal: " + receipt.equals(same));

        // Readable toString for logging
        System.out.println(receipt);
    }
}
TEXT
From: RAJESH-001
Amount: 25000.00
Equal: true
TransferReceipt[fromAccount=RAJESH-001, toAccount=PRIYA-002, amount=25000.00]

How do you add validation with a compact constructor?

A compact constructor is written without a parameter list. It runs before the implicit field assignments, so you validate and normalize the incoming parameters. The compiler appends the assignments automatically at the end.

JAVA
package com.bank.model;

import java.math.BigDecimal;
import java.math.RoundingMode;

public record TransferReceipt(
    String fromAccount,
    String toAccount,
    BigDecimal amount
) {
    // Compact constructor: no parameter list
    public TransferReceipt {
        if (fromAccount == null || fromAccount.isBlank()) {
            throw new IllegalArgumentException("From account is required");
        }
        if (toAccount == null || toAccount.isBlank()) {
            throw new IllegalArgumentException("To account is required");
        }
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        // Normalize: round to 2 decimal places
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}
JAVA
package com.bank.service;

import com.bank.model.TransferReceipt;
import java.math.BigDecimal;

public class ValidationDemo {
    public static void main(String[] args) {
        // Valid: normalized to 2 decimals
        var r = new TransferReceipt("RAJESH-001", "PRIYA-002",
            new BigDecimal("25000.5"));
        System.out.println(r.amount()); // 25000.50

        // Invalid: throws IllegalArgumentException
        try {
            new TransferReceipt("", "PRIYA-002", BigDecimal.TEN);
        } catch (IllegalArgumentException e) {
            System.out.println("Caught: " + e.getMessage());
        }
    }
}
TEXT
25000.50
Caught: From account is required

What are the constraints of records?

Records are designed as transparent data carriers, which imposes several constraints:

  • Implicitly final – a record cannot be subclassed.
  • Cannot extend a class – a record already extends java.lang.Record.
  • Can implement interfaces – useful for sealed hierarchies.
  • No extra instance fields – the only instance fields are the components.
  • Components are final – you cannot reassign them after construction.
JAVA
package com.bank.model;

import java.math.BigDecimal;

// Records can implement interfaces
public sealed interface TransactionResult
    permits Approved, Declined {}

public record Approved(BigDecimal amount) implements TransactionResult {}
public record Declined(String reason) implements TransactionResult {}

// COMPILE ERROR: records cannot extend classes
// public record BadRecord() extends SomeClass {}

// COMPILE ERROR: cannot add instance fields
// public record BadRecord(String name) {
//     private int extra; // not allowed
// }

What is shallow immutability and when does it matter?

Record components are final references. If a component is a mutable type like List, the reference cannot change but the list contents can. For true immutability, defensively copy in the compact constructor.

JAVA
package com.bank.model;

import java.math.BigDecimal;
import java.util.List;

public record AccountStatement(
    String accountId,
    List<BigDecimal> transactions
) {
    public AccountStatement {
        // Defensive copy for true immutability
        transactions = List.copyOf(transactions);
    }
}
JAVA
package com.bank.service;

import com.bank.model.AccountStatement;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class ImmutabilityDemo {
    public static void main(String[] args) {
        var mutable = new ArrayList<>(List.of(
            new BigDecimal("1000"), new BigDecimal("2000")));

        var stmt = new AccountStatement("RAJESH-001", mutable);

        // Original list changed, but record is safe
        mutable.add(new BigDecimal("9999"));
        System.out.println("Record transactions: " + stmt.transactions());

        // Trying to modify the record's list throws
        try {
            stmt.transactions().add(new BigDecimal("5000"));
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify: list is unmodifiable");
        }
    }
}
TEXT
Record transactions: [1000, 2000]
Cannot modify: list is unmodifiable

How do records compare to regular classes?

| Feature | Record | Regular class |
|---------|--------|---------------|
| Boilerplate | One-line declaration | Constructor, getters, equals, hashCode, toString |
| Mutability | Shallowly immutable (final fields) | Mutable by default |
| Inheritance | Cannot extend a class, implicitly final | Can extend and be extended |
| Extra fields | Only components | Any fields |
| Pattern matching | Supports record patterns | Requires instanceof cast |
| Use case | DTOs, value objects, sealed cases | General-purpose |

How do records work with pattern matching?

Records support record patterns, which destructure the components directly in a switch or instanceof. Combined with sealed types, this gives exhaustive, type-safe dispatch.

JAVA
package com.bank.service;

import com.bank.model.*;
import java.math.BigDecimal;

public class PatternDemo {
    public static String describe(TransactionResult result) {
        return switch (result) {
            case Approved(var amount)
                -> "Approved for " + amount;
            case Declined(var reason)
                -> "Declined: " + reason;
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(
            new Approved(new BigDecimal("50000.00"))));
        System.out.println(describe(
            new Declined("Insufficient funds")));
    }
}
TEXT
Approved for 50000.00
Declined: Insufficient funds

What can you add to a record?

You can add static fields, static methods, additional instance methods, and override any generated member.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public record TransferReceipt(
    String fromAccount,
    String toAccount,
    BigDecimal amount
) {
    // Static factory method
    public static TransferReceipt of(
            String from, String to, double amount) {
        return new TransferReceipt(from, to,
            BigDecimal.valueOf(amount));
    }

    // Additional instance method
    public String summary() {
        return fromAccount + " -> " + toAccount + ": " + amount;
    }

    // Override toString to mask account numbers
    @Override
    public String toString() {
        return "TransferReceipt[from=***" +
            fromAccount.substring(fromAccount.length() - 3) +
            ", to=***" +
            toAccount.substring(toAccount.length() - 3) +
            ", amount=" + amount + "]";
    }
}
TEXT
TransferReceipt[from=***001, to=***002, amount=25000.00]

What should a senior engineer know?

Records are perfect for DTOs, value objects, map keys, and sealed-hierarchy cases. They pair beautifully with record patterns for deconstruction. However, because they expose all components through accessors, they are not a tool for hiding internal representation. When a component is mutable, copy defensively so the generated accessor never leaks a reference to internal state.

Visual Overview

DIAGRAM
  record TransferReceipt(String from, String to, BigDecimal amount)
       │
       │  Compiler generates:
       ▼
  ┌──────────────────────────────────────┐
  │  private final String from;           │
  │  private final String to;             │
  │  private final BigDecimal amount;     │
  │                                       │
  │  TransferReceipt(from, to, amount)    │ ← canonical constructor
  │  String from()                        │ ← accessor (not getFrom)
  │  String to()                          │
  │  BigDecimal amount()                  │
  │  equals(Object)                       │ ← value-based
  │  hashCode()                           │ ← value-based
  │  toString()                           │ ← readable
  └──────────────────────────────────────┘

  Compact constructor flow:

  new TransferReceipt("RAJESH", "PRIYA", amount)
       │
       ▼
  compact constructor body runs (validate, normalize)
       │
       ▼
  compiler assigns: this.from = from; this.to = to; ...
       │
       ▼
  immutable record instance

Key Takeaway

A record is a transparent, immutable data carrier declared in a single line. From the header, the compiler generates private final fields, a canonical constructor, accessors named after each component, and value-based equals, hashCode, and toString. Two records with equal components are equal, and the toString is clean for logging.

The compact constructor is where you add validation and normalization. It runs before the fields are assigned, so you can check that the amount is positive and the accounts are present, round money to two decimal places, and the compiler assigns the components at the end. If validation fails, the object is never created.

The constraints come from the goal of transparency. Records are implicitly final, cannot extend a class, cannot have extra instance fields, and their components are final. The important subtlety is shallow immutability: a mutable component like a List can still be changed through its reference, so you defensively copy with List.copyOf() in the compact constructor.

In banking at National Bank of India, records replaced hand-written transfer DTOs. A single-line TransferReceipt gave Rajesh correct equals, hashCode, and toString for free. The compact constructor validated every transfer at the boundary, and because records are immutable they were safe to pass between layers and threads. Combined with sealed types and pattern matching, records give exhaustive, type-safe dispatch with zero boilerplate.

Remember one sentence: a record generates all the boilerplate of an immutable data carrier from a one-line header, and a compact constructor validates before the fields are set.

---

Share
Use ← → arrow keys to navigate