Java 8 to Java 21 Migration: A Practical Step-by-Step Upgrade Guide for Developers

Migrate from Java 8 to Java 21 with confidence. Learn how to replace deprecated APIs, adopt records, sealed classes, and modules step by step. Start upgrading today.

Java 8 to Java 21 Migration: A Practical Step-by-Step Upgrade Guide for Developers

I have been through a few major Java upgrades in my career, and let me tell you—each one felt like moving to a new house. You have to pack everything carefully, decide what to throw away, and then set things up in a way that makes sense for the new space. The move from Java 8 to Java 21 is similar. The language gained records, sealed classes, pattern matching, text blocks, and a module system. But if you try to adopt everything at once, you will break things and frustrate your team. I have learned to take it one pattern at a time.

The first thing you need to do before touching any code is to run your application on the target JDK version with warnings turned on. I usually run a full build with -Xlint:all and also use jdeps. This tool tells me which internal APIs my code and libraries rely on. Internal APIs are those that were never meant to be public, like sun.misc.BASE64Encoder. They are now removed or encapsulated. I have seen projects stuck because they didn’t check early. So do this: jdeps --multi-release 21 -cp my-app.jar. The report will show you every unsupported call. Then fix each one by replacing with the official API. For example, replace sun.misc.BASE64Encoder with java.util.Base64.

jdeps --multi-release 21 --class-path . --module-path . my-app.jar

Now you have a list of trouble spots. The next step is to replace deprecated APIs. Java 9 removed finalize(). I once had a legacy system that used finalizers to close database connections. Big mistake. Replace those with Cleaner or better yet, implement AutoCloseable. Cleaner is more reliable but a bit more work. Show me your finalize method and I will show you a cleaner version.

// Old way - don't do this
@Override
protected void finalize() throws Throwable {
    try {
        connection.close();
    } finally {
        super.finalize();
    }
}

// New way
public class DatabaseConnection implements AutoCloseable {
    private final Connection conn;
    public DatabaseConnection(String url) { conn = DriverManager.getConnection(url); }
    @Override
    public void close() { conn.close(); }
}

Use try-with-resources and let the caller manage the lifecycle. It is safer and faster.

Another common replacement is the old HttpURLConnection. Java 11 introduced HttpClient, which is much nicer. I remember rewriting a REST client that had dozens of boilerplate lines. It became half the size and supported HTTP/2 automatically.

// Before
HttpURLConnection con = (HttpURLConnection) new URL("https://api.example.com").openConnection();
con.setRequestMethod("GET");
int status = con.getResponseCode();
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String line;
StringBuffer content = new StringBuffer();
while ((line = in.readLine()) != null) {
    content.append(line);
}
in.close();
con.disconnect();

// After
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.example.com")).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String content = response.body();

See how clean that is? No streams to close, no manual encoding handling. You should update any code that makes HTTP calls. Do it after you have tests in place.

Now, the module system. This is the scariest part for most people. I do not recommend modularizing your entire application at once. Instead, start with a small, isolated module that has no dependencies on your own code. For example, a utility library. Write a module-info.java file inside src/main/java. List the JDK modules it needs and export the packages you want others to use.

module com.example.utils {
    requires java.sql;
    exports com.example.utils.database;
    exports com.example.utils.json;
}

For any third-party JARs you place on the classpath, they become automatic modules. The module name is derived from the JAR filename. To avoid surprises, add Automatic-Module-Name to the manifest of your own JARs. I do this in my build scripts.

jar {
    manifest {
        attributes('Automatic-Module-Name': 'com.example.myapp')
    }
}

Once the small module works, you can add more modules gradually, merging module-info.java files as you go. Do not force everything into the module path at once. Keep some classes on the classpath until they are ready.

While you are at it, introduce var for local variables. I was skeptical at first, but now I use it all the time. The rule is simple: use var only when the right-hand side makes the type obvious. For example, var list = new ArrayList<String>(); is clear. But var result = calculateSomething(); is not. I saw a codebase where everything was var, and it became impossible to tell what a method returned. So be smart. Start with new code, then refactor old loops and streams.

// Before
Map<String, List<Customer>> groups = new HashMap<>();

// After
var groups = new HashMap<String, List<Customer>>();

The compiler still checks types, so you are safe. But your IDE may need to infer types for you. That is fine.

Anonymous inner classes for event listeners and comparators were the norm in Java 8. Replace them with lambdas. I remember a project where every button had a 10-line anonymous class. Now it’s one line.

// Before
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("Click");
    }
});

// After
button.addActionListener(e -> System.out.println("Click"));

If the lambda just calls a method, use a method reference: button.addActionListener(this::onClick). It is shorter and tells you exactly what happens.

Now, let’s talk about immutable collections. Java 9 gave us List.of(), Set.of(), and Map.of(). Replace old patterns like Collections.unmodifiableList(new ArrayList<>(...)) with these factory methods. They are shorter and produce compact objects. I use them for constants and configuration all the time.

// Before
private static final List<String> ROLES = Collections.unmodifiableList(
        Arrays.asList("ADMIN", "USER"));

// After
private static final List<String> ROLES = List.of("ADMIN", "USER");

The new methods throw NullPointerException if you pass null, which is good—catch bugs early.

Switch statements can be rewritten as switch expressions. I love the arrow syntax because it removes accidental fall-through and the need for break. Start with simple enums, then move to integers and strings.

// Old switch
String response;
switch (statusCode) {
    case 200: response = "OK"; break;
    case 404: response = "Not Found"; break;
    default: response = "Unknown";
}

// New switch expression
String response = switch (statusCode) {
    case 200 -> "OK";
    case 404 -> "Not Found";
    default -> "Unknown";
};

In Java 17+, you can also use pattern matching for instanceof. That saves you from writing the cast.

// Old
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// New
if (obj instanceof String s) {
    System.out.println(s.length());
}

I started using pattern matching in all new code. It is one of those features that feels natural after a week.

Sealed classes are for when you want to control all implementations of an interface. I use them for domain models like payment methods or order statuses. They make switch expressions exhaustive—the compiler warns you if you miss a case.

public sealed interface Payment permits CreditCard, PayPal, Crypto {}
public final class CreditCard implements Payment { ... }
public final class PayPal implements Payment { ... }
public final class Crypto implements Payment { ... }

String fee = switch (payment) {
    case CreditCard c -> "2.5%";
    case PayPal p -> "3%";
    case Crypto c -> "1%";
};

If you add a new payment type, you will get a compile error until you update the switch. That is priceless.

Records are a no‑brainer for data carriers. I used to write a lot of POJOs with constructors, getters, equals, hashcode, and toString. Now I write them in one line.

// Old
public class User {
    private final String name;
    private final int age;
    public User(String name, int age) { this.name = name; this.age = age; }
    public String getName() { return name; }
    public int getAge() { return age; }
    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
    @Override public String toString() { return "User(" + name + ", " + age + ")"; }
}

// New
public record User(String name, int age) {}

Records are immutable by default, which is excellent for DTOs and value objects. But don’t use them for mutable state or if you need inheritance. That is not what they are for.

Finally, text blocks. Every time I see a multi-line SQL or JSON written with string concatenation and \n, I cringe. Text blocks are a gift.

// Before
String sql = "SELECT id, name\n" +
             "FROM users\n" +
             "WHERE active = true";

// After
String sql = """
    SELECT id, name
    FROM users
    WHERE active = true
    """;

You don’t need to escape double quotes inside. The indentation is stripped automatically. Use them for any multi-line string.

Each of these patterns is independent. I usually pick one per sprint and apply it across the codebase. The important thing is to have a solid test suite before you start. Run tests after every few changes. I have learned the hard way that skipping tests leads to midnight panics.

When I first upgraded a large Java 8 application to Java 21, I started with the module system on a small library, then moved to records for data objects. My team complained at first because change is hard. But after a few weeks, they loved the new features. The code became shorter, less error-prone, and easier to read.

I still remember the day I ran jdeps and found 47 internal API uses. Some were in our own code, most were in libraries. Updating libraries took a week. But once that was done, the rest of the migration went smoothly. So start there. Audit, replace deprecated APIs, then introduce new language features one by one.

You can do it too. Don’t be afraid to break things—just break them in small pieces with tests. The Java platform has been evolving for the better. Embrace it slowly, and your codebase will thank you.


// Keep Reading

Similar Articles

Mastering Rust Enums: 15 Advanced Techniques for Powerful and Flexible Code
Java

Mastering Rust Enums: 15 Advanced Techniques for Powerful and Flexible Code

Rust's advanced enum patterns offer powerful techniques for complex programming. They enable recursive structures, generic type-safe state machines, polymorphic systems with traits, visitor patterns, extensible APIs, and domain-specific languages. Enums also excel in error handling, implementing state machines, and type-level programming, making them versatile tools for building robust and expressive code.

Read Article →