Modern Java (16–21) added features that make data-heavy code — exactly what integrations are full of — far more concise and safer: records for data, sealed types for closed hierarchies, and pattern matching to work with both.
Records: data classes in one line
record Money(double amount, String currency) { }
That single line gives you:
- a constructor
new Money(100, "USD"); - accessor methods
amount()andcurrency()(nogetprefix); equalsandhashCodebased on the values;- a readable
toString():Money[amount=100.0, currency=USD]; finalfields — records are immutable.
Compact constructors: validate and normalise
record Money(double amount, String currency) {
Money {
if (amount < 0) throw new IllegalArgumentException("Negative amount");
currency = currency.toUpperCase(); // normalise before the fields are set
}
Money plus(Money other) { // records can have methods
return new Money(amount + other.amount, currency); // return a new one — never modify
}
}
Records are perfect for DTOs, API payloads, map keys, stream intermediate results and anything that is “just data”. Use a normal class when you need mutable state or inheritance.
Sealed types: a closed set of possibilities
sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled { }
record OrderPlaced(String id, Money value) implements Event { }
record OrderShipped(String id, String tracking) implements Event { }
record OrderCancelled(String id, String reason) implements Event { }
sealed ... permits says: these three are the only kinds of Event. Nobody can add a fourth elsewhere. That sounds like a restriction — but it’s what lets the compiler check your logic.
Pattern matching for switch (Java 21)
static String describe(Event e) {
return switch (e) {
case OrderPlaced(String id, Money(double amt, String cur)) when amt > 1000 ->
id + " placed: BIG order of " + amt + " " + cur;
case OrderPlaced(String id, Money value) -> id + " placed for " + value.amount();
case OrderShipped(String id, String tracking) -> id + " shipped, tracking " + tracking;
case OrderCancelled c -> c.id() + " cancelled: " + c.reason();
};
}
Three features work together here:
- Type patterns —
case OrderCancelled ctests the type and gives you a typed variable. - Record patterns —
OrderPlaced(String id, Money(double amt, String cur))takes the record apart, even nested records, in one step. - Guards —
when amt > 1000adds a condition. Order matters: the more specific case comes first.
And because Event is sealed, the switch is exhaustive: no default is needed. If someone later adds OrderReturned to the permits list, every switch that doesn’t handle it fails to compile — the compiler finds every place you need to update. With if/else instanceof chains, that bug would only show up at runtime.
The full example
// Records.java — records, compact constructors, sealed interfaces and pattern matching (Java 21).
import java.util.List;
public class Records {
// A record: an immutable data carrier with constructor, getters, equals, hashCode, toString
record Money(double amount, String currency) {
// Compact constructor: validate (and normalise) the fields
Money {
if (amount < 0) throw new IllegalArgumentException("Negative amount");
currency = currency.toUpperCase();
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
return new Money(amount + other.amount, currency); // records are immutable: return a new one
}
}
// Sealed: only these three types can implement Event
sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled { }
record OrderPlaced(String id, Money value) implements Event { }
record OrderShipped(String id, String tracking) implements Event { }
record OrderCancelled(String id, String reason) implements Event { }
// Pattern matching for switch (Java 21): record patterns + exhaustiveness
static String describe(Event e) {
return switch (e) {
case OrderPlaced(String id, Money(double amt, String cur)) when amt > 1000 ->
id + " placed: BIG order of " + amt + " " + cur;
case OrderPlaced(String id, Money value) -> id + " placed for " + value.amount() + " " + value.currency();
case OrderShipped(String id, String tracking) -> id + " shipped, tracking " + tracking;
case OrderCancelled c -> c.id() + " cancelled: " + c.reason();
}; // no default needed: the compiler knows every case is covered
}
public static void main(String[] args) {
Money a = new Money(100, "usd");
Money b = new Money(25.5, "USD");
System.out.println(a.plus(b)); // Money[amount=125.5, currency=USD]
System.out.println(a.equals(new Money(100, "USD"))); // true: value-based equality
List<Event> events = List.of(
new OrderPlaced("PO-1", new Money(250, "usd")),
new OrderPlaced("PO-2", new Money(4800, "eur")),
new OrderShipped("PO-1", "1Z999"),
new OrderCancelled("PO-3", "customer request"));
events.forEach(e -> System.out.println(describe(e)));
}
}
Run it with Java 21: java Records.java.
Try it yourself
Add an OrderReturned(String id, int units) event: add it to permits, run the program and read the compiler error, then handle it in describe.
Show a solution
sealed interface Event permits OrderPlaced, OrderShipped, OrderCancelled, OrderReturned { }
record OrderReturned(String id, int units) implements Event { }
// in describe(...):
case OrderReturned(String id, int units) -> id + " returned " + units + " unit(s)";
Before adding the case, the compiler reports that the switch does not cover all possible input values — exactly the safety net sealed types give you.
Key takeaways
- Records are immutable data classes with constructor, accessors,
equals,hashCodeandtoStringbuilt in. - Compact constructors validate and normalise.
- Sealed interfaces define a closed set of types.
- Pattern matching in
switchdeconstructs records, supports guards, and is checked for exhaustiveness.