Optional & Modern Java

Project: Refactoring to Modern Java

15 min Lesson 10 of 13

Project: Refactoring to Modern Java

Throughout this tutorial you have learned each modern feature in isolation. Now you bring them together. In this project lesson you start with a realistic chunk of verbose, pre-Java-17 code and progressively refactor it using Optional, var, switch expressions, and enhanced instanceof. The goal is not to use every feature everywhere — it is to identify the right tool for each pain point and understand the trade-offs before committing.

The Starting Code

Below is a small order-processing fragment you might have written in Java 8. It is correct but noisy: null checks everywhere, casting with instanceof, and a chain of if/else if for status mapping.

// --- BEFORE: pre-Java-17 style --- import java.util.HashMap; import java.util.Map; public class OrderProcessor { private Map<Integer, String> orderDb = new HashMap<>(); public OrderProcessor() { orderDb.put(1, "Alice:PENDING:99.99"); orderDb.put(2, "Bob:SHIPPED:149.50"); orderDb.put(3, "Carol:DELIVERED:220.00"); } // Returns null when the order is not found public String findRaw(int id) { return orderDb.get(id); } public String getCustomerName(int id) { String raw = findRaw(id); if (raw == null) { return "UNKNOWN"; } String[] parts = raw.split(":"); return parts[0]; } public String mapStatus(Object statusObj) { if (statusObj instanceof String) { String status = (String) statusObj; if (status.equals("PENDING")) { return "Awaiting processing"; } else if (status.equals("SHIPPED")) { return "On its way"; } else if (status.equals("DELIVERED")) { return "Delivered"; } else { return "Unknown status"; } } return "Invalid input"; } public static void main(String[] args) { OrderProcessor processor = new OrderProcessor(); System.out.println(processor.getCustomerName(1)); // Alice System.out.println(processor.getCustomerName(99)); // UNKNOWN System.out.println(processor.mapStatus("SHIPPED")); // On its way System.out.println(processor.mapStatus(42)); // Invalid input } }

This compiles and works. But there are four distinct pain points worth addressing:

  1. findRaw returns null — callers must remember to check, and nothing in the type signature warns them.
  2. Local variables like String raw and String[] parts repeat type information the compiler already knows.
  3. The mapStatus method uses an old-style cast (String) statusObj after an instanceof check — redundant.
  4. The if/else if chain for status mapping is verbose and does not benefit from exhaustiveness checking.

Step 1 — Replace Null Returns with Optional

Change findRaw to return Optional<String>. This makes the absence explicit in the type and forces callers to handle it:

import java.util.Optional; public Optional<String> findRaw(int id) { return Optional.ofNullable(orderDb.get(id)); } public String getCustomerName(int id) { return findRaw(id) .map(raw -> raw.split(":")[0]) .orElse("UNKNOWN"); }

The null check is gone. The pipeline reads: if a value is present, split and take index 0; if not, use "UNKNOWN". The transformation and the default are co-located, which is easier to scan than a scattered if (raw == null).

Why not just check isPresent()? Writing if (opt.isPresent()) { … opt.get() … } re-introduces the same imperative pattern you had with null. Prefer map, flatMap, and orElse — they keep the intent functional and compose cleanly.

Step 2 — Reduce Type Noise with var

Inside methods with longer pipelines, repeated type annotations add ceremony without adding information. Use var for local variables where the right-hand side already makes the type obvious:

public void printOrderSummary(int id) { var rawOpt = findRaw(id); // Optional<String> var display = rawOpt.orElse("order not found"); // String var parts = display.split(":"); // String[] System.out.println("Customer : " + parts[0]); if (parts.length > 1) { System.out.println("Status : " + parts[1]); } }
Do not over-apply var. It is ideal when the type is obvious from context (constructor calls, factory methods, stream pipelines). Avoid it when the initialiser is an opaque method call whose return type is not clear from its name — you lose the documentation value of the explicit type.

Step 3 — Modernise instanceof with Pattern Variables

The old cast (String) statusObj after instanceof String is boilerplate. Java 16+ lets you bind the cast result to a variable in one step:

// BEFORE if (statusObj instanceof String) { String status = (String) statusObj; // ... use status } // AFTER if (statusObj instanceof String status) { // status is already a String — no cast needed System.out.println(status.toLowerCase()); }

The pattern variable status is scoped to the true branch of the if. If statusObj is not a String, the variable simply does not exist — no risk of a ClassCastException.

Step 4 — Replace if/else Chains with Switch Expressions

The mapStatus method is the best candidate for a switch expression. It maps one value to another, has no side effects, and every branch returns a result:

public String mapStatus(Object statusObj) { if (!(statusObj instanceof String status)) { return "Invalid input"; } return switch (status) { case "PENDING" -> "Awaiting processing"; case "SHIPPED" -> "On its way"; case "DELIVERED" -> "Delivered"; default -> "Unknown status"; }; }

This version combines steps 3 and 4: the guard at the top handles the non-String case once, and the switch expression handles the String cases without any break statements or fall-through risk. Every arm is a single expression — clean and exhaustive.

Switch expressions must be exhaustive. The compiler enforces that all possible input values are covered (by a default arm or by listing all enum constants). This is a compile-time safety net that if/else if chains cannot provide.

The Fully Refactored Class

Putting all four steps together:

import java.util.HashMap; import java.util.Map; import java.util.Optional; public class OrderProcessor { private final Map<Integer, String> orderDb = new HashMap<>(); public OrderProcessor() { orderDb.put(1, "Alice:PENDING:99.99"); orderDb.put(2, "Bob:SHIPPED:149.50"); orderDb.put(3, "Carol:DELIVERED:220.00"); } // Step 1 — explicit absence via Optional public Optional<String> findRaw(int id) { return Optional.ofNullable(orderDb.get(id)); } // Steps 1 + 2 — Optional pipeline, var for locals public String getCustomerName(int id) { return findRaw(id) .map(raw -> raw.split(":")[0]) .orElse("UNKNOWN"); } public void printOrderSummary(int id) { var rawOpt = findRaw(id); var display = rawOpt.orElse("order not found"); var parts = display.split(":"); System.out.println("Customer : " + parts[0]); if (parts.length > 1) System.out.println("Status : " + parts[1]); } // Steps 3 + 4 — pattern instanceof + switch expression public String mapStatus(Object statusObj) { if (!(statusObj instanceof String status)) return "Invalid input"; return switch (status) { case "PENDING" -> "Awaiting processing"; case "SHIPPED" -> "On its way"; case "DELIVERED" -> "Delivered"; default -> "Unknown status"; }; } public static void main(String[] args) { var processor = new OrderProcessor(); // var: type obvious from constructor System.out.println(processor.getCustomerName(1)); // Alice System.out.println(processor.getCustomerName(99)); // UNKNOWN System.out.println(processor.mapStatus("SHIPPED")); // On its way System.out.println(processor.mapStatus(42)); // Invalid input processor.printOrderSummary(2); } }

When Not to Refactor

Modern idioms are not always improvements. Keep the older style when:

  • The codebase targets a Java version older than the feature requires (check your --release flag).
  • var would hide a non-obvious type in a public API or a complex initialiser.
  • An Optional would be stored as a field or passed as a method parameter — this is an anti-pattern that adds wrapping cost with no benefit.
  • A switch expression would need yield for multi-statement arms so often that a traditional switch statement reads more clearly.

Summary

Refactoring to modern Java is a targeted exercise: identify a specific pain point (null leak, noisy cast, verbose branching), apply the feature that resolves it, and verify the behaviour is unchanged. The result is code with the same runtime semantics but higher signal-to-noise ratio — easier to read, harder to misuse, and in the case of Optional and switch expressions, partially verified by the compiler itself.