Skip to content
Commitwise
Menu
Java Backend

Modern Java: records, sealed types and virtual threads

Write today's Java: records for data, sealed interfaces and pattern matching instead of instanceof chains, virtual threads for simple blocking code that scales, and the smaller Java 17-25 features worth using, with Kotlin side by side.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

If the Java you learned had getters, setters, instanceof casts and thread pools sized by guesswork, the language has moved on. Java 21 and 25 (both long-term-support releases) let you model data in one line, have the compiler check that you handled every case, and write plain blocking code that handles tens of thousands of concurrent requests. Most backend codebases are still catching up, which makes knowing these features a real advantage.

New to some of these? Open what you need, or skip ahead.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Part Not started

Records and sealed types

A shipping label has a fixed set of fields (name, street, city) and you never edit one in place; you print a new label. A record is a class for exactly that kind of data: you list the fields once, and Java writes the constructor, the accessors, equals, hashCode and toString.

A sealed interface is a closed list: “a payment result is either Approved, Declined or NeedsVerification, and nothing else”. Because the list is closed, the compiler can check that your code handles every case.

Records are transparent, shallowly immutable carriers with value-based equals/hashCode derived from components, canonical and compact constructors for validation, and deconstruction via record patterns. Sealed hierarchies (sealed … permits) give the compiler a closed set of subtypes, which enables exhaustive switch over types without default. Combined, they’re Java’s algebraic data types: model domain states as data, dispatch with pattern matching, and get a compile error when a new case appears. Kotlin has had the same with data classes and sealed classes; the semantics map almost one to one.

sealed interface PaymentResult
data class Approved(val transactionId: String, val amountCents: Long) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data class NeedsVerification(val redirectUrl: URI) : PaymentResult

data class Money(val cents: Long, val currency: String) {
    init { require(cents >= 0) { "Negative amount" } }
}

fun message(result: PaymentResult): String = when (result) {   // no else: the compiler checks all cases
    is Approved -> "Paid, reference ${result.transactionId}"
    is Declined -> "Declined: ${result.reason}"
    is NeedsVerification -> "Please verify at ${result.redirectUrl}"
}
public sealed interface PaymentResult permits Approved, Declined, NeedsVerification {}

public record Approved(String transactionId, long amountCents) implements PaymentResult {}
public record Declined(String reason) implements PaymentResult {}
public record NeedsVerification(URI redirectUrl) implements PaymentResult {}

public record Money(long cents, String currency) {
    public Money {                                         // compact constructor: validation only
        if (cents < 0) throw new IllegalArgumentException("Negative amount");
        currency = currency.toUpperCase();
    }
}

String message(PaymentResult result) {
    return switch (result) {                                // no default: the compiler checks all cases
        case Approved a -> "Paid, reference " + a.transactionId();
        case Declined d -> "Declined: " + d.reason();
        case NeedsVerification(URI url) -> "Please verify at " + url;   // record pattern
    };
}
  • record Money(long cents, String currency) gives you new Money(950, "eur"), money.cents(), money.currency(), and equals/hashCode/toString based on both fields. Fields are final: to “change” a record, create a new one.
  • The compact constructor (public Money { … }) runs before the fields are assigned: validate and normalize there.
  • sealed … permits lists every allowed implementation. In a switch over a PaymentResult, the compiler knows there are exactly three cases, so you don’t need (and shouldn’t add) default. Add a fourth result type later, and every switch that forgot it stops compiling.
  • Record patterns (case NeedsVerification(URI url)) take the record apart right in the case.
Spot the bugA cache that never hits

Tap the line with the bug.

data class AvatarKey(val userId: String, val fingerprint: ByteArray)

val cache = ConcurrentHashMap<AvatarKey, Image>()

fun avatar(userId: String, imageBytes: ByteArray): Image {
    val key = AvatarKey(userId, sha256(imageBytes))
    return cache.computeIfAbsent(key) { resize(imageBytes) }
}
record AvatarKey(String userId, byte[] fingerprint) {}

Map<AvatarKey, Image> cache = new ConcurrentHashMap<>();

Image avatar(String userId, byte[] imageBytes) {
    var key = new AvatarKey(userId, sha256(imageBytes));
    return cache.computeIfAbsent(key, k -> resize(imageBytes));
}

Before and after

  1. A Java 8 DTO was a class with private fields, a constructor, getters, equals, hashCode and toString: about 50 lines for 4 fields.
  2. A record is one line; add a compact constructor for validation.
  3. An instanceof chain with casts (if (r instanceof Approved) { Approved a = (Approved) r; … }) becomes a switch with type patterns.
  4. A default branch that throws “unknown type” disappears; the sealed interface makes it unnecessary and the compiler checks completeness.

What records are not

  • Not deeply immutable: a List component can be mutated by whoever holds it; copy in the compact constructor (items = List.copyOf(items)). See Immutability.
  • Not JPA entities: entities need a no-arg constructor, mutable state and identity semantics; records fit DTOs, projections (SELECT new …, Spring Data interface/record projections) and value objects (@Embeddable records are supported since Hibernate 6.2).
  • Not extensible: records are final and can’t extend classes, which is the point.
  • Jackson and records: supported out of the box (canonical constructor); Kotlin data classes need jackson-module-kotlin.
  • Records: final, shallowly immutable, component-based equals/hashCode/toString; compact constructors validate/normalize; record patterns deconstruct. Arrays and mutable collections inside break value semantics (copy, or use value types).
  • Sealed interfaces + pattern switch (Java 21) = exhaustive, compiler-checked dispatch; avoid default so new subtypes break the build.
  • Use records for DTOs, projections, value objects, events; not as JPA entities.
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape

fun area(s: Shape) = when (s) {
    is Circle -> Math.PI * s.r * s.r
    is Rect -> s.w * s.h
}
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}

double area(Shape s) {
    return switch (s) {
        case Circle(var r) -> Math.PI * r * r;
        case Rect(var w, var h) when w == h -> w * w;   // guard
        case Rect(var w, var h) -> w * h;
    };
}

Check this part

  1. What does a Java record generate?

  2. Why leave out default in a switch over a sealed interface?

  3. A record has a byte[] component and is used as a HashMap key. What happens?

Records are one-line immutable data classes with equals, hashCode and toString. Sealed interfaces list every possible subtype, and switch with pattern matching handles them all, checked by the compiler. Together they replace getter boilerplate and instanceof chains.

Part Not started

Virtual threads

A restaurant with 20 waiters can serve 20 tables at once, but most of the time a waiter just stands and waits: for the kitchen, for the customer to choose. Now imagine every table gets its own “virtual” waiter who only needs a real person while actually doing something, and gives the real person back while waiting. A few real waiters can then serve hundreds of tables.

Java’s virtual threads work like that. Classic (platform) threads are expensive, so servers keep a small pool and requests wait in line when it’s full. Virtual threads are cheap: you can have one per request, even a million, and while one waits for the database or another service, the real OS thread underneath runs a different one.

Virtual threads (Java 21) are user-mode threads scheduled by the JVM onto a small pool of carrier (platform) threads. Blocking operations in the JDK (sockets, Thread.sleep, java.util.concurrent locks, JDBC drivers that use them) unmount the virtual thread instead of blocking the carrier, so thread-per-request code gets the scalability of async I/O without callbacks or reactive types. They don’t speed up CPU-bound work, they aren’t meant to be pooled, and since Java 24 (JEP 491) synchronized no longer pins the carrier; native calls and some file I/O still do.

// Kotlin usually uses coroutines for the same job:
suspend fun loadAll(ids: List<String>): List<Product> = coroutineScope {
    ids.map { id -> async { catalogClient.fetch(id) } }.awaitAll()
}

// Kotlin on the JVM can also use virtual threads directly:
fun loadAllBlocking(ids: List<String>): List<Product> =
    Executors.newVirtualThreadPerTaskExecutor().use { executor ->
        ids.map { id -> executor.submit<Product> { catalogClient.fetchBlocking(id) } }.map { it.get() }
    }
List<Product> loadAll(List<String> ids) throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Product>> futures = ids.stream()
                .map(id -> executor.submit(() -> catalogClient.fetch(id)))   // plain blocking call
                .toList();
        List<Product> products = new ArrayList<>();
        for (Future<Product> f : futures) products.add(f.get());
        return products;
    }                                                                         // waits for all tasks
}

// Spring Boot 3.2+: serve every request on a virtual thread
// application.yml → spring.threads.virtual.enabled: true

The code looks exactly like ordinary blocking Java: call the service, wait for the answer. There’s no callback and no special return type. The difference is that each task runs on its own virtual thread, so 1,000 product lookups run concurrently, using only a handful of real threads while they wait for the network. In Spring Boot, one property puts every incoming request on its own virtual thread.

Predict the output

10,000 tasks each wait 1 second (simulating a slow service). How long does running them all take, with a fixed pool of 200 platform threads, and with virtual threads?

Runnable task = () -> {
    try { Thread.sleep(Duration.ofSeconds(1)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};

try (var pool = Executors.newFixedThreadPool(200)) {
    for (int i = 0; i < 10_000; i++) pool.submit(task);
}

try (var virtual = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) virtual.submit(task);
}

Production puzzles

Spot the bugA partner API that bans you

Tap the line with the bug.

fun notifyPartner(orders: List<Order>) {
    Executors.newVirtualThreadPerTaskExecutor().use { executor ->
        for (order in orders) {
            executor.submit { partnerApi.notifyShipped(order) }
        }
    }
}
void notifyPartner(List<Order> orders) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (Order order : orders) {
            executor.submit(() -> partnerApi.notifyShipped(order));
        }
    }
}

Things that still bite

  • ThreadLocal-heavy code (some logging MDC setups, old libraries caching per thread) allocates per virtual thread; with a million threads that’s memory. Scoped values (final in Java 25) are the replacement for passing context.
  • Pinning: before Java 24, blocking inside synchronized pinned the carrier and could starve the scheduler; on Java 21 replace hot synchronized blocks around I/O with ReentrantLock. Native/JNI calls still pin. -Djdk.tracePinnedThreads (21–23) and JFR events help find it.
  • CPU-bound work gets nothing from virtual threads; keep a bounded platform pool (or ForkJoinPool) for it.
  • Virtual threads: JVM-scheduled, cheap, unmount on blocking JDK I/O; thread-per-request scales without reactive code. Executors.newVirtualThreadPerTaskExecutor(), spring.threads.virtual.enabled=true.
  • Don’t pool them; limit downstream concurrency with semaphores (or connection pool sizes).
  • Pinning: synchronized fixed in Java 24 (JEP 491); native calls still pin. Prefer scoped values over heavy ThreadLocal. No gain for CPU-bound work.
  • Kotlin: coroutines cover the same ground with structured concurrency; virtual threads still help with blocking Java libraries.

Check this part

  1. What makes virtual threads useful for a backend?

  2. Does switching to virtual threads speed up CPU-heavy work like image resizing?

  3. After moving a job to virtual threads, a partner API starts returning 429. Why, and the fix?

Virtual threads are cheap threads managed by the JVM: blocking on I/O frees the underlying OS thread, so simple one-thread-per-request code scales to many thousands of concurrent tasks. They don't make CPU work faster, and they make it easy to overload whatever you call, so limit concurrency to downstream systems.

Part Not started

Everyday improvements

// Kotlin has had most of these from the start:
val byStatus = HashMap<String, List<Order>>()
val sql = """
    SELECT id, total
    FROM orders
    WHERE customer_id = ?
""".trimIndent()
val ids = orders.map { it.id }
val last = orders.last()
val label = when (status) {
    "NEW" -> "New"
    "PAID" -> "Paid"
    else -> "Unknown"
}
// Then
Map<String, List<Order>> byStatus = new HashMap<String, List<Order>>();
String sql = "SELECT id, total\n" +
             "FROM orders\n" +
             "WHERE customer_id = ?";
List<String> ids = orders.stream().map(Order::id).collect(Collectors.toList());
Order last = orders.get(orders.size() - 1);
String label;
switch (status) {
    case "NEW": label = "New"; break;
    case "PAID": label = "Paid"; break;
    default: label = "Unknown";
}

// Now
var byStatus = new HashMap<String, List<Order>>();         // var (10)
String sql = """
        SELECT id, total
        FROM orders
        WHERE customer_id = ?
        """;                                                // text block (15)
List<String> ids = orders.stream().map(Order::id).toList(); // unmodifiable (16)
Order last = orders.getLast();                              // sequenced collections (21)
String label = switch (status) {                            // switch expression (14)
    case "NEW" -> "New";
    case "PAID" -> "Paid";
    default -> "Unknown";
};

Each of these removes a small, common source of mistakes: a forgotten break in a switch, a missing \n in a long SQL string, an off-by-one in size() - 1. They’re also what interviewers and reviewers expect to see in Java written today. Note one difference: Stream.toList() returns an unmodifiable list, while Collectors.toList() returned an ArrayList that callers could change.

Long-term-support releases are what production runs: 17 (2021), 21 (2023), 25 (2025). Java 25 finalizes compact source files and instance main (void main() { … }, useful for scripts and teaching), module import declarations (import module java.base;), flexible constructor bodies (statements before super(…), e.g. validating arguments) and scoped values. Spring Boot 3.x requires 17+, Boot 4 baselines on 17 and targets 25; there’s little reason to start a new service on anything below 21.

Exerciseeasy

Modernize a class

Rewrite this Java 8 code with records, a sealed interface, a switch expression and toList():

public class Notification {
    public static final int EMAIL = 1, SMS = 2, PUSH = 3;
    private final int type;
    private final String target;
    private final String text;
    public Notification(int type, String target, String text) { this.type = type; this.target = target; this.text = text; }
    public int getType() { return type; }
    public String getTarget() { return target; }
    public String getText() { return text; }
}

static String describe(Notification n) {
    if (n.getType() == Notification.EMAIL) return "Email to " + n.getTarget();
    else if (n.getType() == Notification.SMS) return "SMS to " + n.getTarget();
    else if (n.getType() == Notification.PUSH) return "Push to device " + n.getTarget();
    throw new IllegalArgumentException("Unknown type " + n.getType());
}

static List<String> describeAll(List<Notification> list) {
    return list.stream().map(Main::describe).collect(Collectors.toList());
}
›

Each notification type has different data (an email address, a phone number, a device token). That’s a sealed interface with one record per type.

SolutionTry it yourself first
public sealed interface Notification permits Email, Sms, Push {
    String text();
}
public record Email(String address, String text) implements Notification {}
public record Sms(String phone, String text) implements Notification {}
public record Push(String deviceToken, String text) implements Notification {}

static String describe(Notification n) {
    return switch (n) {
        case Email e -> "Email to " + e.address();
        case Sms s -> "SMS to " + s.phone();
        case Push p -> "Push to device " + p.deviceToken();
    };
}

static List<String> describeAll(List<Notification> list) {
    return list.stream().map(Main::describe).toList();
}

The int constants could hold any number, so the old version needed a “throw on unknown type” branch. Now an unknown type can’t exist, and adding WhatsApp means a new record and compile errors at every switch that must handle it. Each type has fields with honest names instead of a generic target.

Exercisemedium

Plan an upgrade from Java 11 to 25

A Spring Boot 2.7 service runs on Java 11. You want Java 25 and Spring Boot 4, with virtual threads. Write the plan, in order, with the risks at each step.

›

Spring Boot 3 moved from javax.* to jakarta.* and requires Java 17. Jump the framework and the JDK in steps you can verify separately.

SolutionTry it yourself first
  1. Inventory: dependencies and their minimum Java/Jakarta versions (./mvnw versions:display-dependency-updates), uses of removed or encapsulated JDK internals (jdeps --jdk-internals), and test coverage of critical paths.
  2. Java 17 on Boot 2.7: upgrade the JDK first; fix illegal reflective access (strong encapsulation since 17), update Lombok/Mockito/ByteBuddy/Jacoco. Deploy.
  3. Spring Boot 3.x: javax.* → jakarta.* (OpenRewrite recipes do most of it), Hibernate 6 query and type changes, Spring Security 6 configuration (lambda DSL, requestMatchers), Actuator/metrics changes (Micrometer Observation). Run the full suite plus contract tests. Deploy.
  4. Java 21 → 25: mostly drop-in; check GC defaults and flags you set (removed options fail startup), container memory flags, and libraries using sun.misc.Unsafe memory access (warnings in 24+).
  5. Spring Boot 4: modularized starters, Jackson 3 by default, removed deprecations from 3.x; again OpenRewrite plus tests.
  6. Virtual threads last: enable spring.threads.virtual.enabled, then load-test: check connection pool sizing (now the concurrency limit), downstream rate limits (add semaphores), pinning (JFR jdk.VirtualThreadPinned), and ThreadLocal memory.
  7. Adopt the language features gradually: records for new DTOs and events, sealed hierarchies for new domain states; don’t rewrite stable code just to modernize it.
  • Use: var, switch expressions, text blocks, Stream.toList() (unmodifiable), sequenced collections (getFirst/getLast/reversed), String.strip/isBlank/formatted, HttpClient.
  • Java 25 (LTS): compact source files and instance main, module imports, flexible constructor bodies, scoped values.
  • Upgrade paths: JDK first, then framework (Boot 3 needs 17 and jakarta.*), then virtual threads with load tests; OpenRewrite for mechanical migrations.

Check this part

  1. What's the difference between Stream.toList() and collect(Collectors.toList())?

  2. Which feature replaces "line1\n" + "line2\n" for long SQL strings?

  3. Why upgrade the JDK and Spring Boot in separate steps?

Smaller features that make daily Java shorter and safer: var, switch expressions, text blocks, Stream.toList, sequenced collections (getFirst, getLast, reversed), String helpers, and in Java 25, compact source files and flexible constructor bodies. Upgrade to an LTS (21 or 25) and use them.

Key takeaways

  • Records replace data-class boilerplate: fields, accessors, equals, hashCode and toString in one line.
  • Sealed interfaces plus pattern-matching switch let the compiler check that you handled every case.
  • Virtual threads make simple blocking code handle thousands of concurrent waits; they don’t speed up computation.
  • Use today’s small features (var, text blocks, switch expressions, toList, getLast); they remove whole classes of mistakes.
  • Records are shallowly immutable value carriers; watch arrays and mutable collections inside.
  • Sealed hierarchies + exhaustive switch are Java’s ADTs; avoid default branches.
  • Virtual threads move bottlenecks downstream: limit concurrency explicitly, watch pinning and ThreadLocals.
  • Run an LTS (21 or 25), upgrade in verifiable steps, adopt features where they pay off.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.