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 (opens in a new tab).
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 (opens in a new tab) (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 and OAuth2 guide; here everything below the outcome
itself is deliberately missing. The whole domain fits in one file,
TokenValidation.java (opens in a new tab):
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 (opens in a new tab),
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 valuesThe 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 (opens in a new tab),
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 (opens in a new tab)
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-modelingThat is what the mise pin is for: it fetches the pinned JDK 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 or later, 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.
Key takeaways
- Records, sealed interfaces, and pattern matching combine into one modeling primitive: a closed set of alternatives the compiler checks exhaustively. Other languages call it an algebraic data type.
- At a security gate the payoff is concrete: add a fifth validation outcome and every switch stops compiling, handing you the complete to-do list of gates to update.
- An instanceof ladder over the same domain keeps compiling when the domain grows: the new outcome falls through the final branch and the request is allowed. That is a fail-open bug.
- The part that does the proving is the least adopted one: 55% report using or planning records, but only 30% sealed classes. Records are the convenience; the sealed set is what buys the compile error.
Frequently asked questions
- Why should a sealed switch have no default branch?
- Because the default branch is where the guarantee dies. Without it, the compiler checks the switch against the sealed interface's permitted set and refuses to compile if a case is missing. With a default, a newly added alternative silently falls into it and the compiler stays quiet. You have traded a compile-time check for a runtime surprise.
- Do records, sealed interfaces, and pattern matching work independently?
- Yes, and that is the trap. Each one alone is a minor convenience: records save boilerplate, sealed types restrict subclassing, pattern matching shortens casts. The exhaustiveness check itself comes from the sealed set plus the switch, and swapping the records for plain final classes keeps it. What the records add is that every alternative carries its own data, which is what makes the closed set worth modeling as a domain in the first place.
- What is a fail-open bug in token validation?
- A validation path whose fall-through allows the request. An instanceof ladder that matches the failures it knows and returns 'allowed' for everything else is fail-open: any outcome added later (a revoked token, say) matches none of the checks and sails through the gate. The code compiles, the tests that exist still pass, and the bug is invisible in the diff because the gate's file never changed.
- What is an algebraic data type in Java?
- A closed set of alternatives, each carrying its own data, that consumers must handle completely. In Java you build one from a sealed interface (the closed set), records (one alternative's data each), and exhaustive switch with record patterns (the checked consumer). Records were final in Java 16 and sealed types in 17, but pattern matching for switch landed last, in 21, which is when the full primitive became available.
Resources
- (opens in a new tab)
java-foundations
Repository · The runnable bricks behind this post. The data-modeling brick holds TokenValidation, TokenGate, the instanceof counter-example, and the demo that runs them side by side.
- (opens in a new tab)
JEP 409: Sealed Classes
Article · The closed set half of the primitive: which classes may implement an interface, declared in the interface itself. Final in Java 17.
- (opens in a new tab)
JEP 395: Records
Article · Transparent carriers for immutable data, plus the compact constructor the Valid outcome uses for its defensive copy. Final in Java 16.
- (opens in a new tab)
JEP 441: Pattern Matching for switch
Article · The consumer the compiler checks. Spells out the exhaustiveness rules that make a missing case a compile error instead of a runtime surprise. Final in Java 21.
- (opens in a new tab)
JEP 440: Record Patterns
Article · Deconstructing a record inside a case label, which is how each outcome hands its components straight to the branch that needs them. Final in Java 21.
- (opens in a new tab)
BellSoft Java Developer Survey 2024
Article · 308 respondents at Devoxx Belgium. The source of the lopsided adoption numbers: 55% records, 53% pattern matching, 30% sealed classes.
- (opens in a new tab)
RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage
Article · Where the gate's denial shapes come from: a bad bearer token answers 401 with an invalid_token error.
- (opens in a new tab)
mise
Tool · The tool-version manager the repo pins its JDK with, and the task runner behind mise run data-modeling.



