Records, sealed interfaces, and pattern matching ship as three separate Java features, and most of
us adopted them that way: records to kill boilerplate, sealed to lock down a hierarchy, pattern
matching to shorten casts. Each one alone is a minor convenience. Together they are something else:
one modeling primitive, where the compiler proves that every consumer handles every case of your
domain. At a security boundary that proof matters, because there the unhandled case is not a
cosmetic gap but a request that gets through. This post walks the smallest honest example I could
build: the data-modeling brick of
java-foundations.
The claim: one primitive, and sealed is the part that proves it
The primitive has a name in other languages, an algebraic data type, and it has three parts:
- The sealed interface declares the closed set of alternatives: "validating a token ends in exactly one of these four outcomes."
- Each record carries one alternative's data and nothing else. No behavior smuggled in, no mutable state.
- The exhaustive
switchis the consumer the compiler checks against that set.
The three parts are not interchangeable, and it pays to be precise about which one does the proving.
Take the records out and put plain final classes in their place: the switch still refuses to compile
when a case is missing. Take the sealed interface out and you cannot write a switch without a
default at all. The check comes from the closed set and the checked consumer. What the records add
is that each alternative arrives carrying its own data instead of being a marker class you have to
cast and interrogate.
That split explains the adoption numbers, and they are worth a look. In BellSoft's 2024 developer survey (308 respondents at Devoxx Belgium), 55% said they were currently using or planning to use records in production and 53% pattern matching, but only 30% sealed classes. The two convenient parts went in first. The part that turns them into a guarantee is the one still sitting on the shelf.
The domain: four token-validation outcomes, one file
The brick models bearer-token validation, the check a resource server runs before it lets a request
through. The domain is built for this post rather than lifted out of a production codebase: four
outcomes is the smallest set that still behaves like a real gate, and it keeps the example inside
one screen. If you want the surrounding machinery, that is the subject of the
Spring Boot OAuth2 guide; here everything below the outcome
itself is deliberately missing. The whole domain fits in one file,
TokenValidation.java:
public sealed interface TokenValidation {
record Valid(String subject, List<String> scopes, Instant expiresAt) implements TokenValidation {
/**
* Defensive copy so a Valid outcome cannot grow or lose scopes after the fact.
*/
public Valid {
scopes = List.copyOf(scopes);
}
}
record Expired(Instant expiredAt) implements TokenValidation {
}
record WrongAudience(String expected, String actual) implements TokenValidation {
}
record Malformed(String reason) implements TokenValidation {
}
}Read it as a sentence: validating a token ends in Valid, Expired, WrongAudience, or Malformed, and
each alternative says what data it carries. Expired knows when; WrongAudience knows which two
audiences disagreed; Malformed knows why. There are no annotations and no base-class ceremony. The
file is the domain model.
The consumer: an exhaustive switch with no default
The interesting part is what the consumer,
TokenGate.java,
does not contain: no default branch, no visitor interface, no instanceof ladder.
public static Optional<Problem> deny(TokenValidation outcome) {
return switch (outcome) {
case Valid _ -> Optional.empty(); // the only way through — visibly a decision
case Expired(Instant expiredAt) ->
Optional.of(new Problem(401, "invalid_token: token expired at " + expiredAt));
case WrongAudience(String expected, String actual) ->
Optional.of(new Problem(401, "invalid_token: audience is \"" + actual
+ "\", this resource expects \"" + expected + "\""));
case Malformed(String reason) ->
Optional.of(new Problem(401, "invalid_token: " + reason));
};
}An empty result means the request proceeds, and Valid is visibly the only case that produces it.
Record patterns deconstruct each alternative in place, components a case doesn't need are unnamed
(_), and because the set is sealed, the compiler knows these four cases are all there are. The
denial shapes follow RFC 6750: a bad bearer token is a 401 with an invalid_token hint.
A second consumer in the same file, auditLine, maps the same outcomes to audit lines. It also adds
a when guard: a still-valid token within 60 seconds of expiry gets a warning. Exhaustiveness
survives that, because the unguarded Valid case below it still covers the rest.
The experiment: add a fifth outcome
Here's the part you should run yourself. Clone the repo, open TokenValidation.java, and add:
record Revoked(String reason) implements TokenValidation {}Both switches in TokenGate.java immediately stop compiling:
the switch expression does not cover all possible input values
The compiler just handed you the complete to-do list of every gate that must decide what a revoked token means. That is the whole payoff: "you forgot to handle the new outcome" stops being a code-review catch and becomes a compile error, caught by the one reviewer who never skims. Revert the record and the brick compiles clean again.
The counter-example: an instanceof ladder that fails open
The repo keeps the same gate as an instanceof ladder in
InstanceofValidation.java,
so you can run the difference instead of taking my word for it:
public static Optional<Problem> deny(TokenValidation outcome) {
if (outcome instanceof Expired(Instant expiredAt)) {
return Optional.of(new Problem(401, "invalid_token: token expired at " + expiredAt));
}
if (outcome instanceof WrongAudience(String expected, String actual)) {
return Optional.of(new Problem(401, "invalid_token: audience is \"" + actual
+ "\", this resource expects \"" + expected + "\""));
}
if (outcome instanceof Malformed(String reason)) {
return Optional.of(new Problem(401, "invalid_token: " + reason));
}
// "Everything else must be valid" — the fail-open branch that admits future outcomes.
return Optional.empty();
}Today, the demo verifies at runtime that this ladder and the switch produce identical denials for
every outcome. But look at the shape: the ladder enumerates the failures it knows and treats
everything else as valid. Valid isn't even mentioned; acceptance is whatever fails to match. Add
Revoked, and the ladder still compiles without a sound. A revoked token matches none of the
checks, falls through to Optional.empty(), and the request is allowed. There is no warning, no
diff on this file at all, and no test failure until someone thinks to write that test. The mapping
and the output are identical today, and tomorrow it is a fail-open security bug. (The visitor
pattern can force exhaustiveness, but at several lines of ceremony per alternative per consumer,
which is why real codebases fall back to exactly this ladder.)
Where the guarantee stops
Two limits are worth knowing before you go and seal everything in sight.
The first is that a compile-time proof only binds code that was compiled against the current set.
Add an alternative, ship the library, and a consumer built against the old set keeps running: at
runtime its switch throws MatchException instead of failing anyone's build. Closed rather than
open, so the gate still holds, but a crash is not the same thing as a compiler handing you a list.
The sharper version of this is for published APIs. Once a sealed interface is part of one, every new
alternative is a breaking change for everybody who consumes it, and that is the honest price of the
guarantee: you cannot force other people's code to handle a new case without also breaking their
build.
The second is knowing when not to reach for it. A sealed set fits when the alternatives are yours to enumerate. When the set is meant to grow from outside, through plugins or third-party implementations, ordinary polymorphism is the right tool and sealing it only moves the problem somewhere less convenient. It also helps to know that Java is late to this idea rather than early: Rust calls it an enum, Kotlin a sealed class, Scala an ADT, TypeScript a discriminated union. What 21 changed is that the Java spelling of it finally reads as well as theirs.
Try it
The brick is a standalone Maven project, pure JDK with zero dependencies, because the point is what
the language does on its own.
DataModelingDemo.java
runs a fixed set of outcomes through both consumers and prints the gate decisions, the audit lines,
and the ladder-vs-switch sanity check:
git clone https://github.com/lukas-grigis/java-foundations
cd java-foundations
mise run data-modeling
That is what the mise pin is for: it fetches JDK 26 and Maven for you. On a toolchain you manage
yourself, the same thing is
cd bricks/data-modeling && mvn -q compile && java -cp target/classes dev.lukasgrigis.foundations.datamodeling.DataModelingDemo,
and it wants JDK 26 specifically, because the pom sets release 26 and anything older stops with
error: release version 26 not supported. The language features are older than the brick: sealed
types with pattern matching for switch have been final since 21, unnamed variables since 22.
This is the data-modeling brick of java-foundations: one design primitive of modern Java, made to land with a small, runnable example rather than a framework demo or a syntax tour. Next time you reach for a record, ask whether it's actually one alternative of a closed set. If it is, seal the set and delete the default branch, especially at a boundary where the fall-through's behavior becomes the policy for every outcome you add later. The compiler will start doing review work you used to do by hand. The next brick picks another one of these foundations and gets the same treatment, with a module you can run behind it.
