Skip to content
parvezive
Subscribe

Practical notes on leadership and architecture, every other week.

Subscribe to the newsletter
All writing
Java

Modern Java for people who learned it in 2005

If your mental picture of Java still involves getters, setters and anonymous inner classes, the language has quietly become a different one. Here are the features that change how everyday code looks.

Modern Java for people who learned it in 2005: title card in the parvezive style

Parvez · · 4 min read

Share

I first wrote Java for a living in the late nineties, when Java 2 was new and generics were still years away. A lot of engineers of my generation learned the language in that first decade, moved into leadership or other stacks, and still carry a picture of Java that's twenty years out of date: verbose, ceremonial, a getter and a setter for every field.

That picture is wrong now. Since Java 8, and especially since the six-month release cadence started with Java 10, the language has changed more than in its whole first decade. If you review Java code, make technology decisions or are coming back to it after a break, here's what's different, shown the way I wish someone had shown me: before and after.

Records: data without the ceremony

The old way to hold a bit of data was a class with private fields, a constructor, getters, equals, hashCode and toString. Fifty lines for three values, and a bug waiting to happen the day someone adds a field and forgets to update equals.

// 2005
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }

    // equals, hashCode and toString: another 25 lines
}
// Today
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("Too many decimal places for " + currency);
        }
    }
}

A record is an immutable carrier for data. You get the constructor, the accessors (amount(), not getAmount()), equals, hashCode and toString for free, and you can still add validation in a compact constructor. Use records for value objects, DTOs, events and results. Don't use them for entities whose identity matters more than their values, and don't fight them by trying to make them mutable.

Sealed types: say exactly what's allowed

Java has always had a way to say "this class has subclasses". It never had a way to say "and these are all of them". Sealed interfaces fix that.

public sealed interface PaymentResult permits Approved, Declined, NeedsReview {}

public record Approved(String reference) implements PaymentResult {}
public record Declined(String reason) implements PaymentResult {}
public record NeedsReview(String ticketId) implements PaymentResult {}

Now the compiler knows the full set of possible results. On its own that's documentation. Combined with the next feature, it becomes a safety net.

Pattern matching and switch expressions

Here's how we used to handle a value that could be one of several types:

// 2005
if (result instanceof Approved) {
    Approved a = (Approved) result;
    return "Paid: " + a.getReference();
} else if (result instanceof Declined) {
    Declined d = (Declined) result;
    return "Declined: " + d.getReason();
} else {
    throw new IllegalStateException("Unknown result " + result);
}

And today:

return switch (result) {
    case Approved(var reference) -> "Paid: " + reference;
    case Declined(var reason)    -> "Declined: " + reason;
    case NeedsReview(var ticket) -> "Under review: " + ticket;
};

Three things changed at once. Switch is now an expression that returns a value. Patterns test the type and pull out the fields in one step. And because PaymentResult is sealed, there's no default branch: if someone adds a fourth result type next year, every switch that doesn't handle it fails to compile. That's a review comment you never have to write.

Make illegal states unrepresentable, then let the compiler do the reviewing.

Smaller things that add up

var for local variables

var lets the compiler infer a local variable's type. It's not dynamic typing: the type is fixed at compile time, you just don't repeat it. Use it when the type is obvious from the right-hand side, and skip it when it isn't. var orders = new ArrayList<Order>() reads fine; var x = service.process() does not.

Text blocks

Multi-line strings finally look like what they contain. SQL, JSON and HTML in tests stop being a mess of quotes, plus signs and escaped newlines.

String query = """
    SELECT id, total
    FROM orders
    WHERE customer_id = ?
      AND status = 'OPEN'
    """;

Better null handling and helpful errors

Optional, introduced in Java 8, is useful as a return type for "might not be there". It's not meant for fields or parameters. And since Java 14, a NullPointerException tells you exactly which reference was null, which has saved more debugging hours than any library I can think of.

Virtual threads: the biggest change since Java 8

For two decades, Java gave us a hard choice for servers that spend most of their time waiting on databases and other services. Write simple blocking code, one platform thread per request, and run out of threads under load. Or switch to reactive or callback-style code that scales but is much harder to read, debug and hire for.

Virtual threads, final since Java 21, remove most of that trade-off. They're threads managed by the JVM rather than the operating system, cheap enough to create one per task, even a million of them.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var order : orders) {
        executor.submit(() -> fulfilmentClient.ship(order));
    }
}

That's ordinary blocking code. While one virtual thread waits on I/O, the JVM parks it and runs another on the same carrier thread. In Spring Boot it's a single property to switch on.

A few cautions from moving real services over:

  • They don't make CPU-heavy work faster. They remove the cost of waiting, not the cost of working.
  • The bottleneck moves. Once you can run ten thousand concurrent requests, your database connection pool becomes the limit. Load-test with realistic pool sizes.
  • Watch for pinning. Long synchronized blocks around I/O, in your code or in older libraries, can tie up the carrier thread. Recent JDK versions have reduced this a lot, but it's worth checking.

How to adopt it on an existing codebase

Don't rewrite anything for the sake of new syntax. Instead:

  1. Get onto a current long-term-support release. Java 21 or 25. Most of the features above need it, and the performance gains alone usually justify the upgrade.
  2. Use the new features in new code. New DTOs are records. New result types are sealed. Leave the old code until you're changing it anyway.
  3. Agree a short team guide. When to use var, where Optional belongs, when records are appropriate. Half a page avoids a hundred review debates.
  4. Try virtual threads on one I/O-heavy service first, with a load test before and after.

If you left Java years ago because it felt heavy, it's worth another look. It's still the language I'd choose for a system that has to run, and be changed safely, for the next fifteen years.

JavaModern JavaRecordsVirtual threads

Written by Parvez

Engineering Manager at Innova Solutions and a software architect with 25+ years in the industry. I write about leadership, architecture, Java and the books that shaped how I work.

More about me

Keep reading

All posts

Get the next essay in your inbox

Practical notes on engineering leadership and architecture, written for people doing the work. Free, every other week.