Java 8 was a turning point. Lambdas and streams changed the language substantially. Then came a long stretch where each release felt like maintenance work — modules in 9 were important but painful, and the subsequent releases improved the language incrementally without a clear headline.
That changed with Java 14–17. The features that landed in this window — records, sealed classes, pattern matching for instanceof, text blocks, switch expressions — are not conveniences. They change how you model domains and reason about code.
This article covers what actually matters in Java 9–17. Not an exhaustive changelog, but the features that should change how you write production Java code.
Java 9: Modules (Use the Idea, Not Necessarily the System)
The Java Platform Module System (JPMS) arrived in Java 9 and was immediately contentious. Libraries were slow to adopt it, tooling was rough, and the migration path from classpath to modulepath was genuinely hard.
The module system itself is still not widely adopted in application code. Most teams still use classpath. That’s a reasonable position.
What JPMS did introduce was a mental model that matters: explicit dependencies and encapsulation at the package level. Even if you don’t use module-info.java, thinking in terms of module boundaries and what each module exports is a useful discipline.
The other Java 9 feature worth knowing: Collection factory methods.
// Before
List<String> items = Collections.unmodifiableList(Arrays.asList("a", "b", "c"));
// Java 9+
List<String> items = List.of("a", "b", "c");
Map<String, Integer> map = Map.of("one", 1, "two", 2);
Set<String> set = Set.of("x", "y", "z");
These return immutable collections. The null-hostile (they throw on null), unmodifiable, and the API is clean. Use them by default.
Also: Optional gained ifPresentOrElse(), or(), and stream() — small but useful additions that make Optional chains less awkward.
Java 10: var
var enables local variable type inference:
var users = userRepository.findAll(); // List<User>
var config = new HashMap<String, String>(); // HashMap<String, String>
The type is still static — var is not dynamic typing. The compiler infers it. This is primarily a readability feature.
Where it helps most is with complex generic types that repeat themselves:
// Before
Map<String, List<OrderLine>> groupedLines = orderLines.stream()
.collect(Collectors.groupingBy(OrderLine::getProductId));
// With var
var groupedLines = orderLines.stream()
.collect(Collectors.groupingBy(OrderLine::getProductId));
Where it hurts: when the inferred type is genuinely non-obvious from the right-hand side. var result = process(data) is usually worse than var result = fetchUserById(id) — the latter gives context, the former hides it.
Rule of thumb: use var when the type is evident from context or verbose to write. Avoid it in public APIs or when the type carries semantic meaning worth preserving.
Java 14: Switch Expressions
Switch expressions (preview in 12, finalized in 14) resolve the longstanding awkwardness of switch statements:
// Old switch statement — fall-through, mutation
String label;
switch (status) {
case PENDING:
label = "Pending";
break;
case ACTIVE:
label = "Active";
break;
default:
label = "Unknown";
}
// Switch expression — exhaustive, no fall-through, returns a value
String label = switch (status) {
case PENDING -> "Pending";
case ACTIVE -> "Active";
default -> "Unknown";
};
The -> syntax eliminates fall-through. Switch expressions produce a value directly, which means they compose naturally with assignments, return statements, and method arguments.
For complex cases, use yield:
int score = switch (grade) {
case A -> 4;
case B -> 3;
case C -> {
log.debug("Marginal grade");
yield 2;
}
case F -> 0;
};
When the switched type is an enum and all cases are covered, the compiler enforces exhaustiveness — you can drop default. This is valuable: adding a new enum constant will produce a compile error at every unhandled switch site.
Java 15: Text Blocks
Text blocks were finalized in Java 15. They solve a problem that every Java developer has hit: readable multi-line strings.
// Before
String json = "{\n" +
" \"userId\": 42,\n" +
" \"status\": \"active\"\n" +
"}";
// Text block
String json = """
{
"userId": 42,
"status": "active"
}
""";
The indentation is stripped relative to the closing """. The string above has no leading whitespace — it’s clean.
Text blocks are particularly useful for:
- SQL queries embedded in code
- JSON/XML in tests
- HTML templates
- Multi-line regular expressions
They don’t add templating. For dynamic values you still use String.format() or formatted():
String query = """
SELECT *
FROM users
WHERE tenant_id = %d
AND status = '%s'
""".formatted(tenantId, status.name());
Java 16: Records
Records are the most impactful Java feature of this period. They address the boilerplate problem for data-carrier classes:
// Record declaration
public record Point(double x, double y) {}
// Equivalent to a class with:
// - final fields x and y
// - constructor taking x and y
// - accessors x() and y()
// - equals(), hashCode(), toString() based on components
Records are immutable by default. All components are final. This is a deliberate design choice — records model data, not mutable state.
You can add methods:
public record Money(BigDecimal amount, Currency currency) {
// Compact constructor for validation
public Money {
Objects.requireNonNull(amount, "amount required");
Objects.requireNonNull(currency, "currency required");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("amount must be non-negative");
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public boolean isZero() {
return amount.compareTo(BigDecimal.ZERO) == 0;
}
}
Records work particularly well for:
- Value objects in domain models
- DTO (Data Transfer Objects)
- Command and event objects
- API request/response types
What records are not: JPA entities (they can’t be mutable), anything that needs inheritance from another class.
Also finalized in Java 16: pattern matching for instanceof:
// Before
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius() * c.radius();
}
// Java 16+
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
}
The pattern variable c is scoped to the if block. No more redundant casting.
Java 17: Sealed Classes
Sealed classes (finalized in Java 17) allow you to restrict which classes can extend or implement a type:
public sealed interface Shape
permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
The permits clause is exhaustive — the compiler knows exactly which types can implement Shape. This enables exhaustive pattern matching in switch:
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — compiler knows all cases are covered
};
If you add a new Shape subtype, every switch site that handles all cases produces a compile error. This is the correctness guarantee that makes sealed classes genuinely useful rather than cosmetic.
Sealed classes work naturally with records to create algebraic data types in Java:
public sealed interface Result<T>
permits Result.Success, Result.Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String message, Throwable cause) implements Result<T> {}
}
This is how functional languages have modeled sum types for decades. Java 17 finally makes it idiomatic.
Putting It Together: Domain Modeling in Modern Java
The combination of records, sealed classes, and pattern matching changes how you model domains. Consider a payment processing result:
public sealed interface PaymentResult
permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Error {
record Approved(String transactionId, Instant processedAt) implements PaymentResult {}
record Declined(String reason, boolean retryable) implements PaymentResult {}
record Error(String message, Throwable cause) implements PaymentResult {}
}
Handling it:
String summary = switch (result) {
case PaymentResult.Approved a ->
"Approved: " + a.transactionId();
case PaymentResult.Declined d when d.retryable() ->
"Declined (retryable): " + d.reason();
case PaymentResult.Declined d ->
"Declined (final): " + d.reason();
case PaymentResult.Error e ->
"Error: " + e.message();
};
The when guard in the second case is available from Java 21. In Java 17 you’d handle the retryable check inside the case body.
What This Means in Practice
These features aren’t independent quality-of-life improvements. They’re a coherent shift in what idiomatic Java looks like:
- Records replace the mutable-by-default JavaBean pattern for data objects
- Sealed classes replace marker interfaces and instanceof chains
- Pattern matching replaces cast-and-check idioms
- Switch expressions replace the procedural switch statement pattern
- Text blocks replace string concatenation for multi-line literals
Java 17 is the current LTS as of most production deployments. If you’re still writing Java 8 style code on a Java 17 runtime, you’re not using half of what the language now offers.
The barrier is usually habit, not difficulty. These features have clean, incremental adoption paths — you don’t need to rewrite anything to start using them in new code today.