Java 23 Pattern Matching and Data-Oriented Programming: Complete Production Guide

Java 23 Pattern Matching: The Complete Guide to Data-Oriented Programming

Java 23 pattern matching represents the most significant shift in Java programming style since the introduction of generics. After years of preview features, pattern matching has finally matured into a cohesive, production-ready system that fundamentally changes how we model and process data. Therefore, Java developers who master these features gain substantial advantages in code clarity, type safety, and expressiveness. This comprehensive guide covers every aspect of pattern matching in Java 23 — from basic type patterns to advanced sealed hierarchies, record deconstruction, guarded patterns, and the emerging data-oriented programming paradigm.

Java has traditionally been an object-oriented language where behavior is attached to objects through methods and polymorphism. Moreover, the Gang of Four patterns like Visitor and Strategy exist precisely because Java lacked native support for decomposing data structures externally. However, pattern matching inverts this model — instead of asking objects to perform operations on themselves, we examine their structure from the outside and act accordingly. As a result, entire categories of design patterns become unnecessary, replaced by simpler, more direct code.

Understanding Type Patterns and instanceof Evolution

The journey began with JEP 394 in Java 16, which introduced pattern matching for instanceof. Before this enhancement, developers wrote verbose casting code that was error-prone and repetitive. Furthermore, the old pattern required declaring variables, performing type checks, and casting — three separate operations for what is conceptually a single action. Consequently, codebases were littered with unnecessary boilerplate.

// Before: Traditional instanceof with explicit casting
public String formatValue(Object obj) {
    if (obj instanceof String) {
        String s = (String) obj;
        return "String[" + s.length() + "]: " + s.toUpperCase();
    } else if (obj instanceof Integer) {
        Integer i = (Integer) obj;
        return "Integer: " + (i * 2);
    } else if (obj instanceof List) {
        List list = (List) obj;
        return "List[" + list.size() + "]";
    }
    return obj.toString();
}

// After: Java 23 pattern matching with type patterns
public String formatValue(Object obj) {
    return switch (obj) {
        case String s   -> "String[" + s.length() + "]: " + s.toUpperCase();
        case Integer i  -> "Integer: " + (i * 2);
        case List l  -> "List[" + l.size() + "]";
        default         -> obj.toString();
    };
}

The new syntax is not merely shorter — it is fundamentally safer. Additionally, the compiler ensures that the pattern variable is only in scope where the type check has succeeded, eliminating ClassCastException risks entirely. Moreover, when used in switch expressions, the compiler verifies exhaustiveness, catching missing cases at compile time rather than runtime.

Java 23 pattern matching code development
Java 23 pattern matching eliminates verbose casting and enables data-oriented programming

Record Patterns: Deconstructing Data Structures

Record patterns, finalized in Java 21, allow you to deconstruct records directly in pattern matching expressions. This feature is transformative because records are Java’s primary data carriers, and being able to extract their components in a single expression dramatically reduces code complexity. Furthermore, record patterns can be nested, enabling deep structural matching that would previously require multiple lines of accessor calls.

// Define domain records
public record Point(double x, double y) {}
public record Line(Point start, Point end) {}
public record Circle(Point center, double radius) {}
public record Rectangle(Point topLeft, Point bottomRight) {}

// Sealed interface for shapes
public sealed interface Shape permits Circle, Rectangle, Line {}

// Deep pattern matching with nested record deconstruction
public double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle(Point(var cx, var cy), var r)
            -> Math.PI * r * r;
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2))
            -> Math.abs((x2 - x1) * (y2 - y1));
        case Line l
            -> 0.0; // Lines have no area
    };
}

// Nested deconstruction for complex operations
public String describeShape(Shape shape) {
    return switch (shape) {
        case Circle(Point(var x, var y), var r) when r > 100
            -> "Large circle at (" + x + "," + y + ") with radius " + r;
        case Circle(Point(var x, var y), var r)
            -> "Circle at (" + x + "," + y + ") with radius " + r;
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2))
                when x1 == 0 && y1 == 0
            -> "Rectangle from origin to (" + x2 + "," + y2 + ")";
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2))
            -> "Rectangle from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")";
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            -> "Line from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")";
    };
}

Notice how the compiler enforces exhaustiveness — since Shape is sealed, all permitted subtypes must be handled. Additionally, the nested deconstruction of Point within Circle and Rectangle eliminates the need for intermediate variables. Consequently, the code reads almost like a mathematical function definition, directly expressing the relationship between shape structure and the computed result.

Java 23 Pattern Matching: Guarded Patterns and when Clauses

Guard clauses (the when keyword) add conditional logic to patterns, enabling sophisticated matching that combines structural decomposition with business rules. This feature is particularly powerful because it keeps related logic together — the structural match and the conditional check appear in the same case clause, making the code’s intent immediately clear.

// Financial transaction processing with guarded patterns
public sealed interface Transaction permits Deposit, Withdrawal, Transfer {}
public record Deposit(String account, BigDecimal amount, Instant timestamp) implements Transaction {}
public record Withdrawal(String account, BigDecimal amount, Instant timestamp) implements Transaction {}
public record Transfer(String from, String to, BigDecimal amount, Instant timestamp) implements Transaction {}

public TransactionResult process(Transaction tx, Map balances) {
    return switch (tx) {
        // Large deposits require compliance review
        case Deposit(var acct, var amt, var ts) when amt.compareTo(new BigDecimal("10000")) > 0
            -> new TransactionResult(acct, "PENDING_REVIEW",
                "Deposit of " + amt + " requires compliance review");

        // Normal deposits
        case Deposit(var acct, var amt, var ts)
            -> {
                balances.merge(acct, amt, BigDecimal::add);
                yield new TransactionResult(acct, "COMPLETED", "Deposited " + amt);
            }

        // Withdrawals with insufficient funds
        case Withdrawal(var acct, var amt, var ts)
                when balances.getOrDefault(acct, BigDecimal.ZERO).compareTo(amt) < 0
            -> new TransactionResult(acct, "REJECTED", "Insufficient funds");

        // Valid withdrawals
        case Withdrawal(var acct, var amt, var ts)
            -> {
                balances.merge(acct, amt.negate(), BigDecimal::add);
                yield new TransactionResult(acct, "COMPLETED", "Withdrew " + amt);
            }

        // Self-transfer detection
        case Transfer(var from, var to, var amt, var ts) when from.equals(to)
            -> new TransactionResult(from, "REJECTED", "Cannot transfer to same account");

        // Cross-border transfers (international accounts start with "INT-")
        case Transfer(var from, var to, var amt, var ts)
                when from.startsWith("INT-") || to.startsWith("INT-")
            -> new TransactionResult(from, "PENDING_REVIEW",
                "International transfer of " + amt + " requires review");

        // Normal transfers
        case Transfer(var from, var to, var amt, var ts)
            -> {
                balances.merge(from, amt.negate(), BigDecimal::add);
                balances.merge(to, amt, BigDecimal::add);
                yield new TransactionResult(from, "COMPLETED",
                    "Transferred " + amt + " to " + to);
            }
    };
}

Each case clause combines structural decomposition with business logic guards, making the processing rules explicit and exhaustive. Moreover, the compiler ensures that every possible transaction type is handled, and the ordering of guards ensures that special cases (large deposits, insufficient funds, self-transfers) are checked before general cases.

Sealed Hierarchies: The Foundation of Safe Pattern Matching

Sealed classes and interfaces are the architectural foundation that makes pattern matching truly powerful. By restricting which classes can extend a type, sealed hierarchies give the compiler complete knowledge of the type universe, enabling exhaustiveness checking that catches bugs at compile time. Furthermore, sealed types serve as living documentation — they explicitly declare every possible variant of a type, making the domain model self-describing.

// Complete domain model using sealed types
public sealed interface PaymentMethod
    permits CreditCard, DebitCard, BankTransfer, CryptoWallet, DigitalWallet {}

public record CreditCard(String number, String expiry, String cvv,
    CardNetwork network) implements PaymentMethod {}
public record DebitCard(String number, String pin,
    String bankCode) implements PaymentMethod {}
public record BankTransfer(String iban, String swift,
    String bankName) implements PaymentMethod {}
public record CryptoWallet(String address,
    CryptoNetwork network) implements PaymentMethod {}
public record DigitalWallet(String email,
    DigitalWalletProvider provider) implements PaymentMethod {}

public enum CardNetwork { VISA, MASTERCARD, AMEX }
public enum CryptoNetwork { ETHEREUM, BITCOIN, SOLANA }
public enum DigitalWalletProvider { PAYPAL, APPLE_PAY, GOOGLE_PAY }

// Processing fees calculated via pattern matching
public BigDecimal calculateFee(PaymentMethod method, BigDecimal amount) {
    return switch (method) {
        case CreditCard(_, _, _, CardNetwork.AMEX)
            -> amount.multiply(new BigDecimal("0.035")); // 3.5% for Amex
        case CreditCard cc
            -> amount.multiply(new BigDecimal("0.029")); // 2.9% for Visa/MC
        case DebitCard dc
            -> amount.multiply(new BigDecimal("0.015")); // 1.5% for debit
        case BankTransfer bt when amount.compareTo(new BigDecimal("1000")) > 0
            -> new BigDecimal("5.00"); // Flat $5 for large transfers
        case BankTransfer bt
            -> new BigDecimal("2.50"); // Flat $2.50 for small transfers
        case CryptoWallet(_, CryptoNetwork.ETHEREUM)
            -> amount.multiply(new BigDecimal("0.01")); // 1% ETH
        case CryptoWallet cw
            -> amount.multiply(new BigDecimal("0.008")); // 0.8% other crypto
        case DigitalWallet(_, DigitalWalletProvider.PAYPAL)
            -> amount.multiply(new BigDecimal("0.034")); // 3.4% PayPal
        case DigitalWallet dw
            -> amount.multiply(new BigDecimal("0.025")); // 2.5% Apple/Google
    };
}

When you add a new payment method to the sealed interface — say BuyNowPayLater — the compiler immediately flags every switch expression that doesn’t handle it. Consequently, you cannot forget to update your fee calculation, validation logic, or any other code that processes payment methods. This compile-time safety net is invaluable in large codebases where multiple teams might process the same domain types.

Data-oriented programming with sealed types
Sealed hierarchies provide compile-time exhaustiveness checking for domain models

Data-Oriented Programming: A New Java Paradigm

Data-oriented programming (DOP) is an emerging paradigm that Java 23 fully supports through the combination of records, sealed types, and pattern matching. Unlike traditional OOP where data and behavior are bundled together, DOP separates them — records model data, sealed interfaces model type hierarchies, and pattern matching provides external operations on data. Additionally, this separation leads to more modular, testable, and comprehensible code.

// Data-oriented approach to an event sourcing system
public sealed interface DomainEvent permits
    OrderCreated, ItemAdded, ItemRemoved, OrderConfirmed,
    OrderShipped, OrderDelivered, OrderCancelled {}

public record OrderCreated(String orderId, String customerId,
    Instant createdAt) implements DomainEvent {}
public record ItemAdded(String orderId, String productId,
    int quantity, BigDecimal price) implements DomainEvent {}
public record ItemRemoved(String orderId,
    String productId) implements DomainEvent {}
public record OrderConfirmed(String orderId,
    Instant confirmedAt) implements DomainEvent {}
public record OrderShipped(String orderId, String trackingNumber,
    Instant shippedAt) implements DomainEvent {}
public record OrderDelivered(String orderId,
    Instant deliveredAt) implements DomainEvent {}
public record OrderCancelled(String orderId, String reason,
    Instant cancelledAt) implements DomainEvent {}

// State reconstruction through event replay
public record OrderState(
    String orderId, String customerId, OrderStatus status,
    Map items, BigDecimal total,
    String trackingNumber
) {
    public static OrderState empty() {
        return new OrderState(null, null, OrderStatus.NEW,
            Map.of(), BigDecimal.ZERO, null);
    }

    // Pure function: (State, Event) -> State
    public OrderState apply(DomainEvent event) {
        return switch (event) {
            case OrderCreated(var id, var cust, _)
                -> new OrderState(id, cust, OrderStatus.CREATED,
                    new HashMap<>(), BigDecimal.ZERO, null);

            case ItemAdded(_, var pid, var qty, var price)
                -> {
                    var newItems = new HashMap<>(items);
                    newItems.put(pid, new OrderItem(pid, qty, price));
                    var newTotal = newItems.values().stream()
                        .map(i -> i.price().multiply(BigDecimal.valueOf(i.quantity())))
                        .reduce(BigDecimal.ZERO, BigDecimal::add);
                    yield new OrderState(orderId, customerId,
                        status, newItems, newTotal, trackingNumber);
                }

            case ItemRemoved(_, var pid)
                -> {
                    var newItems = new HashMap<>(items);
                    newItems.remove(pid);
                    var newTotal = newItems.values().stream()
                        .map(i -> i.price().multiply(BigDecimal.valueOf(i.quantity())))
                        .reduce(BigDecimal.ZERO, BigDecimal::add);
                    yield new OrderState(orderId, customerId,
                        status, newItems, newTotal, trackingNumber);
                }

            case OrderConfirmed(_, _)
                -> new OrderState(orderId, customerId,
                    OrderStatus.CONFIRMED, items, total, trackingNumber);

            case OrderShipped(_, var tracking, _)
                -> new OrderState(orderId, customerId,
                    OrderStatus.SHIPPED, items, total, tracking);

            case OrderDelivered(_, _)
                -> new OrderState(orderId, customerId,
                    OrderStatus.DELIVERED, items, total, trackingNumber);

            case OrderCancelled(_, _, _)
                -> new OrderState(orderId, customerId,
                    OrderStatus.CANCELLED, items, total, trackingNumber);
        };
    }
}

This data-oriented approach separates concerns cleanly: records hold data, sealed interfaces define the event vocabulary, and the apply method uses pattern matching to transform state. As a result, the event sourcing logic is pure, testable, and easy to reason about. Furthermore, adding a new event type forces you to handle it in every apply method across the codebase.

Unnamed Patterns and Variables in Java 23

Java 23 introduces unnamed patterns (underscore _) for components you want to match but don’t need to use. This feature reduces clutter when you only care about certain parts of a record or when the pattern variable would go unused. Consequently, the code communicates more clearly which components are relevant to each case.

// Unnamed patterns — focus on what matters
public String getShippingStatus(DomainEvent event) {
    return switch (event) {
        case OrderShipped(var orderId, var tracking, _)
            -> "Order " + orderId + " shipped. Track: " + tracking;
        case OrderDelivered(var orderId, _)
            -> "Order " + orderId + " delivered";
        case OrderCancelled(var orderId, var reason, _)
            -> "Order " + orderId + " cancelled: " + reason;
        case OrderCreated _, ItemAdded _, ItemRemoved _, OrderConfirmed _
            -> "No shipping update";
    };
}

// Unnamed variables in loops and try-with-resources
public void processQueue(BlockingQueue queue) {
    try {
        for (int _ = 0; _ < 10; _++) { // loop counter not used
            var task = queue.take();
            switch (task) {
                case Task.Compute(var data, _) -> compute(data);
                case Task.IO(var path, _, _) -> readFile(path);
                case Task.Notify(_, var message) -> send(message);
            }
        }
    } catch (InterruptedException _) { // exception not used
        Thread.currentThread().interrupt();
    }
}

Pattern Matching with Generic Records

Pattern matching works seamlessly with generic records, enabling type-safe decomposition of parameterized types. This capability is essential for building reusable data structures that can be matched against in a type-safe manner. Moreover, the compiler infers generic type parameters during pattern matching, eliminating the need for explicit type annotations.

// Generic result type with pattern matching
public sealed interface Result permits Success, Failure {}
public record Success(T value) implements Result {}
public record Failure(String error, Exception cause) implements Result {}

// Chaining results with pattern matching
public  Result flatMap(Result result, Function> mapper) {
    return switch (result) {
        case Success(var value) -> mapper.apply(value);
        case Failure(var error, var cause) -> new Failure<>(error, cause);
    };
}

// Complex pipeline using pattern matching
public Result processOrder(OrderRequest request) {
    var validated = validate(request);
    return switch (validated) {
        case Failure f -> new Failure<>(f.error(), f.cause());
        case Success(var order) -> switch (checkInventory(order)) {
            case Failure f -> new Failure<>(f.error(), f.cause());
            case Success(var reserved) -> switch (chargePayment(reserved)) {
                case Failure f -> {
                    releaseInventory(reserved); // compensating action
                    yield new Failure<>(f.error(), f.cause());
                }
                case Success(var paid) -> confirmOrder(paid);
            };
        };
    };
}
Java pattern matching production deployment
Generic record patterns enable type-safe functional pipelines in Java 23

Migration Strategy: Moving to Data-Oriented Java

Migrating an existing codebase to use pattern matching requires a systematic approach. Start with the Visitor pattern — it’s the most direct mapping to pattern matching and often the most impactful refactoring. Additionally, look for chains of instanceof checks, type-based dispatch, and enum-driven switch statements. These are all candidates for conversion to sealed hierarchies with pattern matching.

// Step 1: Identify Visitor pattern usage
// BEFORE: Traditional Visitor
interface ShapeVisitor {
    R visit(Circle c);
    R visit(Rectangle r);
    R visit(Triangle t);
}

class AreaCalculator implements ShapeVisitor {
    public Double visit(Circle c) { return Math.PI * c.radius() * c.radius(); }
    public Double visit(Rectangle r) { return r.width() * r.height(); }
    public Double visit(Triangle t) { return 0.5 * t.base() * t.height(); }
}

// AFTER: Pattern matching (delete Visitor entirely)
public double calculateArea(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();
    };
}

// Step 2: Convert enum-driven switches to sealed types
// BEFORE: Enum with behavior
enum NotificationType {
    EMAIL { void send(User u, String msg) { emailService.send(u.email(), msg); } },
    SMS   { void send(User u, String msg) { smsService.send(u.phone(), msg); } },
    PUSH  { void send(User u, String msg) { pushService.send(u.deviceId(), msg); } };
    abstract void send(User u, String msg);
}

// AFTER: Sealed records with external processing
sealed interface Notification permits EmailNotif, SmsNotif, PushNotif {}
record EmailNotif(String to, String subject, String body) implements Notification {}
record SmsNotif(String phone, String message) implements Notification {}
record PushNotif(String deviceId, String title, String body) implements Notification {}

void send(Notification notif) {
    switch (notif) {
        case EmailNotif(var to, var subj, var body) -> emailService.send(to, subj, body);
        case SmsNotif(var phone, var msg) -> smsService.send(phone, msg);
        case PushNotif(var device, var title, var body) -> pushService.push(device, title, body);
    }
}

Performance Considerations and JVM Internals

Pattern matching in Java 23 compiles to efficient bytecode that the JIT compiler can optimize aggressively. The compiler generates tableswitch or lookupswitch instructions for enum patterns and type-test chains for class patterns. Moreover, the JVM’s inline caching mechanisms ensure that repeated pattern matches on the same types are nearly as fast as direct method calls. Benchmarks show pattern matching switch expressions performing within 2-5% of hand-optimized instanceof chains.

However, deeply nested record patterns can generate complex bytecode that may not inline as aggressively. Therefore, for performance-critical paths processing millions of events per second, consider limiting nesting depth to two levels and benchmarking with JMH. Additionally, the escape analysis in modern JVMs can eliminate record allocations in many cases, making the pattern matching overhead essentially zero for short-lived records.

Testing Data-Oriented Code

Data-oriented code with records and pattern matching is inherently more testable than traditional OOP code. Since records are immutable value types with automatic equals/hashCode, test assertions are straightforward. Furthermore, pattern matching functions are typically pure — given the same input, they produce the same output — making them ideal for property-based testing.

@Test
void shouldCalculateCorrectFees() {
    var visa = new CreditCard("4111...", "12/26", "123", CardNetwork.VISA);
    var amex = new CreditCard("3782...", "12/26", "1234", CardNetwork.AMEX);
    var btc = new CryptoWallet("bc1q...", CryptoNetwork.BITCOIN);

    var amount = new BigDecimal("100.00");

    assertThat(calculateFee(visa, amount))
        .isEqualByComparingTo("2.90");   // 2.9%
    assertThat(calculateFee(amex, amount))
        .isEqualByComparingTo("3.50");   // 3.5%
    assertThat(calculateFee(btc, amount))
        .isEqualByComparingTo("0.80");   // 0.8%
}

@ParameterizedTest
@MethodSource("allPaymentMethods")
void everyPaymentMethodShouldHaveNonNegativeFee(PaymentMethod method) {
    var fee = calculateFee(method, new BigDecimal("50.00"));
    assertThat(fee).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}

When NOT to Use Pattern Matching

Pattern matching is not always the right tool. Avoid it when the type hierarchy changes frequently and is not under your control — open class hierarchies work better with traditional polymorphism. Additionally, don’t force pattern matching onto problems that are naturally solved by method dispatch. If each type needs fundamentally different behavior with complex internal state, traditional OOP with methods on the classes themselves may be clearer.

Furthermore, avoid creating sealed hierarchies with dozens of permitted types. If your switch expression has 20+ cases, the code becomes hard to read regardless of the syntax. In these situations, consider grouping related types into intermediate sealed types or using a combination of polymorphism and pattern matching.

Key Takeaways

Java 23 pattern matching transforms Java into a language that supports both object-oriented and data-oriented programming paradigms effectively. Sealed types provide compile-time exhaustiveness, record patterns enable deep structural decomposition, and guarded patterns combine structure matching with business logic. Start migrating by replacing Visitor patterns and instanceof chains, then gradually adopt sealed hierarchies for new domain models. The result is code that is simultaneously more concise, more type-safe, and more expressive than traditional Java.

Related Reading:

External Resources:

Scroll to Top