Java

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

🚀 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.