Java: Modern Java
java, records, sealed classes, pattern matching, switch expressions, virtual threads, var, text blocks, jvm, modern java
Introduction
The class was 84 lines long, and it held two numbers. It was a Money value object — an amount and a currency code — and almost none of those 84 lines were about money. There was a private final field for each value, a constructor that assigned them, a getter for each, a hand-written equals that null-checked and compared field by field, an IDE-generated hashCode nobody had touched since, and a toString that had drifted out of sync with the fields. The two actual facts — an amount and a currency — were buried under sixty lines of mechanical ceremony the compiler could have written and any reviewer skimmed past without reading. Multiply that by the four hundred value objects in the codebase and you have a service that is mostly boilerplate pretending to be logic.
The same codebase had a second tell. Anywhere it reacted differently to different shapes of data, it did so with a ladder: if (event instanceof Click), cast, handle; else if (event instanceof Scroll), cast, handle; and at the bottom an else that threw or, worse, silently did nothing. When someone added a KeyPress event six months later, the ladders did not complain. They compiled cleanly, fell through to the else, and the bug shipped. The type system knew there was a new kind of event; it had no way to make the code that switched on events care. And every reaction to a caller’s input came wrapped in an anonymous inner class — five lines of scaffolding around one line of intent.
A few Java 17 and 21 features collapsed all of it. The 84-line Money became a one-line record. The instanceof ladders became pattern-matching switches over sealed hierarchies the compiler refuses to let you leave incomplete — so the KeyPress that used to slip through silently now stops the build until every switch handles it. The anonymous classes became lambdas. The service got smaller and — the part that matters — safer, because work that had been the programmer’s responsibility to remember became the compiler’s responsibility to enforce. Modern Java is a different, far less verbose language than its reputation, and most of that reputation is a decade out of date.
The Core Insight
Java’s reputation was earned. For its first fifteen years it really was the language of AbstractSingletonProxyFactoryBean, of getters and setters generated by the hundred, of ceremony so heavy that whole books were written about shoveling it faster. But somewhere between Java 8 and Java 21 the language quietly became expressive, and the change was not cosmetic. Four features changed how idiomatic Java reads:
- Records make immutable data transparent. A
recorddeclares its fields once and the compiler derives the constructor, the accessors,equals,hashCode, andtoStringfrom that single declaration — the data, and nothing else. The boilerplate that used to be hand-written and therefore could drift out of sync is now generated and therefore cannot. - Sealed classes let a type declare its complete set of subtypes, closing the hierarchy. The compiler then knows every possible shape the value can take — which is the precondition for everything below.
- Pattern matching — in
instanceofand inswitch— folds the type test and the cast and the binding into one expression, and over a sealed hierarchy it becomes exhaustive: the compiler proves you have handled every case, and the moment a new case appears, every switch that doesn’t handle it stops compiling. The instanceof-cast ladder, and the silent fall-through it enabled, simply stop being how you write Java. - Virtual threads (Project Loom, Java 21) make a thread cheap enough to spawn one per task, retiring the thread-pool tuning that dominated high-concurrency Java for two decades.
The deeper point is that the boilerplate was never the reason to choose Java, and its disappearance is not the reason to choose Java now. The real, enduring draw is the JVM underneath: world-class garbage collectors, a JIT that profiles your code at runtime and recompiles the hot paths, the deepest observability tooling of any managed runtime, and a library ecosystem two decades thick. The verbosity was always a tax on that platform. Modern Java didn’t change the platform; it removed the tax.
A mental model
Hold two pictures in your head, because the two centerpiece features are dual to each other.
A record is the data, and nothing else. Picture a labeled tuple — a fixed list of named, final components — that knows how to compare itself, hash itself, and print itself, because those operations are derived mechanically from the components. You are not writing a class that happens to hold data; you are declaring data and getting the class for free. If you find yourself adding a setter, a mutable field, or a second meaning, you have outgrown the record and should reach for a class — which is exactly the signal you want.
A sealed interface plus its record implementations plus a pattern-matching switch is a closed set of cases the compiler forces you to handle. This is the algebraic data type — the sum type — arriving in an object-oriented language. “A shape is a circle or a rectangle or a triangle, and there are no others” is a statement the sealed declaration makes to the compiler, and the exhaustive switch is how you consume it: you list what to do for each case, you write no default, and the compiler checks that your list is complete. Adding a fourth shape turns into a compile error in every switch that forgot it — the compiler becomes a to-do list that cannot be ignored. Figure 19.1 shows this shape: a sealed Shape, its permitted records, and the switch that must cover them all.
When modern Java fits
The features in this chapter are the how; the whether is a question about the JVM, and the answer is a sweet spot rather than a verdict. Reach for Java when you are building long-lived backend services maintained by large teams, where what matters is not lines of code but staying power: a service that runs for a decade, is touched by dozens of engineers of varying experience, and leans on a mature ecosystem for the unglamorous work — connection pooling, transaction management, message brokers, Spring, the entire Apache data stack. Java’s explicitness, which reads as ceremony in a one-file script, is an asset when forty people share a codebase and code review is the safety net, and the JVM’s sustained-throughput performance rewards exactly the workload that runs for months.
Reach for something else when the JVM’s tradeoffs cut against you. Cold-start time and memory floor make plain Java an awkward fit for serverless functions and tiny containers, where Go’s instant-start static binary or Python’s quick iteration win (GraalVM native images narrow this, at a cost in build complexity). Hard real-time and sub-millisecond-tail-latency systems still flinch at any GC pause and reach for C++ or Rust. For data exploration and ML research, Python’s ecosystem is simply where the work happens. Java’s place is the broad middle band of server software — not the smallest tool and not the most latency-critical, but the durable, team-scale system that has to keep working for years.
What you’ll learn
- How records collapse an immutable data class to one line, what the compiler derives from it, and where a record stops being the right tool
- How sealed interfaces close a type hierarchy, and why that closure is the precondition for compiler-checked exhaustiveness
- How pattern-matching
switchturns a sealed hierarchy into an algebraic sum type, so adding a case becomes a compile error rather than a silent bug - How
instanceofpatterns and switch expressions retire the cast ladder and the fall-throughswitch - How the collections framework maps interfaces (
List,Set,Map,Queue) to implementations, the Big-O intuition for choosing one, and why theequals()/hashCode()contract is what keeps aHashMapworking - Where the smaller niceties —
var, text blocks, enhanced enums — earn their place, and wherevarhurts readability - What the JVM buys you: bytecode portability, the JIT, world-class GC, and why “write once, run anywhere” still matters
- Why virtual threads change the concurrency story, and where this chapter hands that subject to the concurrency chapter
Prerequisites
- Java basics: classes, interfaces, inheritance, generics, and exceptions — the object-oriented core that modern features build on, not replace.
- Object-oriented programming: encapsulation, polymorphism, and the inheritance hierarchies that sealed types deliberately constrain.
- Comfort reading the collections framework (
List,Map,Set) and a working JDK 21, since several features here are Java 21 final.
Records: the data, and nothing else
Start with the case that motivated the chapter. A value object that carries an amount and a currency code, written the old way, is a constructor, two accessors, an equals, a hashCode, and a toString — sixty-odd lines, every one of them derivable from the two fields. A record declares the two fields and stops:
// The whole class. The compiler derives the rest from the components.
public record Money(long amountMinor, String currency) {}That single line generates a canonical constructor over the two components, accessor methods named for them (amountMinor(), currency() — no get prefix, because a record component is the value, not a property behind a bean), and equals/hashCode/toString computed over all components. Two Money values with the same amount and currency are now equal and hash identically — precisely what a value object should mean — and you did not write, and therefore cannot get wrong, the field-by-field comparison that used to drift when a field was added. The components are final, so a record is immutable by construction; the class is final and cannot be subclassed, deliberately, because data does not have subtypes that quietly change its meaning.
The “and nothing else” is load-bearing, and the moment you need something else — validation, a derived value, a defensive copy — the record accommodates it without giving up its transparency. A compact constructor runs before the components are assigned and is the place for invariants:
public record Money(long amountMinor, String currency) {
public Money { // compact constructor
if (currency == null || currency.length() != 3)
throw new IllegalArgumentException("currency must be an ISO code");
currency = currency.toUpperCase(); // normalize, then assigned
}
public boolean isZero() { return amountMinor == 0; } // derived, not stored
}There is one trap worth stating plainly because it surfaces in production. A record’s components are final, but that makes the reference final, not the object it points to. A record Team(String name, List<String> members) hands back, through its generated accessor, the very list you passed in — and a caller who mutates that list has mutated your “immutable” record from the outside. The compact constructor is where you defend, copying mutable collections into unmodifiable ones (members = List.copyOf(members)) so the record owns a snapshot rather than a shared reference. A record is shallowly immutable for free and deeply immutable only when you do this; knowing the difference is the line between a value object and a bug that looks like one.
Records shine as DTOs, query results, API request and response bodies, map keys, and the data-carrying nodes of the sealed hierarchies we turn to next. They are the wrong tool when you need mutability, when the type must join an inheritance hierarchy, or when it is really a service with behavior rather than data with a few derived views. The test is the mental model: if it is the data and nothing else, make it a record; the moment it grows a second job, make it a class.
The collections framework: where your data lives
A record is one value; almost always you have many of them, and the question becomes which container to hold them in. Java’s answer is the Collections Framework, a small set of interfaces that name the abstractions — what a collection can do — backed by a larger set of classes that name the implementations — how it does it. Programming to the interface and choosing the implementation deliberately is the whole discipline; get the pairing right and the rest of your code neither knows nor cares which concrete class it holds. The generics that make these containers type-safe — the <T> in List<T>, the <K, V> in Map<K, V> — are covered in Type Systems and Generics; here the focus is the shapes themselves and how to pick one.
Four interfaces cover the ground. A List is an ordered sequence that allows duplicates, indexed by position — the default when you just need “a bunch of things in order.” A Set is a collection with no duplicates, the type you reach for when membership is the question and order is not. A Map is a dictionary of key-to-value associations, the workhorse for lookups. A Queue (and its double-ended cousin Deque) orders elements for processing — FIFO, LIFO, or by priority. Each interface has a handful of implementations that trade the same operations against different costs:
| Interface | Implementation | Backing structure | Use when |
|---|---|---|---|
List |
ArrayList |
resizable array | the default; fast index access and iteration |
List |
LinkedList |
doubly-linked nodes | frequent insert/remove at the ends (rarely worth it) |
Set |
HashSet |
hash table | the default set; fast membership, no order |
Set |
TreeSet |
red-black tree | you need elements kept sorted |
Map |
HashMap |
hash table | the default map; fast keyed lookup, no order |
Map |
TreeMap |
red-black tree | you need keys kept sorted |
Queue |
ArrayDeque |
circular array | a stack or a FIFO queue — faster than LinkedList |
The choice is really a Big-O question, and the intuition is short. Hash-backed structures (ArrayList for indexing, HashMap/HashSet for membership) give you O(1) average access, lookup, and insertion — at the price of no ordering. Tree-backed structures (TreeMap/TreeSet) give you O(log n) for the same operations but keep everything sorted, which is the only reason to accept the slower factor. ArrayList’s one weakness is inserting or removing in the middle (O(n), because it shifts elements); LinkedList fixes that in theory but is so cache-unfriendly that ArrayList or ArrayDeque wins in practice for almost every real workload. The default toolkit is small: ArrayList, HashMap, HashSet, and ArrayDeque for a stack or queue. Reach past it only when you have a concrete reason — sorting, or a measured hot spot.
List<Money> prices = new ArrayList<>(); // ordered, allows duplicates
Set<String> currencies = new HashSet<>(); // membership, no order, no dups
Map<String, Money> bySymbol = new HashMap<>(); // keyed lookup, O(1) average
Deque<Money> undoStack = new ArrayDeque<>(); // push/pop a stack cheaply
bySymbol.put("USD", new Money(100, "USD"));
Money usd = bySymbol.get("USD"); // O(1) average retrievalThe equals()/hashCode() contract
The hash-backed collections — HashMap and HashSet, the two you’ll use most — rest on a contract that Java cannot enforce for you, and getting it wrong is one of the quietest, most expensive bugs in the language. The rule has two halves that must agree: if two objects are equals(), they must return the same hashCode(); and if you override one, you must override the other. The asymmetry is allowed in one direction only — two unequal objects may share a hash code (a “collision,” which the table handles) — but two equal objects sharing different hash codes is forbidden, because it breaks the very structure that makes a hash map fast.
Here is why it breaks, concretely. A HashMap finds a key in two steps: it computes the key’s hashCode() to pick a bucket, then walks that bucket calling equals() to find the exact entry. Put an object in under its hash code, then later override equals() to make a different instance “equal” to it but leave hashCode() computing a different value, and get() will hash to the wrong bucket and never even reach the equals() check — the entry is in the map, but containsKey returns false and get returns null. The value is there; the map simply cannot find it. A HashSet (which is a HashMap under the hood) fails the same way: duplicates you thought were excluded sneak in, because they land in different buckets.
// Broken: equals() compares the id, but hashCode() is the default identity hash.
// Two Users with the same id are equal yet hash differently — HashMap loses them.
class User {
final long id;
User(long id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof User u && u.id == id; // equal by id
}
// no hashCode() override → inherited identity hash → contract violated
}
var seen = new HashSet<User>();
seen.add(new User(1));
seen.contains(new User(1)); // false! different bucket, never compared by equalsThe fix is to derive both from the same fields: Objects.equals(...) for the comparison and Objects.hash(...) for the matching hash, over the identical set of components. This is exactly the chore that records do for you automatically — a record’s generated equals and hashCode are computed from all components together, so they cannot disagree, which is precisely why a record is the safest possible HashMap key. When you must hand-write a value class, derive both from the same fields or, better, make it a record and let the compiler keep the contract.
A subtler trap: never put a mutable object in a HashSet or use it as a HashMap key and then mutate a field that participates in its hashCode(). The object’s hash changes, but it is still filed in the bucket it had when inserted — so it becomes unreachable in the very collection that holds it. Hash keys should be immutable, which is one more argument for records and String as keys.
Sealed types and pattern matching: sum types arrive in Java
This is the centerpiece, and it is best understood as two halves of one idea. The first half is closing the hierarchy. An ordinary interface is open — anyone, anywhere, can write a new class that implements it, so the compiler can never know the full set of implementations and can never reason about completeness. A sealed interface declares its implementations explicitly, and that is the whole trick:
// "A Shape is a Circle, a Rectangle, or a Triangle — and there are no others."
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}The permits clause turns an open question — what are all the shapes? — into a closed fact the compiler can hold; no code outside this file can add a Shape. Pairing sealed interfaces with records is the idiom, because the variants are almost always pure data, and a closed set of data-carrying cases is exactly the definition of an algebraic sum type — the construct Rust, Haskell, and Scala have used for decades to make illegal states unrepresentable. Java, the conservative enterprise workhorse, now has it.
The second half is consuming the closed set exhaustively. Because the compiler knows the complete list of shapes, a switch over a Shape can be checked for completeness, and the type pattern in each branch does the cast for you:
static double area(Shape shape) {
return switch (shape) { // an expression: it yields a value
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// no default — and that absence is the point
};
}There is no default, and that is deliberate. The compiler verifies that the three branches cover every permitted subtype of Shape — if they do, the switch compiles; if they don’t, it doesn’t. A default branch would suppress that check by absorbing the missing cases, which is exactly why you omit it: a default over a sealed type silently swallows the case you forgot, turning a compile-time guarantee back into a runtime surprise. Leave it out, and the compiler becomes a to-do list the build enforces.
Now run the chapter’s opening scenario. Someone adds a fourth shape — record Pentagon(double side) implements Shape {} — and updates permits. Every exhaustive switch over Shape in the codebase immediately stops compiling, each one pointing at the exact spot a Pentagon case is missing. This is the inversion that makes the feature worth the chapter: the old instanceof-cast ladder with its trailing else would have compiled cleanly and sent every Pentagon down the else path at runtime — a bug that ships. The sealed-plus-exhaustive-switch version cannot ship that bug, because it cannot build. Figure 19.1 traces this loop: the sealed interface permits the closed set, the switch must cover it, and a new variant detonates a compile error everywhere it isn’t handled.
Pattern matching goes one level deeper with record deconstruction, which matches the shape and pulls the components out in one move, so you skip the accessor calls entirely:
static String describe(Shape shape) {
return switch (shape) {
case Circle(double r) -> "circle, radius " + r;
case Rectangle(double w, double h) -> "rectangle " + w + " by " + h;
case Triangle(double b, double h) -> "triangle, base " + b;
};
}A case Circle(double r) tests that the value is a Circle and binds its radius to r in one step. Because records expose their components transparently, the compiler knows the deconstruction is exact — and the pattern nests, so a case Line(Point(var x1, var y1), Point(var x2, var y2)) reaches straight into a two-level structure. This is the destructuring functional languages built their style around, now in Java’s syntax. The pattern’s natural home is anything that is “one of a fixed set of cases”: domain events, abstract-syntax-tree nodes, state-machine states, and the success/failure result type that replaces throwing for control flow.
A payments service routed transactions with a chain everyone had stopped reading: test for CardPayment, else BankTransfer, else Wallet, else a trailing log.warn("unknown payment type"). It had grown one else if at a time and worked for years. Then a new team shipped a CryptoPayment, wired it through ingestion, and deployed. Nothing broke — which was the problem. Every CryptoPayment fell through to the trailing else, logged a warning nobody was alerting on, and was silently dropped: accepted at the edge, never settled. The gap surfaced a week later as a reconciliation discrepancy, after real money had gone missing in the books. The root cause was not the missing branch; it was that the type system had no way to require the branch. The fix was structural: sealed interface Payment permits CardPayment, BankTransfer, Wallet, CryptoPayment, with every routing switch made exhaustive and default-free. Re-run against the new type, the project no longer compiled — it pointed at all eleven switches that had quietly ignored crypto. The compiler found in two seconds what production had hidden for a week. The lesson travels with the feature: over a sealed type, a default branch is a liability, because it converts a compile error you’d have to fix into a runtime bug you might never see.
Switch expressions and instanceof: the cast ladder retires
The exhaustiveness above rides on two smaller upgrades worth naming, because they improve even code that has nothing to do with sealed types. The first is the switch expression. The old switch was a statement: it acted via side effects, each case fell through to the next unless you remembered break, and a forgotten break was a classic bug. The arrow form is an expression: it produces a value, each branch is isolated with no fall-through, and because it can be assigned, the compiler can require it to be exhaustive even for an ordinary enum. The shift from “do something” to “compute a value” is the same shift records make for data — less ceremony, fewer ways to be subtly wrong.
The second is pattern matching for instanceof, which kills the cast. The old idiom tested the type, cast the same reference to it, then used it — three steps, the middle one pure ceremony, the cast able to drift out of sync with the test:
// Old: test, cast, use — the cast is redundant ceremony the compiler could do.
if (obj instanceof String) {
String s = (String) obj;
if (s.length() > 5) return s.toUpperCase();
}
// Modern: test-bind-and-guard in one expression.
if (obj instanceof String s && s.length() > 5) {
return s.toUpperCase(); // s is in scope and already typed
}The pattern obj instanceof String s does the test and, if it passes, binds the already-cast value to s, then in scope for the rest of the condition and the body. The && guard reads naturally because s exists by the time it’s evaluated, and there is no cast to keep in sync with the test — one fewer place for the two to disagree. Between the switch expression and instanceof patterns, the instanceof-and-cast ladder — the single most recognizable shape of “old Java” — has no reason to exist anymore.
The smaller niceties: var, text blocks, enhanced enums
A handful of smaller features round out the modern feel, and the discipline with each is to use it where it clarifies and resist it where it obscures. Local variable type inference — var — lets the compiler infer a local’s type from its initializer: var users = new HashMap<String, List<Order>>(); says once what the old form said twice. It is purely local and changes nothing about Java’s static typing; the variable still has a fixed compile-time type. The judgment call is readability. When the right-hand side names the type — var customer = new Customer(...) — var is pure win. When it hides the type — var result = service.process(input) — the reader must chase process’s signature to know what they’re holding, and you should have written the type. Use var when it removes redundancy, not when it removes information.
Text blocks end the era of escaping every quote in an embedded SQL query or JSON payload. A """-delimited multi-line string preserves layout and strips the common leading indentation, so the embedded content reads as itself:
String query = """
SELECT id, amount_minor, currency
FROM payments
WHERE status = 'SETTLED'
"""; // no \n, no \" — just the SQLAnd enhanced enums deserve a mention because Java enums were always more than named constants — each constant can carry fields and methods, so an enum models a small closed set with behavior (an HttpStatus that knows its code and category, a Planet that knows its mass and computes surface gravity). For a fixed set of singletons with behavior, an enum fits; for a fixed set of data-carrying cases, reach for the sealed interface from the previous section. The two are cousins, and knowing which closed-set tool fits is part of writing idiomatic modern Java.
The JVM platform: the part that endures
Step back from syntax, because the syntax is not why Java has lasted. Underneath every feature in this chapter sits the Java Virtual Machine, the real, durable asset. javac does not compile source to machine code; it compiles to bytecode, a compact, portable instruction set for an abstract stack machine. That bytecode runs on the JVM, and the JVM is what’s installed on the target — Linux server, Windows desktop, an ARM Mac — so the same compiled artifact runs unchanged everywhere. “Write once, run anywhere” was a 1990s slogan, but in the cloud-native era it became something more valuable: the JAR your laptop ran is byte-for-byte the artifact that ships, and the platform difference is the JVM’s problem, not yours.
The JVM does not merely interpret that bytecode; it profiles it. The JIT compiler (HotSpot) watches the program run, finds the hot methods, and compiles them to optimized native code on the fly — inlining, pruning dead branches, even speculating from the types it has actually observed and de-optimizing if an assumption breaks. A long-running Java service gets faster after warm-up, because the compiler has real runtime data an ahead-of-time compiler never sees. This is why Java is a throughput champion for sustained server workloads despite slow cold start: the model is built for the program that runs for months, not the function that runs for fifty milliseconds. And garbage collection completes the platform — modern collectors (G1 by default, the low-pause ZGC and Shenandoah) manage multi-gigabyte and multi-terabyte heaps at single-digit-millisecond pauses, automatically, while production-grade observability (JFR and a deep profiling ecosystem) lets you see inside a running JVM as few runtimes can. Records and sealed types make the code pleasant; the JVM is what makes the resulting service something you can run, scale, and debug for a decade.
Virtual threads: a glance, then a handoff
One Java 21 feature reshapes concurrency enough to name here, though its depth belongs to its own chapter. For its whole history, a Java thread was a thin wrapper over a heavyweight OS thread — about a megabyte of stack apiece — so you could not afford one per request and had to share a small pool, which dragged in thread-pool tuning, queueing, and the callback-heavy asynchronous style people adopted to avoid blocking those scarce threads. Virtual threads (Project Loom) break that constraint: they are lightweight threads the JVM schedules onto a small set of OS threads, cheap enough to create millions of, so you can finally write one thread per task in plain, blocking, top-to-bottom style and let the runtime do the multiplexing.
// One virtual thread per task. Blocking calls inside are fine — and cheap.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
requests.forEach(req -> executor.submit(() -> handle(req)));
} // close() waits for every task to finishFor I/O-bound services — the overwhelming majority of backend Java — this is a large simplification: straightforward blocking code that scales like the asynchronous version without its cognitive cost. The thread-per-task model that was a performance trap for twenty-five years is suddenly the right answer again. Exactly when virtual threads help, where they don’t (CPU-bound work is unchanged), and how they pair with structured concurrency are the concurrency chapter’s subject; for now, know that the constraint that shaped a generation of Java concurrency idioms is gone.
Practical exercise
Difficulty: Level I · Level II · Level III
Level I — Delete the boilerplate. Take a verbose POJO (or write one): a value object with five fields, hand-written constructor, getters,
equals,hashCode, andtoString. Replace it with a single-line record and confirm behavior is identical — equality, hashing, printing. Then find aninstanceof-and-cast ladder nearby and rewrite it with pattern matching forinstanceof. Count the lines you removed, and identify the one place where the old cast could have drifted out of sync with the old test and the new version cannot.Level II — Model a domain as a sum type. Pick a small closed domain — an
Exprarithmetic tree (Num,Add,Mul), aResulttype (Success,Failure), or an order lifecycle (Pending,Shipped,Delivered,Cancelled) — and model it as asealed interfacewithrecordimplementations. Write an exhaustive pattern-matchingswitchover it with nodefault. Then add a new variant, updating onlypermits, and run the compiler: confirm it points you at every switch you must update. Write down what the compiler just did for you that a comment or a code-review checklist would not have.Level III — Redesign a state machine, and argue the guarantee. Take a state machine modeled as a class with an
int/enumstate field and atransition()method full of conditionals on that field — a connection lifecycle, a document workflow, a game phase. Redesign it with sealed types: each state a record carrying exactly the data valid in that state (so aConnectedholds the socket and aDisconnectedcannot, making illegal field access unrepresentable), and transitions as exhaustive pattern-matching switches returning the next state. Then write a short argument enumerating what the compiler now guarantees that the old field-plus-conditionals design did not — exhaustiveness, state-specific data validity, the impossibility of a forgotten case — and name the class of bug each guarantee eliminates.
Summary
Modern Java is a smaller, safer language than its reputation, and the gap is mostly a decade out of date. Records collapse an immutable data class to its essential declaration and derive the rest, so the boilerplate that used to drift can no longer exist. Sealed interfaces close a type hierarchy, and a closed hierarchy is the precondition for pattern-matching switch to be exhaustive — turning a sealed hierarchy of records into an algebraic sum type where adding a case is a compile error in every switch that forgot it, not a silent runtime bug. Switch expressions and instanceof patterns retire the fall-through switch and the cast ladder; var, text blocks, and enhanced enums trim the rest. Beneath all of it, the JVM — bytecode portability, a profiling JIT, world-class GC, deep observability — is the enduring reason to choose Java, and virtual threads remove the last big constraint on how its concurrency is written. The verbosity was a tax on a strong platform; modern Java removed the tax and kept the platform.
Key takeaways
- A record is the data and nothing else: declare the components once, get constructor, accessors,
equals,hashCode, andtoStringderived — and use a compact constructor to validate and to defensively copy mutable collections, since records are only shallowly immutable for free. - Sealed interface + records + pattern-matching
switchis a compiler-checked sum type. Omit thedefaultso the compiler proves exhaustiveness; a new variant then becomes a compile error everywhere it isn’t handled. - Over a sealed type, a
defaultbranch is a liability — it converts a compile error you must fix into a runtime bug you may never see. - Switch expressions and
instanceofpatterns eliminate fall-through bugs and the redundant cast, the most recognizable shapes of “old Java.” - The JVM — portable bytecode, a profiling JIT that makes long-running services faster, and best-in-class GC — is the durable reason to pick Java; virtual threads bring cheap, blocking-style concurrency back.
Connections to other chapters
- The Polyglot Landscape frames where Java sits — the middle band of managed, statically-typed runtimes, trading cold-start time and memory floor for sustained throughput, tooling depth, and a vast ecosystem. The “when modern Java fits” decision here is one slice of that broader map.
- Type Systems and Generics is where these features get their type-system footing: records and sealed types are generic-aware (a
sealed interface Result<T, E>withSuccessandFailurerecords is the canonical example), and that chapter places Java’s generics alongside the other languages’ answers to parametric typing. - Java: Streams & Functional composes directly with this chapter — records are the natural elements of a stream pipeline, and pattern matching is how you branch on the results; the functional style and these data features were designed to reinforce each other.
- Concurrency and Parallelism Models gives virtual threads the full treatment this chapter defers — the scheduling model, structured concurrency, when thread-per-task helps and when it doesn’t — and sets them against how the other five languages model concurrency.
- Python: Advanced Language Features and Go: Fundamentals are the instructive contrasts: Python’s dataclasses chase the same “data and nothing else” goal at runtime rather than compile time, and Go’s structs-with-no-inheritance and absence of sum types throw Java’s sealed-type exhaustiveness into relief — three languages, three answers to “how do you model a closed set of data shapes?”
Further reading
Essential
- Bloch, Effective Java (3rd ed.) — the canonical guide to idiomatic Java; even pre-records, its items on immutability, value objects, and minimizing mutability are the philosophy records make automatic.
- Urma, Fusco & Mycroft, Modern Java in Action — broad, current coverage of the Java 8→21 feature set, including streams, lambdas, and the data-oriented features here.
Deep dives
- JEP 395: Records and JEP 409: Sealed Classes — the design rationale, in the authors’ own words, for the two centerpiece features.
- JEP 441: Pattern Matching for switch and JEP 440: Record Patterns — how exhaustiveness checking and record deconstruction were specified and why.
- JEP 444: Virtual Threads — the final Loom specification and its model of cheap, JVM-scheduled threads.
Historical context
- Project Amber overview — the umbrella effort (records, sealed types, pattern matching, switch expressions) that deliberately reshaped Java toward data-oriented programming, and the design notes explaining the algebraic-data-type endpoint.
- Project Loom overview — the long arc of making threads cheap on the JVM, and why the thread-per-request model was abandoned and is now restored.