Intermediate

Java Records and Pattern Matching

java · records · pattern · matching · backend · architecture · full-stack · web · record · patterns · methods · constructor · method · syntax · accessor · canonical · class · constructors...

Table of Contents

  1. Records: The Basics
  1. Record Builders
  1. Module 3 — Record Methods
  1. Pattern Matching: The Basics
  1. Pattern Matching with Records
  1. Appendix: Full source code

1. Records: The Basics

1.1 Getting Started with Records

New versions of Java offer a set of interesting features that allow you to write clearer and more concise code. As a Java developer, it is important to keep up to date with what’s new in the language. This course covers two of these features: records and pattern matching.

These two topics are presented together because they are not independent — records and pattern matching work very well together. In the last module you will learn how to combine them.

The classic structure of a Java project

The code of a Java project consists of classes that have different roles:

  • Service classes: contain business logic.
  • Data Access Objects (DAO): interact with the database.
  • Data model classes: represent business domain data.

The POJO problem

Data model classes are almost always written according to a specific convention. They have:

  • Fields to store data.
  • A constructor that initializes fields.
  • A getter and setter method for each field.
  • Optionally, equals, hashCode and toString methods.

These classes are often called Plain Old Java Objects (POJO). The obvious disadvantage of a POJO is its verbosity: the purpose of a POJO is simply to be a container of fields, but you have to write many lines of code to implement the constructor, getters, setters, and other methods.

Tools like the IDE or Lombok can automatically generate these methods, but it would be better if Java had a better native mechanism for writing data container classes without all this ritual. This is exactly what records are for.

Example of a classic POJO (example01/Journey.java):

package com.pluralsight.recordspatternmatching.example01;

import java.time.ZonedDateTime;
import java.util.Objects;

// Classe conteneur de données mutable, avec des champs, un constructeur,
// des getters, des setters, ainsi que les méthodes equals, hashCode et toString.
// Une telle classe est appelée un Plain Old Java Object (POJO).
public class Journey {

    private String origin;
    private String destination;
    private ZonedDateTime departureTime;
    private ZonedDateTime arrivalTime;

    public Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
        this.origin = origin;
        this.destination = destination;
        this.departureTime = departureTime;
        this.arrivalTime = arrivalTime;
    }

    public String getOrigin() {
        return origin;
    }

    public void setOrigin(String origin) {
        this.origin = origin;
    }

    public String getDestination() {
        return destination;
    }

    public void setDestination(String destination) {
        this.destination = destination;
    }

    public ZonedDateTime getDepartureTime() {
        return departureTime;
    }

    public void setDepartureTime(ZonedDateTime departureTime) {
        this.departureTime = departureTime;
    }

    public ZonedDateTime getArrivalTime() {
        return arrivalTime;
    }

    public void setArrivalTime(ZonedDateTime arrivalTime) {
        this.arrivalTime = arrivalTime;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        Journey journey = (Journey) o;
        return Objects.equals(origin, journey.origin) &&
                Objects.equals(destination, journey.destination) &&
                Objects.equals(departureTime, journey.departureTime) &&
                Objects.equals(arrivalTime, journey.arrivalTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(origin, destination, departureTime, arrivalTime);
    }

    @Override
    public String toString() {
        return "Journey{" +
                "origin='" + origin + '\'' +
                ", destination='" + destination + '\'' +
                ", departureTime=" + departureTime +
                ", arrivalTime=" + arrivalTime +
                '}';
    }
}

1.2 Records as immutable data containers

Before examining how to set a record, it is essential to understand the concept of immutability.

Mutable class vs immutable class

The POJO presented previously is a mutable class: its fields can be modified anywhere in the code via setters. Mutability is useful in some cases, but it has disadvantages:

  • If different parts of the program keep a reference to the same object and one of them changes it, the change will be visible wherever the object is referenced. This can cause confusion and bugs.
  • In a multi-threaded program, if multiple threads can modify the same data concurrently, significant synchronization precautions are necessary to avoid unpredictable behavior.

To avoid these problems, it is recommended to make data classes immutable. An immutable class is a class whose fields in an instance cannot be changed after the instance has been constructed.

The best-known example: the String class

The standard library String class is the most famous example of an immutable class. The String class has no methods that modify the contents of a String object. For example, toUpperCase() does not modify the String object on which it is called — it returns a new String object. Immutable objects can be safely shared between threads without synchronization.

Mutable class → Immutable class

To make a class immutable, we apply the following transformations:

  • Declare private final fields.
  • Delete setters.
  • Declare class final to prevent inheritance.

Example of immutable class (example02/Journey.java):

package com.pluralsight.recordspatternmatching.example02;

import java.time.ZonedDateTime;
import java.util.Objects;

// Classe Journey comme classe de données immuable.
public final class Journey {

    private final String origin;
    private final String destination;
    private final ZonedDateTime departureTime;
    private final ZonedDateTime arrivalTime;

    public Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
        this.origin = origin;
        this.destination = destination;
        this.departureTime = departureTime;
        this.arrivalTime = arrivalTime;
    }

    public String getOrigin() {
        return origin;
    }

    public String getDestination() {
        return destination;
    }

    public ZonedDateTime getDepartureTime() {
        return departureTime;
    }

    public ZonedDateTime getArrivalTime() {
        return arrivalTime;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        Journey journey = (Journey) o;
        return Objects.equals(origin, journey.origin) &&
                Objects.equals(destination, journey.destination) &&
                Objects.equals(departureTime, journey.departureTime) &&
                Objects.equals(arrivalTime, journey.arrivalTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(origin, destination, departureTime, arrivalTime);
    }

    @Override
    public String toString() {
        return "Journey{" +
                "origin='" + origin + '\'' +
                ", destination='" + destination + '\'' +
                ", departureTime=" + departureTime +
                ", arrivalTime=" + arrivalTime +
                '}';
    }
}

This immutable class is better, but it’s still just as verbose. It is precisely to avoid this verbosity that records exist.


1.3 Basic syntax for defining a Record

Records are available as a standard feature since Java 16. Here’s how to turn the Journey POJO class into a record.

Transformation step by step

  1. Replace the keyword class with record.
  2. Specify the components of the record in parentheses, directly after the record name.
  3. Add a pair of braces (the body of the record, which may be empty).

Example record (example03/Journey.java):

package com.pluralsight.recordspatternmatching.example03;

import java.time.ZonedDateTime;

// Record Journey. Le compilateur génère automatiquement un constructeur, des méthodes accesseurs,
// ainsi que les méthodes equals, hashCode et toString.
public record Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
}

In a single line, this record is functionally equivalent to the dozens of lines in the initial POJO!

What the compiler generates

For a record, the compiler automatically generates:

What is generatedDescription
A private final field for each componentData is stored immutably
The canonical constructorInitializes fields with argument values ​​
An accessor method for each componentReturns the component value (named like the component, not getX())
equals()Compares two records component by component
hashCode()Calculates a hash based on component values ​​
toString()Returns a textual representation of the record

Anatomy of a record

  • Components are declared in parentheses after the record name, which looks like a constructor’s parameter list.
  • Body is defined in braces. In this first example, the body is empty. If necessary, we can define members like static fields, constructors and methods.

We can think of a record as a special syntax for defining a class. In fact, the compiler translates this source code into a class with a private final field for each component.

Use record

Using a record is the same as using a regular class (example03/UsingRecords.java):

package com.pluralsight.recordspatternmatching.example03;

import java.time.ZonedDateTime;

public class UsingRecords {

    public static void main(String[] args) {
        ZonedDateTime departureTime = ZonedDateTime.parse("2025-08-09T08:00+02:00");
        ZonedDateTime arrivalTime = departureTime.plusHours(3).plusMinutes(30);

        // La création d'une instance d'un record se fait de la même façon que pour une classe, avec "new".
        Journey journey = new Journey("Amsterdam", "Paris", departureTime, arrivalTime);

        // La méthode accesseur ("getter") d'un composant porte le même nom que le composant.
        String origin = journey.origin();
        System.out.println("Your journey starts at: " + origin);
    }
}

Important note on accessor naming: unlike POJOs which use the getX() convention, a record’s accessor methods simply carry the component name (e.g. origin(), not getOrigin()).


1.4 Practical Use Cases for Records

Main use cases

The primary use case for records is defining immutable data model classes. Wherever you used POJOs, you can use records instead, provided your code is suitable for working with immutable data.

Many Java libraries were originally designed to work with POJOs. Since records have been around for a while now, most relevant libraries now support them.

Non-use case: JPA Entities

The word “record” may bring to mind records in a database. The Java Persistence API (JPA) is the standard object-relational mapping API in Java. However, you cannot use records to define JPA entity classes. This is because JPA imposes certain constraints on entity classes, one of which is that they must be mutable, which is the exact opposite of records.

Layered architecture: Records as DTOs

Many Java applications have a layered architecture:

  • A data access layer using JPA.
  • A service layer containing business logic.

Records cannot be used for JPA entities, but they are perfectly suited to be used as immutable Data Transfer Objects (DTOs). In this case, we convert the DTOs defined with records to and from the JPA entities in the service layer or the data access layer.

┌─────────────────────────────────────┐
│         Couche de présentation       │
│       (Records comme DTOs ✅)        │
├─────────────────────────────────────┤
│         Couche de service            │
│   Conversion Record ↔ Entité JPA    │
├─────────────────────────────────────┤
│      Couche d'accès aux données      │
│     (Entités JPA — mutables ❌)      │
└─────────────────────────────────────┘

2. Record Builders

2.1 Defining constructors for Records

In the examples in the previous module, you saw that the compiler automatically generates a constructor that initializes the components of a record. This automatically generated constructor simply assigns arguments to component fields. This constructor, which has a parameter for each component, is called the canonical constructor.

When to write the canonical constructor yourself?

It is possible to write the canonical constructor of a record yourself instead of letting the compiler generate it. We will want to do this if the constructor needs to do something different than what the automatically generated constructor does. The most common reasons are:

  1. Validate arguments: check that the values ​​are valid before storing them.
  2. Make defensive copies of mutable arguments: protect the immutability of the record.

Conciseness is the main reason for having records in Java. With this in mind, there is a special syntax for writing the canonical constructor of a record, allowing all unnecessary elements to be omitted. This is called compact constructor syntax.

Just like a regular class, a record can have multiple constructors with different parameter lists. There are some special rules to follow for additional record builders.


2.2 The canonical constructor

Let’s look at the Journey record again and learn how to write the canonical constructor yourself.

Example: explicit canonical constructor (example01/Journey.java, module 2):

package com.pluralsight.recordspatternmatching.example01;

import java.time.ZonedDateTime;
import java.util.List;
import java.util.Objects;

// Record Journey avec un constructeur canonique écrit manuellement.
public record Journey(String origin, List<String> via, String destination,
                      ZonedDateTime departureTime, ZonedDateTime arrivalTime) {

    // Le constructeur canonique.
    public Journey(String origin, List<String> via, String destination,
                   ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
        this.origin = Objects.requireNonNull(origin, "origin must not be null");
        // Copie défensive et immuable de la List mutable
        this.via = List.copyOf(Objects.requireNonNull(via, "via must not be null"));
        this.destination = Objects.requireNonNull(destination, "destination must not be null");
        this.departureTime = Objects.requireNonNull(departureTime, "departureTime must not be null");
        this.arrivalTime = Objects.requireNonNull(arrivalTime, "arrivalTime must not be null");
    }
}

The canonical constructor must have a parameter list with one parameter for each component in the record. This is what makes it the canonical constructor.

The two main reasons to set the canonical constructor manually

Reason 1: Validation of arguments

We use Objects.requireNonNull() to check that the values ​​are not null. This standard library utility method throws a NullPointerException with an appropriate message if the value is null.

Reason 2: Defensive copies of mutable arguments

Records are supposed to be immutable, but Java cannot guarantee this on its own if a component is of a mutable type. To protect immutability, we use List.copyOf() to create a copy of the list. List.copyOf() returns an unmodifiable list: this list has all the methods of java.util.List, including the modification methods, but all these methods throw an UnsupportedOperationException.

Important rule: The canonical constructor does not have the right to call another constructor via this(...). This is the opposite of additional constructors, which are required to call it.


2.3 The compact constructor syntax

If you look at the manually written canonical constructor in the previous example, one thing stands out: the parameter list is an exact copy of the component list in the record. Since records aim for brevity, there should be no need to repeat this code. This is why there is compact constructor syntax.

How compact syntax works

The compact syntax is distinguished in two points:

  1. Parameter list can be omitted entirely, including parentheses. After the name of the manufacturer, we write the body directly between braces.
  2. Field initialization is automatically generated: in a compact constructor, the field initialization code (this.origin = origin, etc.) is inserted automatically by the compiler after any code you write yourself in the body. So you can’t do it explicitly (fields are final and can only be assigned once).

Although we cannot assign fields directly, we can reassign argument variables — this is how defensive copy works in compact syntax.

Example: compact constructor with additional constructor (example02/Journey.java, module 2):

package com.pluralsight.recordspatternmatching.example02;

import java.time.ZonedDateTime;
import java.util.List;
import java.util.Objects;

// Record Journey avec un constructeur canonique en syntaxe compacte
// et un constructeur supplémentaire.
public record Journey(String origin, List<String> via, String destination,
                      ZonedDateTime departureTime, ZonedDateTime arrivalTime) {

    // Constructeur canonique écrit avec la syntaxe compacte
    public Journey {
        Objects.requireNonNull(origin, "origin must not be null");
        // Réassignation de la variable d'argument pour la copie défensive
        via = List.copyOf(Objects.requireNonNull(via, "via must not be null"));
        Objects.requireNonNull(destination, "destination must not be null");
        Objects.requireNonNull(departureTime, "departureTime must not be null");
        Objects.requireNonNull(arrivalTime, "arrivalTime must not be null");
        if (!arrivalTime.isAfter(departureTime)) {
            throw new IllegalArgumentException("arrivalTime must be after departureTime");
        }
    }

    // Constructeur supplémentaire
    public Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
        this(origin, List.of(), destination, departureTime, arrivalTime);
    }
}

In practice: When you need to write the canonical constructor yourself, always use compact syntax — this is the recommended way.


2.4 Define additional constructors

Just like a regular class, a record can have multiple constructors with different parameter lists. Let’s look at an example and learn the rules specific to additional constructors.

Fundamental rule

A special rule to remember for additional constructors of a record is that they are required to call another constructor — meaning they must always have a this(...) statement.

This rule contrasts with the canonical constructor, which does not have the right to call another constructor.

Consequences of this rule

  • Ultimately, the canonical constructor is always called to initialize a record, regardless of the constructor used at creation.
  • In an additional constructor, it is impossible to assign directly to record fields, because the canonical constructor already initializes them.

Why define additional constructors?

The main reason is to allow the creation of an instance of the record with different parameters, for example with default values ​​for certain parameters:

// Constructeur supplémentaire : initialise la liste des villes étapes avec une liste vide par défaut
public Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
    this(origin, List.of(), destination, departureTime, arrivalTime);
}

3. Records Methods

3.1 Accessor methods

For each component of a record, the compiler generates a private final field, initialized by the canonical constructor. In addition to the field, the compiler also generates an accessor method for each component. This method returns the value of the component.

Naming accessor methods

Accessor methods are similar to POJO getter methods, but the naming convention is different. The name of the accessor method is simply the name of the corresponding component (not getX()).

Overriding an accessor method

Just as it is possible to write the canonical constructor manually, it is possible to write the accessor methods manually. Some rules to follow:

  • Accessor method must be public.
  • The return type and name of the method must be the same as the corresponding component.
  • Method cannot have a throws clause (it cannot throw checked exceptions).
  • Best practice: Use the @Override annotation to indicate that this method is intended to override the accessor method that the compiler would otherwise have generated.

Example: overloading an accessor method (example01/Journey.java, module 3):

package com.pluralsight.recordspatternmatching.example01;

import java.time.ZonedDateTime;

public record Journey(String origin, String destination, ZonedDateTime departureTime, ZonedDateTime arrivalTime) {

    // Méthode accesseur surchargée. Attention aux pièges lorsque vous surchargez des méthodes accesseurs,
    // en particulier lorsqu'elles retournent une valeur différente de celle du champ du composant correspondant.
    @Override
    public String origin() {
        System.out.println("Getting the origin of the journey");
        return origin;
    }
}

Important pitfall: If your overloaded accessor method returns a value other than the actual value of the field (for example a transformed value), this can lead to unexpected results, because the automatically generated equals(), hashCode() and toString() methods work directly on the field values, and not on the values ​​returned by the accessor methods.

Use record methods

Example: calling the methods of a record (example01/CallingRecordMethods.java):

package com.pluralsight.recordspatternmatching.example01;

import java.time.ZonedDateTime;

public class CallingRecordMethods {

    public static void main(String[] args) {
        ZonedDateTime departureTime = ZonedDateTime.parse("2025-08-09T08:00+02:00");
        ZonedDateTime arrivalTime = departureTime.plusHours(3).plusMinutes(30);

        Journey journey = new Journey("Amsterdam", "Paris", departureTime, arrivalTime);

        String origin = journey.origin();
        System.out.println("Your journey starts at: " + origin);

        // Montre ce que retourne la méthode toString() générée automatiquement.
        System.out.println(journey.toString());
    }
}

3.2 Other automatically generated methods

In addition to the canonical constructor and accessor methods, the compiler automatically generates the equals(), hashCode() and toString() methods for a record.

The equals() method

Compares two records by comparing the values ​​of their component fields. It returns true if all component fields are equal. The per-field comparison is performed by calling equals() on the field values ​​in a null-safe manner.

The hashCode() method

Returns a hash code based on the component fields.

The toString() method

Returns a character string that displays the name of the record type and the contents of its fields. It is not intended to be displayed directly in an application’s UI, but primarily for debugging.

Just as with accessor methods, it is possible to manually implement equals(), hashCode() and toString(), although in practice this is almost never necessary.


3.3 Defining other methods and members

There are a few more important things to know about records.

What can be defined in the body of a record

Member TypeAllowed ?Notes
Static fields (static)✅ Yes
Instance Fields❌ NoA record cannot have instance fields outside of those generated for components
Static methods✅ YesNo special restrictions
Instance Methods✅ YesNo special restrictions
Interfaces✅ YesA record can implement interfaces
Class extension❌ NoA record cannot extend other classes
Legacy of a record❌ NoRecords are implicitly final

Key point: Records are implicitly final — other records or classes cannot extend them.


3.4 Records Summary

Here is a summary of what to remember about the records:

  • Goal: Define immutable data classes with concise syntax.
  • Canonical constructor: Automatically generated by the compiler unless you write it yourself. Use compact syntax to avoid unnecessary code.
  • Reasons to write the canonical constructor yourself: validate arguments and make defensive copies of mutable arguments.
  • Additional manufacturers: permitted. Rule to remember: additional constructors must always call another constructor via this(...), which ensures that the canonical constructor is always called.
  • Accessor methods: automatically generated. Named like the component (not getX()). Can be overloaded, but rarely necessary.
  • equals(), hashCode(), toString(): automatically generated. Can be written manually, but rarely necessary.
  • Other members: static fields and arbitrary methods can be defined. We cannot define instance fields.
  • final implicit: records cannot be extended and cannot extend other classes, but can implement interfaces.

4. Pattern Matching: The Basics

4.1 Understanding Pattern Matching

Pattern matching is a recent Java feature that makes it easier to check the type of an object and extract its components. This allows you to write more concise and readable code.

Where to use Pattern Matching in Java?

There are currently two places in Java where pattern matching can be used:

  1. With the instanceof operator
  2. In switch statements and expressions

Pattern matching has been added to Java gradually since Java 16, and new pattern matching features are expected in future versions.

Java versions and availability

FeatureAvailability
Pattern matching for instanceofStandard since Java 16
Pattern matching for switchStandard since Java 21
Record patternsStandard since Java 21
Unnamed variables (_)Standard since Java 22
Primitive types in patternsPreview in Java 23 and 24

4.2 Pattern Matching with instanceof

To illustrate the pattern matching with instanceof, the data model evolves: Journey becomes an interface with methods corresponding to the accessor methods of the record studied in the previous modules. Two records, BusJourney and TrainJourney, implement this interface and add specific components.

Journey interface:

package com.pluralsight.recordspatternmatching;

import java.time.ZonedDateTime;

public interface Journey {

    String origin();
    String destination();
    ZonedDateTime departureTime();
    ZonedDateTime arrivalTime();
}

BusJourney record:

package com.pluralsight.recordspatternmatching;

import java.time.ZonedDateTime;

public record BusJourney(String origin, String destination, ZonedDateTime departureTime,
                         ZonedDateTime arrivalTime, String busNumber) implements Journey {
}

TrainJourney record:

package com.pluralsight.recordspatternmatching;

import java.time.ZonedDateTime;

public record TrainJourney(String origin, String destination, ZonedDateTime departureTime,
                           ZonedDateTime arrivalTime, String departurePlatform,
                           String arrivalPlatform) implements Journey {
}

Before pattern matching: the “check and cast” pattern

Before Java 16, we checked the type with instanceof and then performed an explicit cast:

// Sans pattern matching : vérification et cast explicite (pattern classique).
public void printJourneyInfo(Journey journey) {
    if (journey instanceof BusJourney) {
        BusJourney busJourney = (BusJourney) journey;     // cast nécessaire
        System.out.println("Bus number: " + busJourney.busNumber());
    } else if (journey instanceof TrainJourney) {
        TrainJourney trainJourney = (TrainJourney) journey;  // cast nécessaire
        System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
    }
}

This cast is actually useless: if the instanceof test succeeds, we already know that the object is of the right type. Pattern matching solves this problem.

With pattern matching for instanceof

Since Java 16, we can write the type pattern directly in the instanceof expression: after the type name, we write the name of a pattern variable. If the test passes, the variable is initialized with the cast value.

// Avec pattern matching pour instanceof.
public void printJourneyInfo2(Journey journey) {
    if (journey instanceof BusJourney busJourney) {
        // busJourney est disponible ici avec le type BusJourney, sans cast explicite
        System.out.println("Bus number: " + busJourney.busNumber());
    } else if (journey instanceof TrainJourney trainJourney) {
        System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
    }
}

Full code (example01/PatternMatchingInstanceof.java):

package com.pluralsight.recordspatternmatching.example01;

import com.pluralsight.recordspatternmatching.BusJourney;
import com.pluralsight.recordspatternmatching.Journey;
import com.pluralsight.recordspatternmatching.TrainJourney;

public class PatternMatchingInstanceof {

    // Vérification du type d'un objet avec instanceof sans pattern matching.
    public void printJourneyInfo(Journey journey) {
        if (journey instanceof BusJourney) {
            BusJourney busJourney = (BusJourney) journey;
            System.out.println("Bus number: " + busJourney.busNumber());
        } else if (journey instanceof TrainJourney) {
            TrainJourney trainJourney = (TrainJourney) journey;
            System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
        }
    }

    // Vérification du type d'un objet avec instanceof avec pattern matching.
    public void printJourneyInfo2(Journey journey) {
        if (journey instanceof BusJourney busJourney) {
            System.out.println("Bus number: " + busJourney.busNumber());
        } else if (journey instanceof TrainJourney trainJourney) {
            System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
        }
    }
}

4.3 Pattern Matching with switch

Pattern matching can also be used in switch statements and expressions. This is a standard feature since Java 21.

Switch syntax with pattern matching

We specify a type pattern in each case: a type name followed by a variable, exactly as with instanceof. In the case code, we can use the variable declared by the pattern.

// Pattern matching pour switch.
public void printJourneyInfo(Journey journey) {
    switch (journey) {
        case BusJourney busJourney ->
                System.out.println("Bus number: " + busJourney.busNumber());
        case TrainJourney trainJourney ->
                System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
        default ->
                System.out.println(journey);
    }
}

Completeness rule

When using a switch with pattern matching boxes, Java requires that the switch be exhaustive: for all possible values ​​on which we switch, there must be a corresponding box.

If we switch to an interface type value, there could be more classes or records that implement this interface than those listed in the boxes. This problem can be solved by adding a default case.

Order of boxes: importance of dominances

The order in which the boxes are listed in the switch is important. It is possible for an input value to match multiple boxes — in this case, the switch chooses the first box that matches.

If a general box (which corresponds to any type of object) is placed before more specific boxes, the specific boxes will never be reached (compilation error).

Full code (example02/PatternMatchingSwitch.java):

package com.pluralsight.recordspatternmatching.example02;

import com.pluralsight.recordspatternmatching.BusJourney;
import com.pluralsight.recordspatternmatching.Journey;
import com.pluralsight.recordspatternmatching.TrainJourney;

public class PatternMatchingSwitch {

    // Pattern matching pour switch.
    public void printJourneyInfo(Journey journey) {
        switch (journey) {
            case BusJourney busJourney ->
                    System.out.println("Bus number: " + busJourney.busNumber());
            case TrainJourney trainJourney ->
                    System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
            default ->
                    System.out.println(journey);
        }
    }
}

4.4 Guarded Patterns

guarded patterns allow you to add an additional Boolean condition to a switch case. We use the keyword when followed by a Boolean expression.

Problem solved by guarded patterns

Let’s imagine that we want to display a different message when a train leaves Amsterdam. The direct way is to use an if in the case for TrainJourney. This works, but the code can get confusing because the decision logic (the if) is mixed up with the execution code (the println).

With a guarded pattern, we separate the decision logic from the code to be executed, which makes the code clearer and more readable.

// Sans guarded pattern : logique de décision mêlée au code d'exécution.
case TrainJourney trainJourney -> {
    if (trainJourney.origin().equals("Amsterdam")) {
        System.out.println("Platform " + trainJourney.departurePlatform() + " at Amsterdam Central");
    } else {
        System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
    }
}

// Avec guarded pattern : séparation claire entre décision et exécution.
case TrainJourney trainJourney when trainJourney.origin().equals("Amsterdam") ->
    System.out.println("Platform " + trainJourney.departurePlatform() + " at Amsterdam Central");
case TrainJourney trainJourney ->
    System.out.println("The train departs from platform: " + trainJourney.departurePlatform());

Order rule: the rules on dominant squares apply here too. It is important to place the case with the guard before the general case for TrainJourney; otherwise, the case with guard would never be reached.

Full code (example03/PatternMatchingGuardedPatterns.java):

package com.pluralsight.recordspatternmatching.example03;

import com.pluralsight.recordspatternmatching.BusJourney;
import com.pluralsight.recordspatternmatching.Journey;
import com.pluralsight.recordspatternmatching.TrainJourney;

public class PatternMatchingGuardedPatterns {

    public void printJourneyInfo(Journey journey) {
        switch (journey) {
            case BusJourney busJourney ->
                    System.out.println("Bus number: " + busJourney.busNumber());
            case TrainJourney trainJourney -> {
                // Vérification si l'origine est Amsterdam
                if (trainJourney.origin().equals("Amsterdam")) {
                    System.out.println("Platform " + trainJourney.departurePlatform() + " at Amsterdam Central");
                } else {
                    System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
                }
            }
            default ->
                    System.out.println(journey);
        }
    }

    // Pattern matching pour switch avec guarded patterns.
    public void printJourneyInfo2(Journey journey) {
        switch (journey) {
            case BusJourney busJourney ->
                    System.out.println("Bus number: " + busJourney.busNumber());
            case TrainJourney trainJourney when trainJourney.origin().equals("Amsterdam") ->
                    System.out.println("Platform " + trainJourney.departurePlatform() + " at Amsterdam Central");
            case TrainJourney trainJourney ->
                    System.out.println("The train departs from platform: " + trainJourney.departurePlatform());
            default ->
                    System.out.println(journey);
        }
    }
}

4.5 Primitive types in patterns

Pattern matching is still a relatively new feature in Java. One of the current limitations is that you cannot use type patterns with primitive types. This capability is available as a preview feature in Java 23 and 24.

Warning: being a preview feature, it is not yet completely finalized. It should not be used in production. Things might change before it becomes a standard feature.

Example: switch with primitive types in patterns

Consider an example method that takes an HTTP status code and returns the corresponding category. Without the primitive patterns, we use a sequence of if/else if:

// Sans patterns primitifs : séquence de if/else if.
public HttpStatusCategory httpStatusCategory(int statusCode) {
    if (statusCode >= 100 && statusCode < 200) {
        return HttpStatusCategory.INFORMATIONAL;
    } else if (statusCode < 300) {
        return HttpStatusCategory.SUCCESSFUL;
    } else if (statusCode < 400) {
        return HttpStatusCategory.REDIRECTION;
    } else if (statusCode < 500) {
        return HttpStatusCategory.CLIENT_ERROR;
    } else if (statusCode < 600) {
        return HttpStatusCategory.SERVER_ERROR;
    } else {
        throw new IllegalArgumentException("Invalid status code: " + statusCode);
    }
}

With primitive patterns (feature preview), we can write this with a switch:

// Avec patterns primitifs (preview feature Java 23/24) : switch plus expressif.
public HttpStatusCategory httpStatusCategory2(int statusCode) {
    return switch (statusCode) {
        case int s when s >= 100 && s < 200 -> HttpStatusCategory.INFORMATIONAL;
        case int s when s < 300              -> HttpStatusCategory.SUCCESSFUL;
        case int s when s < 400              -> HttpStatusCategory.REDIRECTION;
        case int s when s < 500              -> HttpStatusCategory.CLIENT_ERROR;
        case int s when s < 600              -> HttpStatusCategory.SERVER_ERROR;
        default -> throw new IllegalArgumentException("Invalid status code: " + statusCode);
    };
}

Full code (example04/PrimitiveTypePatternsSwitch.java):

package com.pluralsight.recordspatternmatching.example04;

// Feature preview : Pattern matching avec types primitifs pour switch.
public class PrimitiveTypePatternsSwitch {

    public enum HttpStatusCategory {
        INFORMATIONAL, SUCCESSFUL, REDIRECTION, CLIENT_ERROR, SERVER_ERROR
    }

    public HttpStatusCategory httpStatusCategory(int statusCode) {
        if (statusCode >= 100 && statusCode < 200) {
            return HttpStatusCategory.INFORMATIONAL;
        } else if (statusCode < 300) {
            return HttpStatusCategory.SUCCESSFUL;
        } else if (statusCode < 400) {
            return HttpStatusCategory.REDIRECTION;
        } else if (statusCode < 500) {
            return HttpStatusCategory.CLIENT_ERROR;
        } else if (statusCode < 600) {
            return HttpStatusCategory.SERVER_ERROR;
        } else {
            throw new IllegalArgumentException("Invalid status code: " + statusCode);
        }
    }

    public HttpStatusCategory httpStatusCategory2(int statusCode) {
        return switch (statusCode) {
            case int s when s >= 100 && s < 200 -> HttpStatusCategory.INFORMATIONAL;
            case int s when s < 300 -> HttpStatusCategory.SUCCESSFUL;
            case int s when s < 400 -> HttpStatusCategory.REDIRECTION;
            case int s when s < 500 -> HttpStatusCategory.CLIENT_ERROR;
            case int s when s < 600 -> HttpStatusCategory.SERVER_ERROR;
            default -> throw new IllegalArgumentException("Invalid status code: " + statusCode);
        };
    }
}

Example: primitive type conversion with instanceof

Another use case is to check if the value of a primitive type can be converted without loss of information into another primitive type.

// Sans patterns primitifs : vérification manuelle de la plage de valeurs.
private static void example1(int number) {
    if (number >= -128 && number <= 127) {
        byte b = (byte) number;
        System.out.println("Byte: " + b);
    } else {
        System.out.println("Does not fit in a byte: " + number);
    }
}

// Avec patterns primitifs (preview feature Java 23/24) :
// instanceof vérifie si la valeur tient dans un byte.
private static void example2(int number) {
    if (number instanceof byte b) {
        System.out.println("Byte: " + b);
    } else {
        System.out.println("Does not fit in a byte: " + number);
    }
}

Full code (example04/PrimitiveTypePatternsInstanceof.java):

package com.pluralsight.recordspatternmatching.example04;

import java.util.Random;

// Feature preview : Pattern matching avec types primitifs pour instanceof.
public class PrimitiveTypePatternsInstanceof {

    public static void main(String[] args) {
        int number = new Random().nextInt(-300, 300);

        example1(number);
        example2(number);
    }

    private static void example1(int number) {
        if (number >= -128 && number <= 127) {
            byte b = (byte) number;
            System.out.println("Byte: " + b);
        } else {
            System.out.println("Does not fit in a byte: " + number);
        }
    }

    private static void example2(int number) {
        if (number instanceof byte b) {
            System.out.println("Byte: " + b);
        } else {
            System.out.println("Does not fit in a byte: " + number);
        }
    }
}

5. Pattern Matching with Records

5.1 Record Patterns

Records and pattern matching are both very useful features individually, but they are even more powerful when combined. By using record patterns, we can extract data from objects with clear and concise code. Record patterns are available since Java 21.

How does a record pattern work?

In a switch case with pattern matching, instead of declaring a single variable after the type, we specify the list of record components in parentheses after the name of the record. This record pattern then declares several variables at once, each initialized with the value of the component extracted from the record.

Gradual evolution of the code

Step 1: Pattern matching without record patterns — we call the accessor methods:

// Pattern matching sans record patterns.
public void printJourneyInfo(Journey journey) {
    switch (journey) {
        case BusJourney busJourney ->
                System.out.printf("Bus number %s travels from %s to %s%n",
                    busJourney.busNumber(), busJourney.origin(), busJourney.destination());
        case TrainJourney trainJourney ->
                System.out.printf("The train departs from platform %s at %s%n",
                    trainJourney.departurePlatform(), trainJourney.departureTime());
        default ->
                System.out.println(journey);
    }
}

Step 2: With record pattern and explicit types — direct extraction of components:

// Avec record pattern : extraction des composants du record (types explicites).
public void printJourneyInfo2(Journey journey) {
    switch (journey) {
        case BusJourney(String origin, String destination, ZonedDateTime departureTime,
                        ZonedDateTime arrivalTime, String busNumber) ->
                System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
        case TrainJourney(String origin, String destination, ZonedDateTime departureTime,
                          ZonedDateTime arrivalTime, String departurePlatform, String arrivalPlatform) ->
                System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
        default ->
                System.out.println(journey);
    }
}

Step 3: With record pattern and type inference (var) — even more concise:

// Avec record pattern et inférence de type (var).
public void printJourneyInfo3(Journey journey) {
    switch (journey) {
        case BusJourney(var origin, var destination, var departureTime, var arrivalTime, var busNumber) ->
                System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
        case TrainJourney(var origin, var destination, var departureTime,
                          var arrivalTime, var departurePlatform, var arrivalPlatform) ->
                System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
        default ->
                System.out.println(journey);
    }
}

Full code (example01/RecordPatterns.java):

package com.pluralsight.recordspatternmatching.example01;

import com.pluralsight.recordspatternmatching.BusJourney;
import com.pluralsight.recordspatternmatching.Journey;
import com.pluralsight.recordspatternmatching.TrainJourney;

import java.time.ZonedDateTime;

public class RecordPatterns {

    // Pattern matching avec records sans utiliser les record patterns.
    public void printJourneyInfo(Journey journey) {
        switch (journey) {
            case BusJourney busJourney ->
                    System.out.printf("Bus number %s travels from %s to %s%n",
                        busJourney.busNumber(), busJourney.origin(), busJourney.destination());
            case TrainJourney trainJourney ->
                    System.out.printf("The train departs from platform %s at %s%n",
                        trainJourney.departurePlatform(), trainJourney.departureTime());
            default ->
                    System.out.println(journey);
        }
    }

    // Utilisation d'un record pattern pour extraire les composants d'un record.
    public void printJourneyInfo2(Journey journey) {
        switch (journey) {
            case BusJourney(String origin, String destination, ZonedDateTime departureTime,
                            ZonedDateTime arrivalTime, String busNumber) ->
                    System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
            case TrainJourney(String origin, String destination, ZonedDateTime departureTime,
                              ZonedDateTime arrivalTime, String departurePlatform, String arrivalPlatform) ->
                    System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
            default ->
                    System.out.println(journey);
        }
    }

    // Utilisation d'un record pattern avec inférence de type (var).
    public void printJourneyInfo3(Journey journey) {
        switch (journey) {
            case BusJourney(var origin, var destination, var departureTime, var arrivalTime, var busNumber) ->
                    System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
            case TrainJourney(var origin, var destination, var departureTime,
                              var arrivalTime, var departurePlatform, var arrivalPlatform) ->
                    System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
            default ->
                    System.out.println(journey);
        }
    }
}

5.2 Unnamed Variables

When using record patterns, you are sometimes required to list all of the components of the record, even if you are only interested in some of them. This creates “noise” in the code.

Before Java 22: short names

A first solution is to give short names (one or two letters) to variables that are not used. But this is not ideal: we always declare variables that we will not use.

Since Java 22: the underscore character _

Since Java 22, we can replace components that do not interest us with an underscore _. The corresponding variable is said to be “unnamed” (unnamed variable). The IDE or compiler no longer reports a warning for unused variables declared as _.

// Utilisation de '_' (variables non nommées) pour les composants non utilisés.
public void printJourneyInfo(Journey journey) {
    switch (journey) {
        case BusJourney(var origin, var destination, _, _, var busNumber) ->
                System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
        case TrainJourney(_, _, var departureTime, _, var departurePlatform, _) ->
                System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
        default ->
                System.out.println(journey);
    }
}

General scope of unnamed variables

Unnamed variables (_) are not only useful in record patterns. Generally speaking, in versions prior to Java 22, when declaring a deliberately unused variable, the IDE or compiler could issue a warning. Now we can use _ to explicitly indicate that this is an intentionally unused variable.

Full code (example02/UnnamedVariables.java):

package com.pluralsight.recordspatternmatching.example02;

import com.pluralsight.recordspatternmatching.BusJourney;
import com.pluralsight.recordspatternmatching.Journey;
import com.pluralsight.recordspatternmatching.TrainJourney;

public class UnnamedVariables {

    // Utilisation de '_' (variables non nommées) pour les composants non intéressants,
    // afin de rendre le code plus concis.
    public void printJourneyInfo(Journey journey) {
        switch (journey) {
            case BusJourney(var origin, var destination, _, _, var busNumber) ->
                    System.out.printf("Bus number %s travels from %s to %s%n", busNumber, origin, destination);
            case TrainJourney(_, _, var departureTime, _, var departurePlatform, _) ->
                    System.out.printf("The train departs from platform %s at %s%n", departurePlatform, departureTime);
            default ->
                    System.out.println(journey);
        }
    }
}

5.3 Nested Record Patterns

What makes record patterns really powerful is that you can nest them inside each other. Nested record patterns allow you to extract data from complex data structures into a single pattern expression.

The extended data model

To illustrate nested record patterns, here is the full data model used in this example:

Booking Record:

package com.pluralsight.recordspatternmatching;

public record Booking(Journey journey, Passenger passenger, Ticket ticket) {
}

Passenger Record:

package com.pluralsight.recordspatternmatching;

public record Passenger(String id, String firstName, String lastName, int age) {
}

Record Ticket:

package com.pluralsight.recordspatternmatching;

import java.math.BigDecimal;

public record Ticket(String number, BigDecimal price, ComfortLevel comfortLevel) {
}

Enum ComfortLevel:

package com.pluralsight.recordspatternmatching;

public enum ComfortLevel {
    FIRST, SECOND
}

Without nested record patterns

Code without nested record patterns requires a lot of plumbing code: null checks, calls to accessor methods, variable declarations:

// Affichage des informations de réservation sans record patterns.
public void printBookingInfo(Booking booking) {
    if (booking != null) {
        Journey journey = booking.journey();
        Passenger passenger = booking.passenger();
        Ticket ticket = booking.ticket();
        if (journey != null && passenger != null && ticket != null) {
            String origin = journey.origin();
            String destination = journey.destination();
            ZonedDateTime departureTime = journey.departureTime();
            String firstName = passenger.firstName();
            String lastName = passenger.lastName();
            BigDecimal price = ticket.price();
            if (journey instanceof BusJourney busJourney) {
                String busNumber = busJourney.busNumber();
                System.out.printf("Passenger %s %s, journey from %s to %s, bus %s, departure at %s, ticket price %s%n",
                        firstName, lastName, origin, destination, busNumber, departureTime, price);
            } else if (journey instanceof TrainJourney trainJourney) {
                String departurePlatform = trainJourney.departurePlatform();
                System.out.printf("Passenger %s %s, from %s to %s, train at platform %s, departure at %s, ticket price %s%n",
                        firstName, lastName, origin, destination, departurePlatform, departureTime, price);
            }
        }
    }
}

With nested record patterns

With nested record patterns, all this plumbing code disappears. The pattern directly extracts all the necessary data in a single expression:

// Affichage des informations de réservation avec record patterns imbriqués.
public void printBookingInfo2(Booking booking) {
    switch (booking) {
        case Booking(
                BusJourney(var origin, var destination, var departureTime, _, var busNumber),
                Passenger(_, var firstName, var lastName, _),
                Ticket(_, var price, _)) ->
            System.out.printf("Passenger %s %s, journey from %s to %s, bus %s, departure at %s, ticket price %s%n",
                firstName, lastName, origin, destination, busNumber, departureTime, price);

        case Booking(
                TrainJourney(var origin, var destination, var departureTime, _, var departurePlatform, _),
                Passenger(_, var firstName, var lastName, _),
                Ticket(_, var price, _)) ->
            System.out.printf("Passenger %s %s, from %s to %s, train at platform %s, departure at %s, ticket price %s%n",
                firstName, lastName, origin, destination, departurePlatform, departureTime, price);

        default -> {}
    }
}

Point to note about completeness: when using record patterns in a switch, Java always requires completeness. Here, a default -> {} case is added to cover the case where Booking corresponds to neither a BusJourney nor a TrainJourney, or if booking is null.

Full code (example03/NestedRecordPatterns.java):

package com.pluralsight.recordspatternmatching.example03;

import com.pluralsight.recordspatternmatching.*;

import java.math.BigDecimal;
import java.time.ZonedDateTime;

public class NestedRecordPatterns {

    // Affichage des informations de réservation sans record patterns.
    public void printBookingInfo(Booking booking) {
        if (booking != null) {
            Journey journey = booking.journey();
            Passenger passenger = booking.passenger();
            Ticket ticket = booking.ticket();
            if (journey != null && passenger != null && ticket != null) {
                String origin = journey.origin();
                String destination = journey.destination();
                ZonedDateTime departureTime = journey.departureTime();
                String firstName = passenger.firstName();
                String lastName = passenger.lastName();
                BigDecimal price = ticket.price();
                if (journey instanceof BusJourney busJourney) {
                    String busNumber = busJourney.busNumber();
                    System.out.printf("Passenger %s %s, journey from %s to %s, bus %s, departure at %s, ticket price %s%n",
                            firstName, lastName, origin, destination, busNumber, departureTime, price);
                } else if (journey instanceof TrainJourney trainJourney) {
                    String departurePlatform = trainJourney.departurePlatform();
                    System.out.printf("Passenger %s %s, from %s to %s, train at platform %s, departure at %s, ticket price %s%n",
                            firstName, lastName, origin, destination, departurePlatform, departureTime, price);
                }
            }
        }
    }

    // Affichage des informations de réservation avec record patterns imbriqués.
    public void printBookingInfo2(Booking booking) {
        switch (booking) {
            case Booking(
                    BusJourney(var origin, var destination, var departureTime, _, var busNumber),
                    Passenger(_, var firstName, var lastName, _),
                    Ticket(_, var price, _)) ->
                System.out.printf("Passenger %s %s, journey from %s to %s, bus %s, departure at %s, ticket price %s%n",
                    firstName, lastName, origin, destination, busNumber, departureTime, price);

            case Booking(
                    TrainJourney(var origin, var destination, var departureTime, _, var departurePlatform, _),
                    Passenger(_, var firstName, var lastName, _),
                    Ticket(_, var price, _)) ->
                System.out.printf("Passenger %s %s, from %s to %s, train at platform %s, departure at %s, ticket price %s%n",
                    firstName, lastName, origin, destination, departurePlatform, departureTime, price);

            default -> {}
        }
    }
}

5.4 Guarded Record Patterns

Just like in a standard switch with pattern matching, it is possible to add a when clause to a case that uses record patterns. This allows data extraction via a record pattern to be combined with an additional condition.

Example: filtering by comfort level

In this example, we display whether a passenger has a first or second class ticket. The when clause can use the variables declared by the record pattern:

void printTicketInfo(Booking booking) {
    switch (booking) {
        // Case pour les billets de première classe
        case Booking(_, Passenger(_, var firstName, var lastName, _), Ticket(_, _, var level))
                when level == ComfortLevel.FIRST ->
                System.out.printf("Passenger %s %s has a first class ticket%n", firstName, lastName);

        // Case pour les billets de deuxième classe
        case Booking(_, Passenger(_, var firstName, var lastName, _), Ticket(_, _, var level))
                when level == ComfortLevel.SECOND ->
                System.out.printf("Passenger %s %s has a second class ticket%n", firstName, lastName);

        default -> {}
    }
}

Note that in the when clause, we can use the level variable which is one of the variables declared by the record pattern. The _ score is used for the journey component of Booking, which we are not concerned with here.

Full code (example04/GuardedRecordPatterns.java):

package com.pluralsight.recordspatternmatching.example04;

import com.pluralsight.recordspatternmatching.Booking;
import com.pluralsight.recordspatternmatching.ComfortLevel;
import com.pluralsight.recordspatternmatching.Passenger;
import com.pluralsight.recordspatternmatching.Ticket;

public class GuardedRecordPatterns {

    void printTicketInfo(Booking booking) {
        switch (booking) {
            case Booking(_, Passenger(_, var firstName, var lastName, _), Ticket(_, _, var level))
                    when level == ComfortLevel.FIRST ->
                    System.out.printf("Passenger %s %s has a first class ticket%n", firstName, lastName);

            case Booking(_, Passenger(_, var firstName, var lastName, _), Ticket(_, _, var level))
                    when level == ComfortLevel.SECOND ->
                    System.out.printf("Passenger %s %s has a second class ticket%n", firstName, lastName);

            default -> {}
        }
    }
}

5.5 Course Summary

Here is a summary of everything learned in this course.

Records

  • When writing software in Java, we often need to define classes that represent the domain data model.
  • Records are a way to define immutable data container classes without all the overhead you would get with ordinary classes.
  • A record has components defined directly after the record name.
  • The compiler automatically generates the canonical constructor, the accessor methods, and the equals(), hashCode() and toString() methods.
  • You can write the canonical constructor yourself if necessary, using compact constructor syntax to keep it concise.
  • The most common reasons to write the canonical constructor yourself: validate arguments and make defensive copies of mutable arguments.
  • Additional constructors can be defined. Rule: They must always call another constructor, which ensures that the canonical constructor is always called.
  • Accessor methods and other automatically generated methods can be overridden, although this is rarely necessary.
  • Just like in an ordinary class, static fields and arbitrary methods can be defined. We cannot define instance fields.

Pattern Matching

  • pattern matching allows you to extract data from data structures with concise code.
  • There are currently two places to use pattern matching in Java: with instanceof and with switch.
  • You can use guards in the patterns of a switch by adding a when clause.
  • Preview feature: use of primitive types in patterns (Java 23/24).
  • Record patterns are the connection between records and pattern matching — they allow you to easily extract values ​​from a data structure without explicitly writing calls to accessor methods.
  • nested record patterns allow you to extract data from complex nested structures into a single expression, making the code even more declarative and readable.

6. Appendix: Full source code

Maven configuration (pom.xml)

All demo projects use the same basic Maven framework, with Java 24 and JUnit Jupiter and AssertJ dependencies for testing.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.pluralsight.recordspatternmatching</groupId>
    <artifactId>01-records-basics</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>24</maven.compiler.source>
        <maven.compiler.target>24</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.12.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.27.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Structure of demonstration projects

java-records-pattern-matching/
├── 01/demos/01-records-basics/
│   └── src/main/java/com/pluralsight/recordspatternmatching/
│       ├── example01/Journey.java          ← POJO mutable
│       ├── example02/Journey.java          ← Classe immuable manuelle
│       └── example03/
│           ├── Journey.java                ← Record simple
│           └── UsingRecords.java           ← Utilisation d'un record
│
├── 02/demos/02-record-constructors/
│   └── src/main/java/com/pluralsight/recordspatternmatching/
│       ├── example01/Journey.java          ← Constructeur canonique explicite
│       └── example02/Journey.java          ← Syntaxe compacte + constructeur supplémentaire
│
├── 03/demos/03-record-methods/
│   └── src/main/java/com/pluralsight/recordspatternmatching/
│       └── example01/
│           ├── Journey.java                ← Méthode accesseur surchargée
│           └── CallingRecordMethods.java   ← Appel des méthodes d'un record
│
├── 04/demos/04-pattern-matching-basics/
│   └── src/main/java/com/pluralsight/recordspatternmatching/
│       ├── Journey.java                    ← Interface Journey
│       ├── BusJourney.java                 ← Record BusJourney
│       ├── TrainJourney.java               ← Record TrainJourney
│       ├── example01/PatternMatchingInstanceof.java
│       ├── example02/PatternMatchingSwitch.java
│       ├── example03/PatternMatchingGuardedPatterns.java
│       └── example04/
│           ├── PrimitiveTypePatternsSwitch.java
│           └── PrimitiveTypePatternsInstanceof.java
│
└── 05/demos/05-pattern-matching-records/
    └── src/main/java/com/pluralsight/recordspatternmatching/
        ├── Journey.java                    ← Interface Journey
        ├── BusJourney.java                 ← Record BusJourney
        ├── TrainJourney.java               ← Record TrainJourney
        ├── Passenger.java                  ← Record Passenger
        ├── Ticket.java                     ← Record Ticket
        ├── ComfortLevel.java               ← Enum ComfortLevel
        ├── Booking.java                    ← Record Booking
        ├── example01/RecordPatterns.java
        ├── example02/UnnamedVariables.java
        ├── example03/NestedRecordPatterns.java
        └── example04/GuardedRecordPatterns.java

Summary table: Records vs POJO

CharacteristicClassic POJORecord
VerbosityHigh (constructor, getters, setters, equals, hashCode, toString)Minimal (one line)
ImmutabilityOptional (manual)Implicit (final fields)
Generated methodsNo (requires IDE or Lombok)Yes (compiler)
Naming accessorsgetX()x()
LegacyPossibleForbidden (final implied)
Implementation of interfacesYesYes
Arbitrary instance fieldsYesNo (only components)
Using with JPAYes (entities)No (entities); Yes (DTOs)

Summary table: evolution of Pattern Matching

FeatureJava versionExample
classic instanceofAlwaysif (obj instanceof BusJourney) { BusJourney b = (BusJourney) obj; ... }
Pattern matching for instanceofJava 16if (obj instanceof BusJourney b) { ... }
Pattern matching for switchJava 21case BusJourney b -> ...
Record patternsJava 21case BusJourney(var o, var d, _, _, var n) -> ...
Unnamed variables (_)Java 22case BusJourney(var origin, _, _, _, var busNumber) -> ...
Guarded patternsJava 21case TrainJourney t when t.origin().equals("Amsterdam") -> ...
Primitive types in patternsJava Preview 23/24case int s when s >= 100 && s < 200 -> ...

Search Terms

java · records · pattern · matching · backend · architecture · full-stack · web · record · patterns · methods · constructor · method · syntax · accessor · canonical · class · constructors · defining · guarded · immutable · instanceof · nested · primitive

Interested in this course?

Contact us to book it or get a custom training plan for your team.