Data-oriented programming in Java
Combining records, sealed types and patterns to model data.
Data as plain values
Data-oriented programming models data as plain, immutable values and puts behavior in operations over them, functions with pattern switches, instead of methods spread across every type. The data says *what is*; the functions decide *what to do*.
The building blocks
Record: immutable data carrier. Sealed interface: a closed set of alternatives. Pattern switch: an operation handling each alternative. Exhaustiveness check: compiler proof that no case was forgotten. Together they give Java algebraic data types.
sealed interface Result {}
record Ok(int value) implements Result {}
record Err(String msg) implements Result {}
String show(Result r) {
return switch (r) {
case Ok(var v) -> "ok " + v;
case Err(var m) -> "error: " + m;
};
}Make illegal states unrepresentable
class Order {
boolean paid;
String receiptId; // only if paid!
}Nothing stops paid = true with a null receipt, or the reverse. A validate() method only helps if everyone remembers to call it.
sealed interface Order
permits Unpaid, Paid {}
record Unpaid(String id) implements Order {}
record Paid(String id, String receiptId)
implements Order {}An unpaid order has no receipt field; a paid one always has one. The invalid combination can't be built.
Your turn
What does eval(new Neg(new Neg(new Num(7)))) return?
sealed interface Expr {}
record Num(int v) implements Expr {}
record Neg(Expr e) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Neg(var x) -> -eval(x);
};
}7-7Compile error: missing default
Show the answer
eval recursively unpacks the tree: Neg(Neg(7)) = −(−7) = 7. Num and Neg are the only Exprs, so the switch is exhaustive without a default.
New operations are cheap
Need a print or simplify operation? Write a new function with a switch. No need to add an abstract method to every record type. The trade-off: adding a new *variant* means updating the switches, but the compiler lists every one.
Model this
A lookup can return a user, "not found", or a rate-limit error with a retry-after value. How would you model it?
Think about it, then reveal the answer
sealed interface Lookup with record Found(User u), record NotFound() and record RateLimited(int retryAfter). Each case carries only its own data, and an exhaustive switch forces every caller to handle all three. Far safer than a nullable User plus an error code, or a Map<String, Object>.
Where it shines
Results and errors, messages between services, domain events, and syntax trees in parsers and compilers all fit this style. Many teams keep classic objects for long-lived stateful services and use records plus sealed types for the data flowing between them.
Key takeaways
- Records = immutable data; sealed = a closed set of alternatives
- Behavior lives in switch-based operations over the data
- Model so illegal states can't be represented
- Great for results, messages, events and syntax trees
Brian Goetz, Java's language architect, laid out this style in his 2022 article "Data-Oriented Programming in Java".
Practice questions
What does eval(new Add(new Num(2), new Add(new Num(3), new Num(4)))) return?
sealed interface Expr {}
record Num(int v) implements Expr {}
record Add(Expr l, Expr r) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
};
}- 9
- 7
- Compile error: missing default
- Throws MatchException
Check your answer
9. eval recursively deconstructs the tree: 2 + (3 + 4) = 9. Num and Add are the only Exprs, so the switch is exhaustive.
A lookup can return a user, "not found", or a rate-limit error with a retry-after value. Which model is the most data-oriented?
- sealed interface Lookup with records Found(User u), NotFound(), RateLimited(int retryAfter)
- A User that may be null plus a separate int errorCode field
- Throwing a different exception for each case
- A Map<String, Object> with a "type" key
Check your answer
sealed interface Lookup with records Found(User u), NotFound(), RateLimited(int retryAfter). Each record carries exactly the data for its case, and an exhaustive switch forces callers to handle all three.