All Posts

Java 14 to 17: Records, Sealed Classes, Text Blocks, and Pattern Matching

Records eliminated boilerplate. Sealed classes enforced type hierarchies. Pattern matching removed redundant casts. Java 17 LTS made it stable.

Abstract AlgorithmsAbstract Algorithms
ยทยท24 min read
๐Ÿ“š

Intermediate

For developers with some experience. Builds on fundamentals.

Estimated read time: 24 min

AI-assisted content.

TLDR: Java 14โ€“17 ran a deliberate four-release preview-to-stable conveyor belt. Records replaced 50-line POJOs with one line. Text blocks ended escape-sequence chaos in multi-line strings. Sealed classes turned "please only subclass these types" comments into compiler errors. Pattern matching instanceof eliminated the check-then-cast antipattern. All four features are stable in Java 17 LTS and work together as a cohesive system for expressing immutable value types and finite domain hierarchies. Upgrade path: records for DTOs immediately, sealed classes for domain hierarchies, text blocks for all inline SQL/JSON in tests โ€” in that order.


๐Ÿ“– The Preview-to-Stable Graduation: Why Java 14โ€“17 Is a Design Story, Not Just a Changelog

Java has long carried a reputation for verbosity. A data class that would take one line in Kotlin or Python required forty or fifty lines in Java โ€” a constructor, private fields, getters, equals(), hashCode(), and toString(), all hand-written and all prone to subtle mistakes. Class hierarchies were enforced only by Javadoc comments: "only extend this class with Circle, Rectangle, or Triangle." Nothing stopped class Hexagon extends Shape {} from compiling cleanly. And multi-line strings looked like someone had accidentally held down the shift key while typing \n and + across half the file.

Java's designers knew these were real productivity problems. But they also knew from experience โ€” from features that shipped rough in earlier versions โ€” that rushing language changes creates long-term pain. So they built a preview system: propose a feature, ship it as non-default in one release, invite community feedback, refine it in a second preview, and only finalize it when the design is solid.

Java 14 through 17 executed this process for four features simultaneously, each on a slightly staggered schedule. Text blocks started the cycle in Java 13 and graduated in Java 15. Records previewed in Java 14 and became stable in Java 16. Pattern matching for instanceof followed the same schedule. Sealed classes previewed in Java 15 and became the capstone of Java 17 LTS. The result is not four isolated additions โ€” it is a coherent system where records define immutable value types, sealed classes restrict which types can exist in a hierarchy, and pattern matching makes exhaustive handling of that hierarchy elegant.

Java 17 LTS is the release where all four features landed simultaneously in their stable forms. For teams that jumped from Java 11 LTS, every one of these features arrived at once. This guide covers what each feature replaced, how they work together, and the failure modes that catch migrating teams off guard.


๐Ÿ” Records: Replacing the 50-Line Data Class with a One-Line Declaration

Open any pre-Java-16 codebase and search for classes named UserResponse, OrderDto, or AddressRecord. You will find the same pattern repeated hundreds of times: a class with private final fields, a constructor that assigns each one, a getter for every field (named getX() because that is what the Java convention demanded), and then equals(), hashCode(), and toString() generated by the IDE โ€” and thereafter silently out of date whenever someone adds a field and forgets to regenerate.

Here is what that pattern looked like for a two-field class:

// Java 15 and below โ€” POJO with 40+ lines for two fields
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) { this.x = x; this.y = y; }
    public int getX() { return x; }
    public int getY() { return y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return x == other.x && y == other.y;
    }

    @Override
    public int hashCode() { return Objects.hash(x, y); }

    @Override
    public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

Java 16's records reduce this to a single declaration:

// Java 16+ โ€” record
public record Point(int x, int y) {}

// With compact constructor for validation
public record Range(int min, int max) {
    Range {
        if (min > max) throw new IllegalArgumentException(
            "min (%d) must not exceed max (%d)".formatted(min, max));
    }
}

The compiler generates the canonical constructor, component accessors named x() and y() (not getX()), and correct implementations of equals(), hashCode(), and toString(). Every field is implicitly private final. The record itself is final and extends java.lang.Record.

Two differences are worth memorizing immediately. First, accessor method names drop the get prefix: point.x() not point.getX(). This breaks Jackson deserialization by default until Jackson 2.12 (see the OSS section). Second, the compact constructor syntax runs its body before the compiler-generated field assignments โ€” it validates inputs but does not need to assign this.min = min explicitly. The compiler does that after your body runs.


โš™๏ธ Four Features, Four Before/After Pairs: Text Blocks, Switch, Sealed, and Pattern Matching

Text Blocks: Ending the Escape-Sequence Mess in Multi-line Strings

Before Java 15, any multi-line string โ€” a SQL query, a JSON payload for a test, an HTML template fragment โ€” required manual escape sequences and concatenation:

// Java 14 and below โ€” string concatenation hell
String query = "SELECT u.id, u.name, o.total\n" +
               "FROM users u\n" +
               "JOIN orders o ON u.id = o.user_id\n" +
               "WHERE u.active = true\n" +
               "ORDER BY o.total DESC";

Every line requires \n. Every string literal needs opening and closing quotes. The actual SQL is buried in noise. Java 15 introduced text blocks as the solution:

// Java 15+ โ€” text block
String query = """
        SELECT u.id, u.name, o.total
        FROM users u
        JOIN orders o ON u.id = o.user_id
        WHERE u.active = true
        ORDER BY o.total DESC
        """;

The indentation is stripped automatically based on the position of the closing """. The result is a string with no leading spaces and a trailing newline. The critical detail: the closing """ determines how many leading spaces are stripped. Misplace it and you get unexpected leading whitespace in every line.

Switch Expressions: Removing Fall-Through and the Requirement for break

Java 14 made switch expressions stable. The old switch statement was error-prone because fall-through was the default, and forgetting a break produced bugs that were silent at compile time:

// Java 13 and below โ€” switch statement (falls through, requires break)
int numLetters;
switch (day) {
    case MONDAY:
    case FRIDAY:
    case SUNDAY:
        numLetters = 6;
        break;
    case TUESDAY:
        numLetters = 7;
        break;
    default:
        numLetters = 8;
}

The new switch expression form is exhaustive, returns a value, and uses -> to eliminate fall-through entirely:

// Java 14+ โ€” switch expression (exhaustive, no fall-through)
int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY               -> 7;
    default                    -> 8;
};

Multiple labels are grouped with commas. The compiler enforces exhaustiveness โ€” every possible value must be handled or a default arm must be present.

Sealed Classes: Turning Inheritance Conventions into Compiler Guarantees

Before Java 17, abstract class hierarchies were open by default. The only enforcement mechanism was documentation. Any class in any package could extend your Shape class:

// Java 16 and below โ€” open hierarchy, only enforced by convention
public abstract class Shape {
    // README says: only subclass with Circle, Rectangle, or Triangle.
    // Nothing stops: class Octagon extends Shape {}
}

Java 17 sealed classes let you declare the exact set of permitted subtypes. The compiler rejects any class not in the permits list:

// Java 17+ โ€” sealed class
public sealed class Shape permits Circle, Rectangle, Triangle {}

public final class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double radius() { return radius; }
}

public final class Rectangle extends Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double width() { return width; }
    public double height() { return height; }
}

// Result type using sealed interface + records
public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T value)      implements Result<T> {}
public record Failure<T>(String error) implements Result<T> {}

The Result<T> example is important: sealed classes and records compose naturally. A sealed interface with record implementations gives you a type-safe, immutable, exhaustively-checkable sum type.

Pattern Matching for instanceof: Eliminating the Check-Then-Cast Antipattern

The most common pre-Java-16 Java antipattern was the check-then-cast sequence: test instanceof, then immediately cast to the same type. The redundancy is obvious but unavoidable in the old model:

// Java 15 and below โ€” redundant cast
if (obj instanceof String) {
    String s = (String) obj;  // we JUST checked it's a String
    System.out.println(s.toUpperCase());
}

Java 16 pattern matching binds a variable directly in the instanceof test:

// Java 16+ โ€” pattern variable in instanceof
if (obj instanceof String s) {
    System.out.println(s.toUpperCase());
}

The pattern variable s is in scope in the positive branch and automatically typed to String. It is not in scope in the else branch. This scoping rule is intentional and occasionally surprises developers who try to use s after the closing brace.


๐Ÿง  Under the Hood: How the Compiler and JVM Handle Records and Sealed Classes

Internals: How the Java Compiler Generates Record Components

When the Java compiler encounters public record Point(int x, int y) {}, it generates a final class that explicitly extends java.lang.Record. The class is not a magic runtime construct โ€” it compiles to ordinary JVM bytecode. The generated elements are:

  • Canonical constructor: takes int x and int y, assigns this.x and this.y
  • Component accessors: public int x() and public int y() โ€” note the absence of the get prefix
  • equals(): performs field-by-field comparison using the declared component types
  • hashCode(): uses Objects.hash() across all components, consistently matching equals()
  • toString(): returns "Point[x=1, y=2]" style output, field names included

The compact constructor is a syntactic shortcut. When you write:

public record Range(int min, int max) {
    Range {
        if (min > max) throw new IllegalArgumentException(...);
    }
}

The compiler treats the body as a prefix to the canonical constructor. It runs your validation code first, then assigns this.min = min and this.max = max automatically. You cannot do the assignment yourself inside a compact constructor โ€” the compiler owns the assignment phase.

Records have two key restrictions. They are always final โ€” you cannot subclass a record. And they always extend java.lang.Record โ€” they cannot extend any other class. They can implement interfaces, which is how the sealed Result<T> pattern works: records implement a sealed interface to form a closed, type-safe hierarchy.

Sealed classes generate a special PermittedSubclasses attribute in the class file. At compile time, the compiler reads this attribute to validate that only listed subtypes extend or implement the sealed type. At runtime, the JVM enforces the same restriction via class loading. A non-sealed modifier on a permitted subtype re-opens the hierarchy for that branch โ€” all further subtypes of that non-sealed class are unrestricted.

Performance Analysis: Records vs POJOs vs Lombok at Runtime

At the JVM bytecode level, a record and its hand-written POJO equivalent produce identical class files. Decompile a record with javap -c and you see the same field declarations, the same constructor, the same method bodies as you would write by hand. There is no record-specific bytecode instruction. The JVM does not know or care that a class was declared with record.

The practical consequence: there is zero runtime performance difference between records, hand-written POJOs, and Lombok-generated POJOs for equivalent immutable classes. Choose between them purely on authoring and maintenance grounds.

The authoring comparison matters more. Lombok provides @Data (mutable), @Value (immutable), and @Builder, which records do not have. Lombok requires an annotation processor and can generate code that breaks under certain serialization frameworks unless configured correctly. Records require no annotation processor, are part of the language specification, and produce deterministic output. The trade-off is that Lombok supports mutability through @Setter and @Builder, while records are always immutable. For DTOs and value objects โ€” where immutability is correct โ€” records are unambiguously the right choice.

MetricHand-Written POJORecordLombok @Value
Runtime performanceBaselineIdenticalIdentical
Lines of code~45 for 3 fields12โ€“4 with annotation
Requires annotation processorNoNoYes
Mutable variant supportedYesNoNo (@Data for that)
Part of Java specificationYesYesNo (third-party tool)
Jackson compatible out-of-boxYes2.12+ (ser), 2.14+ (deser)Yes

๐Ÿ“Š The Preview-to-Stable Graduation Timeline and Feature Map

Every modern Java feature goes through the same lifecycle: an initial preview guarded by --enable-preview, a refinement in a second preview, and finally graduation to a stable, non-preview feature. The Java 14โ€“17 window ran this process for five features simultaneously on staggered schedules.

The diagram below shows how each release added one or more features to the stable column while advancing others through preview. The six-month release cadence โ€” a policy introduced with Java 9 โ€” is what made this rapid iteration possible. Without predictable release dates, the feedback loop between community preview usage and language designer refinement could not have operated at this speed.

flowchart TD
    A["Java 14 (2020) โ€” Text Blocks Preview 2, Records Preview 1"] --> B["Java 15 (2020) โ€” Text Blocks Stable, Records Preview 2, Sealed Preview 1"]
    B --> C["Java 16 (2021) โ€” Records Stable, Pattern Matching Stable, Sealed Preview 2"]
    C --> D["Java 17 LTS (2021) โ€” Sealed Classes Stable, Strong Encapsulation Final"]
    D --> E["Production teams: upgrade to Java 17 as new baseline"]
    A --> F["6-month cadence enables rapid preview iteration"]
    F --> D

The flow illustrates two paths to Java 17: the sequential feature graduation chain (A โ†’ B โ†’ C โ†’ D) and the enabling infrastructure of the six-month cadence (A โ†’ F โ†’ D). For teams on Java 11 LTS, the jump to Java 17 delivers all five features at once โ€” what felt like a long wait was actually a carefully sequenced rollout.

Feature Graduation Table

FeaturePreviewSecond PreviewStable
Text BlocksJava 13Java 14Java 15
RecordsJava 14Java 15Java 16
Pattern Matching instanceofJava 14Java 15Java 16
Sealed ClassesJava 15Java 16Java 17
Switch ExpressionsJava 12Java 13Java 14

Switch expressions completed the cycle earliest. Text blocks followed in Java 15. Records and pattern matching graduated together in Java 16. Sealed classes โ€” the most complex in terms of modular system interactions โ€” took until Java 17. Each graduation date is the date from which you can use the feature in production without --enable-preview flags.


๐ŸŒ Real-World Adoption: Stripe's API Client, Domain Result Types, and SQL Test Fixtures

Records as API Response Models in the Stripe Java SDK

Stripe's Java SDK represents API response objects as immutable data structures. Before records, these were hand-authored POJOs with careful immutability enforcement. With records, response types like a charge summary become a single-line declaration:

// REST API response model as a record
public record ChargeResponse(
    String id,
    long amount,
    String currency,
    String status,
    String customerId
) {}

The record's auto-generated equals() and hashCode() make these objects safe to use as map keys or in sets โ€” behavior that hand-written POJOs required explicit effort to guarantee. The auto-generated toString() produces structured, readable log output with no additional code.

Sealed Interfaces as Result<T> Types Replacing Exception-Driven Control Flow

A common enterprise Java pattern before Java 17 was throwing and catching exceptions for expected failure cases โ€” invalid input, resource not found โ€” because there was no clean way to return "either a success or a typed failure" from a method. Sealed interfaces with record subtypes solve this precisely:

public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T value)      implements Result<T> {}
public record Failure<T>(String error) implements Result<T> {}

// Service layer usage
public Result<User> findUser(String id) {
    return userRepository.findById(id)
        .map(Success::new)
        .orElse(new Failure<>("User not found: " + id));
}

// Caller โ€” exhaustive handling with Java 21 pattern switch (the payoff)
switch (result) {
    case Success<User> s -> renderUser(s.value());
    case Failure<User> f -> renderError(f.error());
}

The Result<T> pattern eliminates try/catch for non-exceptional cases and makes the failure mode explicit in the return type โ€” a direct improvement in readability and correctness.

Text Blocks for SQL and JSON in Integration Tests

Integration tests that rely on inline SQL or JSON are notoriously noisy when written with pre-Java-15 string concatenation. Text blocks eliminate all of that ceremony:

// Test fixture SQL โ€” readable, indentation-preserving, zero escape sequences
String insertSql = """
        INSERT INTO orders (id, user_id, total, status)
        VALUES ('ord-001', 'usr-123', 9900, 'CONFIRMED')
        """;

// JSON assertion payload โ€” indent matches the code, not the string content
String expectedJson = """
        {
          "id": "ord-001",
          "status": "CONFIRMED",
          "total": 9900
        }
        """;

Both of these are direct String values at runtime โ€” no library, no template engine, no external file. The text block closing """ strips the common leading indent automatically.


โš–๏ธ Where These Features Break Down: JPA Entities, Module Boundaries, and Scope Traps

Records Cannot Replace JPA Entities โ€” Scope Them to DTOs Only

This is the most common migration mistake teams make after upgrading to Java 17. JPA's dirty checking mechanism requires that entities be mutable. The persistence provider reads an entity from the database, keeps track of its state, and when a transaction commits, it compares the current field values to the snapshot taken at load time. This mechanism requires:

  1. A no-argument constructor (JPA spec requirement)
  2. Mutable fields that the provider can modify via reflection or setter methods

Records violate both requirements. They are final, they have no no-arg constructor, and their fields are private final. Attempting to annotate a record as a JPA @Entity will either fail at startup or produce incorrect persistence behavior. The correct scope for records in a JPA application is the DTO layer: the output of repository projections, REST response bodies, event payloads, and value objects that are never persisted directly.

Sealed Classes Require All Permitted Subtypes in the Same Package or Module

A sealed class and its permitted subtypes must be in the same compilation unit or the same package. When working with the Java Module System, permitted subtypes must be in a module that opens or exports the required package. Moving a permitted subtype to a separate module without the correct opens/exports declarations causes a compile error. This restriction is by design โ€” the sealed contract must be verifiable at compile time โ€” but it means sealed hierarchies do not cross module boundaries freely.

The non-sealed modifier re-opens a branch of the hierarchy. A permitted subtype declared non-sealed can be extended by any class in any package. This is an intentional design choice for frameworks that need to extend a sealed type โ€” it is not a workaround and it must be documented as a deliberate architectural decision.

Text Block Indent Stripping: The Closing """ Position Controls Everything

Text block indentation stripping works by finding the minimum leading whitespace across all non-empty lines, including the position of the closing """. If the closing """ is placed at column 0 (flush left), no indentation is stripped. If it is indented to match the content lines, the common indent is removed. A misaligned closing delimiter produces unexpected leading whitespace that is invisible in the source file but shows up at runtime in SQL queries, JSON payloads, or HTTP requests.

// Correct โ€” closing """ matches content indent, strips 8 spaces
String correct = """
        SELECT id FROM users
        """;  // closing """ at 8 spaces: leading 8 spaces stripped

// Broken โ€” closing """ at column 0, nothing stripped
String broken = """
        SELECT id FROM users
""";  // result has 8 leading spaces on the SQL line

Pattern Matching instanceof Scoping: Positive Branch Only

The pattern variable introduced in a instanceof test is in scope only in the positive branch. It is not available in the else branch, and it is not available after the if block completes. This surprises developers who attempt the following:

// Will not compile โ€” s is not in scope in the else branch
if (!(obj instanceof String s)) {
    throw new IllegalArgumentException("expected String");
}
// s IS in scope here when the negated-check form is used โ€” Java allows this
System.out.println(s.toUpperCase());

The negated-check form !(obj instanceof String s) does make s available after the if block โ€” the compiler performs flow analysis and knows that reaching the line after the block means the cast succeeded. This pattern is useful for early-exit validation.


๐Ÿงญ Choosing Between Records, POJOs, Lombok, Sealed Classes, and Abstract Hierarchies

The decision between these constructs comes down to two questions: does the type need to be mutable, and does the hierarchy need to be open or closed?

ScenarioUseReason
DTO / API response modelRecordAuto-generated equals/hashCode/toString, immutable, zero boilerplate
JPA entityPlain classJPA requires mutable fields and a no-arg constructor
Value object (Domain-Driven Design)RecordStructural equality and immutability match DDD value object semantics
Finite domain hierarchy (shapes, events, states)Sealed classCompiler-enforced exhaustiveness in switch; no unexpected subtypes
Open extension point / plugin architectureAbstract class or interfaceSealed classes restrict extension to the permitted list
Builder pattern with optional fieldsLombok @BuilderRecords cannot express optional fields cleanly without a builder
Multi-line SQL/JSON/HTML in testsText blockNo escape sequences, preserves formatting, Java 15+
Legacy string concatenationText blockIndentation-preserving, immediate readability improvement

The key rule of thumb: use records for any type that represents immutable data and does not need to be managed by a persistence provider. Use sealed classes for any hierarchy where you want the compiler to enforce exhaustive handling in switch expressions. These two constructs are designed to be used together.


๐Ÿงช Migrating a REST API Response: Full Before/After Refactoring

This example walks through the migration of a real-world REST layer from verbose POJOs and unchecked exception handling to records and a sealed Result<T> type. The before snapshot is representative of what typical Spring Boot applications looked like on Java 11.

The before state is a UserResponse class with all the boilerplate and a service method that uses exception-driven control flow for a not-found case:

// BEFORE โ€” Java 11 style: 50-line POJO + exception-driven control flow

public final class UserResponse {
    private final String id;
    private final String name;
    private final String email;

    public UserResponse(String id, String name, String email) {
        this.id    = id;
        this.name  = name;
        this.email = email;
    }

    public String getId()    { return id; }
    public String getName()  { return name; }
    public String getEmail() { return email; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserResponse)) return false;
        UserResponse u = (UserResponse) o;
        return id.equals(u.id) && name.equals(u.name) && email.equals(u.email);
    }

    @Override public int hashCode() { return Objects.hash(id, name, email); }

    @Override public String toString() {
        return "UserResponse{id=" + id + ", name=" + name + ", email=" + email + "}";
    }
}

// Service โ€” throws exception for a business-logic "not found"
public UserResponse getUser(String id) {
    return repository.findById(id)
        .map(u -> new UserResponse(u.getId(), u.getName(), u.getEmail()))
        .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}

The after state collapses the response model to one line and replaces exception-driven control flow with an explicit Result<T> type:

// AFTER โ€” Java 17 style: record + sealed Result type

// Response model: one line
public record UserResponse(String id, String name, String email) {}

// Sealed result type
public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T value)      implements Result<T> {}
public record Failure<T>(String error) implements Result<T> {}

// Service โ€” explicit return type, no exceptions for expected cases
public Result<UserResponse> getUser(String id) {
    return repository.findById(id)
        .map(u -> (Result<UserResponse>) new Success<>(
            new UserResponse(u.getId(), u.getName(), u.getEmail())))
        .orElse(new Failure<>("User not found: " + id));
}

The UserResponse record automatically provides correct equals(), hashCode(), and toString(). The Result<T> hierarchy makes the failure case visible in the return type signature, eliminating an invisible contract that previously relied on catching UserNotFoundException in the right place.


๐Ÿ› ๏ธ Spring Boot 3 and Jackson: Records as First-Class HTTP Response Types

Spring Boot 3.x, released in November 2022, targets Java 17 as its baseline and treats records as first-class HTTP response types. When a @RestController method returns a record, Spring uses Jackson to serialize it to JSON automatically โ€” no configuration required beyond Spring Boot's defaults.

// Spring Boot 3 โ€” record as HTTP response type
public record UserResponse(String id, String name, String email) {}

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable String id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

Jackson compatibility notes:

  • Jackson 2.12+: Records serialize correctly โ€” Jackson reads the component accessors (id(), name(), email()) and maps them to JSON fields.
  • Jackson 2.14+: Full deserialization support via constructor detection โ€” Jackson can reconstruct a record from a JSON payload without requiring @JsonCreator annotations.
  • Before Jackson 2.12: Records cause InvalidDefinitionException because Jackson could not find standard JavaBean getters (getId() etc.). If your Spring Boot version bundles Jackson < 2.12, upgrade Jackson explicitly.

Spring Data projections with records: Spring Data JPA supports interface-based projections natively. For record-based projections, use the @Query return type as a DTO constructor expression:

// Spring Data DTO projection using record
@Query("SELECT new com.example.UserResponse(u.id, u.name, u.email) FROM User u WHERE u.active = true")
List<UserResponse> findActiveUserSummaries();

This pattern is available with any Spring Data version that supports JPQL constructor expressions. For a full deep-dive on Spring Boot 3 and Java 17 integration, see the Spring Boot 3.x Migration Guide in the Spring documentation.


๐Ÿ“š Lessons Learned from Teams Migrating to Java 17

Records for DTOs: zero-risk, immediate win. In every codebase, DTO classes are the lowest-risk migration target. They have no persistence annotations, no framework magic, and no mutability requirements. Replace them with records first and ship the change. The PR review will be short because the diff is almost entirely deletions.

Do not convert JPA entities to records. This is not a warning โ€” it is a hard stop. JPA's dirty checking, lazy loading proxies, and no-arg constructor requirement are all incompatible with records. Keep entities as mutable classes. Period.

Sealed classes + Java 21 pattern switch is the design that Java 17 was building toward. Java 17's sealed classes are most powerful when combined with Java 21's pattern matching for switch. If your team is on Java 17, plan the migration to Java 21 because the payoff of sealed classes multiplies there. A sealed Result<T> hierarchy that you define in Java 17 becomes exhaustively checked in a Java 21 pattern switch with no code changes to the hierarchy.

Text blocks: migrate all inline SQL, JSON, and HTML in tests on day one of the Java 15+ migration. This is a pure improvement with no behavioral change. Search for string concatenation with \n in test files and replace with text blocks. The readability gain is immediate.

Pattern matching instanceof eliminates the most common Java antipattern. Any instanceof check followed by an immediate cast is a candidate for replacement. Some static analysis tools (including IntelliJ IDEA) flag these automatically on Java 16+ codebases.

non-sealed is a deliberate design choice, not a workaround. When a framework needs to extend a sealed type โ€” for example, a mock framework creating test doubles of a sealed class โ€” non-sealed is the correct tool. Document the decision in the class comment so future readers understand it is intentional.


๐Ÿ“Œ Summary: What Java 14โ€“17 Changed, in Order of Impact

Java 14โ€“17 delivered a coordinated set of language improvements that, taken together, eliminate the most common sources of Java verbosity and type-safety gaps.

Records (stable Java 16) replace boilerplate data classes with a single-line declaration. The compiler generates the canonical constructor, component accessors, equals(), hashCode(), and toString(). Records are always immutable and final. Use them for DTOs, value objects, and record-based projections. Never use them for JPA entities.

Text blocks (stable Java 15) replace concatenated escape-sequence strings with indentation-preserving multi-line literals. The closing """ position controls indent stripping. Use them for all inline SQL, JSON, HTML, and YAML in tests and configuration classes.

Switch expressions (stable Java 14) eliminate fall-through and break from switch. They are expressions โ€” they return values โ€” and the compiler enforces exhaustiveness. Use them anywhere a switch statement currently exists.

Sealed classes (stable Java 17) restrict class hierarchies to a declared set of permitted subtypes. They are the foundation for exhaustive pattern matching in Java 21. Use them for finite domain hierarchies โ€” shapes, states, events, result types.

Pattern matching instanceof (stable Java 16) eliminates the check-then-cast antipattern. The pattern variable is in scope in the positive branch. Use it immediately everywhere instanceof is followed by an explicit cast.


Share

Test Your Knowledge

๐Ÿง 

Ready to test what you just learned?

AI will generate 4 questions based on this article's content.

Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms

Abstract Algorithms

Exploring the fascinating world of algorithms, data structures, and software engineering through clear explanations and practical examples.

ยฉ 2026 Abstract Algorithms. All rights reserved.

Powered by Hashnode