Definition
- 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-basedequals,hashCode, andtoString. - 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 extendsjava.lang.Record), can implement interfaces, components arefinal. - Shallow immutability – record references are final but a mutable component like
Listcan 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.
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 finalfield for each component. - A canonical constructor matching the component order.
- A public accessor for each component named exactly like the component (not
getAmount()butamount()). - A value-based
equalsthat compares all components. - A value-based
hashCodethat hashes all components. - A
toStringthat prints the record name and all components.
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);
}
}
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.
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);
}
}
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());
}
}
}
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.
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.
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);
}
}
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");
}
}
}
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.
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")));
}
}
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.
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 + "]";
}
}
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
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.
---