Skip to main content

Records — Practical Demo

Hands-on examples for Records (Java 16+). See what the compiler generates, validate with compact constructors, use records as Map keys, and model polymorphic shapes with sealed interfaces.

Prerequisites

Understand Classes & Objects and Encapsulation — records replace the boilerplate of immutable value classes.


Example 1: What the Compiler Generates for You​

A before/after comparison showing the 30+ line handwritten class versus a one-line record.

RecordGenerated.java
import java.util.Objects;

public class RecordGenerated {

// BEFORE Java 16 — 30+ lines of boilerplate
public static final class PointOld {
private final int x;
private final int y;

public PointOld(int x, int y) { this.x = x; this.y = y; }

public int x() { return x; }
public int y() { return y; }

@Override public boolean equals(Object o) {
if (!(o instanceof PointOld p)) return false;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "PointOld[x=" + x + ", y=" + y + "]"; }
}

// AFTER Java 16 — one line; compiler generates everything above
record Point(int x, int y) {}

public static void main(String[] args) {
// Records behave identically to the hand-written class
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
Point p3 = new Point(5, 6);

System.out.println("p1 : " + p1); // Point[x=3, y=4]
System.out.println("p2 : " + p2); // Point[x=3, y=4]
System.out.println("p1.x() : " + p1.x()); // 3 ← accessor, NOT getX()
System.out.println("p1.equals(p2) : " + p1.equals(p2)); // true — value equality
System.out.println("p1.equals(p3) : " + p1.equals(p3)); // false
System.out.println("p1 == p2 : " + (p1 == p2)); // false — different references

// Records work as Map keys because hashCode/equals are correct by default
java.util.Map<Point, String> labels = java.util.Map.of(
new Point(0, 0), "origin",
new Point(1, 0), "unit-x"
);
System.out.println("label at (0,0): " + labels.get(new Point(0, 0))); // origin
}
}

Expected Output:

p1            : Point[x=3, y=4]
p2 : Point[x=3, y=4]
p1.x() : 3
p1.equals(p2) : true
p1.equals(p3) : false
p1 == p2 : false
label at (0,0): origin
Key takeaway

Records use x() not getX() — a deliberate design choice. Because equals and hashCode are value-based by default, records are safe and natural to use as Map keys and Set elements without any extra work.


Example 2: Compact Constructors for Validation and Normalization​

A Range, Email, and Money record showing validation and normalization in compact constructors.

CompactConstructors.java
public class CompactConstructors {

record Range(int min, int max) {
// Compact constructor — no parameter list; compiler adds `this.min = min; this.max = max;` after
Range {
if (min > max)
throw new IllegalArgumentException(
"min (" + min + ") must be <= max (" + max + ")");
}
}

record Email(String address) {
Email {
if (address == null || !address.contains("@"))
throw new IllegalArgumentException("Invalid email: " + address);
address = address.strip().toLowerCase(); // ← normalize BEFORE auto-assignment
}
}

record Money(double amount, String currency) {
Money {
if (amount < 0) throw new IllegalArgumentException("amount cannot be negative");
currency = currency.toUpperCase(); // ← normalize currency code
}

// Custom instance method — records can have these
Money add(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
return new Money(amount + other.amount, currency); // ← new instance (immutable)
}

@Override public String toString() { return String.format("%.2f %s", amount, currency); }
}

public static void main(String[] args) {
// Valid constructions
System.out.println(new Range(1, 10)); // Range[min=1, max=10]
System.out.println(new Email(" Alice@Example.COM ")); // Email[address=alice@example.com]
Money m1 = new Money(10.50, "usd");
Money m2 = new Money(5.25, "USD");
System.out.println(m1 + " + " + m2 + " = " + m1.add(m2));

// Invalid constructions — compact constructor catches these
try { new Range(10, 1); }
catch (IllegalArgumentException e) { System.out.println("Caught: " + e.getMessage()); }

try { new Email("not-an-email"); }
catch (IllegalArgumentException e) { System.out.println("Caught: " + e.getMessage()); }

try { new Money(-5, "EUR"); }
catch (IllegalArgumentException e) { System.out.println("Caught: " + e.getMessage()); }
}
}

Expected Output:

Range[min=1, max=10]
Email[address=alice@example.com]
10.50 USD + 5.25 USD = 15.75 USD
Caught: min (10) must be <= max (1)
Caught: Invalid email: not-an-email
Caught: amount cannot be negative
Key takeaway

The compact constructor body runs before the compiler's automatic this.min = min assignments. This means you can normalize values (like address = address.lowercase()) and the normalized value is what gets stored — not the raw input. Contrast this with full canonical constructors where you'd need to do it manually.


Example 3: Records as DTOs and with Pattern Matching​

A full data pipeline: domain records, transformation to response records, and Java 21 record pattern destructuring.

RecordDtoPipeline.java
import java.util.*;
import java.util.stream.*;

public class RecordDtoPipeline {

// Domain records — represent stored data
record UserId(long value) {}

record User(UserId id, String name, String email, boolean active) {}

// Response DTO — what the API returns (subset of User fields)
record UserSummary(long id, String name, String email) {
// Convenience factory from domain User
static UserSummary from(User u) {
return new UserSummary(u.id().value(), u.name(), u.email());
}
}

// A sealed event type for user activity
sealed interface UserEvent permits UserEvent.LoggedIn, UserEvent.ProfileUpdated, UserEvent.Deactivated {
record LoggedIn(UserId userId, String ipAddress) implements UserEvent {}
record ProfileUpdated(UserId userId, String field) implements UserEvent {}
record Deactivated(UserId userId, String reason) implements UserEvent {}
}

static String describeEvent(UserEvent event) {
// Java 21 record pattern — destructures the record directly in the case
return switch (event) {
case UserEvent.LoggedIn(var uid, var ip) -> "User " + uid.value() + " logged in from " + ip;
case UserEvent.ProfileUpdated(var uid, var field) -> "User " + uid.value() + " updated " + field;
case UserEvent.Deactivated(var uid, var reason) -> "User " + uid.value() + " deactivated: " + reason;
};
}

public static void main(String[] args) {
// Build domain users
List<User> users = List.of(
new User(new UserId(1), "Alice", "alice@example.com", true),
new User(new UserId(2), "Bob", "bob@example.com", false),
new User(new UserId(3), "Carol", "carol@example.com", true)
);

// Filter + transform to DTOs using streams
System.out.println("=== Active User Summaries ===");
users.stream()
.filter(User::active)
.map(UserSummary::from)
.forEach(System.out::println);

// Process events with record pattern matching
System.out.println("\n=== Event Log ===");
List<UserEvent> events = List.of(
new UserEvent.LoggedIn(new UserId(1), "192.168.1.10"),
new UserEvent.ProfileUpdated(new UserId(1), "email"),
new UserEvent.Deactivated(new UserId(2), "policy violation")
);
events.stream().map(RecordDtoPipeline::describeEvent).forEach(System.out::println);
}
}

Expected Output:

=== Active User Summaries ===
UserSummary[id=1, name=Alice, email=alice@example.com]
UserSummary[id=3, name=Carol, email=carol@example.com]

=== Event Log ===
User 1 logged in from 192.168.1.10
User 1 updated email
User 2 deactivated: policy violation
Key takeaway

Records compose naturally — UserId is a tiny wrapper record used as a field inside User, which prevents accidentally passing a raw long in the wrong place (type safety). The sealed interface + record variants + switch pattern matching in describeEvent is exhaustive — add a new event type and the compiler will flag every unhandled switch immediately.


Exercises​

Try these on your own to solidify understanding:

  1. Easy: Add a contains(int value) method to the Range record in Example 2 that returns true if min <= value <= max. Verify with a few test cases.
  2. Medium: Create a PageRequest record with page (0-based) and size (1–100). Use a compact constructor to clamp size to the range [1, 100] rather than throwing — so new PageRequest(0, 500) becomes PageRequest[page=0, size=100].
  3. Hard: Extend Example 3's UserEvent sealed hierarchy with a new PasswordChanged(UserId userId, String strength) event. Update describeEvent to handle it. Verify the compiler flags the missing case before you fix it.

Back to Topic​

Return to the Records (Java 16+) note for theory, interview questions, and further reading.