Core Java

Modern Java Language Features: Records, Sealed Classes, Pattern Matching

Java’s evolution over the past few years represents the most significant transformation in how we write code since the introduction of generics in Java 5. The addition of records, sealed classes, and pattern matching doesn’t just provide syntactic sugar—these features fundamentally change how we model data, express domain concepts, and handle control flow. They bring Java closer to modern functional programming paradigms while preserving its object-oriented foundations and legendary backward compatibility.

The impact is already visible in adoption statistics. According to BellSoft’s 2024 Java Developer Survey, records lead the way as the most popular modern feature with 55% adoption among developers. This isn’t surprising—records solve real pain points that Java developers have experienced for decades. The survey also reveals that these language features facilitating development and maintenance rank at the top of priorities, even ahead of performance-focused innovations like virtual threads.

What makes these features particularly transformative is how they work together synergistically. Records provide immutable data carriers with minimal boilerplate. Sealed classes enable algebraic data types that Java never supported before. Pattern matching allows elegant deconstruction and type testing. Combined, they enable programming patterns that were previously impossible or required verbose workarounds that obscured intent.

For teams managing large Java codebases, these features represent both opportunity and challenge. The opportunity lies in dramatically reduced boilerplate, improved type safety, and more expressive domain models. The challenge involves migrating existing code strategically, training teams on new idioms, and deciding when modern features provide genuine value versus when they’re unnecessary complexity. Understanding these features deeply and applying them appropriately separates code that merely uses modern Java from code that leverages it effectively.

Java requirements:

  • Java 16 or newer: Required for Records (standard feature).
  • Java 17 or newer: Required for Sealed Classes (standard feature).
  • Java 21 or newer: Required for the full power of Switch Pattern Matching (standard feature) and all the combined code examples (like the Command Processing $switch$).
  • String Templates: The STR."..." template is a preview feature in Java 21/22 and requires compiling with the --enable-preview flag.

Records and Data-Oriented Programming

Records, introduced as a standard feature in Java 16, address one of Java’s longest-standing sources of boilerplate: the data carrier class. Before records, creating a simple immutable class to hold data required writing constructors, getters, equals, hashCode, and toString methods. Even with IDE generation or libraries like Lombok, these classes remained verbose and their intent obscured by mechanical code.

A record transforms this verbosity into a single declaration. The canonical example shows the dramatic reduction:

// Pre-records: ~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 x() { return x; }
    public int y() { return y; }
        
    @Override
    public boolean equals(Object obj) {
        // 10+ lines of equals logic
    }
        
    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
        
    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}
// With records: 1 line
public record Point(int x, int y) {}

The record declaration automatically generates a canonical constructor, accessor methods (note they’re named x() and y(), not getX() and getY()), proper equals and hashCode implementations, and a useful toString. More importantly, records communicate intent—this is pure data, not behavior-heavy objects with complex state management.

Records’ immutability proves more than a convenience feature. It enables safe sharing across threads without synchronization, makes objects suitable as map keys or set elements, and supports functional programming patterns where data flows through transformations rather than being mutated in place. For distributed systems where data crosses service boundaries, immutability prevents the subtle bugs that arise from unexpected mutations.

The constraints on records reflect deliberate design decisions. Records cannot extend other classes (though they implicitly extend java.lang.Record) and all fields are final. These restrictions might seem limiting, but they enforce the record’s purpose: simple, transparent data carriers. When you need inheritance hierarchies or mutable state, you’re no longer modeling pure data and shouldn’t use records.

Records support customization where needed. You can add additional constructors that delegate to the canonical constructor, validate arguments in compact constructors, and implement interfaces. Static methods and instance methods can provide derived data or transformations. This flexibility allows records to handle real-world requirements while maintaining their core simplicity:

public record Temperature(double celsius) {
    // Compact constructor for validation
    public Temperature {
        if (celsius < -273.15) {
            throw new IllegalArgumentException("Below absolute zero");
        }
    }
        
    // Derived properties via methods
    public double fahrenheit() {
        return celsius * 9/5 + 32;
    }
        
    // Static factory methods
    public static Temperature fromFahrenheit(double f) {
        return new Temperature((f - 32) * 5/9);
    }
}

Records work excellently with Java’s Stream API and functional constructs. Collections of records become natural data pipelines. The combination of immutability, value-based equality, and straightforward transformation methods enables clean, expressive code for data processing:

List<Point> points = List.of(
    new Point(0, 0),
    new Point(3, 4),
    new Point(1, 1)
);

double averageDistance = points.stream()
    .mapToDouble(p -> Math.sqrt(p.x() * p.x() + p.y() * p.y()))
    .average()
    .orElse(0.0);

The shift toward data-oriented programming that records enable represents a philosophical change in Java development. Rather than encapsulating data within objects with complex internal state and behavior, we increasingly model data explicitly and operate on it through functions and transformations. This aligns with how distributed systems naturally work—data flows between services, gets transformed, stored, and retrieved. Records make this programming model natural in Java where it previously felt awkward.

For API design, records provide clarity about data transfer objects. A record in a method signature immediately communicates that this object is immutable data being passed between layers. This differs semantically from traditional classes where mutability status requires reading documentation or inspecting code. The type system now expresses concepts that previously existed only in conventions and comments.

Sealed Classes and Algebraic Data Types

Sealed classes, finalized in Java 17, bring algebraic data types to Java—a feature long available in functional languages like Haskell, Scala, and Kotlin but entirely absent from Java’s type system. They solve the “expression problem”—how to define a type that can be exactly one of several known alternatives, nothing more and nothing less.

Traditional Java inheritance is open—any class can extend your base class unless you mark it final. This openness creates uncertainty: when you write code handling a base type, you can never know whether someone has subclassed it. Sealed classes close this hierarchy, declaring explicitly and exhaustively which subclasses exist:

public sealed interface Shape
    permits Circle, Rectangle, Square {
}

public final class Circle implements Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double radius() { return radius; }
}

public final class Rectangle implements Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double width() { return width; }
    public double height() { return height; }
}

public final class Square implements Shape {
    private final double side;
    public Square(double side) { this.side = side; }
    public double side() { return side; }
}

The permits clause defines the complete set of implementations. No other class can implement Shape, even in the same package. Each permitted subclass must be marked as sealed (allowing further controlled extension), non-sealed (opening it for unrestricted extension), or final (preventing any extension). This creates a type hierarchy that’s simultaneously flexible in design yet locked down in implementation.

The power of sealed classes emerges when combined with pattern matching. Because the compiler knows exactly which implementations exist, it can verify exhaustiveness—ensuring you’ve handled every possible case. This catches bugs at compile time rather than runtime:

public static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Square s -> s.side() * s.side();
        // No default needed - compiler knows these are all cases
    };
}

If you add a new shape to the sealed interface, the compiler immediately flags every switch expression that doesn’t handle it. This compile-time safety is impossible with traditional inheritance where the set of implementations is open-ended. For domain modeling, sealed classes provide precisely the guarantees you want: this payment can be CreditCard, BankTransfer, or Cash—nothing else. This order state is New, Processing, Shipped, or Delivered—exhaustively.

Sealed classes naturally express state machines where states and transitions are well-defined. An order processing system might model order states as a sealed hierarchy:

public sealed interface OrderState permits New, Paid, Shipped, Delivered, Cancelled {}

record New(LocalDateTime created) implements OrderState {}
record Paid(LocalDateTime paidAt, PaymentMethod method) implements OrderState {}
record Shipped(LocalDateTime shippedAt, String trackingNumber) implements OrderState {}
record Delivered(LocalDateTime deliveredAt) implements OrderState {}
record Cancelled(LocalDateTime cancelledAt, String reason) implements OrderState {}

Combining sealed classes with records creates powerful yet simple domain models. Each state carries exactly the data relevant to that state, type-safely enforced by the compiler. Transitions between states become explicit functions that produce new state objects, maintaining immutability throughout.

The algebraic data type capability comes from how sealed classes interact with generics. You can model sum types (this or that) and combine them with product types (this and that). This enables sophisticated type-level modeling:

public sealed interface Result<T, E> permits Success, Failure {}
record Success<T, E>(T value) implements Result<T, E> {}
record Failure<T, E>(E error) implements Result<T, E> {}

This Result type explicitly models operations that might succeed (producing a value of type T) or fail (producing an error of type E). Unlike exceptions that escape the type system, Result types make failure handling explicit and compiler-checked. Code using Result must explicitly handle both success and failure cases, eliminating the overlooked exception problem.

For API design, sealed classes provide a powerful tool for versioning and compatibility. By carefully controlling which implementations exist, you can evolve your API without breaking existing clients. Add a new implementation, and the compiler flags all locations that need updating. This explicit breaking change is far preferable to runtime surprises.

The philosophical shift sealed classes enable moves Java toward more precise type modeling. Rather than broad abstractions with infinite possible implementations, we model domains with finite, explicit alternatives. This precision enables better tooling support, clearer code, and stronger compile-time guarantees—the combination that made Java successful but previously required verbose patterns like extensive switch statements on enum types.

Pattern Matching Evolution

Pattern matching, progressively enhanced from Java 14 through Java 21, transforms how we test types and extract data. Traditional Java required explicit instanceof checks followed by casts—a pattern so common that its verbosity became background noise:

// Traditional approach
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println("Length: " + s.length());
}

Pattern matching with instanceof collapses this into a single expression:

// Modern pattern matching
if (obj instanceof String s) {
    System.out.println("Length: " + s.length());
}

The pattern variable s is automatically in scope and properly typed, eliminating both the cast and the duplicate declaration. This seems like minor sugar, but it compounds through real codebases where instanceof checks appear constantly. More importantly, it enables patterns that were previously too verbose to write naturally.

The evolution continues with switch pattern matching, finalized in Java 21. Switch expressions, which themselves were a significant improvement, now support patterns beyond just constant cases:

public static String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case String s -> "String of length: " + s.length();
        case int[] arr -> "Array of length: " + arr.length;
        case null -> "null value";
        default -> "Unknown type";
    };
}

Each case can include a pattern that binds to a variable, allowing immediate use of the matched value in its correct type. The pattern variable exists only in the scope of that case, preventing the accidental reuse across unrelated cases that plagued traditional switch statements.

Guarded patterns add conditional logic directly in switch cases:

public static String categorize(String s) {
    return switch (s) {
        case String str when str.isEmpty() -> "empty";
        case String str when str.length() < 10 -> "short";
        case String str when str.length() < 100 -> "medium";
        case String str -> "long";
    };
}

The when clause specifies an additional condition that must hold for the pattern to match. This enables expressing complex conditional logic declaratively without nested if-else chains. Guards make intent clearer—you’re categorizing based on specific criteria, not implementing generic conditional logic.

Record patterns, the most powerful pattern matching enhancement, enable deconstruction of record components directly in patterns:

record Point(int x, int y) {}

public static String quadrant(Object obj) {
    return switch (obj) {
        case Point(int x, int y) when x > 0 && y > 0 -> "First quadrant";
        case Point(int x, int y) when x < 0 && y > 0 -> "Second quadrant";
        case Point(int x, int y) when x < 0 && y < 0 -> "Third quadrant";
        case Point(int x, int y) when x > 0 && y < 0 -> "Fourth quadrant";
        case Point(int x, int y) -> "Origin or axis";
        default -> "Not a point";
    };
}

The pattern Point(int x, int y) simultaneously tests if obj is a Point and extracts its components. This nested destructuring works recursively for records containing other records:

record Rectangle(Point topLeft, Point bottomRight) {}

public static boolean contains(Object obj, Point p) {
    return switch (obj) {
        case Rectangle(Point(int x1, int y1), Point(int x2, int y2)) ->
            p.x() >= x1 && p.x() <= x2 && p.y() >= y1 && p.y() <= y2;
        default -> false;
    };
}

This deeply nested pattern matching would require multiple levels of instanceof checks and casts in traditional Java. The pattern matching version is not only more concise but more clearly expresses intent—we’re testing for a rectangle and immediately accessing its constituent points’ coordinates.

Pattern matching’s real power emerges when combined with sealed classes. Because sealed classes define exhaustive sets of implementations, the compiler can verify that switch expressions handle all cases without requiring a default:

public sealed interface Result<T> permits Success, Failure {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}

public static <T> String format(Result<T> result) {
    return switch (result) {
        case Success<T>(T value) -> "Success: " + value;
        case Failure<T>(String error) -> "Failed: " + error;
        // No default needed - compiler knows these are exhaustive
    };
}

If you add a new implementation to the Result sealed interface, every switch expression becomes a compile error until you add a case for the new type. This exhaustiveness checking catches bugs at the earliest possible moment—when you’re adding the new type, not weeks later when it reaches production.

The combination of records, sealed classes, and pattern matching enables expressing complex domain logic with exceptional clarity. Consider a command processing system:

public sealed interface Command permits GetUser, UpdateUser, DeleteUser {}
record GetUser(long userId) implements Command {}
record UpdateUser(long userId, UserData data) implements Command {}
record DeleteUser(long userId, String reason) implements Command {}

public Result<String> process(Command cmd) {
    return switch (cmd) {
        case GetUser(long id) -> fetchUser(id);
        case UpdateUser(long id, UserData data) -> updateUser(id, data);
        case DeleteUser(long id, String reason) -> deleteUser(id, reason);
    };
}

This code is simultaneously type-safe, exhaustive, and self-documenting. The types express exactly what commands exist, what data each carries, and the compiler ensures all cases are handled. Adding a new command type flags every location that processes commands, preventing the forgotten case bug that plagues systems with open-ended command sets.

Text Blocks and String Templates

Text blocks, introduced in Java 15, solve the longstanding problem of embedding multi-line strings in source code. Before text blocks, including SQL queries, JSON, HTML, or any multi-line text required error-prone string concatenation or clumsy formatting:

// Pre-text blocks
String query = "SELECT users.name, orders.id, orders.total\n" +
               "FROM users\n" +
               "JOIN orders ON users.id = orders.user_id\n" +
               "WHERE orders.status = 'PENDING'\n" +
               "ORDER BY orders.created_at DESC";

Text blocks use triple quotes to delimit multi-line strings, preserving formatting and eliminating escape sequences:

// With text blocks
String query = """
    SELECT users.name, orders.id, orders.total
    FROM users
    JOIN orders ON users.id = orders.user_id
    WHERE orders.status = 'PENDING'
    ORDER BY orders.created_at DESC
    """;

The incidental indentation (the common whitespace prefix on all lines) is automatically removed, allowing natural source code formatting while producing the expected string value. This feature dramatically improves readability for embedded DSLs, SQL queries, JSON templates, and configuration snippets that appear throughout Java codebases.

Text blocks support escape sequences when needed, including the new escape for suppressing newlines and managing trailing whitespace. This flexibility handles edge cases while maintaining the natural formatting for typical usage:

String json = """
    {
        "name": "John Doe",
        "age": 30,
        "email": "john@example.com"
    }
    """;

String templates, recently introduced, extend text blocks with interpolation capabilities. While still a preview feature as of Java 21 and evolving through Java 22, string templates address the common need for embedding variables and expressions in strings safely:

// Preview feature - syntax may change
String name = "Alice";
int age = 30;
String message = STR."Hello \{name}, you are \{age} years old";

The STR template processor evaluates embedded expressions (delimited by curly braces) and interpolates them into the result string. Unlike string concatenation or String.format, templates are type-safe and can be validated at compile time. Different template processors enable various behaviors—JSON templates that properly escape values, SQL templates that prevent injection, or custom processors for domain-specific formatting.

The type safety aspect proves crucial. Traditional string concatenation with variables creates strings that might be invalid for their purpose—SQL with injection vulnerabilities, JSON with improper escaping, URLs with unencoded characters. Template processors can enforce correct formatting while maintaining the natural appearance of embedded expressions:

// SQL template with proper escaping (conceptual)
String userId = getUserInput();
String query = SQL."SELECT * FROM users WHERE id = \{userId}";
// Template processor ensures userId is properly escaped

For codebases that generate significant amounts of formatted text—REST API responses, HTML pages, configuration files, log messages—text blocks and templates reduce both error rates and cognitive load. The code looks like the output it produces, making intent obvious and reducing the mental translation between source representation and runtime string value.

Switch Expressions and Enhanced Control Flow

Switch expressions, standardized in Java 14, transform switch from a statement with side effects into an expression that produces values. This seemingly simple change has profound implications for code clarity and safety.

Traditional switch statements execute code blocks with fall-through behavior requiring explicit break statements:

// Traditional switch statement
String dayType;
switch (day) {
    case MONDAY:
    case TUESDAY:
    case WEDNESDAY:
    case THURSDAY:
    case FRIDAY:
        dayType = "Weekday";
        break;
    case SATURDAY:
    case SUNDAY:
        dayType = "Weekend";
        break;
    default:
        throw new IllegalArgumentException("Invalid day");
}

Switch expressions use arrow syntax and automatically break after each case, eliminating the fall-through bugs that have plagued switch statements since Java’s inception:

// Switch expression
String dayType = switch (day) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
    case SATURDAY, SUNDAY -> "Weekend";
};

The expression form makes several improvements simultaneously. Multiple constant cases can be combined with comma separation. No breaks are needed because each arrow case is self-contained. Most importantly, the compiler enforces exhaustiveness for enum types—if you don’t handle all enum values and don’t provide a default, it’s a compile error. This catches the missing case bug at compile time rather than discovering it through production null pointer exceptions.

Switch expressions can include blocks for multi-statement cases, with the block yielding the result value:

int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY -> 7;
    case THURSDAY, SATURDAY -> 8;
    case WEDNESDAY -> {
        System.out.println("Midweek");
        yield 9;
    }
};

The yield keyword returns a value from a switch block, analogous to return in methods. This allows complex computation within a case while maintaining the expression semantics—the switch must produce a value for every path.

When combined with pattern matching, switch expressions become a powerful tool for expressing polymorphic behavior without traditional OO polymorphism. Rather than implementing visitor patterns or using instanceof chains, you can directly pattern match and extract:

public static double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle(double radius) -> Math.PI * radius * radius;
        case Rectangle(double width, double height) -> width * height;
        case Square(double side) -> side * side;
    };
}

This approach provides the extensibility of the visitor pattern (adding new operations) while avoiding its complexity. The sealed interface ensures exhaustiveness checking, the record patterns extract data declaratively, and the switch expression produces the result naturally.

For null handling, modern switch treats null as a distinct case rather than throwing NullPointerException:

String result = switch (value) {
    case null -> "null value";
    case String s -> "String: " + s;
};

This makes null handling explicit in the control flow rather than requiring separate null checks before the switch. The pattern “check for null, then do real work” collapses into a single switch expression that handles both cases uniformly.

The expression-oriented style that switch expressions enable aligns Java more closely with functional programming languages while maintaining its imperative foundation. You can write code that computes values through pattern matching without introducing mutable variables for intermediate results. This reduces cognitive load—readers follow data transformations rather than tracking state mutations.

Impact on Code Design and Architecture

The cumulative impact of records, sealed classes, pattern matching, and enhanced control flow reaches far beyond syntax improvements. These features enable architectural patterns that were previously too verbose or awkward to implement cleanly in Java.

Domain modeling becomes more precise and self-documenting. Rather than modeling concepts with classes that might have any of dozens of possible states, sealed hierarchies explicitly enumerate valid states. This precision eliminates entire categories of invalid state bugs—the order cannot be both Pending and Delivered because those are distinct sealed subtypes. Combined with records that carry exactly the data relevant to each state, domain models become both more accurate and more concise.

The move toward immutability that records encourage ripples through architecture. When data structures are immutable, sharing them across threads becomes safe without synchronization. Passing them to external services or storing them in caches happens without defensive copying. This simplification reduces bugs and improves performance by eliminating unnecessary copies. The functional programming community learned these lessons decades ago, but records make immutability natural in Java where it previously required discipline and boilerplate.

Error handling patterns improve significantly with sealed Result types that explicitly model success and failure. Rather than relying on exceptions for control flow or returning null for failure, Result types make both success and failure explicit in the type system. Code consuming Result values must handle both cases, enforced by pattern matching exhaustiveness. This catches the common bug where developers forget to handle error cases, assuming success.

Testing becomes easier when dealing with immutable records and sealed hierarchies. Tests can construct specific states trivially—records have simple constructors taking exactly the data they contain. Testing all cases of a sealed hierarchy becomes mechanical—the type system itself enumerates all possibilities. The combination reduces the cognitive load of test writing while increasing confidence that tests cover all meaningful cases.

API design benefits from the semantic precision these features enable. A method returning a sealed Result type explicitly documents that it might fail and forces callers to handle failure. A parameter typed as a sealed interface explicitly enumerates what values are acceptable. Records used as DTOs clearly communicate immutability and value semantics. These guarantees exist in the type system rather than in documentation that might become stale.

Pattern matching’s real architectural impact lies in how it enables expression-oriented programming in Java. Traditional Java tends toward statement-oriented style with mutable variables that accumulate results. Modern Java enables expressing computations as transformations of immutable values through pattern matching and switch expressions. This style, long standard in functional languages, now works naturally in Java without sacrificing the language’s fundamental character.

The layered architecture common in enterprise Java applications improves with these features. Domain models in the core can use records and sealed classes to precisely express business concepts. Service layers transform between these domain types using pattern matching. Data access layers map between domain records and persistence formats. Each layer uses the features that best express its concerns, with the type system ensuring transformations are complete and correct.

For microservices architectures where data crosses service boundaries frequently, records provide ideal DTOs. Their explicit immutability prevents accidental modification during serialization. Their structural equality (based on field values, not identity) works naturally with JSON serialization libraries. Their compact declaration reduces the boilerplate that made Java DTOs painful to maintain.

Migration Strategies for Legacy Codebases

Adopting modern Java features in existing codebases requires strategy. Wholesale conversion is rarely practical or valuable. Instead, strategic migration that focuses on areas gaining most benefit while avoiding disruption to working code provides better results.

Start with data transfer objects and value classes—the low-hanging fruit. Classes that exist solely to hold data with no behavior beyond getters, equals, hashCode, and toString convert directly to records. These conversions reduce code volume significantly while improving clarity. An IDE can identify candidates: final classes with private final fields, a constructor assigning all fields, and mechanical accessors.

The conversion itself is straightforward but requires attention to detail. Record accessor names differ from traditional getter names (x() instead of getX()), which affects calling code. Serialization frameworks might need configuration updates to work with records, though modern frameworks like Jackson handle them naturally. Code using reflection to access fields needs updates since record fields are private and accessed through accessor methods.

// Before: 30+ lines
public final class UserDTO {
    private final long id;
    private final String username;
    private final String email;
    // Constructor, getters, equals, hashCode, toString...
}

// After: 1 line
public record UserDTO(long id, String username, String email) {}

For classes with validation logic, compact constructors handle validation cleanly while maintaining the simple record declaration:

public record Email(String address) {
    public Email {
        if (!address.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}

Sealed classes best apply when refactoring existing inheritance hierarchies with known, fixed sets of subclasses. If your codebase has an abstract base class that only a few known subclasses extend, converting to a sealed hierarchy provides compile-time guarantees about the complete set of implementations. This particularly benefits state machine implementations where states are modeled as classes.

The conversion requires some refactoring. Seal the base interface or abstract class, listing permitted subclasses in the permits clause. Mark each subclass as final, sealed, or non-sealed depending on whether it should allow further extension. Then find all instanceof checks or switch statements on the base type and convert them to pattern matching switches, removing default cases where exhaustiveness allows.

// Before: Open hierarchy
public abstract class Payment {
    public abstract double amount();
}

public class CreditCard extends Payment { /* ... */ }
public class BankTransfer extends Payment { /* ... */ }
public class Cash extends Payment { /* ... */ }

// After: Sealed hierarchy
public sealed interface Payment permits CreditCard, BankTransfer, Cash {
    double amount();
}

public record CreditCard(double amount, String cardNumber) implements Payment {}
public record BankTransfer(double amount, String accountNumber) implements Payment {}
public record Cash(double amount) implements Payment {}

Pattern matching adoption should focus on complex conditional logic that tests types and extracts data. The instanceof-cast pattern appears frequently in parsing code, message handlers, and polymorphic operations. Converting these to pattern matching reduces verbosity and eliminates the cast that separates type checking from usage:

// Before: Traditional instanceof
if (message instanceof UserCreated) {
    UserCreated uc = (UserCreated) message;
    handleUserCreated(uc.userId(), uc.username());
} else if (message instanceof UserDeleted) {
    UserDeleted ud = (UserDeleted) message;
    handleUserDeleted(ud.userId());
}

// After: Pattern matching
switch (message) {
    case UserCreated(long userId, String username) -> 
        handleUserCreated(userId, username);
    case UserDeleted(long userId) -> 
        handleUserDeleted(userId);
}

Text blocks simplify any code that builds multi-line strings—SQL queries, JSON templates, HTML snippets, formatted logs. Search for string concatenation with newline characters or StringBuilder patterns building multi-line output. These convert naturally to text blocks with improved readability.

A systematic migration strategy starts with new code. Establish team conventions that new DTOs use records, new state machines use sealed classes, new type tests use pattern matching. This prevents technical debt from accumulating while allowing the team to gain experience with modern features in low-risk contexts.

Next, identify high-value targets for conversion—frequently modified classes, areas with known bugs, or code that’s particularly verbose. The 80-20 rule applies: a small percentage of classes likely account for most boilerplate. Converting these provides immediate benefit while leaving less problematic legacy code unchanged.

For teams concerned about IDE support or tooling compatibility, gradual adoption reduces risk. Modern IDEs handle records, sealed classes, and pattern matching well, but older tools or plugins might struggle. Testing conversion in a feature branch allows validation before committing to organization-wide adoption.

Training and documentation matter significantly. Developers familiar with traditional Java might find records and pattern matching intuitive, but sealed classes and their interaction with pattern matching represent new concepts. Code reviews that explain rationale and patterns help spread knowledge organically. Internal documentation with examples from your actual codebase proves more valuable than generic tutorials.

Consider automation where practical. Tools like OpenRewrite can automate some conversions, particularly for simple record adoption. While full automation isn’t possible for complex refactorings like introducing sealed hierarchies, partial automation reduces mechanical work and allows teams to focus on design decisions.

Migration success ultimately depends on understanding when modern features provide value versus when they’re unnecessary complexity. Not every class should become a record. Not every hierarchy should be sealed. Not every instanceof should use pattern matching. The value comes from applying features where they genuinely improve code quality—reducing boilerplate, improving type safety, or making intent clearer.

What We’ve Learned

Modern Java language features represent more than syntactic convenience—they enable fundamentally different approaches to code design. Records bring data-oriented programming to Java with minimal boilerplate and built-in immutability. Sealed classes introduce algebraic data types, allowing precise domain modeling with exhaustive sets of alternatives. Pattern matching enables elegant type testing and deconstruction that was previously verbose and error-prone.

The adoption statistics tell the story. With 55% of developers using records according to the 2024 BellSoft survey, these features have moved from experimental to mainstream in just a few years. Java 21’s pattern matching enhancements and Java 17’s sealed classes are driving developers to update from older LTS versions—35% now use Java 17 and 45% have adopted Java 21, despite these versions being released relatively recently.

The synergy between these features matters more than any individual capability. Records provide immutable data carriers. Sealed classes define exhaustive alternatives. Pattern matching deconstructs and tests types declaratively. Together, they enable programming patterns that match how we think about problems—as precise types and transformations rather than mutation and side effects.

For teams managing Java codebases, the migration path is clear but requires strategy. Start with high-value targets like DTOs that convert directly to records. Introduce sealed hierarchies for state machines and domain models with fixed sets of alternatives. Adopt pattern matching in complex conditional logic where type tests and casts obscure intent. Let new code use modern features while legacy code remains unchanged until there’s reason to touch it.

The architectural implications extend beyond individual classes. Immutable records enable safer concurrency and simpler data flow through systems. Sealed types with exhaustive pattern matching catch entire categories of bugs at compile time.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button