Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Modern Java 9-21: Records, Sealed Types, Pattern Matching

What actually changed after Java 8 and why it matters in an interview: var, records, sealed interfaces, pattern matching for switch, text blocks, the module system and virtual threads.

Intermediate7 min readUpdated
On this page

Java 8 changed how code is written; Java 9 through 21 changed what it is possible to express. An interviewer asking "what have you used since Java 8?" is really asking whether you have kept up. This is the working answer.

Key Takeaways

  • Records (16) are transparent immutable data carriers with generated equals, hashCode, toString and accessors.
  • Sealed types (17) close a hierarchy, which lets switch be checked for exhaustiveness.
  • Pattern matching for instanceof (16) and switch (21), plus record deconstruction, replaces the visitor pattern for most cases.
  • Virtual threads (21) make blocking cheap — a million threads instead of a pool of two hundred.
  • Sequenced collections (21) finally give List, Set and Map a common getFirst/getLast.

Records

the whole class
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
 
    // Compact constructor: validation and normalisation, before assignment.
    public Money {
        Objects.requireNonNull(currency);
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
        }
    }
 
    // Extra behaviour is allowed; extra *state* is not.
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(amount.add(other.amount), currency);
    }
 
    @Override public int compareTo(Money o) { return amount.compareTo(o.amount); }
 
    // Static factory alongside the canonical constructor.
    public static Money gbp(String amount) { return new Money(new BigDecimal(amount), GBP); }
}

The compiler generates the canonical constructor, an accessor per component (amount(), not getAmount()), and equals, hashCode and toString. A record is implicitly final, cannot extend another class, and cannot declare additional instance fields.

The interview point beyond the syntax: a record is shallowly immutable. A List component is still mutable unless the compact constructor copies it — see Immutable objects and defensive copying.

Sealed types and exhaustive switch

a closed hierarchy
public sealed interface PaymentResult
        permits Approved, Declined, RequiresAction { }
 
public record Approved(String authCode, Money captured)      implements PaymentResult { }
public record Declined(String reason, boolean retryable)     implements PaymentResult { }
public record RequiresAction(URI redirect)                   implements PaymentResult { }

sealed tells the compiler the complete list of subtypes. That single fact unlocks exhaustiveness:

no default branch needed
String describe(PaymentResult result) {
    return switch (result) {
        case Approved(String code, Money amount) -> "approved " + amount + " (" + code + ")";
        case Declined(String reason, boolean retryable) when retryable -> "retry: " + reason;
        case Declined(String reason, var ignored) -> "declined: " + reason;
        case RequiresAction(URI redirect) -> "redirect to " + redirect;
        // No default. Add a fourth subtype and THIS METHOD STOPS COMPILING.
    };
}

That last comment is the whole value. With a default branch or an if/else chain, adding a case compiles fine and silently takes the fallback path at runtime. With a sealed hierarchy and an exhaustive switch, the compiler lists every place that needs updating.

The permitted subtypes must be in the same module, or the same package for an unnamed module, and each must be declared final, sealed or non-sealed.

Pattern matching

three generations of the same code
// Java 8
if (obj instanceof String) {
    String s = (String) obj;
    if (s.length() > 5) { ... }
}
 
// Java 16 — pattern matching for instanceof
if (obj instanceof String s && s.length() > 5) { ... }
 
// Java 21 — pattern matching for switch, with record deconstruction and guards
String format(Object obj) {
    return switch (obj) {
        case null              -> "nothing";
        case Integer i when i < 0 -> "negative " + i;
        case Integer i         -> "number " + i;
        case String s          -> "text of length " + s.length();
        case Money(var amount, var currency) -> currency + " " + amount;
        case int[] arr         -> "int array of " + arr.length;
        default                -> obj.getClass().getSimpleName();
    };
}

Three things to note for the interview. case null is now expressible — previously a switch on a null selector always threw. Guards use when, not &&. And record deconstruction binds components directly, which is what makes this a genuine replacement for the visitor pattern rather than a nicer cast.

Switch expressions and text blocks

switch as an expression (Java 14)
int days = switch (month) {
    case FEB -> isLeap ? 29 : 28;
    case APR, JUN, SEP, NOV -> 30;
    default -> 31;
};
 
// yield for a multi-statement branch
int score = switch (grade) {
    case "A" -> 100;
    case "B" -> { int base = 80; yield base + bonus; }
    default -> 0;
};

The arrow form has no fall-through, must be exhaustive when used as an expression, and returns a value. The old colon form still works and still falls through.

text blocks (Java 15)
String query = """
        SELECT o.id, o.total, c.name
        FROM orders o
        JOIN customers c ON c.id = o.customer_id
        WHERE o.placed_at >= ?
        """;

Incidental leading whitespace is stripped based on the least-indented line, including the closing delimiter — which is why the closing """ position controls the indentation. \ at end of line suppresses the newline; \s preserves a trailing space.

Virtual threads

Java 21 — the executor is the only change
// Platform threads: bounded pool, ~1MB stack each, expensive to create.
ExecutorService old = Executors.newFixedThreadPool(200);
 
// Virtual threads: one per task, a few hundred bytes, created freely.
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Order order : tenThousandOrders) {
        exec.submit(() -> {
            enrich(order);           // blocking I/O — the virtual thread unmounts
            persist(order);          // and the carrier thread runs something else
            return null;
        });
    }
}   // close() waits for all tasks

A virtual thread is scheduled by the JVM onto a small pool of carrier (platform) threads. When it blocks on I/O it unmounts, freeing the carrier immediately. The result is that ordinary blocking, request-per-thread code scales like asynchronous code, without the callback structure.

Two caveats worth stating, because they are the interesting part of the answer: a virtual thread pins its carrier inside a synchronized block (largely fixed in Java 24, but still true on 21), so hot locks should be ReentrantLock; and thread-locals still work but are no longer nearly free when there are a million threads. Full treatment in Virtual threads and structured concurrency.

The rest, in one pass

VersionFeatureWhy it matters
9JPMS modulesStrong encapsulation; the reason --add-opens exists
9Collection factoriesList.of, Map.of — immutable, null-hostile
9Stream.takeWhile / dropWhile / ofNullableFills real gaps in the stream API
10varLocal inference only; not var fields, not parameters
11HttpClient, String.strip, isBlank, linesRemoved the need for Apache Commons in most code
12Collectors.teeingTwo collectors, one traversal
14Helpful NullPointerExceptionsThe message now names the expression that was null
15Text blocksMulti-line SQL and JSON without escaping
16Records, instanceof patterns, Stream.toList()
17Sealed classes, LTSSpring Boot 3's floor
21Virtual threads, pattern matching for switch, sequenced collections, LTS

Sequenced collections (21) are the small one people appreciate most: List, Deque, LinkedHashSet and LinkedHashMap now share getFirst(), getLast(), addFirst(), addLast() and reversed(). Before this, getting the last element of a LinkedHashSet required a full iteration.

Helpful NPEs (14, on by default since 15) changed debugging materially: instead of NullPointerException at a line with four dereferences, the message reads "Cannot invoke String.length() because the return value of Order.customer() is null".

How to answer "what have you used since Java 8?"

Pick three, and say what each one removed from your code. For example: records replaced hand-written value classes and their generated equals; text blocks removed the escaped-newline SQL strings; var removed the duplicated type on the left of every new. Then mention one you have deliberately not adopted and why — that answer is far more credible than a feature list.

Frequently Asked Questions

Which Java version should a new project target?
Java 21, the current long-term-support release, unless a dependency blocks it. It brings virtual threads, pattern matching for switch, sequenced collections and record patterns, all as final features. Java 17 remains a reasonable floor because Spring Boot 3 requires it, but nothing in 21 forces a migration cost worth avoiding — the language changes are additive.
Are records just a shortcut for a POJO?
They are a distinct kind of type with semantics attached. A record is implicitly final, its fields are final, and the compiler generates a canonical constructor, accessors, equals, hashCode and toString from the components. It also participates in pattern matching through record deconstruction, which a hand-written class cannot. The intent is a transparent carrier of immutable data, not a general class shorthand.
Do virtual threads make reactive programming unnecessary?
For most request-per-thread server code, largely yes: blocking a virtual thread is cheap, so the main reason to write reactive chains — not wasting an OS thread while waiting for I/O — disappears. Reactive still wins where you genuinely need backpressure across a streaming pipeline or a non-blocking pull model. But "we went reactive for throughput" is now often solvable by switching the executor.

Related tutorials