Java Module 26 – Advanced language and platform features
Data-oriented programming with records
Model data as sealed records, process it with exhaustive switches, nested record patterns and guards, and compare the design with classic object orientation.
What you will learn
- Model data with records and sealed interfaces, and refuse invalid values in compact constructors
- Write exhaustive switches over sealed data with nested record patterns, guards and unnamed patterns
- Decide when a switch over sealed data should have a default branch and when it must not
- Compare data-oriented and classic object-oriented designs by what a new type or a new operation costs
Before you start
What you’ll learn · Before you start
What you will learn
- Model data with records and sealed interfaces, and refuse invalid values in compact constructors
- Write exhaustive switches over sealed data with nested record patterns, guards and unnamed patterns
- Decide when a switch over sealed data should have a default branch and when it must not
- Compare data-oriented and classic object-oriented designs by what a new type or a new operation costs
Before you start
On this page
Data-oriented programming keeps a program’s data and its behaviour apart. The data is a set of plain,
immutable records, and when a value can take one of several shapes, a sealed interface lists the shapes. The
behaviour lives in ordinary methods that take the data apart with record patterns in a switch, and javac checks
that every such switch handles every shape. The style suits data that many parts of a program read and that has a
fixed set of forms: messages, events, responses from another service, configuration and syntax trees. Everything it
needs is standard Java: records since Java 16 (JEP 395), sealed interfaces since Java 17 (JEP 409), record patterns
and pattern matching for switch since Java 21 (JEPs 440 and 441), and the unnamed pattern _ since Java 22
(JEP 456). Brian Goetz, the Java language architect, described the approach for Java in an article listed in the
references.
This lesson builds a small expression language from five records and three operations, simplifies expressions with nested patterns, replaces a response class whose fields can contradict its status, and lets the compiler show what a new type and a new operation cost in each design. Every session was recorded with Eclipse Temurin 25.0.4.1, and one change was compiled again with JDK 27.
The data first: an expression language in five records
Expr is a sealed interface with five records: a number, a variable, a sum, a product and a negation. They hold data
and nothing else, and because they share Expr.java with the interface, javac permits exactly these five without a
permits clause. All the behaviour is in Calc, as static methods that each switch over the records:
expr/Expr.java
/**
* A small arithmetic language as data: a sealed interface and five records, with no behaviour at all. The records sit
* in this file, so the interface needs no permits clause: javac permits exactly these five.
*/
sealed interface Expr {}
record Num(int value) implements Expr {}
record Var(String name) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {} expr/Calc.java
import java.util.Map;
/** The operations on Expr: each one a switch with a case for every record, and no default. */
final class Calc {
private Calc() {}
/** The value of an expression, with the value of each variable taken from env. */
static int eval(Expr e, Map<String, Integer> env) {
return switch (e) {
case Num(int value) -> value;
case Var(String name) -> lookup(name, env);
case Add(Expr left, Expr right) -> eval(left, env) + eval(right, env);
case Mul(Expr left, Expr right) -> eval(left, env) * eval(right, env);
case Neg(Expr operand) -> -eval(operand, env);
};
}
/** The expression with every bracket written out, such as ((1 * x) + 0). */
static String text(Expr e) {
return switch (e) {
case Num(int value) -> Integer.toString(value);
case Var(String name) -> name;
case Add(Expr left, Expr right) -> "(" + text(left) + " + " + text(right) + ")";
case Mul(Expr left, Expr right) -> "(" + text(left) + " * " + text(right) + ")";
case Neg(Expr operand) -> "(-" + text(operand) + ")";
};
}
/** Simplifies the children first, then tries one rule on the node itself. */
static Expr simplify(Expr e) {
return switch (e) {
case Num _, Var _ -> e;
case Add(Expr left, Expr right) -> rule(new Add(simplify(left), simplify(right)));
case Mul(Expr left, Expr right) -> rule(new Mul(simplify(left), simplify(right)));
case Neg(Expr operand) -> rule(new Neg(simplify(operand)));
};
}
/** The rewrite rules: nested record patterns, some with a guard. Most nodes match none and stay as they are. */
private static Expr rule(Expr e) {
return switch (e) {
case Add(Num(int a), Num(int b)) -> new Num(a + b);
case Mul(Num(int a), Num(int b)) -> new Num(a * b);
case Add(Expr x, Num(int n)) when n == 0 -> x;
case Add(Num(int n), Expr x) when n == 0 -> x;
case Mul(Expr x, Num(int n)) when n == 1 -> x;
case Mul(Num(int n), Expr x) when n == 1 -> x;
case Mul(_, Num(int n)) when n == 0 -> new Num(0);
case Mul(Num(int n), _) when n == 0 -> new Num(0);
case Neg(Num(int value)) -> new Num(-value);
case Neg(Neg(Expr x)) -> x;
default -> e;
};
}
private static int lookup(String name, Map<String, Integer> env) {
Integer value = env.get(name);
if (value == null) throw new IllegalArgumentException("no value for " + name);
return value;
}
} expr/Main.java
/** Four expressions: each one printed, simplified and evaluated with x = 5 and y = 7. */
void main() {
var x = new Var("x");
var y = new Var("y");
List<Expr> expressions = List.of(
new Add(new Mul(new Num(1), x), new Num(0)),
new Mul(y, new Add(new Num(2), new Num(-2))),
new Neg(new Neg(new Mul(x, new Num(3)))),
new Mul(new Add(new Num(2), new Num(3)), new Neg(new Num(4))));
Map<String, Integer> env = Map.of("x", 5, "y", 7);
for (Expr e : expressions) {
Expr simple = Calc.simplify(e);
IO.println("%-18s simplifies to %-9s value %d".formatted(Calc.text(e), Calc.text(simple), Calc.eval(simple, env)));
}
} from session import run
# The expression language: compiled with javac, because the five records share Expr.java with the interface, then four
# expressions printed, simplified and evaluated with x = 5 and y = 7.
run("""
javac -d out expr/Expr.java expr/Calc.java expr/Main.java
java -cp out Main
""") Output
# JDK in use: openjdk version "25.0.4.1" LTS $ javac -d out expr/Expr.java expr/Calc.java expr/Main.java $ java -cp out Main ((1 * x) + 0) simplifies to x value 5 (y * (2 + -2)) simplifies to 0 value 0 (-(-(x * 3))) simplifies to (x * 3) value 15 ((2 + 3) * (-4)) simplifies to -20 value -20
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 expr_session.py
eval, text and simplify each have one case per record and no default, so the compiler has confirmed that all
three handle all five shapes. A record pattern such as Add(Expr left, Expr right) does two jobs in one step: it
tests that the value is an Add, and it binds the record’s components to new variables, in the order the record
declares them. The names of those variables are your own choice; only the order and the types have to fit the
record.
simplify works from the leaves up. It simplifies the children of a node first, then passes the rebuilt node to
rule, which tries one rewrite at the top. That is how (y * (2 + -2)) became 0 in the run: the inner sum folded
to Num(0) first, and the product with zero then became zero too. Main.java itself is a compact source file with an
instance main method, which is standard since Java 25 (JEP 512).
How a nested pattern takes a tree apart
The cases of rule put patterns inside patterns. Add(Num(int a), Num(int b)) matches only a sum whose two children
are both numbers, and binds both values. Mul(Expr x, Num(int n)) when n == 1 matches a product whose right child is a
number, then tests that number in a guard, the condition after when. Written without patterns, the same test
takes two instanceof checks, two casts and three accessor calls. The diagram follows the first expression of the run
through both rules it needs:
How simplify() reduces ((1 * x) + 0) to x
Text description of the diagram
The diagram is a column of four boxes joined by arrows.
- The value: Add(Mul(Num(1), Var("x")), Num(0)), which text() prints as ((1 * x) + 0).
- An arrow labelled "simplify the children first" leads to step 1. The left child, Mul(Num(1), Var("x")), matches the case Mul(Num(int n), Expr x) when n == 1. The pattern binds n to 1 and x to Var("x"), the guard holds, and the rule returns x.
- An arrow labelled "the rule returns x" leads to step 2. The node is now Add(Var("x"), Num(0)). It matches the case Add(Expr x, Num(int n)) when n == 0, which binds x to Var("x") and n to 0. The guard holds, and the rule returns x again.
- The last arrow leads to the result, Var("x"), which text() prints as x.
Each step is one switch case: a record pattern that tests the shape of the node and names its parts in one go, and a guard that tests a value the pattern has just bound.
Four habits of rule and simplify are worth copying:
_is the unnamed pattern.Mul(_, Num(int n))matches any left child without naming it, andcase Num _, Var _ -> einsimplifygives two shapes the same answer in one case.- Cases are tried from the top, and the first one that applies wins. A guarded case must come before an unguarded case with the same pattern. In the opposite order the unguarded case would win every time, so javac refuses to compile the guarded one and calls it dominated.
- A guard may use the variables its pattern has just bound, such as
n, and it must be abooleancondition. ruleis the only switch here with adefault, and that is deliberate. It is a list of rewrites for a few shapes, and every other node comes back unchanged, so a default is the honest answer.eval,textandsimplifymust answer for every record, and leaving the default out is what lets javac check that they do.
Illegal states that cannot be built
Data-oriented design also changes the shape of a model. StatusResponse is how many clients of a payment gateway
begin: one class for every outcome, a status string, and fields that only some statuses fill in. Nothing in the
type forbids a pending response with a reference, and nothing stops code from reading the reference of a payment that
is still pending. The second version gives each outcome its own record:
classic/StatusResponse.java · status/classic/StatusResponse.java
/**
* The classic shape of a payment gateway's answer: one class for every outcome, a status string, and fields that only
* some statuses fill in. Nothing stops a PENDING response with a reference, or a PAID one without.
*/
final class StatusResponse {
final String status; // "PAID", "DECLINED" or "PENDING"
final String reference; // set only when PAID
final long paise; // set only when PAID
final String reason; // set only when DECLINED
StatusResponse(String status, String reference, long paise, String reason) {
this.status = status;
this.reference = reference;
this.paise = paise;
this.reason = reason;
}
} classic/ClassicReceipts.java · status/classic/ClassicReceipts.java
/** A receipt for each answer. The code compiles, and the third answer gets a receipt it should never have had. */
public class ClassicReceipts {
static String receipt(StatusResponse r) {
if (r.status.equals("DECLINED")) return "declined: " + r.reason;
return "receipt " + r.reference + " for Rs " + r.paise / 100;
}
public static void main(String[] args) {
StatusResponse[] answers = {
new StatusResponse("PAID", "4257120938", 1_250_00, null),
new StatusResponse("DECLINED", null, 0, "insufficient funds"),
new StatusResponse("PENDING", null, 0, null),
};
for (StatusResponse r : answers) System.out.println(receipt(r));
}
} status/PaymentStatus.java
/**
* The same answers as data: one record per outcome, each with exactly the data that outcome has. A Paid without a
* reference, or with an amount of zero or less, cannot be built: the compact constructor refuses it.
*/
sealed interface PaymentStatus {}
record Paid(String reference, long paise) implements PaymentStatus {
Paid {
if (reference == null || reference.isBlank()) throw new IllegalArgumentException("a paid status needs a reference");
if (paise <= 0) throw new IllegalArgumentException("a paid amount must be positive, not " + paise);
}
}
record Declined(String reason) implements PaymentStatus {}
record Pending(int minutes) implements PaymentStatus {} status/Receipts.java
/** One message per status, chosen by record patterns and guards, in a switch without a default. */
public class Receipts {
/** Receipts from this amount on are also sent by email (a rule of this example shop): Rs 2,00,000.00 in paise. */
static final long EMAIL_FROM = 2_00_000_00;
static String message(PaymentStatus status) {
return switch (status) {
case Paid(String reference, long paise) when paise >= EMAIL_FROM -> "receipt " + reference + " for " + rupees(paise) + ", also sent by email";
case Paid(String reference, long paise) -> "receipt " + reference + " for " + rupees(paise);
case Declined(String reason) -> "declined: " + reason;
case Pending(int minutes) when minutes >= 30 -> "pending for " + minutes + " minutes: ask the bank, no receipt yet";
case Pending(int minutes) -> "pending for " + minutes + " minutes: check again soon";
};
}
static String rupees(long paise) {
return "Rs %d.%02d".formatted(paise / 100, paise % 100);
}
public static void main(String[] args) {
PaymentStatus[] answers = {
new Paid("4257120938", 1_250_00), new Paid("4257120977", 2_40_000_00),
new Declined("insufficient funds"), new Pending(4), new Pending(45),
};
for (PaymentStatus s : answers) System.out.println(message(s));
try {
new Paid(" ", 500_00);
} catch (IllegalArgumentException e) {
System.out.println("refused at the boundary: " + e.getMessage());
}
}
} from session import run
# The same three gateway answers twice: as one class with a status string, then as sealed records with guarded cases.
run("""
javac -d classic status/classic/StatusResponse.java status/classic/ClassicReceipts.java
java -cp classic ClassicReceipts
javac -d records status/PaymentStatus.java status/Receipts.java
java -cp records Receipts
""") Output
# JDK in use: openjdk version "25.0.4.1" LTS $ javac -d classic status/classic/StatusResponse.java status/classic/ClassicReceipts.java $ java -cp classic ClassicReceipts receipt 4257120938 for Rs 1250 declined: insufficient funds receipt null for Rs 0 $ javac -d records status/PaymentStatus.java status/Receipts.java $ java -cp records Receipts receipt 4257120938 for Rs 1250.00 receipt 4257120977 for Rs 240000.00, also sent by email declined: insufficient funds pending for 4 minutes: check again soon pending for 45 minutes: ask the bank, no receipt yet refused at the boundary: a paid status needs a reference
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 status_session.py
The classic code compiled without a warning and printed receipt null for Rs 0 for a payment that had not happened.
In the second model a Pending has minutes and no reference, so no code can print a receipt reference for it, and
the switch has to say what every outcome means. The compact constructor of Paid checks its data once, when the
record is created at the edge of the program, where the gateway’s answer arrives. From then on every Paid has a
reference and a positive amount, and no other code has to check again; the last line of the run shows a blank
reference refused at that boundary. The guards carry the shop’s own rules, an email for large amounts and a question
to the bank after half an hour, as separate cases that read like the rules themselves.
A new type or a new operation?
Each design makes some changes cheap and others expensive. To see which, the session below makes four changes to the
expression language and compiles each one on its own: a new type, Div, and a new operation, size. It makes them
first in a classic version, where every record carries its own eval and text methods, then in the data-oriented
version above:
oo/Expr.java · change/oo/Expr.java
import java.util.Map;
/** The same language written the classic way: every record carries its own eval and text. */
interface Expr {
int eval(Map<String, Integer> env);
String text();
}
record Num(int value) implements Expr {
public int eval(Map<String, Integer> env) { return value; }
public String text() { return Integer.toString(value); }
}
record Var(String name) implements Expr {
public int eval(Map<String, Integer> env) { return env.get(name); }
public String text() { return name; }
}
record Add(Expr left, Expr right) implements Expr {
public int eval(Map<String, Integer> env) { return left.eval(env) + right.eval(env); }
public String text() { return "(" + left.text() + " + " + right.text() + ")"; }
}
record Mul(Expr left, Expr right) implements Expr {
public int eval(Map<String, Integer> env) { return left.eval(env) * right.eval(env); }
public String text() { return "(" + left.text() + " * " + right.text() + ")"; }
}
record Neg(Expr operand) implements Expr {
public int eval(Map<String, Integer> env) { return -operand.eval(env); }
public String text() { return "(-" + operand.text() + ")"; }
} oo/Div.java · change/oo/Div.java
import java.util.Map;
/** A new type: one more record with its own methods. Nothing else has to change. */
record Div(Expr left, Expr right) implements Expr {
public int eval(Map<String, Integer> env) { return left.eval(env) / right.eval(env); }
public String text() { return "(" + left.text() + " / " + right.text() + ")"; }
} oo-more/Expr.java · change/oo-more/Expr.java
import java.util.Map;
/** The same language with a third operation, size, declared in the interface. */
interface Expr {
int eval(Map<String, Integer> env);
String text();
int size();
}
record Num(int value) implements Expr {
public int eval(Map<String, Integer> env) { return value; }
public String text() { return Integer.toString(value); }
}
record Var(String name) implements Expr {
public int eval(Map<String, Integer> env) { return env.get(name); }
public String text() { return name; }
}
record Add(Expr left, Expr right) implements Expr {
public int eval(Map<String, Integer> env) { return left.eval(env) + right.eval(env); }
public String text() { return "(" + left.text() + " + " + right.text() + ")"; }
}
record Mul(Expr left, Expr right) implements Expr {
public int eval(Map<String, Integer> env) { return left.eval(env) * right.eval(env); }
public String text() { return "(" + left.text() + " * " + right.text() + ")"; }
}
record Neg(Expr operand) implements Expr {
public int eval(Map<String, Integer> env) { return -operand.eval(env); }
public String text() { return "(-" + operand.text() + ")"; }
} dop/Size.java · change/dop/Size.java
/** A new operation: the number of nodes in an expression. Nothing else has to change. */
final class Size {
private Size() {}
static int of(Expr e) {
return switch (e) {
case Num _, Var _ -> 1;
case Add(Expr left, Expr right) -> 1 + of(left) + of(right);
case Mul(Expr left, Expr right) -> 1 + of(left) + of(right);
case Neg(Expr operand) -> 1 + of(operand);
};
}
} dop/Expr.java · change/dop/Expr.java
/** The same language with a sixth record, Div. */
sealed interface Expr {}
record Num(int value) implements Expr {}
record Var(String name) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
record Div(Expr left, Expr right) implements Expr {} from session import run
# Four changes, each compiled on its own: a new type and a new operation, in the classic design (methods in every record)
# and in the data-oriented one (switches over sealed records). Two of them are meant to fail. The last change is
# compiled once more with JDK 27, whose javac also names what is missing.
run("""
# classic design, new type: Div is one more record with its own methods
javac -d a change/oo/Expr.java change/oo/Div.java
# classic design, new operation: size() declared in the interface
javac -d b change/oo-more/Expr.java
# data-oriented design, new operation: one more method with a switch
javac -d c expr/Expr.java expr/Calc.java change/dop/Size.java
# data-oriented design, new type: Div added to the sealed interface
javac -d d change/dop/Expr.java expr/Calc.java
""", expect_failure=("javac -d b change/oo-more/Expr.java", "javac -d d change/dop/Expr.java expr/Calc.java"))
run("""
javac -d d change/dop/Expr.java expr/Calc.java
""", jdk="27", expect_failure=("javac -d d change/dop/Expr.java expr/Calc.java",)) Output
# JDK in use: openjdk version "25.0.4.1" LTS
# classic design, new type: Div is one more record with its own methods
$ javac -d a change/oo/Expr.java change/oo/Div.java
# classic design, new operation: size() declared in the interface
$ javac -d b change/oo-more/Expr.java
change/oo-more/Expr.java:10: error: Num is not abstract and does not override abstract method size() in Expr
record Num(int value) implements Expr {
^
change/oo-more/Expr.java:15: error: Var is not abstract and does not override abstract method size() in Expr
record Var(String name) implements Expr {
^
change/oo-more/Expr.java:20: error: Add is not abstract and does not override abstract method size() in Expr
record Add(Expr left, Expr right) implements Expr {
^
change/oo-more/Expr.java:25: error: Mul is not abstract and does not override abstract method size() in Expr
record Mul(Expr left, Expr right) implements Expr {
^
change/oo-more/Expr.java:30: error: Neg is not abstract and does not override abstract method size() in Expr
record Neg(Expr operand) implements Expr {
^
5 errors
[exit status 1]
# data-oriented design, new operation: one more method with a switch
$ javac -d c expr/Expr.java expr/Calc.java change/dop/Size.java
# data-oriented design, new type: Div added to the sealed interface
$ javac -d d change/dop/Expr.java expr/Calc.java
expr/Calc.java:9: error: the switch expression does not cover all possible input values
return switch (e) {
^
expr/Calc.java:20: error: the switch expression does not cover all possible input values
return switch (e) {
^
expr/Calc.java:31: error: the switch expression does not cover all possible input values
return switch (e) {
^
3 errors
[exit status 1]
# JDK in use: openjdk version "27"
$ javac -d d change/dop/Expr.java expr/Calc.java
expr/Calc.java:9: error: the switch expression does not cover all possible input values
return switch (e) {
^
missing patterns:
Div _
expr/Calc.java:20: error: the switch expression does not cover all possible input values
return switch (e) {
^
missing patterns:
Div _
expr/Calc.java:31: error: the switch expression does not cover all possible input values
return switch (e) {
^
missing patterns:
Div _
3 errors
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 change_session.py
The two designs mirror each other. In the classic one, the new type was one more record and compiled at once, while
the new operation in the interface broke all five records, one error each. In the data-oriented one, the new
operation was one more method, while the new record made javac report the three switches of Calc that had no case
for it: eval at line 9, text at line 20 and simplify at line 31. rule was not reported, because its default
already covers Div, which is what a list of optional rewrites should do. On JDK 27, javac also names what each
switch misses, Div _.
Neither design escapes the compiler. They put the work in different places, a trade-off that Philip Wadler named the expression problem: one design makes new types cheap to add, the other makes new operations cheap.
| Criterion | Methods in every class | Records and switches |
|---|---|---|
| Code of one operation | In every class | In one method |
| New type | One new class | javac lists the switches to update |
| New operation | javac lists the classes to update | One new method |
| Who adds types | Anyone, if the interface is open | Only the owners |
| When to choose | Many types, few operations: plugins, widgets | Fixed shapes, many operations: events, messages, syntax trees |
Most programs need both. Behaviour that belongs to one type and protects its state, such as an account that guards its balance, stays a method of that type. Data that crosses boundaries and that many parts of a program read is where records and switches pay off.
Version note
Records became final in Java 16 (JEP 395), sealed classes in Java 17 (JEP 409), record patterns and pattern
matching for switch in Java 21 (JEPs 440 and 441), and unnamed patterns and variables in Java 22 (JEP 456). The
records, Calc and the receipts therefore compile on Java 22 and later, and on Java 21 once each _ has a name;
Main.java needs Java 25 for its compact form.
Check yourself
5 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.
Your turn: a printer with fewer brackets
text writes every bracket, which is safe but hard to read. Write Printer.show, which writes only the brackets that
precedence needs, following three rules:
- A
Numprints its value, in brackets when it is negative, as in(-3). AVarprints its name. - An
Addprintsleft + right, aMulprintsleft * right, and aNegprints-operand. - A child gets brackets when it binds more loosely than its parent.
Negbinds most tightly, thenMul, thenAdd, so anAddinside aMulor aNeggets brackets, and so does aMulinside aNeg.
printer/Printer.java
/** Your turn: print an expression with only the brackets that precedence needs. */
final class Printer {
private Printer() {}
/** Neg binds most tightly, then Mul, then Add (the rules are in the lesson). */
static String show(Expr e) {
return ""; // replace this line with one switch over the five records
}
} printer/Checks.java
/** Sample checks for Printer.show: javac -d out expr/Expr.java printer/*.java, then java -cp out Checks */
public class Checks {
private static int passed;
private static int total;
public static void main(String[] args) {
var x = new Var("x");
check("7", new Num(7));
check("x + 2 * 3", new Add(x, new Mul(new Num(2), new Num(3))));
check("(x + 2) * 3", new Mul(new Add(x, new Num(2)), new Num(3)));
check("-(x + 2)", new Neg(new Add(x, new Num(2))));
check("-x * 3", new Mul(new Neg(x), new Num(3)));
check("-(-3)", new Neg(new Num(-3)));
System.out.println(passed + " of " + total + " checks passed");
}
private static void check(String expected, Expr e) {
total++;
String got = Printer.show(e);
if (got.equals(expected)) passed++;
else System.out.println("expected \"" + expected + "\" but got \"" + got + "\"");
}
} from session import run
# The exercise's sample checks against the starter code, which returns an empty string for every expression.
run("""
javac -d out expr/Expr.java printer/Printer.java printer/Checks.java
java -cp out Checks
""") Output
# JDK in use: openjdk version "25.0.4.1" LTS $ javac -d out expr/Expr.java printer/Printer.java printer/Checks.java $ java -cp out Checks expected "7" but got "" expected "x + 2 * 3" but got "" expected "(x + 2) * 3" but got "" expected "-(x + 2)" but got "" expected "-x * 3" but got "" expected "-(-3)" but got "" 0 of 6 checks passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py
- Write
showas one switch over the five records, with nodefault. A small helper that gives each record a precedence level keeps the third rule short. - Compile with
javac -d out expr/Expr.java printer/*.javaand runjava -cp out Checksuntil it prints6 of 6 checks passed. - Add
record Sub(Expr left, Expr right) implements Expr {}toExpr.javaand compile everything withjavac -d out expr/*.java printer/*.java. Read which switches javac reports before you change anything. Then print a subtraction asleft - right, with brackets around a right child that is anAddor aSub, as inx - (2 + 3).
Interview questions
Fresher · What is data-oriented programming in Java, and which language features support it?
Data-oriented programming models a program’s data as plain, immutable values and keeps the code that works on them in separate methods. In Java, records hold the data, and a sealed interface lists the shapes a value can take, such as the outcomes of a payment. Methods take values apart with record patterns in switch expressions; because the interface is sealed, a switch without a default must handle every shape, and javac reports each switch that has to change when a shape is added. Guards add a condition to a case, and the unnamed pattern ignores the parts that do not matter. Compact constructors check data once, when it enters the program, so invalid values cannot be built. The style suits data with a fixed set of forms and many operations, such as messages, events and syntax trees, while behaviour that belongs to one type stays a method of that type.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An order service models 14 kinds of order event as subclasses with a visitor interface, and every new event touches nine visitor classes. A colleague proposes sealed records with pattern switches instead. How would you decide, and how would you migrate safely?
-
Fresher to mid
A service returns a Result class with the fields status, data and errorMessage. Every few weeks a bug reads data when status is FAILED and shows an empty screen or throws NullPointerException. How would you remodel the result with Java 25 features, and how do the callers change?
Key takeaways
- Records hold the data, a sealed interface lists its shapes, and operations are methods that switch over the shapes with record patterns.
- Nested record patterns test the shape of a whole subtree and bind its parts in one case; guards add a condition,
and
_ignores what does not matter. - Leave the
defaultout of a switch that must answer for every shape, so javac checks it; keep adefaultwhere only some shapes need special handling. - Give each outcome a record with exactly its own data, and validate it in the compact constructor, so contradictory states cannot be built.
- A new record makes javac list every switch to update, and a new operation is one method: the mirror image of classic object-oriented design, where new types are cheap and new operations touch every class.
References
- JEP 395: Records (OpenJDK)
- JEP 409: Sealed Classes (OpenJDK)
- JEP 440: Record Patterns (OpenJDK)
- JEP 441: Pattern Matching for switch (OpenJDK)
- JEP 456: Unnamed Variables & Patterns (OpenJDK)
- JEP 512: Compact Source Files and Instance Main Methods (OpenJDK)
- Java Language Updates, Java SE 25: Pattern Matching for switch (Oracle)
- Java Language Updates, Java SE 25: Record Patterns (Oracle)
- The Java Language Specification, Java SE 25: 14.11.1 Switch Blocks (Oracle)
- The Java Language Specification, Java SE 25: 14.30 Patterns (Oracle)
- The Java Language Specification, Java SE 25: 8.10.4.2 Compact Canonical Constructors (Oracle)
- Data Oriented Programming in Java (Brian Goetz) (InfoQ)
- The Expression Problem (Philip Wadler) (University of Edinburgh)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress