Skip to main content
Java intermediate Lesson 32 of 58

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

SituationUse
Pure data carrier (no mutable state)record
Value object in domain modelrecord
DTO / API request/responserecord
Map key, set elementrecord
Needs inheritance from a non-recordclass
Has mutable stateclass
Complex lifecycle / builder patternclass
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.