> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Implement the State Machine Pattern in Java
- URL: https://www.ggorantala.dev/how-to-implement-state-machine-pattern-java/
- Published: 2026-03-21T05:58:00.000Z
- Updated: 2026-10-31T06:34:39.000Z
- Description: Build a parcel-tracking state machine in Java 21 with enums and a transition table, add a guard for failed deliveries, and prove the rules with 19 checks.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, design-patterns, java-tutorials

## Where a parcel can and can't go

A parcel can't be delivered before someone picks it up. A delivered parcel can't go back out for delivery. Say that out loud and it sounds obvious, but I've reviewed plenty of code where those rules live in five `if` statements spread over three services, and a boolean called `isDelivered` that quietly disagrees with a status string.

By the end of this you'll have a small, self-contained **state machine pattern in Java**: an enum for the states, an enum for the events, one table that holds every legal move, and a `Parcel` that refuses anything else. Here's what it prints when I run it:

```log
== PK-1001 ends DELIVERED
  CREATED --PICK_UP--> PICKED_UP
  PICKED_UP --ARRIVE_AT_HUB--> IN_TRANSIT
  IN_TRANSIT --ARRIVE_AT_HUB--> IN_TRANSIT
  IN_TRANSIT --OUT_FOR_DELIVERY--> OUT_FOR_DELIVERY
  OUT_FOR_DELIVERY --DELIVER--> DELIVERED
== PK-1002 ends RETURNED after 3 failed attempts
== rejected: No transition from DELIVERED on OUT_FOR_DELIVERY

```

Parcel PK-1002 never got a "return it" event from the caller. The machine sent it home by itself after the third failed delivery. That's a guard, and we'll build it in Step 4.

It's for any Java developer who has a `status` field and a growing pile of checks around it. No framework, no library. I built and ran everything below on a plain JDK. The only thing that tripped me was a `serialVersionUID` lint warning on the exception class, which you'll see fixed in the code.

```mermaid
stateDiagram-v2
    [*] --> CREATED
    CREATED --> PICKED_UP: PICK_UP
    PICKED_UP --> IN_TRANSIT: ARRIVE_AT_HUB
    IN_TRANSIT --> IN_TRANSIT: ARRIVE_AT_HUB
    IN_TRANSIT --> OUT_FOR_DELIVERY: OUT_FOR_DELIVERY
    IN_TRANSIT --> RETURNED: RETURN_TO_SENDER
    OUT_FOR_DELIVERY --> DELIVERED: DELIVER
    OUT_FOR_DELIVERY --> FAILED_ATTEMPT: FAIL_ATTEMPT
    FAILED_ATTEMPT --> OUT_FOR_DELIVERY: OUT_FOR_DELIVERY
    FAILED_ATTEMPT --> RETURNED: RETURN_TO_SENDER
    DELIVERED --> [*]
    RETURNED --> [*]

```

## Versions I used

| Tool           | Version I used                               | Check it with  |
| -------------- | -------------------------------------------- | -------------- |
| JDK            | OpenJDK 21.0.12.1                            | java -version  |
| Build          | plain javac, no Maven or Gradle              | javac -version |
| IDE (optional) | IntelliJ IDEA or Eclipse, any recent release | Help → About   |

You need Java 17 or newer. I also compiled the main code with `--release 17` to make sure nothing here needs 21\. A few vocabulary words before we start: a **state** is where the object is right now, an **event** is something that happens to it, and a **transition** is the allowed jump from one state to another when an event arrives. A **guard** is a condition that decides whether, or where, a transition goes.

Not sure why you'd pick this over the other behavioural patterns? If the *algorithm* changes, that's [strategy](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev). If a request travels along a *fixed line of handlers*, that's [chain of responsibility](https://ggorantala.dev/how-to-implement-chain-responsibility-pattern-java/?ref=ggorantala.dev). If the *object's own history* decides what's legal next, that's a state machine.

## Create the project by hand

No wizard needed. Make the folders and you're done.

```bash
mkdir -p parcel-tracker/src/main/java/com/example/parcel parcel-tracker/src/test
cd parcel-tracker

```

```log
parcel-tracker/
├── src/
│   ├── main/java/com/example/parcel/
│   │   ├── State.java
│   │   ├── Event.java
│   │   ├── Step.java
│   │   ├── IllegalTransitionException.java
│   │   ├── TransitionTable.java
│   │   ├── Parcel.java
│   │   └── Demo.java
│   └── test/Checks.java

```

### In IntelliJ IDEA

Choose File → New → Project, pick **Java**, select your JDK 21, and leave the build system as **IntelliJ**. Create the package `com.example.parcel` under `src` and paste the files in. Mark the test folder as a test source root if you want the editor to treat it that way. I didn't click through the wizard in this run; the menu path is from JetBrains' current documentation.

### In Eclipse

File → New → Java Project, JRE 21, then create the `com.example.parcel` package under `src` and add the classes. Same caveat: docs only, not executed.

### With Maven or Gradle

The files drop straight into `src/main/java`. Put `Checks.java` under `src/test/java` and port it to JUnit 5 when you're ready. Maven Central wasn't reachable from my build machine, so I didn't run a Maven or JUnit build for this article. The checks below are a plain `main` method, and they behave the same way.

## Write the machine, one piece at a time

### Step 1: Name the states and events

**Goal:** make the vocabulary impossible to misspell.

`src/main/java/com/example/parcel/State.java`

```java
package com.example.parcel;

public enum State {
    CREATED, PICKED_UP, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, FAILED_ATTEMPT, RETURNED;

    public boolean isTerminal() {
        return this == DELIVERED || this == RETURNED;
    }
}

```

`src/main/java/com/example/parcel/Event.java`

```java
package com.example.parcel;

public enum Event {
    PICK_UP, ARRIVE_AT_HUB, OUT_FOR_DELIVERY, DELIVER, FAIL_ATTEMPT, RETURN_TO_SENDER
}

```

Enums beat strings here for a boring reason: the compiler catches `State.DELIVRED`. `isTerminal()` marks the two states that end the story. I'd rather ask the enum than scatter `== DELIVERED || == RETURNED` through the codebase.

**Checkpoint:** compile with `javac -d out src/main/java/com/example/parcel/*.java`. Nothing should print.

### Step 2: Write the rejection and the history record

**Goal:** decide what failure looks like before writing the happy path.

`src/main/java/com/example/parcel/IllegalTransitionException.java`

```java
package com.example.parcel;

public class IllegalTransitionException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public IllegalTransitionException(State from, Event event) {
        super("No transition from " + from + " on " + event);
    }
}

```

`src/main/java/com/example/parcel/Step.java`

```java
package com.example.parcel;

public record Step(State from, Event event, State to) {
    @Override
    public String toString() {
        return from + " --" + event + "--> " + to;
    }
}

```

`Step` is a [record](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev): three components, and the `toString` override gives the arrow format you saw in the output. The `serialVersionUID` line is there because my first compile with `-Xlint:all` printed:

```log
IllegalTransitionException.java:3: warning: [serial] serializable class IllegalTransitionException has no definition of serialVersionUID

```

Harmless, but I like a clean lint run.

### Step 3: Put every rule in one table

**Goal:** one place to read, review, and change the rules.

`src/main/java/com/example/parcel/TransitionTable.java`

```java
package com.example.parcel;

import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;

/** The whole rulebook in one place: which event moves which state where. */
public final class TransitionTable {

    private final Map<State, Map<Event, State>> rules = new EnumMap<>(State.class);

    private TransitionTable() { }

    public static TransitionTable parcelRules() {
        TransitionTable t = new TransitionTable();
        t.allow(State.CREATED, Event.PICK_UP, State.PICKED_UP);
        t.allow(State.PICKED_UP, Event.ARRIVE_AT_HUB, State.IN_TRANSIT);
        t.allow(State.IN_TRANSIT, Event.ARRIVE_AT_HUB, State.IN_TRANSIT);
        t.allow(State.IN_TRANSIT, Event.OUT_FOR_DELIVERY, State.OUT_FOR_DELIVERY);
        t.allow(State.OUT_FOR_DELIVERY, Event.DELIVER, State.DELIVERED);
        t.allow(State.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT, State.FAILED_ATTEMPT);
        t.allow(State.FAILED_ATTEMPT, Event.OUT_FOR_DELIVERY, State.OUT_FOR_DELIVERY);
        t.allow(State.FAILED_ATTEMPT, Event.RETURN_TO_SENDER, State.RETURNED);
        t.allow(State.IN_TRANSIT, Event.RETURN_TO_SENDER, State.RETURNED);
        return t;
    }

    private void allow(State from, Event event, State to) {
        rules.computeIfAbsent(from, s -> new EnumMap<>(Event.class)).put(event, to);
    }

    public Optional<State> next(State from, Event event) {
        return Optional.ofNullable(rules.getOrDefault(from, Map.of()).get(event));
    }
}

```

The table is a map of maps: state → event → next state. Both levels are `EnumMap`s, which suit enum keys well (more in [the EnumMap and EnumSet tutorial](https://ggorantala.dev/how-to-use-enummap-enumset-java/?ref=ggorantala.dev)). `next` returns an `Optional`, so "no such move" is a value, not a `null` to forget about.

Read the `allow` lines top to bottom and you've read the business rules. The one that surprises people is `IN_TRANSIT` → `IN_TRANSIT` on `ARRIVE_AT_HUB`: a parcel passes through many hubs, so that self-loop is deliberate.

### Step 4: Add the Parcel and its guard

**Goal:** apply events, record history, and handle "three strikes".

`src/main/java/com/example/parcel/Parcel.java`

```java
package com.example.parcel;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Parcel {

    static final int MAX_FAILED_ATTEMPTS = 3;

    private final String trackingId;
    private final TransitionTable table;
    private State state = State.CREATED;
    private int failedAttempts;
    private final List<Step> history = new ArrayList<>();

    public Parcel(String trackingId) {
        this(trackingId, TransitionTable.parcelRules());
    }

    public Parcel(String trackingId, TransitionTable table) {
        this.trackingId = trackingId;
        this.table = table;
    }

    public State fire(Event event) {
        State from = state;
        State to = table.next(from, event)
                .orElseThrow(() -> new IllegalTransitionException(from, event));
        record(from, event, to);

        // Guard: the third failed attempt sends the parcel home automatically.
        if (to == State.FAILED_ATTEMPT && ++failedAttempts >= MAX_FAILED_ATTEMPTS) {
            State auto = table.next(to, Event.RETURN_TO_SENDER).orElseThrow();
            record(to, Event.RETURN_TO_SENDER, auto);
        }
        return state;
    }

    private void record(State from, Event event, State to) {
        history.add(new Step(from, event, to));
        state = to;
    }

    public String trackingId() { return trackingId; }
    public State state() { return state; }
    public int failedAttempts() { return failedAttempts; }
    public List<Step> history() { return Collections.unmodifiableList(history); }
}

```

`fire` does four things in order: look up the move (throw if there isn't one), record it, count failed deliveries, and apply the guard. When the third `FAIL_ATTEMPT` lands, the parcel records a second step, `FAILED_ATTEMPT --RETURN_TO_SENDER--> RETURNED`, using the table itself, so the auto-return is still a legal move rather than a back door. `history()` hands out an unmodifiable view, so callers can read the audit trail but not rewrite it.

**Checkpoint:** compile again. Same silent success.

### Step 5: Write a demo

**Goal:** see all three outcomes: delivered, auto-returned, rejected.

`src/main/java/com/example/parcel/Demo.java`

```java
package com.example.parcel;

public class Demo {
    public static void main(String[] args) {
        Parcel happy = new Parcel("PK-1001");
        for (Event e : new Event[] {Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB,
                Event.OUT_FOR_DELIVERY, Event.DELIVER}) {
            happy.fire(e);
        }
        System.out.println("== " + happy.trackingId() + " ends " + happy.state());
        happy.history().forEach(s -> System.out.println("  " + s));

        Parcel unlucky = new Parcel("PK-1002");
        unlucky.fire(Event.PICK_UP);
        unlucky.fire(Event.ARRIVE_AT_HUB);
        for (int i = 1; i <= 3; i++) {
            unlucky.fire(Event.OUT_FOR_DELIVERY);
            unlucky.fire(Event.FAIL_ATTEMPT);
        }
        System.out.println("== " + unlucky.trackingId() + " ends " + unlucky.state()
                + " after " + unlucky.failedAttempts() + " failed attempts");

        try {
            happy.fire(Event.OUT_FOR_DELIVERY);
        } catch (IllegalTransitionException ex) {
            System.out.println("== rejected: " + ex.getMessage());
        }
    }
}

```

## Run it

```bash
mkdir -p out
javac -Xlint:all -d out src/main/java/com/example/parcel/*.java
java -cp out com.example.parcel.Demo

```

```log
== PK-1001 ends DELIVERED
  CREATED --PICK_UP--> PICKED_UP
  PICKED_UP --ARRIVE_AT_HUB--> IN_TRANSIT
  IN_TRANSIT --ARRIVE_AT_HUB--> IN_TRANSIT
  IN_TRANSIT --OUT_FOR_DELIVERY--> OUT_FOR_DELIVERY
  OUT_FOR_DELIVERY --DELIVER--> DELIVERED
== PK-1002 ends RETURNED after 3 failed attempts
== rejected: No transition from DELIVERED on OUT_FOR_DELIVERY

```

If you see those three `==` lines, the hard part's done.

## Lock the rules in with 19 checks

`src/test/Checks.java`

```java
import com.example.parcel.*;
import java.util.*;

public class Checks {
    static int run, failed;

    static void check(String name, boolean ok) {
        run++;
        if (!ok) failed++;
        System.out.println((ok ? "PASS " : "FAIL ") + name);
    }

    static Parcel fireAll(Parcel p, Event... events) {
        for (Event e : events) p.fire(e);
        return p;
    }

    public static void main(String[] args) {
        TransitionTable table = TransitionTable.parcelRules();

        check("new parcel starts CREATED", new Parcel("A").state() == State.CREATED);
        check("happy path ends DELIVERED",
            fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.DELIVER)
                .state() == State.DELIVERED);
        check("hub-to-hub loops stay IN_TRANSIT",
            fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB)
                .state() == State.IN_TRANSIT);

        Parcel retry = fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB,
            Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT, Event.OUT_FOR_DELIVERY, Event.DELIVER);
        check("one failed attempt then delivered", retry.state() == State.DELIVERED && retry.failedAttempts() == 1);

        Parcel three = new Parcel("A");
        fireAll(three, Event.PICK_UP, Event.ARRIVE_AT_HUB);
        for (int i = 0; i < 3; i++) fireAll(three, Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT);
        check("third failure auto-returns", three.state() == State.RETURNED);
        check("auto-return is recorded in history",
            three.history().get(three.history().size() - 1).event() == Event.RETURN_TO_SENDER);

        Parcel two = new Parcel("A");
        fireAll(two, Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT,
            Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT);
        check("two failures do not auto-return", two.state() == State.FAILED_ATTEMPT);

        Parcel p = new Parcel("A");
        try { p.fire(Event.DELIVER); check("illegal event throws", false); }
        catch (IllegalTransitionException e) {
            check("illegal event throws", e.getMessage().equals("No transition from CREATED on DELIVER"));
        }
        check("illegal event leaves state unchanged", p.state() == State.CREATED && p.history().isEmpty());

        Parcel done = fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.DELIVER);
        boolean anyOut = false;
        for (Event e : Event.values()) if (table.next(State.DELIVERED, e).isPresent()) anyOut = true;
        check("DELIVERED has no outgoing transitions", !anyOut && done.state().isTerminal());

        for (State s : State.values()) {
            boolean hasOut = Arrays.stream(Event.values()).anyMatch(e -> table.next(s, e).isPresent());
            check("terminal flag matches table for " + s, s.isTerminal() != hasOut);
        }

        Set<State> seen = EnumSet.of(State.CREATED);
        Deque<State> todo = new ArrayDeque<>(seen);
        while (!todo.isEmpty()) {
            State s = todo.poll();
            for (Event e : Event.values())
                table.next(s, e).ifPresent(n -> { if (seen.add(n)) todo.add(n); });
        }
        check("every state reachable from CREATED", seen.equals(EnumSet.allOf(State.class)));

        boolean immutable;
        try { three.history().add(null); immutable = false; } catch (UnsupportedOperationException e) { immutable = true; }
        check("history view is read-only", immutable);

        System.out.println(run + " checks, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

```

```bash
javac -cp out -d out src/test/Checks.java
java -cp out Checks

```

```log
PASS new parcel starts CREATED
PASS happy path ends DELIVERED
PASS hub-to-hub loops stay IN_TRANSIT
PASS one failed attempt then delivered
PASS third failure auto-returns
PASS auto-return is recorded in history
PASS two failures do not auto-return
PASS illegal event throws
PASS illegal event leaves state unchanged
PASS DELIVERED has no outgoing transitions
PASS terminal flag matches table for CREATED
PASS terminal flag matches table for PICKED_UP
PASS terminal flag matches table for IN_TRANSIT
PASS terminal flag matches table for OUT_FOR_DELIVERY
PASS terminal flag matches table for DELIVERED
PASS terminal flag matches table for FAILED_ATTEMPT
PASS terminal flag matches table for RETURNED
PASS every state reachable from CREATED
PASS history view is read-only
19 checks, 0 failed

```

Two of these are worth copying into any state machine you write. The "terminal flag matches table" check fails if someone marks a state terminal but leaves a way out, or forgets the flag on a dead end. The reachability check walks the table from `CREATED` and fails if any state is an island. Both catch real mistakes when somebody adds a state in six months.

To prove the guard check can fail, I changed `>= MAX_FAILED_ATTEMPTS` to `>= 99` in a scratch copy and re-ran:

```log
FAIL third failure auto-returns
FAIL auto-return is recorded in history
19 checks, 2 failed

```

Two red lines, which is what I wanted. (The grep I used also matched a harmless `PASS ... FAILED_ATTEMPT` line, which I've left out.) These are plain-Java checks, not JUnit; porting them to [JUnit 5](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) is mechanical.

## Why a table beats a pile of conditionals

The usual first attempt is a `switch` inside `Parcel.fire`. It works, and for three states it's fine. The problem shows up when you add a state. With a switch *expression* over an enum and no `default`, the compiler does warn you. I tried it with a cut-down enum:

```log
Next.java:4: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error

```

That's useful, but it only tells you the *state* is unhandled. It can't tell you the *event* combination is unhandled, and you end up with a `default -> throw` that hides the gaps. The table makes every legal pair explicit and everything else illegal by default, and it's data you can loop over, which is how the reachability check works.

The guard is deliberately outside the table. The table says what's *possible*; `Parcel` decides what happens *this time* based on state it carries (`failedAttempts`). If your rules depend on more than the current state and event, keep that logic in code like this rather than inventing a DSL.

Two sibling patterns are worth a glance. A [command](https://ggorantala.dev/how-to-implement-command-pattern-java/?ref=ggorantala.dev) object could wrap each `Event` if you need undo. And the enum-with-behaviour approach from [the enums tutorial](https://ggorantala.dev/how-to-use-java-enums-fields-methods/?ref=ggorantala.dev) is a legitimate alternative: each state implements its own `next(Event)`. I prefer the table for small machines because the whole rulebook fits on one screen.

## Errors I hit, and the fixes

| What you see                                                                                            | Cause                                                                                                                                                                            | Fix                                                                                    |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| warning: \[serial\] serializable class IllegalTransitionException has no definition of serialVersionUID | \-Xlint:all and a RuntimeException subclass                                                                                                                                      | Add private static final long serialVersionUID = 1L;                                   |
| error: the switch expression does not cover all possible input values                                   | You added an enum constant to a switch without a default                                                                                                                         | Handle the new constant, or move the rule into the table                               |
| No transition from CREATED on null, or a bare NullPointerException                                      | fire(null): states that have rules look the event up in an EnumMap (returns nothing), but a state with no rules, like DELIVERED, falls back to Map.of(), which rejects null keys | Add Objects.requireNonNull(event) at the top of fire so every state fails the same way |
| FAIL third failure auto-returns after you edit the guard                                                | The threshold or the FAILED\_ATTEMPT condition in Step 4 no longer matches the rule                                                                                              | Restore the guard; the check is doing its job                                          |

The `null` case surprised me. On a `CREATED` parcel I got a polite illegal-transition message; on a `DELIVERED` one the same call threw a bare `NullPointerException` (I ran both). Same bug, two behaviours, which is exactly why I'd add the `requireNonNull` line.

## Before you ship it

- **Thread safety.** `Parcel` isn't thread-safe. Two threads firing events on the same parcel can interleave. Make `fire` `synchronized`, or serialise events per parcel through a queue. Concurrency tutorials here cover the options.
- **Persistence.** Store the `State` name as a string in the database and replay `history` as an audit table. Don't store the ordinal; reordering the enum would silently corrupt old rows.
- **Idempotency.** Real scanners send the same event twice. Decide whether a duplicate `DELIVER` is an error or a no-op, and write the check for it.
- **Bigger machines.** At a few dozen states with timers and nested states, look at Spring Statemachine or a workflow engine. For parcels, orders, and tickets this hand-rolled version carries you a long way.
- **Next up:** [JUnit 5 tests](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) for the checks, and [sealed classes](https://ggorantala.dev/how-to-use-sealed-classes-interfaces-java/?ref=ggorantala.dev) if you want events that carry data (a signature, a hub id).

## The short version

- Name states and events as enums.
- Put every legal move in one table; anything absent is illegal.
- Keep conditional logic (guards) in code beside the table, and route the result back through the table.
- Record each step so you have an audit trail for free.
- Test terminal states and reachability, beyond the happy path.

## State machine questions people ask

**What is the state machine pattern in Java?**  
It's a way to model an object that can only be in one of a fixed set of states and moves between them in response to events. In Java the lightest form is an enum for states, an enum for events, and a map of allowed transitions.

**Should I use an enum or a class per state?**  
For small machines, an enum plus a transition table is easier to read and test. Class-per-state (the classic GoF State pattern) pays off when each state has substantial behaviour of its own.

**Do I need Spring Statemachine?**  
Not for a handful of states. Reach for it when you need hierarchical states, timers, persistence, or distributed execution.

**How do I stop invalid transitions?**  
Make "illegal" the default: look the move up in the table and throw when nothing is found, as `Parcel.fire` does here.

## References

- [Java SE 21 API: EnumMap](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/EnumMap.html?ref=ggorantala.dev)
- [Java SE 21 API: Optional](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Optional.html?ref=ggorantala.dev)
- [JEP 395: Records](https://openjdk.org/jeps/395?ref=ggorantala.dev)
- [JLS 15.28 switch expressions](https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html?ref=ggorantala.dev#jls-15.28)