Java · lesson 32 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Records in Java
Learn Java records by running them: generated accessors and equals, compact constructors, custom methods, sealed hierarchies, and record patterns.
By SkillByExample · Updated
A record is a concise, immutable data carrier (Java 16+). You declare the components once in
the header and the compiler generates the constructor, accessors, equals, hashCode, and
toString. Every example is a single file run with the JDK 21 source launcher (java File.java).
What a record generates
public class RecordDemo {
record Point(int x, int y) {}
public static void main(String[] args) {
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
Point p3 = new Point(1, 2);
System.out.println(p1.x() + " " + p1.y());
System.out.println(p1);
System.out.println(p1.equals(p2) + " " + p1.equals(p3));
System.out.println(p1.hashCode() == p2.hashCode());
System.out.println(p1 == p2);
}
}
$ java RecordDemo.java
3 4
Point[x=3, y=4]
true false
true
false
p1.equals(p2) is true even though they are two separate objects — the generated equals
compares components, not identity, which p1 == p2 still reports as false. Accessors are named
after the components (x(), not getX()), and toString uses a fixed Name[field=value] format.
The equivalent hand-written class needs a constructor, two getters and three overrides.
Compact constructors: validation and normalisation
A compact constructor has no parameter list. Its body runs before the fields are assigned, so it can reject arguments or reassign the parameters.
public class CompactCtorDemo {
record Range(int min, int max) {
Range {
if (min > max)
throw new IllegalArgumentException("min %d > max %d".formatted(min, max));
}
}
record Email(String address) {
Email {
if (address == null || !address.contains("@"))
throw new IllegalArgumentException("Invalid email: " + address);
address = address.strip().toLowerCase(); // reassign the parameter
}
}
public static void main(String[] args) {
System.out.println(new Range(1, 10));
try {
new Range(10, 1);
} catch (IllegalArgumentException e) {
System.out.println("Caught: " + e.getMessage());
}
System.out.println(new Email(" Alice@Example.COM ").address());
}
}
$ java CompactCtorDemo.java
Range[min=1, max=10]
Caught: min 10 > max 1
alice@example.com
Email reassigns its own parameter, and the implicit field assignment that follows the compact
body picks up the normalised value. Assigning this.address directly would be a compile error —
in a compact constructor the fields are written for you, after your code runs.
Final fields are not deep immutability
import java.util.ArrayList;
import java.util.List;
public class ShallowDemo {
record Team(String name, List<String> members) {}
record SafeTeam(String name, List<String> members) {
SafeTeam { members = List.copyOf(members); }
}
public static void main(String[] args) {
List<String> people = new ArrayList<>(List.of("Ada"));
Team team = new Team("core", people);
SafeTeam safe = new SafeTeam("core", people);
people.add("Linus");
System.out.println(team);
System.out.println(safe);
try {
safe.members().add("Grace");
} catch (UnsupportedOperationException e) {
System.out.println("SafeTeam is unmodifiable");
}
}
}
$ java ShallowDemo.java
Team[name=core, members=[Ada, Linus]]
SafeTeam[name=core, members=[Ada]]
SafeTeam is unmodifiable
Team stores a reference to the caller’s list, so adding Linus afterwards changed the record’s
contents. SafeTeam took an immutable copy in its compact constructor, so it kept the original
member list and refuses modification through its accessor.
Adding methods: derived values and withers
public class PointMethodsDemo {
record Point(double x, double y) {
static final Point ORIGIN = new Point(0, 0);
double distanceTo(Point other) {
return Math.hypot(x - other.x, y - other.y);
}
Point withX(double newX) { return new Point(newX, y); }
static Point polar(double r, double theta) {
return new Point(r * Math.cos(theta), r * Math.sin(theta));
}
}
public static void main(String[] args) {
Point p = new Point(3, 4);
System.out.println(p.distanceTo(Point.ORIGIN));
Point moved = p.withX(6);
System.out.println(moved + " " + p);
Point polar = Point.polar(5, Math.PI / 4);
System.out.printf("%.2f, %.2f%n", polar.x(), polar.y());
}
}
$ java PointMethodsDemo.java
5.0
Point[x=6.0, y=4.0] Point[x=3.0, y=4.0]
3.54, 3.54
withX cannot mutate p — its fields are final — so it returns a new Point, and p is
unchanged on the same output line. This “wither” pattern gives update-like ergonomics on an
immutable type. Static fields and static factories like polar are allowed; extra instance fields
are not.
Records implementing interfaces
import java.util.Comparator;
import java.util.List;
public class ShapeRecordsDemo {
interface Shape { double area(); }
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
}
public static void main(String[] args) {
List<Shape> shapes = List.of(new Circle(5), new Rectangle(4, 6), new Circle(3));
shapes.stream()
.sorted(Comparator.comparingDouble(Shape::area).reversed())
.forEach(s -> System.out.printf("%s -> area=%.2f%n", s, s.area()));
}
}
$ java ShapeRecordsDemo.java
Circle[radius=5.0] -> area=78.54
Circle[radius=3.0] -> area=28.27
Rectangle[width=4.0, height=6.0] -> area=24.00
Records cannot extend a class but can implement interfaces, so they take part in ordinary
polymorphism. The %s calls each record’s generated toString, which is why the output shows
Circle[radius=5.0] rather than a class name and hash. Interface methods implemented in a record
must be public, as in any class.
Records in sealed hierarchies with record patterns
public class SealedResultDemo {
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String error, int code) implements Result<T> {}
static <T> String handle(Result<T> result) {
return switch (result) {
case Ok<T>(var value) -> "Success: " + value;
case Err<T>(var error, var code) -> "Error " + code + ": " + error;
};
}
public static void main(String[] args) {
System.out.println(handle(new Ok<>(42)));
System.out.println(handle(new Err<>("timeout", 504)));
}
}
$ java SealedResultDemo.java
Success: 42
Error 504: timeout
sealed ... permits Ok, Err tells the compiler these are the only implementations, so the
switch needs no default and stays exhaustive. The record patterns Ok<T>(var value)
(Java 21) test the type and pull out the components in one step — no casts, no accessor calls.
Records as map keys and set members
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class CoordinateDemo {
record Coordinate(int row, int col) {}
public static void main(String[] args) {
Map<Coordinate, String> grid = new HashMap<>();
grid.put(new Coordinate(0, 0), "start");
grid.put(new Coordinate(3, 4), "end");
System.out.println(grid.get(new Coordinate(0, 0)));
Set<Coordinate> visited = new HashSet<>();
visited.add(new Coordinate(1, 1));
visited.add(new Coordinate(1, 1));
System.out.println(visited.contains(new Coordinate(1, 1)) + " size=" + visited.size());
}
}
$ java CoordinateDemo.java
start
true size=1
The lookup key is a different object from the one passed to put; it matches only because the
generated equals and hashCode agree on row and col. Adding an equal coordinate twice leaves
the set at size 1. A plain class without both overrides would fail the lookup and store duplicates.
When to use records vs classes
| Situation | Use |
|---|---|
| Pure data carrier, value object, DTO | record |
| Map key or set element | record |
| Needs to extend a non-record class | class |
| Mutable state or a complex lifecycle | class |
| JPA entity (needs no-arg constructor + setters) | class |
Practice
- Add a
translate(int dRow, int dCol)method toCoordinatethat returns a shifted copy, and confirmnew Coordinate(0, 0).translate(3, 4)equalsnew Coordinate(3, 4).
Solution
public class Translate {
record Coordinate(int row, int col) {
Coordinate translate(int dRow, int dCol) {
return new Coordinate(row + dRow, col + dCol);
}
}
public static void main(String[] args) {
Coordinate moved = new Coordinate(0, 0).translate(3, 4);
System.out.println(moved + " " + moved.equals(new Coordinate(3, 4)));
}
}
$ java Translate.java
Coordinate[row=3, col=4] true
- Give
Rangeaboolean contains(int n)method (inclusive on both ends) and a second constructorRange(int max)that delegates tothis(0, max).
Solution
public class RangeExtra {
record Range(int min, int max) {
Range {
if (min > max) throw new IllegalArgumentException("min > max");
}
Range(int max) { this(0, max); }
boolean contains(int n) { return n >= min && n <= max; }
}
public static void main(String[] args) {
Range r = new Range(5);
System.out.println(r + " " + r.contains(5) + " " + r.contains(6));
}
}
$ java RangeExtra.java
Range[min=0, max=5] true false
Any non-canonical constructor must start by calling another constructor with this(...), so the
compact constructor’s validation always runs.
- Add a third permitted type
Pending<T>()toResultand see the compiler rejecthandle; then add a case for it.
Solution
public class ResultPending {
sealed interface Result<T> permits Ok, Err, Pending {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String error) implements Result<T> {}
record Pending<T>() implements Result<T> {}
static <T> String handle(Result<T> result) {
return switch (result) {
case Ok<T>(var value) -> "Success: " + value;
case Err<T>(var error) -> "Error: " + error;
case Pending<T> p -> "Still waiting";
};
}
public static void main(String[] args) {
System.out.println(handle(new Pending<>()));
}
}
$ java ResultPending.java
Still waiting
Without the Pending case the compiler reports the switch expression does not cover all possible input values.
- Make a
Playlist(String name, List<String> songs)record that is safe against the caller mutating the list they passed in, and show it.
Solution
import java.util.ArrayList;
import java.util.List;
public class PlaylistDemo {
record Playlist(String name, List<String> songs) {
Playlist { songs = List.copyOf(songs); }
}
public static void main(String[] args) {
List<String> songs = new ArrayList<>(List.of("Intro"));
Playlist p = new Playlist("mix", songs);
songs.clear();
System.out.println(p);
}
}
$ java PlaylistDemo.java
Playlist[name=mix, songs=[Intro]]