Records in Java
Learn Java records (Java 16+) — immutable data carriers, compact constructors, custom methods, and when to use records over classes.
What Are Records?
A record is a concise, immutable data carrier introduced in Java 16. One line replaces the typical boilerplate of a POJO: constructor, accessors, equals, hashCode, and toString are all generated automatically.
// Old-style POJO — 30+ lines of boilerplate
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
// Record — one line
public record Point(int x, int y) {}
Basic Usage
public record Point(int x, int y) {}
public class RecordDemo {
public static void main(String[] args) {
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
Point p3 = new Point(1, 2);
// Accessors — named after the component, no "get" prefix
System.out.println(p1.x()); // 3
System.out.println(p1.y()); // 4
// toString — auto-generated
System.out.println(p1); // Point[x=3, y=4]
// equals — compares all components
System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false
// hashCode — consistent with equals
System.out.println(p1.hashCode() == p2.hashCode()); // true
// Fields are final — no setters
// p1.x = 5; // compile error
}
}
Compact Constructors — Validation
The compact constructor runs before the canonical constructor assigns the fields. Use it for validation and normalisation:
public record Range(int min, int max) {
// Compact constructor — no parameter list, no field assignments
// (assignments happen automatically after this block)
public Range {
if (min > max) {
throw new IllegalArgumentException(
"min (%d) must be <= max (%d)".formatted(min, max));
}
}
}
public record Email(String address) {
public Email {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + address);
}
address = address.strip().toLowerCase(); // normalise before assignment
}
}
// Usage
var r = new Range(1, 10); // OK
var bad = new Range(10, 1); // IllegalArgumentException
var e = new Email(" Alice@Example.COM ");
System.out.println(e.address()); // alice@example.com
Adding Methods to Records
Records can have instance methods, static methods, and static fields:
public record Point(double x, double y) {
// Static constant
public static final Point ORIGIN = new Point(0, 0);
// Instance method
public double distanceTo(Point other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
public double distanceFromOrigin() {
return distanceTo(ORIGIN);
}
// Derived property
public boolean isOnXAxis() {
return y == 0;
}
// "Wither" — produces a new record with one field changed
public Point withX(double newX) { return new Point(newX, this.y); }
public Point withY(double newY) { return new Point(this.x, newY); }
// Static factory
public static Point polar(double r, double theta) {
return new Point(r * Math.cos(theta), r * Math.sin(theta));
}
}
// Usage
Point p = new Point(3, 4);
System.out.println(p.distanceFromOrigin()); // 5.0
Point moved = p.withX(6);
System.out.println(moved); // Point[x=6.0, y=4.0]
Point polar = Point.polar(5, Math.PI / 4);
System.out.printf("%.2f, %.2f%n", polar.x(), polar.y()); // 3.54, 3.54
Records Implementing Interfaces
public interface Shape {
double area();
double perimeter();
}
public record Circle(double radius) implements Shape {
public Circle {
if (radius <= 0) throw new IllegalArgumentException("Radius must be positive");
}
@Override public double area() { return Math.PI * radius * radius; }
@Override public double perimeter() { return 2 * Math.PI * radius; }
}
public record Rectangle(double width, double height) implements Shape {
@Override public double area() { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}
// Polymorphism with records
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()));
Records in Sealed Hierarchies
Records pair perfectly with sealed interfaces to model algebraic data types:
public sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String error) implements Result<T> {}
}
// Usage with pattern matching
static <T> void handle(Result<T> result) {
switch (result) {
case Result.Ok<T> ok -> System.out.println("Success: " + ok.value());
case Result.Err<T> err -> System.err.println("Error: " + err.error());
}
}
handle(new Result.Ok<>(42)); // Success: 42
handle(new Result.Err<>("timeout")); // Error: timeout
Records as DTOs (Data Transfer Objects)
// Request DTO
public record CreateUserRequest(
String username,
String email,
String password
) {
public CreateUserRequest {
if (username == null || username.isBlank())
throw new IllegalArgumentException("Username required");
if (!email.contains("@"))
throw new IllegalArgumentException("Invalid email");
if (password.length() < 8)
throw new IllegalArgumentException("Password too short");
}
}
// Response DTO — no sensitive fields
public record UserResponse(
long id,
String username,
String email,
String createdAt
) {}
// Database row mapping
public record OrderSummary(
String orderId,
String customerName,
double total,
String status
) {}
Records in Collections
Because records auto-generate correct equals and hashCode, they work seamlessly as Map keys and Set members:
record Coordinate(int row, int col) {}
// Safe to use as HashMap key
Map<Coordinate, String> grid = new HashMap<>();
grid.put(new Coordinate(0, 0), "start");
grid.put(new Coordinate(3, 4), "end");
// Lookup works correctly — same values = same key
System.out.println(grid.get(new Coordinate(0, 0))); // start
// Safe in Sets
Set<Coordinate> visited = new HashSet<>();
visited.add(new Coordinate(1, 1));
System.out.println(visited.contains(new Coordinate(1, 1))); // true
When to Use Records vs Classes
| Situation | Use |
|---|---|
| Pure data carrier (no mutable state) | record |
| Value object in domain model | record |
| DTO / API request/response | record |
| Map key, set element | record |
| Needs inheritance from a non-record | class |
| Has mutable state | class |
| Complex lifecycle / builder pattern | class |
| JPA entity (needs no-arg constructor + setters) | class |
Frequently Asked Questions
Are Java records the same as Kotlin data classes?
They are similar — both auto-generate equals, hashCode, toString, and accessors. The main difference: Java records are always immutable (all fields are final), while Kotlin data classes can have mutable var fields. Records also have a canonical constructor and support compact constructors for validation.
Can a record extend another class?
No. Records implicitly extend java.lang.Record and cannot extend any other class. They can, however, implement interfaces, which makes them useful in sealed interface hierarchies.
Can I add methods to a record?
Yes. Records can have instance methods, static methods, static fields, and inner classes. They just cannot have instance fields beyond the components declared in the header.
Are records serializable?
Yes, if you add 'implements Serializable'. Java has special handling for records during serialization — it uses the canonical constructor during deserialization, which means custom validation in the compact constructor runs correctly.