Java / Domain Modeling
Records Aren’t Just Less Typing: Modeling Ambiguity Without Nulls
How I use records, sealed interfaces, and pattern matching to model “present,” “missing,” and “unverifiable” — and force callers to handle every case.
I’m an engineering manager with a background in Java backend systems. One lesson that keeps paying dividends is this: ambiguity deserves types, not comments. Nulls and booleans blur meaning. “false,” “zero,” and “we don’t know” are not interchangeable. With modern Java (records, sealed interfaces, pattern matching), I model uncertainty explicitly and make ambiguity impossible to ignore.
Modern Java can express these outcomes as records and sealed types: Present, Missing, and Unverifiable. The same pattern works in ordinary service domains; it does not depend on a scoring model.
The failure mode
You return a number for a score and a boolean “hasEvidence.” A caller subtracts two scores and treats “0” as “no evidence,” then logs an anomaly when “0” appears alongside “hasEvidence=true.” The model caused this. Nulls and booleans encourage shortcuts and misinterpretation.
What I learned
Use records for immutable, transparent domain data
Records let me define concise, immutable carriers without boilerplate. They encourage a bias toward immutability and make the intent clear.
Example response
public record AceuResponse(
ScoreOrUnknown authenticity,
ScoreOrUnknown credibility,
ScoreOrUnknown empathy,
ScoreOrUnknown uniqueness
) {}
public sealed interface ScoreOrUnknown permits Score, Missing, Unverifiable {}
public record Score(double value) implements ScoreOrUnknown {
public Score {
if (Double.isNaN(value) || value < 0.0 || value > 1.0) {
throw new IllegalArgumentException("Score must be [0,1]");
}
}
}
public record Missing(String reason) implements ScoreOrUnknown {}
public record Unverifiable(String note) implements ScoreOrUnknown {}
Records keep the data small and immutable. The sealed hierarchy enforces the set of states at compile time.
Replace nulls and boolean flags with sealed states
The temptation to use Double/Boolean and encode special states with null or false is strong. Resist it. Sealed interfaces make the valid state space explicit and force exhaustive handling. A boolean can’t distinguish “not applicable,” “not yet computed,” and “calculation failed.”
Use pattern matching for exhaustive case handling
Pattern matching in switch makes code read like the domain. It also turns omissions into compile errors when you use exhaustive switches.
Example handler:
public String render(ScoreOrUnknown s) {
return switch (s) {
case Score sc -> "score=" + sc.value();
case Missing miss -> "missing=" + miss.reason();
case Unverifiable unv -> "unverifiable=" + unv.note();
};
}
By handling each case explicitly, I make it impossible to “forget” one or conflate states.
Prefer intent-rich types over ad-hoc maps and strings
When a state needs more context (e.g., why data is missing), capture it in the type. Missing(String reason) and Unverifiable(String note) beat opaque strings and comments. If callers need to act differently based on the reason, add a reason code enum to the record.
Keep aggregates typed—don’t flatten too early
A response with four dimensions should preserve each dimension’s state. Avoid flattening to a single “overall score.” The minute you collapse, you discard information the caller needs for good decisions. In the demo, each dimension returns a ScoreOrUnknown. Only the caller decides how to combine them, and the compiler forces handling of unknowns.
Embed validation where it belongs
Put invariants in the type’s constructor. Scores are [0,1]; reasons should be concise, not multi-paragraph blobs. Records make these guardrails trivial to implement consistently.
Use test fixtures that exercise every branch
Define parameterized tests for your sealed states. For each dimension, test Score, Missing, and Unverifiable. Assert that pattern matching hits each branch, and that any new state added later causes compilation failures in exhaustive switches until it’s handled.
Trade-offs and failure modes
Trade-offs
More types vs fewer booleans: Sealed types add structure and a few extra classes. The upside is clarity and compile-time enforcement; the downside is minor boilerplate, which records minimize.
Exhaustiveness vs flexibility: Exhaustive switches break on new states until handled. That’s a feature (safety) but can feel strict when you prototype quickly.
Text reasons vs structured codes: Free-form strings are easier; structured enums enable programmatic handling. Choose based on how callers use the data.
Failure modes
“Shadow nulls”
A downstream library returns null and you forget to wrap it in a sealed state. Guard external boundaries with adapters.
“State leakage”: Someone serializes ScoreOrUnknown to a shape that drops the state label (e.g., value=0.0 only). Add JSON configuration that includes a type field and test the wire format.
“Catch-all case”: Using a default branch in pattern matching reintroduces ambiguity. Avoid default; keep exhaustive cases explicit.
Try it yourself
Replace nullable fields for ambiguous states with a sealed interface and record implementations. Add exhaustive pattern matching where you consume them.
Write tests that construct each state per dimension and assert wire-format correctness (e.g., JSON includes the state “type”).
Audit one path where a null sneaks through and add an adapter to convert it into Missing or Unverifiable with a reason code.