Quick Start: The Old Pain, and the New Fix (5 Minutes)
If you’ve been writing Java for a while, you know the ritual. Create a data class, write a constructor, write getters, override equals(), override hashCode(), override toString(). Rinse and repeat for every DTO, value object, or response model in your project.
Here’s what a simple Point class used to look like:
// Java 8–15: the classic boilerplate tax
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) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
Now compare that with Java 16+:
public record Point(int x, int y) {}
One line. The compiler generates a canonical constructor, accessors (x() and y() — not getX()), plus correct equals, hashCode, and toString automatically. The record is also implicitly final and immutable.
That single change cuts 30–40 lines per class. In a typical Spring Boot project with 20+ DTOs, you’re looking at 600–800 lines of boilerplate simply gone. The cognitive load of reading that stuff is real — removing it makes code reviews actually useful again.
Deep Dive: Understanding Each Feature
Records — Beyond Simple DTOs
Records aren’t just syntactic sugar. They encode a contract: this type is a transparent, immutable carrier of its components. That semantic clarity matters when you’re reading unfamiliar code at 2 AM during an incident.
You can add validation in a compact constructor:
public record Range(int min, int max) {
// Compact constructor: no need to repeat parameter list
Range {
if (min > max) throw new IllegalArgumentException(
"min %d must be <= max %d".formatted(min, max)
);
}
}
Beyond validation, records can implement interfaces and define static or additional instance methods. The one firm restriction: no new instance fields outside the declared components.
public interface Measurable {
double length();
}
public record Segment(Point start, Point end) implements Measurable {
@Override
public double length() {
int dx = end.x() - start.x();
int dy = end.y() - start.y();
return Math.sqrt(dx * dx + dy * dy);
}
}
Sealed Classes — Controlled Type Hierarchies
Open inheritance has a fundamental problem: you can never enumerate all the subclasses that exist. Write a method that takes a Shape, and there’s no guarantee that nobody, somewhere, added a Hexagon subclass your switch statement doesn’t cover.
Sealed classes fix this at the language level:
// Only Circle, Rectangle, and Triangle are permitted
public sealed interface Shape
permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
The permits clause is explicit and verified at compile time. Each permitted subclass must be final, sealed (further restricted), or non-sealed (deliberately reopened).
If you’ve worked with Rust’s enum or Haskell’s sum types, this is the Java equivalent — algebraic data types, finally in the JVM.
Pattern Matching — Switch Expressions That Actually Make Sense
Pattern matching arrived in stages. The instanceof version stabilized in Java 16:
// Old way
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.length());
}
// Java 16+ pattern variable
if (obj instanceof String s) {
System.out.println(s.length()); // s is already typed
}
Java 21 LTS brought the full switch pattern matching, which pairs perfectly with sealed classes:
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — the compiler knows these are all cases
};
}
No default case required. Add Pentagon to the sealed interface without updating this switch, and the compiler refuses to build. Caught at compile time — not at 3 AM when the on-call engineer is already stressed.
Advanced Usage: Combining All Three Features
Combining all three is where things get genuinely interesting. A simple expression evaluator shows the pattern clearly:
// Sealed hierarchy with records
public sealed interface Expr
permits Literal, Add, Multiply, Negate {}
public record Literal(double value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Multiply(Expr left, Expr right) implements Expr {}
public record Negate(Expr expr) implements Expr {}
// Pattern matching for evaluation — exhaustive and readable
static double eval(Expr expr) {
return switch (expr) {
case Literal(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Multiply(var l, var r) -> eval(l) * eval(r);
case Negate(var e) -> -eval(e);
};
}
The Literal(var v) syntax is record pattern deconstruction, available in Java 21. It binds the record components directly in the case label — no need to call expr.value() inside the branch.
Need finer control? Add guards with when:
String describe(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "Large circle";
case Circle c -> "Small circle";
case Rectangle r when r.width() == r.height() -> "Square";
case Rectangle r -> "Rectangle";
case Triangle t -> "Triangle";
};
}
The switch evaluates top-to-bottom, first matching case wins. No nested if statements, no fall-through surprises.
Practical Tips for Real Projects
Use Records for API Response Models
Jackson 2.12+ handles records out of the box — it maps JSON fields to constructor parameters directly. Add @JsonIgnoreProperties(ignoreUnknown = true) if your API payload includes fields you don’t need:
public record UserResponse(
long id,
String username,
String email
) {}
No @JsonProperty, no empty constructor, no Lombok. Clean and immutable by default.
Seal Your Domain Events
Event sourcing and CQRS patterns benefit enormously from sealed interfaces. List every event type in the permits clause, and your handler becomes exhaustive — the compiler catches missing cases before they reach production:
public sealed interface OrderEvent
permits OrderPlaced, OrderShipped, OrderCancelled {}
public record OrderPlaced(String orderId, List<String> items) implements OrderEvent {}
public record OrderShipped(String orderId, String trackingNumber) implements OrderEvent {}
public record OrderCancelled(String orderId, String reason) implements OrderEvent {}
void handleEvent(OrderEvent event) {
switch (event) {
case OrderPlaced e -> processNewOrder(e.orderId(), e.items());
case OrderShipped e -> notifyCustomer(e.orderId(), e.trackingNumber());
case OrderCancelled e -> refundOrder(e.orderId(), e.reason());
}
}
Check Your Java Version Before Adopting
- Java 16: Records stable,
instanceofpattern stable - Java 17 LTS: Sealed classes stable — minimum recommended version for these features
- Java 21 LTS: Switch pattern matching stable, record deconstruction stable — current target
Stuck on Java 11? Start planning your upgrade now. Java 17 LTS has extended support until 2029; Java 21 LTS runs through 2031. Neither will leave you stranded, and both give you everything covered in this article.
Don’t Force Records Everywhere
Records shine for data carriers — DTOs, value objects, response models, configuration snapshots. They’re a poor fit for JPA entities (mutable state, no-arg constructor required) or classes built around complex inheritance. Use the right tool.
Together, Records, Sealed Classes, and Pattern Matching encode domain rules in the type system — making invalid states fail at compile time instead of sneaking into production. That guarantee is hard to retrofit once a codebase matures. Worth baking in from day one.

