Low-Level Design (Object-Oriented Design) Module 9 – One design, six languages
LLD in Java 25: records, sealed types, patterns
Write a low-level design in Java 25: records for values, sealed interfaces for states, switches the compiler checks, and the traps of each, all run.
What you will learn
- Model values with records and closed sets of states with sealed interfaces
- Use pattern matching for switch so that the compiler checks every case
- Explain how a default branch hides a new state, and what a MatchException means
- Apply the Java 25 features that shorten small LLD programs, and avoid their traps
Before you start
What you’ll learn · Before you start
What you will learn
- Model values with records and closed sets of states with sealed interfaces
- Use pattern matching for switch so that the compiler checks every case
- Explain how a default branch hides a new state, and what a MatchException means
- Apply the Java 25 features that shorten small LLD programs, and avoid their traps
Before you start
On this page
Java 25 lets a low-level design say what it means with very little code. A record is a value: one line gives
it a constructor, accessors, equality and a readable toString. A sealed interface is a closed set: it names
every type that may implement it, and no other code can add one. A switch with patterns takes each of those
types apart, and because the set is closed, the compiler refuses a switch that forgets one. A payment’s states, a
booking’s result or an order’s events become a handful of records, and a missing case becomes a compile error
instead of a bug in production.
This lesson builds a payment’s lifecycle with those three tools and then reproduces the mistakes that matter: a
deleted case, a default branch that hides a new state, an old build that meets a new state while it runs, a
record that leaks its list, a constructor that runs too early and a compact source file that will not compile.
Every Java program ran on Java 25, a long-term support release from most vendors (here Eclipse Temurin 25.0.4.1),
and the comparisons ran on Java 21. Lifecycles themselves, drawn as state machines, are the subject of the
state machine diagrams lesson; here they are written in Java.
Which Java construct for which part of a design
Most types in a low-level design are one of four kinds, and Java 25 has a construct for each:
- A value, described completely by its data, such as an amount of money, a seat number or a date range, is a
record. Equality, hashing andtoStringcome from its components, and its fields are final. - A closed set of alternatives, each with its own data, such as a payment’s states, the events it accepts or the
outcome of a booking, is a
sealed interfacewhose permitted types are records. The compiler knows every alternative and checks each switch over them. - An entity, an identity whose data changes, such as a payment, an account or a parking lot, is an ordinary
final class. Two payments of the same amount are still two payments, and a payment’s state changes over time. - A fixed set of constants without data of their own, such as a seat class or a vehicle size, is an
enum. There is one object per constant, and a switch expression over an enum is checked as well.
The payment design uses the first three. Money is a record. Payment is a class, because it has an identity and
a lifecycle. Its states, Pending, Authorised, Captured, Refunded and Failed, are records that implement a
sealed interface, Payment.State, and each one carries only the data that belongs to it: an authorisation code
exists once the bank has authorised the payment, and a failure reason once it has failed. Its events,
Authorise, Decline, Capture and Refund, are records of a second sealed interface, Payment.Event.
The payment design: a class with an identity, a value record, and a closed set of states
Text description of the diagram
A UML class diagram in one column, from top to bottom.
- Money, marked «record»: public component paise: long and operation plus(other): Money.
- Payment, an ordinary class: private attributes id: String, amount: Money and state: State; public operations apply(event): void, history(): List<State> and summary(state): String. An association named amount goes from Payment to Money, with an arrowhead at Money: a payment has exactly 1 amount.
- State, marked «sealed interface», with no members of its own. An association named state goes from Payment to State, with an arrowhead at State: a payment is in exactly 1 state at a time.
- A group labelled "records declared in Payment.java" holds five boxes marked «record»: Pending with no components, Authorised with authCode: String, Captured with amount: Money, Refunded with amount: Money, and Failed with reason: String. One realization line (dashed, with a hollow triangle at State) goes from the group to State: each of the five records implements State, and because they are declared in the same file, they are the only types that may.
The events Authorise, Decline, Capture and Refund have the same shape as the states (records implementing a sealed Event interface) and are not drawn.
How to tell an entity from a value in any language is covered in the entities, value objects and aggregates lesson; this one is about writing each kind in Java.
Records for values
A record declares its data in its header, record Money(long paise), and Java derives the rest (JEP 395): a private
final field and a public accessor for each component, a canonical constructor that takes them all, and equals,
hashCode and toString built from every component. Two records of the same type with equal components are equal,
so a record works as a key of a HashMap or an element of a HashSet without a line of your own. A record is
implicitly final, cannot extend a class and cannot declare instance fields beyond its components. It can implement
interfaces and declare methods, static fields and nested types.
Checks go in the compact constructor, a constructor written without a parameter list. It runs before the fields
are assigned from the parameters, so it can refuse a component by throwing, or normalise it by assigning a new value
to the parameter. Money’s compact constructor refuses negative amounts, so a negative Money cannot exist anywhere
in the program. Amounts are whole paise in a long, because a binary double cannot hold most decimal fractions
exactly.
session · values_session.py
from session import run, version
version()
run("java values/Values.java") Output
# openjdk version "25.0.4.1" LTS $ java values/Values.java Money[paise=49900] equals Money[paise=49900]: true, same object: false a set given a, b and 5000 paise holds 2 amounts refused: an amount cannot be negative: -1 after the cart changed: O1 has 3 lines, O2 has 2 O2's lines are read-only: UnsupportedOperationException O2 total: 54900 paise
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 values_session.py
values/Values.java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Values {
record Money(long paise) {
Money {
if (paise < 0) throw new IllegalArgumentException("an amount cannot be negative: " + paise);
}
}
/** Looks immutable, but keeps a reference to the caller's list. */
record LeakyOrder(String id, List<Money> lines) {}
/** Copies the list in its compact constructor, so nobody else holds it. */
record Order(String id, List<Money> lines) {
Order {
lines = List.copyOf(lines);
}
long totalPaise() {
return lines.stream().mapToLong(Money::paise).sum();
}
}
public static void main(String[] args) {
Money a = new Money(49_900);
Money b = new Money(49_900);
System.out.println(a + " equals " + b + ": " + a.equals(b) + ", same object: " + (a == b));
Set<Money> seen = new HashSet<>(List.of(a, b, new Money(5_000)));
System.out.println("a set given a, b and 5000 paise holds " + seen.size() + " amounts");
try {
new Money(-1);
} catch (IllegalArgumentException e) {
System.out.println("refused: " + e.getMessage());
}
List<Money> cart = new ArrayList<>(List.of(a, new Money(5_000)));
LeakyOrder leaky = new LeakyOrder("O1", cart);
Order order = new Order("O2", cart);
cart.add(new Money(100_000));
System.out.println("after the cart changed: O1 has " + leaky.lines().size() + " lines, O2 has " + order.lines().size());
try {
order.lines().add(new Money(1));
} catch (UnsupportedOperationException e) {
System.out.println("O2's lines are read-only: " + e.getClass().getSimpleName());
}
System.out.println("O2 total: " + order.totalPaise() + " paise");
}
} The first two lines show value semantics. a and b are two objects, so a == b is false, yet they are equal, and
a set given both of them and a third amount holds 2 elements, not 3. The third line is the compact constructor
refusing -1.
A record is only shallowly immutable
The documentation of java.lang.Record calls a record a “shallowly immutable, transparent carrier” of its
components. The fields are final, but a final field that refers to an ArrayList still refers to an ArrayList,
and whoever else holds that list can change it. LeakyOrder keeps the caller’s list, so when the caller adds a line
to its cart afterwards, the order gains a line too: after the cart changed, O1 has 3 lines.
Order copies the list with List.copyOf in its compact constructor. The copy is unmodifiable and does not see
later changes to the original, so O2 keeps its 2 lines, and order.lines().add(...) throws
UnsupportedOperationException. List.copyOf also refuses null elements, which is what an order wants anyway.
Copy every mutable component this way, or hold only immutable types such as other records, strings and numbers.
Sealed interfaces and switch patterns
A sealed interface lists the types that may implement it. When its permits clause is left out, the permitted types
are the ones declared in the same source file (JLS §9.1.4). Payment.State and its five records are all declared in
Payment.java, so no other file can add a sixth state. A class that implements a sealed interface must itself be
final, sealed or non-sealed, and records are implicitly final, so they qualify as they are.
What the compiler gains is a complete list, and pattern matching uses it:
- A record pattern such as
case Captured(Money m)matches aCapturedand binds its amount tomin one step (JEP 440, final in Java 21). Record patterns nest: a pattern can take apart a record inside a record. - A guard written with
whenadds a condition:case Captured(Money m) when m.paise() > 0. - The unnamed pattern
_(JEP 456, Java 22) matches a component without giving it a name. - A
switchexpression must be exhaustive: it has to handle every possible value of its selector (JEP 441, Java 21). Over a sealed type, one case for each permitted type is enough, and nodefaultis needed.
Payment uses two shapes of switch. summary(state) has one case per state and no default. apply(event)
switches over a small private record, Step(State state, Event event), so that each case matches a state and an
event together with nested patterns: case Step(Captured(Money paid), Refund(Money m)) when m.equals(paid) is the
transition “a captured payment can be refunded in full”. The last case, case Step(State s, Event e), matches any
step at all, so every pair that no earlier case allowed is refused with an IllegalStateException that names the
payment, the event and the state.
Since Java 22, java payment/Main.java compiles the other files the program uses from the same folder,
Payment.java and Money.java, in memory and runs the result (JEP 458), so a design spread over several files needs
no build tool. After the run, the session deletes the Failed case from summary() and asks javac to compile that
copy:
session · payment_session.py
from session import run, version
# The design runs from its main file. Then summary() loses one case, and javac is asked to compile that copy.
version()
run(
"""
java payment/Main.java
grep -v "case Failed" payment/Payment.java > Payment.java
javac -d classes Payment.java payment/Money.java
""",
expect_failure=("javac -d classes Payment.java payment/Money.java",),
) Output
# openjdk version "25.0.4.1" LTS
$ java payment/Main.java
P1 waiting for the bank
P1 authorised, code A7K2
P1 captured INR 499.00
P1 refunded INR 499.00
P2 failed: insufficient funds
refused: P2 cannot take Capture[amount=INR 499.00] while failed: insufficient funds
refused: P3 cannot take Refund[amount=INR 100.00] while captured INR 499.00
equal values: true
a state is data: Captured[amount=INR 499.00]
$ grep -v "case Failed" payment/Payment.java > Payment.java
$ javac -d classes Payment.java payment/Money.java
Payment.java:59: error: the switch expression does not cover all possible input values
return switch (state) {
^
1 error
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payment_session.py
payment/Payment.java
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** A payment has an identity and a lifecycle, so it is a class; its states and events are records. */
public final class Payment {
/** Every state a payment can be in. Sealed: no code outside this file can add one. */
public sealed interface State {}
public record Pending() implements State {}
public record Authorised(String authCode) implements State {}
public record Captured(Money amount) implements State {}
public record Refunded(Money amount) implements State {}
public record Failed(String reason) implements State {}
/** Everything that can happen to a payment. */
public sealed interface Event {}
public record Authorise(String authCode) implements Event {}
public record Decline(String reason) implements Event {}
public record Capture(Money amount) implements Event {}
public record Refund(Money amount) implements Event {}
/** A state and an event, matched together by apply(). */
private record Step(State state, Event event) {}
private final String id;
private final Money amount;
private final List<State> history = new ArrayList<>();
private State state = new Pending();
public Payment(String id, Money amount) {
this.id = Objects.requireNonNull(id);
this.amount = Objects.requireNonNull(amount);
history.add(state);
}
/** The lifecycle: one case per allowed transition, then one case that refuses everything else. */
public void apply(Event event) {
Objects.requireNonNull(event);
state = switch (new Step(state, event)) {
case Step(Pending _, Authorise(String code)) -> new Authorised(code);
case Step(Pending _, Decline(String reason)) -> new Failed(reason);
case Step(Authorised _, Capture(Money m)) when m.atMost(amount) -> new Captured(m);
case Step(Captured(Money paid), Refund(Money m)) when m.equals(paid) -> new Refunded(m);
case Step(State s, Event e) -> throw new IllegalStateException(id + " cannot take " + e + " while " + summary(s));
};
history.add(state);
}
public State state() {
return state;
}
public List<State> history() {
return List.copyOf(history);
}
/** One line for a support agent. No default: a new state needs a line of its own. */
public static String summary(State state) {
return switch (state) {
case Pending() -> "waiting for the bank";
case Authorised(String code) -> "authorised, code " + code;
case Captured(Money m) -> "captured " + m;
case Refunded(Money m) -> "refunded " + m;
case Failed(String reason) -> "failed: " + reason;
};
}
} payment/Money.java
/** An amount in paise, the hundredth part of a rupee. A value: two equal amounts are equal. */
public record Money(long paise) {
/** The compact constructor: checks the component, then the field is assigned from it. */
public Money {
if (paise < 0) throw new IllegalArgumentException("an amount cannot be negative: " + paise);
}
public static Money rupees(long rupees) {
return new Money(Math.multiplyExact(rupees, 100));
}
public Money plus(Money other) {
return new Money(Math.addExact(paise, other.paise));
}
public boolean atMost(Money other) {
return paise <= other.paise;
}
@Override
public String toString() {
return String.format("INR %d.%02d", paise / 100, paise % 100);
}
} payment/Main.java
/** Drives three payments through their lifecycle: `java payment/Main.java` compiles the other files it needs. */
public class Main {
public static void main(String[] args) {
Money price = Money.rupees(499);
Payment p1 = new Payment("P1", price);
p1.apply(new Payment.Authorise("A7K2"));
p1.apply(new Payment.Capture(price));
p1.apply(new Payment.Refund(price));
for (Payment.State s : p1.history()) System.out.println("P1 " + Payment.summary(s));
Payment p2 = new Payment("P2", price);
p2.apply(new Payment.Decline("insufficient funds"));
System.out.println("P2 " + Payment.summary(p2.state()));
try {
p2.apply(new Payment.Capture(price));
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
Payment p3 = new Payment("P3", price);
p3.apply(new Payment.Authorise("B9Q4"));
p3.apply(new Payment.Capture(price));
try {
p3.apply(new Payment.Refund(Money.rupees(100)));
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
System.out.println("equal values: " + price.equals(new Money(49_900)));
System.out.println("a state is data: " + p3.state());
}
} session.py
"""Replays shell commands the way a terminal shows them: "$ " and the command, then everything it printed.
Standard output and standard error are merged in the order they were written. A command that exits with a
status other than 0 is followed by "[exit status N]". Unless the script lists that command in expect_failure,
the session then exits with status 1 too, so a failure nobody expected stops it.
`java` and `javac` are Eclipse Temurin 25.0.4.1, from the mise installation that also holds the Python running
this file; jdk="21" switches to Temurin 21.0.12.1 for the comparisons with Java 21. To replay a session on your
own computer, point JDKS at the bin folders of your JDKs.
"""
import os
import re
import subprocess
import sys
from pathlib import Path
# <mise>/installs/python/3.14.8/bin/python3 -> <mise>/installs
INSTALLS = Path(sys.executable).resolve().parents[3]
JDKS = {
"25": INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin",
"21": INSTALLS / "java" / "temurin-21.0.12+101.0.LTS" / "bin",
}
# `java -version` also prints the release date, which the session leaves out.
RELEASE_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}")
def _shell(command, jdk):
env = dict(os.environ, PATH=os.pathsep.join([str(JDKS[jdk]), os.environ.get("PATH", "")]))
return subprocess.run(command, shell=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def version(jdk="25"):
"""Prints the first line of `java -version` as a comment, so the record says which JDK ran."""
done = _shell("java -version", jdk)
lines = done.stdout.splitlines()
if done.returncode != 0 or not lines:
sys.exit(f"JDK {jdk} is not installed on this computer")
print("#", RELEASE_DATE.sub("", lines[0]))
def run(script, jdk="25", expect_failure=()):
"""Runs each line of the script as one shell command, in the examples folder."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
if not command:
continue
print(f"$ {command}")
done = _shell(command, jdk)
print(done.stdout, end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if command not in expect_failure:
unexpected += 1
if unexpected:
sys.exit(1) The first lines are the lifecycle. P1 is authorised, captured and refunded in full, and its history lists each
state on the way. P2 is declined, and a capture after that is refused. P3 is refused a partial refund, because this
version of the design allows refunds of the whole amount only. a state is data shows that a state is an ordinary
record value, printed with its component.
The last lines are the compiler. Without a case for Failed, javac reports that “the switch expression does not
cover all possible input values”, and nothing compiles. That error is the reason to model states this way: add a
state to the sealed interface, and every switch that does not handle it stops compiling, each one a place where the
design needs a decision.
Two more rules decide how the cases are written:
- Order matters. The first case that matches is chosen, and a case that can never be reached, because an earlier
case matches everything it would, is a compile-time error: it is “dominated” (JEP 441). The catch-all
Step(State s, Event e)therefore comes last. Moved to the top, it makes javac reject the four transitions below it. - Null is not a state. A switch with patterns throws
NullPointerExceptionwhen its selector is null, unless it has acase null, and adefaultdoes not catch null.Paymentnever holds a null state, andapply()refuses a null event in its first line.
When a default branch hides a new state
The compile error helps only if nothing swallows it, and a default branch does: it covers every type, including the
ones added later, so the compiler has nothing to report. The report below settles a day’s payments. A teammate has
just added PartlyRefunded(captured, refunded) to its entries, for refunds of part of an amount. The day holds
500.00 captured, 800.00 captured of which 300.00 was refunded, and one failed payment, so it should settle
500.00 + (800.00 − 300.00) = 1,000.00. The session builds the same report three ways:
session · report_session.py
from session import run, version
# A teammate adds PartlyRefunded to Entry. The same report is built three times:
# with a default branch, with a case per entry, and as an old build that is never recompiled.
version()
run(
"""
javac -d lenient report/new/*.java report/lenient/Settlement.java
java -cp lenient Day
javac -d strict report/new/*.java report/strict/Settlement.java
javac -d stale report/old/Entry.java report/strict/Settlement.java
javac -cp stale -d stale report/new/*.java
java -cp stale Day
""",
expect_failure=(
"javac -d strict report/new/*.java report/strict/Settlement.java",
"java -cp stale Day",
),
) Output
# openjdk version "25.0.4.1" LTS
$ javac -d lenient report/new/*.java report/lenient/Settlement.java
$ java -cp lenient Day
settled: 500.00
$ javac -d strict report/new/*.java report/strict/Settlement.java
report/strict/Settlement.java:8: error: the switch expression does not cover all possible input values
total += switch (e) {
^
1 error
[exit status 1]
$ javac -d stale report/old/Entry.java report/strict/Settlement.java
$ javac -cp stale -d stale report/new/*.java
$ java -cp stale Day
Exception in thread "main" java.lang.MatchException
at Settlement.settled(Settlement.java:8)
at Day.main(Day.java:10)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 report_session.py
lenient/Settlement.java · report/lenient/Settlement.java
import java.util.List;
/** Settles the captured money, with a default branch for "everything else". */
public final class Settlement {
public static long settled(List<Entry> entries) {
long total = 0;
for (Entry e : entries) {
total += switch (e) {
case Entry.Captured(long paise) -> paise;
default -> 0;
};
}
return total;
}
} strict/Settlement.java · report/strict/Settlement.java
import java.util.List;
/** Settles the captured money, with a case for every kind of entry and no default. */
public final class Settlement {
public static long settled(List<Entry> entries) {
long total = 0;
for (Entry e : entries) {
total += switch (e) {
case Entry.Captured(long paise) -> paise;
case Entry.Refunded _, Entry.Failed _ -> 0;
};
}
return total;
}
} new/Entry.java · report/new/Entry.java
/** The same entries after a teammate added partial refunds. */
public sealed interface Entry {
record Captured(long paise) implements Entry {}
record PartlyRefunded(long captured, long refunded) implements Entry {}
record Refunded(long paise) implements Entry {}
record Failed(String reason) implements Entry {}
} old/Entry.java · report/old/Entry.java
/** A payment as the settlement report sees it, before partial refunds existed. */
public sealed interface Entry {
record Captured(long paise) implements Entry {}
record Refunded(long paise) implements Entry {}
record Failed(String reason) implements Entry {}
} new/Day.java · report/new/Day.java
import java.util.List;
/** One day: 500.00 captured, 800.00 captured of which 300.00 was refunded, and one failed payment. */
public class Day {
public static void main(String[] args) {
List<Entry> day = List.of(
new Entry.Captured(50_000),
new Entry.PartlyRefunded(80_000, 30_000),
new Entry.Failed("declined"));
long paise = Settlement.settled(day);
System.out.printf("settled: %d.%02d%n", paise / 100, paise % 100);
}
} - With
default -> 0(thelenientbuild), the report compiles and prints 500.00. Half of the day’s money is missing, and nothing failed. - With a case for each entry and no
default(thestrictbuild), javac refuses the report. The switch was complete when it was written; the new state made it incomplete, and the error points at the line to change. - An old build (the
stalefolder):Settlementwas compiled against the oldEntry, and then onlyEntryandDaywere rebuilt, as happens when a library is built separately from the code that uses it. When the JVM met aPartlyRefundedthat the compiled switch has no case for, it threwMatchExceptioninstead of choosing a branch. For a switch that the compiler found exhaustive, it adds a hidden default that throws (JEP 441), so the program stops at the place where it would otherwise have settled the wrong amount.
So a switch over your own sealed type should have no default. When several types share a result, list them in one
case: case Entry.Refunded _, Entry.Failed _ -> 0 names both with unnamed patterns, and still breaks the build when
a new type arrives. A catch-all case belongs only where most combinations are illegal and must all be refused the
same way, as in Payment.apply(). There a new state is refused until someone writes its transitions, which is a safe
failure; a default that settles money is not.
Constructors that check before super()
Inheritance appears less in modern Java designs than records and interfaces, but it is still there: a parking lot’s
EvSpot that extends Spot, an account type that extends a base account. Before Java 25, a constructor had to
call super(...) in its first statement, so a subclass could check its own arguments, and set its own fields, only
after the superclass constructor had run. Java 25 allows statements before super(...) (JEP 513). They may check
arguments, compute values and assign the subclass’s own fields (those declared without an initializer), but may not
use the object in any other way, such as reading a field or calling a method.
That fixes two bugs at once, and the run below shows both. Spot’s constructor announces the new spot and calls
describe(), which EvSpot overrides. JEP 513 describes exactly this trap: a superclass constructor that calls an
overridden method runs it before the subclass has set its fields.
session · constructor_session.py
from session import run, version
# Spots.java on Java 25, then the same file given to the Java 21 compiler.
version()
run("java constructors/Spots.java")
version("21")
run(
"javac -d classes constructors/Spots.java",
jdk="21",
expect_failure=("javac -d classes constructors/Spots.java",),
) Output
# openjdk version "25.0.4.1" LTS
$ java constructors/Spots.java
opened B1: EV spot, 0 kW charger
opened B2: EV spot, 7 kW charger
opened B3: EV spot, 0 kW charger
refused B3: charger must be positive: 0
refused B4: charger must be positive: 0
# openjdk version "21.0.12.1" LTS
$ javac -d classes constructors/Spots.java
constructors/Spots.java:37: error: call to super must be first statement in constructor
super(id);
^
1 error
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 constructor_session.py
constructors/Spots.java
/** A parking spot announces itself when it opens, as a stand-in for any work a superclass constructor does. */
abstract class Spot {
private final String id;
Spot(String id) {
this.id = id;
System.out.println("opened " + id + ": " + describe());
}
/** Overridable, and called from the constructor above: the subclass's version runs before its constructor body. */
abstract String describe();
}
/** Written the pre-Java 25 way: super(...) first, then the check and the field. */
final class OldEvSpot extends Spot {
private final int chargerKw;
OldEvSpot(String id, int chargerKw) {
super(id);
if (chargerKw <= 0) throw new IllegalArgumentException("charger must be positive: " + chargerKw);
this.chargerKw = chargerKw;
}
@Override
String describe() {
return "EV spot, " + chargerKw + " kW charger";
}
}
/** Java 25: the check and the field assignment come before super(...). */
final class EvSpot extends Spot {
private final int chargerKw;
EvSpot(String id, int chargerKw) {
if (chargerKw <= 0) throw new IllegalArgumentException("charger must be positive: " + chargerKw);
this.chargerKw = chargerKw;
super(id);
}
@Override
String describe() {
return "EV spot, " + chargerKw + " kW charger";
}
}
public class Spots {
public static void main(String[] args) {
new OldEvSpot("B1", 7);
new EvSpot("B2", 7);
try {
new OldEvSpot("B3", 0);
} catch (IllegalArgumentException e) {
System.out.println("refused B3: " + e.getMessage());
}
try {
new EvSpot("B4", 0);
} catch (IllegalArgumentException e) {
System.out.println("refused B4: " + e.getMessage());
}
}
} OldEvSpot, written the way older Java requires, announced a 0 kW charger. Itsdescribe()ran whilechargerKwstill held its default value, 0, although the field isfinaland was set to 7 a moment later.OldEvSpot("B3", 0)was opened before its check refused the bad argument, so whatever the superclass constructor does, such as registering the spot with its lot, had already happened.EvSpotchecks and assigns beforesuper(id), so B2 is described with its real charger, and B4 is refused before any spot exists.
The Java 21 compiler rejects the same file: “call to super must be first statement in constructor”. Calling an overridable method from a constructor is still best avoided, but when a design does inherit, Java 25 lets the subclass be ready first.
Small programs: the source launcher and compact source files
A machine-coding round rewards running code early, and two features remove most of the ceremony around it:
java Main.javaruns a folder. The source launcher compiles the initial file and the.javafiles its classes refer to, in memory, without writing class files (JEP 458, Java 22). It compiles only what the program refers to, so a file that nothing uses yet is never checked: compile the whole folder once withjavac -d out *.javabefore you call a design finished.- A compact source file needs no class. A file with a
void main()method and no class around it is a compact source file (JEP 512, final in Java 25). Its fields and methods become members of a class that the compiler declares for it, it imports every public type of thejava.basemodule automatically, soListandMapneed no import, and the newjava.lang.IOclass prints withIO.println.
A compact file makes a good scratch driver beside a design that lives in ordinary files, as payment/Demo.java
does. The trap is putting the design itself into the compact file. Every class declared there becomes a member of
the implicitly declared class (JLS §7.3), and a member class that is not static is an inner class (JLS §8.1.3),
whose objects each need an object of the enclosing class. A static method has no such object, so it cannot create
one:
session · compact_session.py
from session import run, version
# A compact driver beside ordinary files, a design inside a compact file, its fix, and Java 21 on the fixed file.
version()
run(
"""
java payment/Demo.java
java compact/Gotcha.java
java compact/Fixed.java
""",
expect_failure=("java compact/Gotcha.java",),
)
version("21")
run("java compact/Fixed.java", jdk="21", expect_failure=("java compact/Fixed.java",)) Output
# openjdk version "25.0.4.1" LTS
$ java payment/Demo.java
authorised, code C3D8
$ java compact/Gotcha.java
compact/Gotcha.java:17: error: non-static variable this cannot be referenced from a static context
Wallet wallet = new Wallet();
^
1 error
error: compilation failed
[exit status 1]
$ java compact/Fixed.java
balance: 700
# openjdk version "21.0.12.1" LTS
$ java compact/Fixed.java
compact/Fixed.java:16: error: unnamed classes are a preview feature and are disabled by default.
static Wallet walletWith(long... coins) {
^
(use --enable-preview to enable unnamed classes)
1 error
error: compilation failed
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compact_session.py
compact/Gotcha.java
// A small design written straight into a compact source file: a class, a record and a static helper.
class Wallet {
private long balance;
void add(Coin coin) {
balance += coin.paise();
}
long balance() {
return balance;
}
}
record Coin(long paise) {}
static Wallet walletWith(long... coins) {
Wallet wallet = new Wallet();
for (long c : coins) wallet.add(new Coin(c));
return wallet;
}
void main() {
IO.println("balance: " + walletWith(500, 200).balance());
} compact/Fixed.java
// The same file with one word added: Wallet is now a static member class.
static class Wallet {
private long balance;
void add(Coin coin) {
balance += coin.paise();
}
long balance() {
return balance;
}
}
record Coin(long paise) {}
static Wallet walletWith(long... coins) {
Wallet wallet = new Wallet();
for (long c : coins) wallet.add(new Coin(c));
return wallet;
}
void main() {
IO.println("balance: " + walletWith(500, 200).balance());
} payment/Demo.java · payment/Demo.java
// A compact source file used as a scratch driver: the design itself stays in ordinary files beside it.
void main() {
var payment = new Payment("P9", Money.rupees(250));
payment.apply(new Payment.Authorise("C3D8"));
IO.println(Payment.summary(payment.state()));
} Demo.java runs and prints the payment’s summary. Gotcha.java fails at new Wallet() inside the static method
walletWith, with “non-static variable this cannot be referenced from a static context”: an error about a this
that the file never wrote. new Coin(c) on the next line is fine, because member records, like member enums and
interfaces, are implicitly static. Fixed.java adds one word, static, to class Wallet, and prints the balance.
Two other fixes work as well: make the helper an instance method, since main itself is an instance method here,
or move the design into ordinary files, which is what an interviewer reading your code expects anyway.
The last run is the fixed file on Java 21, where compact source files were still a preview feature under the name “unnamed classes”, switched off unless asked for. Check which Java an interview platform runs before you rely on any of the newer features.
Which Java has what
Each feature in this lesson is final from the Java release named beside it. On an older Java, the second column is what to write instead.
| Feature (JEP, final in) | On an older Java |
|---|---|
| Records (JEP 395, Java 16) | A final class with final fields and hand-written equals, hashCode and toString |
Pattern matching for instanceof (JEP 394, Java 16) |
A cast after each instanceof test |
| Sealed classes and interfaces (JEP 409, Java 17) | A plain interface; nothing checks that the set is complete |
Pattern matching for switch and record patterns (JEPs 441 and 440, Java 21) |
An if chain of instanceof patterns, with no exhaustiveness check |
Unnamed variables and patterns, _ (JEP 456, Java 22) |
A named variable that is never used |
Running a folder with java Main.java (JEP 458, Java 22) |
javac -d out *.java, then java -cp out Main |
import module java.base; (JEP 511, Java 25) |
Ordinary imports |
Compact source files, IO and instance main (JEP 512, Java 25) |
A class with public static void main(String[] args) |
Statements before super(...) (JEP 513, Java 25) |
A static helper that checks the argument inside the super(...) call |
Primitive types in patterns (case int i) are still a preview feature in Java 25 and in JDK 27, the newest feature
release, so leave them out of interview code.
Practise: port a ticket lifecycle to Java 25
A support ticket is open, assigned to an agent, resolved by that agent with a note, or closed. An assigned ticket can
be reassigned; a resolved ticket can be reopened, which assigns it back to the agent who resolved it, or closed; any
other request is refused. Here is the lifecycle in Python, with frozen dataclasses as the states and match for the
transitions. Run it and read what it prints:
"""A support ticket's lifecycle in Python: the behaviour the Java 25 port must reproduce."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Open:
pass
@dataclass(frozen=True)
class Assigned:
agent: str
@dataclass(frozen=True)
class Resolved:
agent: str
note: str
@dataclass(frozen=True)
class Closed:
pass
class Ticket:
def __init__(self, number):
self.number = number
self.state = Open()
def assign(self, agent):
match self.state:
case Open() | Assigned():
self.state = Assigned(agent)
case _:
self._refuse("assign")
def resolve(self, note):
match self.state:
case Assigned(agent):
self.state = Resolved(agent, note)
case _:
self._refuse("resolve")
def reopen(self):
match self.state:
case Resolved(agent, _):
self.state = Assigned(agent)
case _:
self._refuse("reopen")
def close(self):
match self.state:
case Resolved():
self.state = Closed()
case _:
self._refuse("close")
def summary(self):
match self.state:
case Open():
return "open"
case Assigned(agent):
return f"assigned to {agent}"
case Resolved(agent, note):
return f"resolved by {agent}: {note}"
case Closed():
return "closed"
def _refuse(self, event):
raise ValueError(f"{self.number} cannot {event} while {self.summary()}")
t1 = Ticket("T1")
print("T1", t1.summary())
steps = [
lambda: t1.assign("asha"),
lambda: t1.assign("ravi"),
lambda: t1.resolve("password reset"),
t1.reopen,
lambda: t1.resolve("account unlocked"),
t1.close,
]
for step in steps:
step()
print("T1", t1.summary())
t2 = Ticket("T2")
for attempt in (t1.reopen, lambda: t2.resolve("guessed fix"), t2.close):
try:
attempt()
except ValueError as e:
print("refused:", e) Output
T1 open T1 assigned to asha T1 assigned to ravi T1 resolved by ravi: password reset T1 assigned to ravi T1 resolved by ravi: account unlocked T1 closed refused: T1 cannot reopen while closed refused: T2 cannot resolve while open refused: T2 cannot close while open
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tickets.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Java does not run in this site’s browser runner, so the port runs on your own JDK 25. Copy Ticket.java and
CheckTickets.java below into a folder named practice, then replace the TODOs in Ticket.java:
- Make
Statea sealed interface with one record per state:Open,Assigned(String agent),Resolved(String agent, String note)andClosed. - Write the four transitions as switches over the state, and refuse everything else with an
IllegalStateExceptionwhose message matches the Python version, such as “T1 cannot reopen while closed”. - Write
summary()as a switch with one case per state and nodefault.
Run java practice/CheckTickets.java from the folder that holds practice. On the starter file all 13 checks fail:
session · practice_session.py
from session import run, version
# The checker run against the starter file, before any TODO is done: every check fails, and that is the start.
version()
run("java practice/CheckTickets.java", expect_failure=("java practice/CheckTickets.java",)) Output
# openjdk version "25.0.4.1" LTS $ java practice/CheckTickets.java FAIL a new ticket is open: expected "open", got "TODO" FAIL assign: threw UnsupportedOperationException: TODO: assign FAIL reassign: threw UnsupportedOperationException: TODO: assign FAIL resolve: threw UnsupportedOperationException: TODO: resolve FAIL reopen returns to the same agent: threw UnsupportedOperationException: TODO: reopen FAIL resolve again: threw UnsupportedOperationException: TODO: resolve FAIL close: threw UnsupportedOperationException: TODO: close FAIL a closed ticket cannot reopen: threw UnsupportedOperationException: TODO: reopen, expected IllegalStateException FAIL an open ticket cannot be resolved: threw UnsupportedOperationException: TODO: resolve, expected IllegalStateException FAIL an open ticket cannot be closed: threw UnsupportedOperationException: TODO: close, expected IllegalStateException FAIL State is a sealed interface: it is not sealed FAIL its states are Assigned, Closed, Open and Resolved: found [] FAIL every state is a record: not every permitted type is a record 0 of 13 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
practice/Ticket.java
/**
* The Java 25 port of tickets.py, to be finished. Keep the public methods and replace every TODO.
* Check it after each step with `java practice/CheckTickets.java`.
*/
public final class Ticket {
/** TODO: make this sealed, with one record per state: Open, Assigned(agent), Resolved(agent, note), Closed. */
public interface State {}
private final String number;
private State state;
public Ticket(String number) {
this.number = number;
// TODO: a new ticket is open.
}
public void assign(String agent) {
throw new UnsupportedOperationException("TODO: assign");
}
public void resolve(String note) {
throw new UnsupportedOperationException("TODO: resolve");
}
public void reopen() {
throw new UnsupportedOperationException("TODO: reopen");
}
public void close() {
throw new UnsupportedOperationException("TODO: close");
}
public State state() {
return state;
}
/** "open", "assigned to asha", "resolved by asha: <note>" or "closed", from a switch with no default. */
public String summary() {
return "TODO";
}
} practice/CheckTickets.java
import java.util.Arrays;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Supplier;
/** Runs the scenario of tickets.py against Ticket.java and compares every line: java practice/CheckTickets.java */
public class CheckTickets {
private static int passed;
private static int failed;
public static void main(String[] args) {
Ticket t1 = new Ticket("T1");
expect("a new ticket is open", t1::summary, "open");
expect("assign", () -> { t1.assign("asha"); return t1.summary(); }, "assigned to asha");
expect("reassign", () -> { t1.assign("ravi"); return t1.summary(); }, "assigned to ravi");
expect("resolve", () -> { t1.resolve("password reset"); return t1.summary(); }, "resolved by ravi: password reset");
expect("reopen returns to the same agent", () -> { t1.reopen(); return t1.summary(); }, "assigned to ravi");
expect("resolve again", () -> { t1.resolve("account unlocked"); return t1.summary(); }, "resolved by ravi: account unlocked");
expect("close", () -> { t1.close(); return t1.summary(); }, "closed");
Ticket t2 = new Ticket("T2");
refused("a closed ticket cannot reopen", t1::reopen, "T1 cannot reopen while closed");
refused("an open ticket cannot be resolved", () -> t2.resolve("guessed fix"), "T2 cannot resolve while open");
refused("an open ticket cannot be closed", t2::close, "T2 cannot close while open");
Class<?> state = Ticket.State.class;
Class<?>[] permitted = state.isSealed() ? state.getPermittedSubclasses() : new Class<?>[0];
Set<String> names = new TreeSet<>();
for (Class<?> c : permitted) names.add(c.getSimpleName());
report("State is a sealed interface", state.isSealed(), "it is not sealed");
report("its states are Assigned, Closed, Open and Resolved", names.equals(Set.of("Assigned", "Closed", "Open", "Resolved")), "found " + names);
report("every state is a record", permitted.length > 0 && Arrays.stream(permitted).allMatch(Class::isRecord), "not every permitted type is a record");
System.out.println(passed + " of " + (passed + failed) + " checks passed");
if (failed > 0) System.exit(1);
}
private static void expect(String name, Supplier<String> step, String want) {
try {
String got = step.get();
report(name, want.equals(got), "expected \"" + want + "\", got \"" + got + "\"");
} catch (RuntimeException e) {
report(name, false, "threw " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static void refused(String name, Runnable action, String message) {
try {
action.run();
report(name, false, "it was allowed");
} catch (IllegalStateException e) {
report(name, message.equals(e.getMessage()), "message \"" + e.getMessage() + "\", expected \"" + message + "\"");
} catch (RuntimeException e) {
report(name, false, "threw " + e.getClass().getSimpleName() + ": " + e.getMessage() + ", expected IllegalStateException");
}
}
private static void report(String name, boolean ok, String why) {
if (ok) {
passed++;
System.out.println("ok " + name);
} else {
failed++;
System.out.println("FAIL " + name + ": " + why);
}
}
} CheckTickets replays the Python scenario step by step and compares every line, then uses reflection to check that
State is sealed and that its permitted types are exactly the four records. When all 13 pass, take the follow-up an
interviewer would ask next: an assigned ticket can be escalated to a second-level team with a reason. Add an
Escalated record to the sealed interface, compile, and let the errors list every switch that needs a decision.
Check yourself
8 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · When should a type in a low-level design be a Java record, and when a class?
Use a record for a value: data that is described completely by its fields and compared by them, such as an amount
of money, a seat number or an event like Refund(amount). A record gets its constructor, accessors, equals,
hashCode and toString from its components, and its fields are final, so two records with equal components are
equal and work as map keys. Use a class for an entity, something whose identity outlives changes to its data, such
as a payment, an account or a parking lot: its state changes over time, and two payments of the same amount are
still different payments. A record also cannot extend a class or add instance fields, and it is only shallowly
immutable, so copy any list it holds with List.copyOf in its compact constructor.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Payment states are records that implement a sealed interface. A teammate adds PartlyRefunded, and the build now fails in nine switch expressions. Another engineer suggests adding default branches so that it compiles. What do you reply, and where, if anywhere, does a default branch belong?
-
Fresher to mid
Design the value types of a wallet in Java 25: an amount of money, a transfer between two wallets and a monthly statement that lists transfers. Which of them are records, what does each constructor check, and how do you keep the statement immutable?
Key takeaways
- Make values records: check components in the compact constructor, and copy mutable components with
List.copyOf. - Make a closed set of alternatives a sealed interface whose permitted types are records, each carrying only its own data.
- A switch over a sealed type with no
defaultis checked by the compiler: a new type breaks every switch that does not handle it, and an old build throwsMatchExceptioninstead of guessing. - Use a catch-all case only where most combinations must be refused, and put it last, or the cases after it are dominated.
- Java 25 lets a constructor check its arguments and set its own fields before
super(...). java Main.javaruns a folder of files; keep the design in ordinary files and use a compact source file only as a driver, because the classes declared in one are inner classes.
References
- JEP 395: Records (OpenJDK)
- JEP 409: Sealed Classes (OpenJDK)
- JEP 394: Pattern Matching for instanceof (OpenJDK)
- JEP 440: Record Patterns (OpenJDK)
- JEP 441: Pattern Matching for switch (OpenJDK)
- JEP 456: Unnamed Variables & Patterns (OpenJDK)
- JEP 458: Launch Multi-File Source-Code Programs (OpenJDK)
- JEP 511: Module Import Declarations (OpenJDK)
- JEP 512: Compact Source Files and Instance Main Methods (OpenJDK)
- JEP 513: Flexible Constructor Bodies (OpenJDK)
- The Java Language Specification, Java SE 25: 7.3 Compilation Units (Oracle)
- The Java Language Specification, Java SE 25: 8.1.3 Inner Classes and Enclosing Instances (Oracle)
- The Java Language Specification, Java SE 25: 9.1.4 Permitted Direct Subclasses and Subinterfaces (Oracle)
- Java SE 25 API: Record (Oracle)
- Java SE 25 API: List (unmodifiable lists and copyOf) (Oracle)
- Java SE 25 API: MatchException (Oracle)
- Java SE 25 API: IO (Oracle)
- JDK 25 (OpenJDK)
- JDK 27 (OpenJDK)
- dataclasses: Data Classes (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress