Sitemap

Modern Java 17–21: Records, Sealed Classes, Pattern Matching, and Virtual Threads

12 min readOct 26, 2026

--

A practical guide to records, sealed types, pattern matching, text blocks, switch expressions, and virtual threads

Modern Java introduces features that make code more expressive, type-safe, and efficient. Java 17 and Java 21 are particularly important because they are Long-Term Support (LTS) releases that include features useful for building production applications.

This guide covers records, sealed classes and interfaces, pattern matching, text blocks, switch expressions, and virtual threads, with practical examples and production use cases.

1. Records: Simplifying Data-Carrying Classes

Records were finalized in Java 16. They provide a concise way to declare classes whose primary purpose is to carry data.

The problem with traditional classes

Consider a typical API response DTO:

public final class UserResponse {
    private final Long id;
private final String name;
private final String email;
public UserResponse(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}

This class contains a significant amount of boilerplate for simply carrying three values.

Using a record

public record UserResponse(
Long id,
String name,
String email
) {}

The compiler automatically provides:

  • A canonical constructor.
  • Accessor methods such as id(), name(), and email().
  • Implementations of equals(), hashCode(), and toString().

Usage:

UserResponse user = new UserResponse(
101L,
"Karthick",
"karthick@example.com"
);
System.out.println(user.id());
System.out.println(user.name());
System.out.println(user.email());

Notice that record accessors use the component name directly rather than the conventional getName() style.

Records are shallowly immutable

Record components cannot be reassigned after construction. However, this does not automatically make referenced objects immutable.

public record TeamResponse(
String name,
List<String> members
) {}

The members reference cannot be reassigned, but the list itself may still be modified.

For a defensive copy:

public record TeamResponse(
String name,
List<String> members
) {
public TeamResponse {
members = List.copyOf(members);
}
}

The compact constructor copies the list and prevents callers from modifying the record’s internal list through the original reference.

List.copyOf() also rejects null elements and returns an unmodifiable list.

Adding validation

Records support compact constructors, making them useful for enforcing invariants.

public record CreateUserRequest(
String name,
String email
) {
public CreateUserRequest {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException(
"Name cannot be blank"
);
}
        if (email == null || email.isBlank()) {
throw new IllegalArgumentException(
"Email cannot be blank"
);
}
}
}

This ensures that an invalid object cannot be created through the canonical constructor.

In production, you may still want framework-level validation, such as Jakarta Bean Validation annotations, to return structured HTTP validation errors instead of propagating constructor exceptions.

When should you use records?

Records work particularly well for:

  • API request and response DTOs.
  • Immutable configuration values.
  • Data returned from service methods.
  • Events passed between application components.
  • Query results and lightweight domain values.

Records are not a replacement for every class. They cannot extend arbitrary classes, and their component fields are final. Use a regular class when you need mutable state, identity-based entity behavior, inheritance from a concrete superclass, or a more complex lifecycle.

2. Sealed Classes and Interfaces: Controlling Type Hierarchies

Sealed classes and interfaces were finalized in Java 17.

Traditionally, a public interface could be implemented by any accessible class. Sometimes, however, an application needs to define a fixed set of permitted implementations.

For example, an API might return one of three response types:

  • Success.
  • Validation failure.
  • Not found.

A sealed interface allows you to express that restriction directly.

Defining a sealed interface

public sealed interface ApiResponse
permits SuccessResponse,
ValidationErrorResponse,
NotFoundResponse {
}

Now define the implementations:

public record SuccessResponse(
Object data
) implements ApiResponse {}
public record ValidationErrorResponse(
String message
) implements ApiResponse {}
public record NotFoundResponse(
String resource
) implements ApiResponse {}

The compiler restricts direct implementations of ApiResponse to the permitted types.

This makes the hierarchy easier to understand and enables exhaustive handling when combined with pattern matching.

Understanding sealed, final, and non-sealed

Every direct permitted subclass of a sealed class or interface must explicitly choose how its own hierarchy behaves.

final prevents further inheritance.

public final class NotFoundException
extends RuntimeException {
}

sealed allows only another specified set of subclasses.

public sealed class ApplicationException
extends RuntimeException
permits ValidationException,
AuthorizationException {
}

non-sealed reopens the hierarchy, allowing further inheritance.

public non-sealed class ValidationException
extends ApplicationException {
}

In this example, ApplicationException is sealed, but ValidationException can have additional subclasses.

A permitted subtype can also be a record or an enum where appropriate. Records are implicitly final, so they cannot be declared non-sealed.

Why this matters in production

Sealed types are valuable when the possible outcomes are intentionally limited.

Examples include:

  • Payment processing results.
  • Authentication outcomes.
  • Command execution results.
  • Domain events.
  • API response models.
  • Validation and business-rule outcomes.

They communicate the supported variants in the type system rather than relying only on documentation.

One important distinction: a sealed interface does not automatically dictate how a JSON serializer represents the type. If these types cross an HTTP boundary, configure your serialization framework and API schema appropriately.

3. Pattern Matching for instanceof

Pattern matching for instanceof became a permanent feature in Java 16.

Before this feature, developers commonly wrote code like this:

if (value instanceof String) {
String text = (String) value;
    System.out.println(text.toUpperCase());
}

The type check and cast are repetitive.

Modern approach

if (value instanceof String text) {
System.out.println(text.toUpperCase());
}

Java checks whether value is a String and, if so, introduces the pattern variable text.

This makes the code shorter and removes the possibility of an inconsistent manual cast.

Using pattern matching in application logic

Imagine a service that accepts objects from multiple sources:

public String describe(Object value) {
    if (value instanceof String text) {
return "Text: " + text;
}
if (value instanceof Integer number) {
return "Number: " + number;
}
return "Unknown value";
}

The variable declared by a successful pattern is available only where Java’s flow analysis can establish that the pattern matched.

For example:

if (!(value instanceof String text)) {
return "Not a string";
}
return text.toUpperCase();

After the conditional returns for non-strings, text is available in the remaining code.

This is especially useful when working with polymorphic objects, framework-provided values, or legacy APIs that expose broad types such as Object.

4. Pattern Matching for switch

Pattern matching for switch was finalized in Java 21.

It extends switch expressions and statements so that they can match object types, bind variables, and handle multiple variants more naturally.

Consider the sealed ApiResponse hierarchy introduced earlier.

Traditional approach

public String describe(ApiResponse response) {
    if (response instanceof SuccessResponse success) {
return "Success: " + success.data();
}
if (response instanceof ValidationErrorResponse error) {
return "Validation error: " + error.message();
}
if (response instanceof NotFoundResponse notFound) {
return "Not found: " + notFound.resource();
}
throw new IllegalStateException(
"Unsupported response type"
);
}

This is valid, but the repeated conditional structure can become cumbersome.

Using pattern matching with switch

public String describe(ApiResponse response) {
    return switch (response) {
case SuccessResponse success ->
"Success: " + success.data();
case ValidationErrorResponse error ->
"Validation error: " + error.message();
case NotFoundResponse notFound ->
"Not found: " + notFound.resource();
};
}

Because ApiResponse is sealed and all its permitted variants are covered, Java can verify that the switch is exhaustive.

If you add another permitted subtype, the compiler can identify switches that no longer cover all possibilities.

This is a major advantage of combining sealed types with pattern matching.

Adding conditions with when

Java 21 also supports guarded patterns.

public String describeStatus(Object value) {
    return switch (value) {
case Integer number when number >= 200
&& number < 300 ->
"Successful status";
case Integer number when number >= 400
&& number < 500 ->
"Client error";
case Integer number when number >= 500
&& number < 600 ->
"Server error";
case Integer number ->
"Other numeric status";
case String text ->
"Text value: " + text;
case null ->
"No value";
default ->
"Unsupported value";
};
}

The when clauses add conditions to type patterns. Their order matters because the first matching case is selected.

This example also demonstrates explicit handling for null, which would otherwise cause a null-related failure when switching on a null reference.

Production benefit: Pattern matching is particularly useful when implementing business rules over a finite set of domain variants. It reduces casting, improves readability, and makes changes to sealed hierarchies easier to detect at compile time.

5. Text Blocks: Readable Multiline Strings

Text blocks became a permanent feature in Java 15.

Get Karthick K’s stories in your inbox

Join Medium for free to get updates from this writer.

They are especially useful for multiline content such as SQL statements, JSON payloads, HTML templates, and test fixtures.

Traditional string concatenation

String json =
"{\n" +
" \"id\": 101,\n" +
" \"name\": \"Karthick\"\n" +
"}";

Using a text block

String json = """
{
"id": 101,
"name": "Karthick"
}
""";

Text blocks preserve the readability of the content without requiring explicit newline escapes.

Example with SQL

String sql = """
SELECT id, name, email
FROM users
WHERE status = ?
ORDER BY created_at DESC
""";

This is easier to read and maintain than a long concatenated string.

However, text blocks do not make SQL safe by themselves. Continue using prepared statements or parameter binding instead of inserting untrusted values directly into SQL strings.

Text blocks also have specific rules for indentation and escape sequences, so they should not be treated as completely raw strings.

6. Switch Expressions

Switch expressions were finalized in Java 14.

Unlike traditional switch statements, switch expressions return a value and can be assigned directly to a variable or returned from a method.

Traditional switch statement

String roleName;
switch (role) {
case "ADMIN":
roleName = "Administrator";
break;
case "USER":
roleName = "Regular User";
break;
case "SUPPORT":
roleName = "Support Agent";
break;
default:
roleName = "Unknown";
}

This requires a mutable variable and explicit break statements.

Modern switch expression

String roleName = switch (role) {
case "ADMIN" -> "Administrator";
case "USER" -> "Regular User";
case "SUPPORT" -> "Support Agent";
default -> "Unknown";
};

The arrow syntax avoids accidental fall-through between cases.

For cases that require multiple statements, use a block and yield:

int priority = switch (severity) {
case "LOW" -> 1;
case "MEDIUM" -> 2;
    case "HIGH" -> {
System.out.println("High severity detected");
yield 3;
}
default -> 0;
};

Switch expressions are useful for mapping enums, assigning priorities, translating statuses, and converting domain values into API representations.

7. Virtual Threads: Lightweight Concurrency in Java 21

Virtual threads were finalized in Java 21 as part of Project Loom.

To understand why they matter, first consider traditional platform threads.

Platform threads

Platform threads are backed by operating-system threads. They are effective for many workloads, but managing very large numbers of threads can consume substantial memory and operating-system resources.

Consider a backend API that handles thousands of concurrent requests. Each request might call several external services:

  1. Fetch user details.
  2. Call a billing service.
  3. Retrieve account permissions.
  4. Wait for the responses.
  5. Combine the results.

When these operations involve blocking I/O, threads may spend much of their time waiting.

Creating a large number of platform threads just to wait on I/O can be expensive.

Virtual threads

Virtual threads are lightweight threads managed by the Java runtime. They allow applications to support many concurrent tasks without requiring one operating-system thread per task.

Example:

public class VirtualThreadExample {
    public static void main(String[] args)
throws InterruptedException {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println(
"Task " + taskId +
" running on " +
Thread.currentThread()
);
Thread.sleep(1_000); return taskId;
});
}
}
}
}

This example submits 1,000 independent tasks to a virtual-thread-per-task executor.

The try-with-resources block closes the executor, waits for its submitted tasks to finish, and ensures orderly shutdown. Because each task sleeps for one second, the entire program does not necessarily finish in one second; scheduling and resource constraints also affect execution time.

The example demonstrates that you do not need to maintain a traditional fixed-size pool merely to avoid creating too many platform threads.

How virtual threads work

A virtual thread is scheduled by the Java runtime onto a platform thread, often called a carrier thread.

When a virtual thread performs certain blocking operations, the runtime can suspend it and use the carrier thread to run other virtual threads.

When the blocked operation becomes ready to continue, the virtual thread can be scheduled again.

This is especially valuable for blocking I/O operations because many tasks can remain logically active while relatively few platform threads execute Java code at any instant.

Virtual threads do not make CPU-intensive work inherently faster. They primarily improve scalability for workloads involving significant waiting.

8. Virtual Threads vs. Platform Threads

Runtime representation
Platform threads: Backed by OS threads
Virtual threads: Managed by the Java runtime

Resource cost
Platform threads: Generally higher per thread
Virtual threads: Generally lower per thread

Large numbers of concurrent blocking tasks
Platform threads: Can require substantial resources
Virtual threads: Often a better fit

CPU-intensive workloads
Platform threads: Appropriate
Virtual threads: No inherent speed advantage

Blocking I/O
Platform threads: Can occupy an OS thread while waiting
Virtual threads: Many supported blocking operations can release the carrier

Thread-per-request programming
Platform threads: Can become expensive at high concurrency
Virtual threads: Can make this programming model more scalable

Typical use
Platform threads: CPU pools, controlled worker pools, existing applications
Virtual threads: High-concurrency I/O-bound tasks

Virtual threads do not eliminate all concurrency limits. Applications still need to account for database connection pools, remote-service rate limits, memory consumption, and downstream capacity.

When should you use virtual threads?

Virtual threads are a strong option for:

  • Backend APIs making blocking HTTP calls.
  • Services that use JDBC or other compatible blocking database access.
  • Concurrent file operations.
  • Tasks that spend substantial time waiting for network responses.
  • Applications that benefit from straightforward thread-per-task code.

They are not a replacement for every concurrency mechanism. CPU-intensive work may still benefit from a bounded executor sized around available processors.

Avoid using virtual threads as a reason to remove all resource limits. For example, creating thousands of concurrent database calls does not help if the connection pool supports only a few dozen active connections.

Also, some blocking operations or native interactions can pin a virtual thread to its carrier thread in certain circumstances. Review the behavior of your JDK version and libraries when diagnosing unexpected scalability issues.

9. Practical Exercise: Model API Responses with Sealed Interfaces and Records

Let’s combine records, sealed interfaces, and pattern matching into a small API response model.

Step 1: Define the response hierarchy

public sealed interface ApiResponse
permits Success, Failure, NotFound {
}

Step 2: Create response variants

public record Success(
Object data
) implements ApiResponse {}
public record Failure(
String message,
int statusCode
) implements ApiResponse {}
public record NotFound(
String resource
) implements ApiResponse {}

Step 3: Implement response handling

public class ApiResponseHandler {
    public String handle(ApiResponse response) {        return switch (response) {
case Success success ->
"Request succeeded: " + success.data();
case Failure failure ->
"Request failed with status "
+ failure.statusCode()
+ ": "
+ failure.message();
case NotFound notFound ->
"Resource not found: "
+ notFound.resource();
};
}
}

Step 4: Test the model

public class Main {
    public static void main(String[] args) {        ApiResponseHandler handler =
new ApiResponseHandler();
ApiResponse success =
new Success("User created");
ApiResponse failure =
new Failure("Invalid input", 400);
ApiResponse notFound =
new NotFound("User 101");
System.out.println(handler.handle(success));
System.out.println(handler.handle(failure));
System.out.println(handler.handle(notFound));
}
}

Expected output:

Request succeeded: User created
Request failed with status 400: Invalid input
Resource not found: User 101

In a production API, prefer typed payloads over Object when the response structure is known. Also distinguish between internal domain results and actual HTTP status codes or serialization formats. A sealed response hierarchy is a modeling tool, not an automatic HTTP response mechanism.

10. Practical Exercise: Build a Virtual-Thread I/O Task Runner

Create a small program that runs independent blocking tasks concurrently.

import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class IoTaskRunner {    public static void main(String[] args)
throws Exception {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> userTask = executor.submit(() -> {
Thread.sleep(500);
return "User data fetched";
});
Future<String> orderTask = executor.submit(() -> {
Thread.sleep(700);
return "Order data fetched";
});
Future<String> paymentTask = executor.submit(() -> {
Thread.sleep(400);
return "Payment data fetched";
});
System.out.println(userTask.get());
System.out.println(orderTask.get());
System.out.println(paymentTask.get());
}
}
}

The tasks simulate waiting for independent external operations. In a real application, replace Thread.sleep() with actual blocking I/O calls, such as HTTP requests or compatible database operations.

What should you observe?

  • Each submitted task gets its own virtual thread.
  • The tasks can make progress concurrently instead of waiting for the previous task to finish.
  • Calling get() waits for the relevant result.
  • The executor is closed after the tasks complete.
  • The total elapsed time can be close to the duration of the slowest simulated operation rather than the sum of all three delays, excluding scheduling and other overhead.

For a real-world application, also consider task timeouts, exception handling, cancellation, and limits on concurrent calls to external systems.

11. Production Considerations and Common Mistakes

Records

  • Remember that records are only shallowly immutable.
  • Use defensive copies for mutable collections when needed.
  • Avoid using records automatically for entities that require mutable lifecycle management.
  • Keep API validation and serialization requirements in mind.

Sealed types and pattern matching

  • Use sealed hierarchies when the set of variants is intentionally controlled.
  • Prefer exhaustive switches for finite domain models.
  • Add new cases when introducing a new permitted subtype.
  • Do not assume the compiler guarantees that a serialized API contract remains backward-compatible.

Virtual threads

  • Use them primarily for high-concurrency, I/O-bound workloads.
  • Do not expect automatic speedups for CPU-bound computation.
  • Continue to limit database connections and downstream requests.
  • Test third-party libraries and blocking operations under realistic load.
  • Measure throughput, latency, resource consumption, and error rates before and after adopting them.

Conclusion

Modern Java makes it easier to express intent directly in code.

Records reduce boilerplate for data models. Sealed interfaces let developers control type hierarchies. Pattern matching reduces casting and supports exhaustive handling of known variants. Text blocks improve multiline string readability, while switch expressions simplify value selection.

Virtual threads extend these improvements to concurrent programming by making large numbers of blocking I/O tasks more practical.

For backend developers, the greatest benefit comes from combining these features appropriately: use records for data, sealed interfaces for controlled result types, pattern matching for clear business logic, and virtual threads when the workload benefits from high-concurrency blocking I/O.

The goal is not simply to write less code. It is to build software that is easier to understand, safer to change, and more scalable under real production workloads.

--

--

0 followers

Google Certified Professional Cloud Architect | Backend Engineer | AI & Spec-Driven Development