Chapter 18: Modern Java — Records, Pattern Matching, Sealed Types, and Expressive Syntax
By Ali Naqi | October 28, 2026
🚀 Chapter 18: Modern Java — Records, Pattern Matching, Sealed Types, and Expressive Syntax
1. Records: Immutable Data Carriers without the Ceremony
In enterprise architectures, a large percentage of your classes exist solely to hold and transport data: Data Transfer Objects (DTOs), database projection rows, API request/response payloads, and event messages.
The Old Approach: The 60-Line POJO
Before Java 14, writing a simple two-field immutable carrier required this boilerplate:
// The pre-Java 14 way: 50+ lines of clutter
public final class LegacyUserDto {
private final String id;
private final String email;
public LegacyUserDto(String id, String email) {
this.id = id;
this.email = email;
}
public String getId() { return id; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof LegacyUserDto that)) return false;
return Objects.equals(id, that.id) && Objects.equals(email, that.email);
}
@Override
public int hashCode() {
return Objects.hash(id, email);
}
@Override
public String toString() {
return "LegacyUserDto[id=" + id + ", email=" + email + "]";
}
}
The Modern Approach: One Line
Introduced as standard in Java 16, a Record is a transparent, immutable carrier for shallowly immutable data:
public record UserDto(String id, String email) {}
With that single line, the compiler automatically synthesizes:
* A final class that cannot be extended.
* private final fields corresponding to each record component.
* A canonical public constructor matching the component list.
* Getter methods matching component names directly (e.g., userDto.id(), not getId()).
* Comprehensive, value-based equals() and hashCode() implementations.
* A descriptive toString() showing all components.
Compact Constructors for Clean Validation
Records allow validation using a compact constructor. You omit the parameter list and assignment statements; Java executes your validation rules before running the internal field assignments:
public record BankAccount(String iban, double balance) {
// Compact constructor: No arguments or field assignments needed!
public BankAccount {
if (iban == null || iban.isBlank()) {
throw new IllegalArgumentException("IBAN cannot be null or empty.");
}
if (balance < 0.0) {
throw new IllegalArgumentException("Opening balance cannot be negative.");
}
// Fields are automatically assigned at the end of this block
}
}
> Record Rules:
> * Records cannot extend other classes (they implicitly inherit from java.lang.Record).
> * Records can implement any number of interfaces.
> * Records cannot declare non-static instance fields; all state must be declared within the record header.
> * Record fields are shallowly immutable. If a component holds a mutable object (like a standard ArrayList), that collection's internal contents can still be mutated unless wrapped in Collections.unmodifiableList().
>
2. Text Blocks: Multiline String Cleanliness
Embedding structured data formats (SQL queries, JSON documents, HTML templates, YAML) in classic Java required escaped characters and line-by-line string concatenations:
// Fragile and difficult to read
String legacyJson = "{\n" +
" \"service\": \"auth-gateway\",\n" +
" \"port\": 8080,\n" +
" \"active\": true\n" +
"}";
Standardized in Java 15, Text Blocks use triple quotation marks (""") to preserve newlines and indentation without escape sequences:
String modernJson = """
{
"service": "auth-gateway",
"port": 8080,
"active": true
}
""";
String sqlQuery = """
SELECT u.id, u.username, o.total_amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 'COMPLETED'
ORDER BY o.created_at DESC
LIMIT 25;
""";
Essential Text Block Operators:
* Escaped Line Breaks (\): Suppresses the newline character, keeping long SQL statements readable in source code while compiling into a single continuous line.
* Preserving Trailing Spaces (\s): Retains intentional white spaces at the end of lines that standard IDE auto-formatters would otherwise trim.
3. Pattern Matching for instanceof
Before Java 16, using instanceof was a two-step process: test the type, then manually cast it:
// Pre-Java 16: The redundant cast ceremony
public void processMessage(Object message) {
if (message instanceof String) {
String s = (String) message; // Redundant manual cast
System.out.println("Text length: " + s.length());
}
}
With Pattern Matching for instanceof, the type test and variable binding merge into a single step:
public void processMessage(Object message) {
if (message instanceof String s) {
// 's' is automatically cast and in scope here
System.out.println("Text length: " + s.length());
}
}
Flow Scoping: Smart Compiler Boundaries
The bound variable is scoped strictly to where the condition has proven to be true:
// Condition chaining is legal because Java short-circuits on '&&'
if (message instanceof String s && !s.isBlank()) {
System.out.println("Non-empty text: " + s.toUpperCase());
}
// Scoping with negation:
public void validate(Object input) {
if (!(input instanceof String s)) {
return; // Execution stops here if it's not a String
}
// 's' is safely in scope here because the method returns otherwise!
System.out.println("Valid string: " + s.trim());
}
4. Pattern Matching for switch and Guard Patterns
Java 21 expanded pattern matching into switch statements and expressions, turning switch into a declarative type-dispatch engine.
Type-Matching Switch
Instead of testing simple numbers and strings, switch can now branch directly on the target's runtime type:
public static String formatEntity(Object entity) {
return switch (entity) {
case Integer i -> String.format("Integer: %,d", i);
case Double d -> String.format("Float: %.2f", d);
case Long l -> String.format("Long: %dL", l);
case String s -> "String: \"" + s + "\" (Length: " + s.length() + ")";
case null -> "Null value provided"; // Explicit null handling!
default -> "Unknown Object: " + entity.toString();
};
}
Guard Conditions with when
When you need to match both a type and a specific property value, modern switch introduces the when clause:
public static String evaluateScore(Object input) {
return switch (input) {
case Integer score when score >= 90 -> "Grade: A (Honor Roll)";
case Integer score when score >= 75 -> "Grade: B (Satisfactory)";
case Integer score when score < 50 -> "Grade: F (Failing)";
case Integer score -> "Grade: C (Average)";
case String note when note.equalsIgnoreCase("exempt") -> "Student Exempted";
default -> "Unrecognized scoring payload";
};
}
5. Record Patterns: Deconstructing Data
Java 21 introduced Record Patterns, allowing you to unpack a record's inner components directly in place:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
public class DeconstructionDemo {
public static void printPoint(Object obj) {
// Unpacks x and y into local variables immediately
if (obj instanceof Point(int x, int y)) {
System.out.println("Point coordinates: " + x + ", " + y);
}
}
// Nested deconstruction:
public static void printLineOrigin(Object obj) {
if (obj instanceof Line(Point(int x1, int y1), Point end)) {
System.out.println("Line begins at origin: (" + x1 + ", " + y1 + ")");
}
}
}
6. Sealed Classes: Controlled, Exhaustive Hierarchies
Inheritance is useful, but unbounded inheritance creates maintenance headaches. Anyone can extend your classes unless marked final.
What if you need a middle ground? You want a class to be extended, but only by a specific, known set of subclasses. This is what Sealed Classes (Java 17) provide.
Declaring Sealed Hierarchies
Use the sealed keyword and specify authorized child classes using permits:
// 1. Base interface restricts who can implement it
public sealed interface PaymentStatus
permits PaymentPending, PaymentApproved, PaymentRejected {}
// 2. Permitted implementations must explicitly declare their extension policy:
// They must be either: final, sealed, or non-sealed
public final record PaymentPending(String trackingId) implements PaymentStatus {}
public final record PaymentApproved(String authorizationCode, double amount) implements PaymentStatus {}
public final record PaymentRejected(String reasonCode) implements PaymentStatus {}
Every permitted subclass must choose one of three modifiers:
* final: No further subclasses allowed.
* sealed: The subclass continues the chain, permitting its own specific list of children.
* non-sealed: Opens the subclass for unrestricted extension by anyone.
The Powerhouse Combination: Sealed Types + Switch Exhaustiveness
Because the compiler knows the full set of types that can ever implement PaymentStatus, a switch expression over the interface does not require a default branch:
public static String buildUserNotification(PaymentStatus status) {
// No 'default' branch needed! The compiler proves all paths are covered.
return switch (status) {
case PaymentPending pending -> "Transaction " + pending.trackingId() + " is processing.";
case PaymentApproved approved -> "Payment verified. Auth Code: " + approved.authorizationCode();
case PaymentRejected rejected -> "Payment failed due to: " + rejected.reasonCode();
};
}
If a developer adds a new permitted record later (PaymentRefunded), the code above will fail compilation immediately, pinpointing every single switch statement across your codebase that needs updating. This eliminates missing-case bugs at compile time.
7. Practical Example: Event-Driven Order Processing Engine
Let's integrate Records, Sealed Classes, Pattern Matching, Guard Conditions, and Text Blocks into a domain-driven transaction router.
import java.time.Instant;
// Sealed event hierarchy representing state changes
sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderShipped, OrderCancelled {}
record OrderPlaced(String orderId, double subtotal, String customerEmail) implements OrderEvent {}
record OrderPaid(String orderId, String paymentTransactionId, Instant timestamp) implements OrderEvent {}
record OrderShipped(String orderId, String carrier, String trackingNumber) implements OrderEvent {}
record OrderCancelled(String orderId, String reason, boolean isRefundEligible) implements OrderEvent {}
public class OrderEventDispatcher {
public static String handleEvent(OrderEvent event) {
// Exhaustive switch with record pattern matching and guards
return switch (event) {
case OrderPlaced(var id, var total, var email) when total >= 1000.0 -> """
{
"action": "ROUTE_VIP_FULFILLMENT",
"orderId": "%s",
"priority": "HIGH",
"contact": "%s"
}
""".formatted(id, email);
case OrderPlaced(var id, var total, var email) -> """
{
"action": "ROUTE_STANDARD_FULFILLMENT",
"orderId": "%s",
"priority": "NORMAL"
}
""".formatted(id);
case OrderPaid(var id, var txId, var time) -> """
{
"action": "CAPTURE_PAYMENT",
"orderId": "%s",
"transactionReference": "%s"
}
""".formatted(id, txId);
case OrderShipped(var id, var carrier, var trackNo) -> """
{
"action": "SEND_SHIPPING_NOTIFICATION",
"orderId": "%s",
"courier": "%s",
"trackingUrl": "https://trace.shipping.internal/%s"
}
""".formatted(id, carrier, trackNo);
case OrderCancelled(var id, var reason, true) -> """
{
"action": "ISSUE_AUTOMATED_REFUND",
"orderId": "%s",
"notes": "%s"
}
""".formatted(id, reason);
case OrderCancelled(var id, var reason, false) -> """
{
"action": "CANCEL_WITHOUT_REFUND",
"orderId": "%s",
"notes": "%s"
}
""".formatted(id, reason);
};
}
public static void main(String[] args) {
OrderEvent vipOrder = new OrderPlaced("ORD-9801", 1450.50, "corporate@client.com");
OrderEvent shipped = new OrderShipped("ORD-9801", "FedEx", "FX-9988112");
System.out.println(handleEvent(vipOrder));
System.out.println(handleEvent(shipped));
}
}
📝 Chapter Summary
* Records (record) provide shallowly immutable data carriers with concise syntax, automatically synthesizing getters, constructors, equals(), hashCode(), and toString().
* Use Compact Constructors within records for clean boundary assertions and parameter normalization without explicit field assignment.
* Text Blocks (""") preserve indentation and line breaks, simplifying multi-line strings like JSON, SQL, and HTML.
* Pattern Matching for instanceof pairs type-checks with automatic variable binding, eliminating redundant manual casting.
* switch Expressions & Statements handle pattern matching on types and support Guard Clauses (when) for fine-grained branching.
* Record Patterns allow direct deconstruction of record components within conditions and pattern matches.
* Sealed Classes (sealed, permits) restrict inheritance to an explicitly defined set of subclasses, enabling the compiler to enforce exhaustive switch evaluation without needing a fallback default branch.