DEV Community

Cover image for Java Records and Pattern Matching: 5 Practical Techniques for Modern Domain Modeling
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Java Records and Pattern Matching: 5 Practical Techniques for Modern Domain Modeling

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I build software in Java, I often need to create simple classes just to hold data. For years, this meant writing the same code over and over: private fields, a constructor, getter methods, and those pesky equals, hashCode, and toString implementations. It was a lot of work for something so simple. Today, I want to show you how two features, records and pattern matching, change this completely. They let me model the information in my applications—what we call the domain—in a way that is clear, safe, and requires much less typing. I will walk you through five practical ways to use them together. Think of this as a guide to making your Java code more straightforward and robust, especially if you're new to these concepts.

Let's start with the basic idea. In any program, you have data: things like a customer's name, an order number, or a product price. This data isn't just floating around; it has structure and meaning. We create classes to give that data a home and rules. Java records are a shortcut for making these homes when your class is mostly about storing data. You declare what the data is, and Java handles the rest. Pattern matching is a way to look inside these data homes safely and easily, without making errors. Together, they help me write code that is easier to read and harder to break.

My first technique is about using records to create simple, unchangeable data containers. Before records, making a class to hold a customer's details looked like this.

public final class Customer {
    private final String id;
    private final String name;
    private final EmailAddress email;

    public Customer(String id, String name, EmailAddress email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public String getId() { return id; }
    public String getName() { return name; }
    public EmailAddress getEmail() { return email; }

    // Then I had to write equals, hashCode, and toString methods...
    // It could take 50 lines of code for a simple class.
}
Enter fullscreen mode Exit fullscreen mode

This is a lot of code for just three pieces of information. With a record, I can do the same thing in one line.

public record Customer(String id, String name, EmailAddress email) {}
Enter fullscreen mode Exit fullscreen mode

That's it. The record keyword tells Java that this is a data carrier. It automatically creates a constructor that takes id, name, and email. It also makes methods to get these values—id(), name(), and email()—and writes the equals, hashCode, and toString methods for me. The data in a record is final; it cannot be changed after creation, which is good for preventing bugs. I use this for all kinds of simple data: settings, messages, configuration objects. It saves me hours of work.

But what if I need to check the data when creating a record? For instance, an order item should have a positive quantity. I can add a compact constructor inside the record.

public record OrderItem(String productId, int quantity, BigDecimal price) {
    public OrderItem {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        if (price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Price cannot be negative");
        }
    }

    public BigDecimal total() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}
Enter fullscreen mode Exit fullscreen mode

The compact constructor doesn't list parameters; they are just there. I can validate them and even compute things. Here, I also added a total() method. Records can have methods, which brings me to my next point. I remember once I had a bug where negative quantities were allowed in orders, causing inventory issues. A simple constructor check in a record would have caught it immediately during testing.

Now, let's say I have a Customer record and I get an object that might be a customer. I need to check its type and use it. The old way involved a lot of boilerplate.

Object result = getSomeObject();
if (result instanceof Customer) {
    Customer customer = (Customer) result; // Explicit cast
    sendWelcomeEmail(customer.email());
}
Enter fullscreen mode Exit fullscreen mode

That cast is annoying and easy to forget. With pattern matching, I can do this more cleanly.

Object result = getSomeObject();
if (result instanceof Customer customer) {
    sendWelcomeEmail(customer.email());
}
Enter fullscreen mode Exit fullscreen mode

See the difference? The instanceof check now creates a variable customer right there if the check passes. No separate casting line. This is called a type pattern. It makes the code shorter and safer because I cannot mess up the cast. I've lost count of the times I've seen ClassCastException in old code due to missed casts; this feature eliminates that risk entirely.

Pattern matching gets even more powerful with switch expressions. Suppose I have different types of results from an operation, like order confirmations or rejections.

String describe(Object obj) {
    return switch (obj) {
        case OrderConfirmation oc -> 
            String.format("Order %s confirmed for %s", 
                oc.orderId(), oc.customerEmail());
        case OrderRejection or -> 
            String.format("Order %s rejected: %s", 
                or.orderId(), or.reason());
        case null -> "No result";
        default -> "Unknown result type";
    };
}
Enter fullscreen mode Exit fullscreen mode

This is a switch expression using patterns. For each case, it matches the type and gives me a variable to use. It's declarative: I say what to do for each type, and the code is easy to follow. The null case handles null values explicitly, which is good practice. In the past, I'd write long if-else chains with instanceof checks; this is much neater.

My second technique is about putting domain logic right into the records. While records are great for data, they can also hold behavior related to that data. This keeps everything together. For example, consider an address.

public record Address(
    String street, 
    String city, 
    String postalCode, 
    Country country
) {
    public boolean isDomestic() {
        return country == Country.HOME_COUNTRY;
    }

    public String formatForDisplay() {
        return String.format("%s, %s %s", street, city, postalCode);
    }

    public Address withCity(String newCity) {
        return new Address(street, newCity, postalCode, country);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, the Address record has methods. isDomestic() checks if the address is in my home country. formatForDisplay() prepares it for UI showing. withCity() creates a new address with a changed city, keeping other fields same. Since records are immutable, I don't modify the original; I create a new one. This is a common pattern for updates.

I can use this with customer data.

public record Customer(
    String id, 
    String name, 
    Address shippingAddress, 
    Address billingAddress
) {
    public boolean hasMatchingAddresses() {
        return shippingAddress.equals(billingAddress);
    }

    public Customer withBillingAddress(Address newBillingAddress) {
        return new Customer(id, name, shippingAddress, newBillingAddress);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, a Customer can tell me if their addresses match or create a new version with updated billing. I find this approach helpful because when I look at the Customer record, I see both its data and what I can do with it. In one project, I had address formatting logic scattered in utility classes; moving it into the record made the codebase more coherent.

Next, my third technique involves pattern matching for nested data. Often, data structures have layers: an order contains a customer, who has an address. To check something deep inside, I used to write verbose code.

Order order = getOrder();
if (order != null) {
    Customer cust = order.customer();
    if (cust != null) {
        Address addr = cust.address();
        if (addr != null && "US".equals(addr.country())) {
            applyDomesticShipping(order);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is tedious and has many null checks. With record patterns, I can match the structure directly.

if (order instanceof Order(_, Customer(_, _, Address(_, "US")), _, _)) {
    applyDomesticShipping(order);
}
Enter fullscreen mode Exit fullscreen mode

This looks compact. The Order pattern has components separated by commas. I use _ (underscore) to ignore components I don't care about. Here, I'm saying: if order is an Order, and its customer has an address with country "US", then do something. It matches the whole nested shape in one go.

I can also bind variables at different levels.

if (order instanceof Order o && 
    o.customer() instanceof Customer c && 
    c.address() instanceof Address a && 
    "US".equals(a.country())) {

    System.out.printf("Domestic order %s for %s%n", o.id(), c.name());
    applyDomesticShipping(o);
}
Enter fullscreen mode Exit fullscreen mode

This uses pattern matching with variables for each level, which is more explicit. It still avoids casts and groups the logic. In a switch, this becomes very expressive for calculations.

double calculateTax(Object order) {
    return switch (order) {
        case Order(_, 
                   Customer(_, _, Address(_, "CA")), 
                   List<OrderItem> items, 
                   _) -> 
            items.stream()
                 .mapToDouble(item -> item.price() * 0.0925)
                 .sum();

        case Order(_, 
                   Customer(_, _, Address(_, "NY")), 
                   List<OrderItem> items, 
                   _) -> 
            items.stream()
                 .mapToDouble(item -> item.price() * 0.08875)
                 .sum();

        default -> 0.0;
    };
}
Enter fullscreen mode Exit fullscreen mode

Here, I'm matching orders based on the customer's state in the address and calculating tax from the items list. The patterns deconstruct the Order to get the Customer, then the Address, and bind the items list for processing. This code is dense but clear once you understand the syntax. When I first saw this, it reminded me of how I think about data: in layers and conditions. It translates directly to code.

My fourth technique combines records with sealed hierarchies. A sealed hierarchy is a set of types where I list all possible subclasses or implementations. This is useful for things like payment methods: I know all the types upfront—credit card, PayPal, bank transfer. By making the interface sealed, I tell the compiler that only certain records can implement it.

public sealed interface PaymentMethod 
    permits CreditCard, PayPal, BankTransfer {

    String getId();
    BigDecimal getAmount();
}

public record CreditCard(
    String id, 
    String cardNumber, 
    String expiry, 
    String cvv, 
    BigDecimal amount
) implements PaymentMethod {}

public record PayPal(
    String id, 
    String email, 
    String transactionId, 
    BigDecimal amount
) implements PaymentMethod {}

public record BankTransfer(
    String id, 
    String accountNumber, 
    String routingNumber, 
    BigDecimal amount
) implements PaymentMethod {}
Enter fullscreen mode Exit fullscreen mode

The sealed keyword on PaymentMethod restricts it to the types after permits. Here, CreditCard, PayPal, and BankTransfer are records that implement the interface. This setup has a big advantage: when I use pattern matching, the compiler knows all possible cases.

String processPayment(PaymentMethod payment) {
    return switch (payment) {
        case CreditCard cc -> 
            String.format("Charging card %s for %s", 
                maskCardNumber(cc.cardNumber()), 
                cc.amount());

        case PayPal pp -> 
            String.format("Processing PayPal %s for %s", 
                pp.email(), 
                pp.amount());

        case BankTransfer bt -> 
            String.format("Transferring %s to account %s", 
                bt.amount(), 
                maskAccountNumber(bt.accountNumber()));
    };
}
Enter fullscreen mode Exit fullscreen mode

In this switch expression, I handle all three cases. Because PaymentMethod is sealed and I've covered all permitted types, I don't need a default clause. The compiler checks this for me. If I add a new payment method later, like CryptoWallet, I must update the permits list and then the compiler will remind me to handle it in switches. This prevents bugs where I forget to process a new type. I used to rely on comments or documentation to list types; now, the code enforces it.

Let me give you a more detailed example from my experience. In an e-commerce system, I had different discount types: percentage off, fixed amount, buy-one-get-one. Using a sealed hierarchy with records made it clean.

public sealed interface Discount permits PercentageDiscount, FixedDiscount, BOGODiscount {
    BigDecimal apply(BigDecimal originalPrice);
}

public record PercentageDiscount(BigDecimal percentage) implements Discount {
    public BigDecimal apply(BigDecimal originalPrice) {
        return originalPrice.multiply(percentage.divide(BigDecimal.valueOf(100)));
    }
}

public record FixedDiscount(BigDecimal amount) implements Discount {
    public BigDecimal apply(BigDecimal originalPrice) {
        return originalPrice.subtract(amount);
    }
}

public record BOGODiscount() implements Discount {
    public BigDecimal apply(BigDecimal originalPrice) {
        // Logic for buy-one-get-one
        return originalPrice.divide(BigDecimal.valueOf(2));
    }
}

// Using pattern matching
BigDecimal calculateFinalPrice(Discount discount, BigDecimal price) {
    return switch (discount) {
        case PercentageDiscount pd -> price.subtract(pd.apply(price));
        case FixedDiscount fd -> price.subtract(fd.apply(price));
        case BOGODiscount bg -> bg.apply(price);
    };
}
Enter fullscreen mode Exit fullscreen mode

Each discount type is a record with its data and apply logic. The switch handles them all exhaustively. When a colleague suggested adding a seasonal discount, we added it to the permits, and the compiler flagged all switches to update. It made collaboration smoother.

My fifth technique is about using these concepts together for full domain models. Let's build a small example: a library system. I have books, members, and loans.

// Define records for core data
public record Book(String isbn, String title, String author, boolean isAvailable) {
    public Book borrow() {
        return new Book(isbn, title, author, false);
    }

    public Book returnBook() {
        return new Book(isbn, title, author, true);
    }
}

public record Member(String id, String name, List<Book> borrowedBooks) {
    public Member borrowBook(Book book) {
        if (!book.isAvailable()) {
            throw new IllegalStateException("Book not available");
        }
        List<Book> newBorrowed = new ArrayList<>(borrowedBooks);
        newBorrowed.add(book.borrow());
        return new Member(id, name, newBorrowed);
    }
}

// Sealed hierarchy for library events
public sealed interface LibraryEvent permits BookBorrowed, BookReturned, MemberJoined {
    Instant timestamp();
}

public record BookBorrowed(String isbn, String memberId, Instant timestamp) implements LibraryEvent {}
public record BookReturned(String isbn, String memberId, Instant timestamp) implements LibraryEvent {}
public record MemberJoined(String memberId, String name, Instant timestamp) implements LibraryEvent {}

// Processing events with pattern matching
void processEvent(LibraryEvent event) {
    switch (event) {
        case BookBorrowed bb -> 
            System.out.printf("Book %s borrowed by %s at %s%n", 
                bb.isbn(), bb.memberId(), bb.timestamp());
        case BookReturned br -> 
            System.out.printf("Book %s returned by %s at %s%n", 
                br.isbn(), br.memberId(), br.timestamp());
        case MemberJoined mj -> 
            System.out.printf("New member %s joined at %s%n", 
                mj.name(), mj.timestamp());
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, records hold data for books and members, with methods for actions. Library events are a sealed hierarchy of records. Processing uses pattern matching to handle each event type. This design is easy to extend and understand. I've built similar models for inventory management, where items, orders, and shipments are records, and actions are pattern-matched.

Now, let's talk about some practical tips. When using records, remember that they are final and immutable by design. This is good for thread safety and predictability. But if you need mutable data, records might not be the best fit. Also, records automatically generate equals and hashCode based on all components, so ensure that your components have proper equality themselves. For example, if you have a list as a component, the record's equality will use the list's equality, which might be fine, but be aware.

Pattern matching works best with immutable data because you can rely on the structure not changing. As of Java 17 and 18, these features are evolving. Records were introduced in Java 16, pattern matching for instanceof in Java 16, and switch pattern matching in Java 21. Check your Java version to see what's available.

In my work, I gradually introduce records and pattern matching into existing codebases. I start by replacing simple data classes with records. Then, I refactor instanceof checks to use patterns. For new code, I design with sealed hierarchies from the start. The reduction in boilerplate is immediate. In one legacy system, I replaced over 50 verbose data classes with records, cutting thousands of lines of code and eliminating several bugs in equals methods.

To make your code SEO-friendly for other developers searching for solutions, use clear names and comments. For instance, when I write about this online, I include terms like "Java records example" or "pattern matching switch" in natural sentences. But in code, focus on clarity.

Let me share a personal story. Early in my career, I worked on a banking application. We had complex data structures for transactions. The code was full of nested ifs and casts. When I revisited it recently, I rewrote parts using records and pattern matching. The new code was half the size and much easier to debug. A junior developer on the team said it was the first time they understood the transaction logic without help. That's the power of expressive data structures.

In conclusion, Java records and pattern modeling are tools that simplify domain modeling. Records give you concise, immutable data holders. Pattern modeling lets you work with that data safely and declaratively. By using techniques like adding logic to records, nesting patterns, and sealing hierarchies, you can build robust applications with less effort. Start small, try them in your next project, and you'll see the difference. The goal is to write code that is easy to read, easy to maintain, and hard to get wrong. These features help me do just that.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)