Using Optional Well

Use Optional for return values only, avoid the eager orElse trap and isPresent/get pairs, and know when an empty collection or a null is better.

Syntaxrepo.findById(id).map(Mapper::toDto).orElseThrow(() -> new NotFoundException(id)); opt.orElseGet(this::expensiveDefault); // not orElse(expensiveDefault())

Optional was added to give methods a clear way to say "there may be no result". Used for that, it removes a whole class of NullPointerExceptions. Used everywhere, it adds allocation, noise and new ways to go wrong. These are the habits that keep it useful.

Do

  • Return it from lookups that can legitimately find nothing: findById, findByEmail, reading an optional header or config key.
  • Chain instead of unwrapping. findUser(id).map(User::name).orElse("guest") says in one line what an if (opt.isPresent()) { ... opt.get() ... } block says in five, and cannot call get() on an empty value by mistake.
  • Use orElseGet when the default costs something. The argument to orElse is an ordinary argument, so it is always evaluated, even when the Optional has a value. orElse(loadDefaultFromDatabase()) runs the query on every call. orElseGet(this::loadDefaultFromDatabase) runs it only when empty.
  • Use OptionalInt, OptionalLong and OptionalDouble for primitives; they are what IntStream.max() and average() return.

Do not

  • Do not return null from a method declared to return Optional. Return Optional.empty().
  • Do not wrap collections. Return an empty List instead of Optional<List<T>>. "No orders" and "an empty list of orders" are the same answer, and an empty list already iterates zero times.
  • Do not use it for fields. Optional is not Serializable and adds an object per field. Keep the field nullable and return Optional.ofNullable(field) from the getter if callers need it.
  • Do not use it for parameters. Callers then have to wrap every argument, and can still pass null for the Optional itself. Overload the method, or accept a nullable value and handle it once inside.
  • Do not put it in JSON request or response types. Keep DTOs plain with nullable fields or defaults; how a serialiser treats Optional depends on its configuration, and the wire format should not.
  • Do not use == or synchronized on Optionals. They are value-based: compare with equals.

Is Optional slow?

Each non-empty Optional is a small object, and the JIT often removes short-lived ones. For a repository call that just went to the database, the cost is irrelevant. In a loop over millions of elements, or as a map value, it is not, which is another reason to keep it to return values.

The back-end angle

A clean layering is: the repository returns Optional<Entity>; the service either maps it or calls orElseThrow(() -> new NotFoundException(...)); one exception handler turns that into a 404 problem response. Each layer handles absence once, in the place that knows what it means.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turn an empty findById result into a NotFoundException in the service layer and a 404 problem response in one handler.
  • Load a tenant's default settings with orElseGet so the settings query only runs for tenants without their own.
  • Return an empty list of roles for a user with none, so authorisation code can loop without null or Optional checks.

More examples

Replace isPresent and get with one chain

Both versions give the same result, but the chained one has no get() to misuse and no branch to forget. A constant default like 'free' is fine with orElse because it costs nothing.

Example · java
record User(String name, String plan) {}

Optional<User> findUser(String id) {
    return id.equals("u1") ? Optional.of(new User("Ada", "team")) : Optional.empty();
}

// Works, but every call to get() is a NoSuchElementException waiting for a refactor.
String planBefore(String id) {
    Optional<User> user = findUser(id);
    if (user.isPresent()) {
        return user.get().plan();
    }
    return "free";
}

String planAfter(String id) {
    return findUser(id).map(User::plan).orElse("free");
}

void main() {
    for (String id : List.of("u1", "u2")) {
        IO.println(id + " " + planBefore(id) + " " + planAfter(id));
    }
}
// Output:
// u1 team team
// u2 free free

Empty collections and OptionalDouble instead of Optional<List>

Callers can loop over the result of rolesFor without checking anything. OptionalDouble is what the JDK returns for the average of possibly zero numbers, so you handle the empty case without boxing.

Example · java
Map<String, List<String>> rolesByUser = Map.of("ada", List.of("admin", "editor"));

// An empty list already means "none"; no Optional needed.
List<String> rolesFor(String user) {
    return rolesByUser.getOrDefault(user, List.of());
}

String averageLatency(int[] millis) {
    OptionalDouble avg = Arrays.stream(millis).average();
    return avg.isPresent() ? "avg " + avg.getAsDouble() + " ms" : "no requests yet";
}

void main() {
    IO.println(rolesFor("ada"));
    IO.println(rolesFor("bo").size());
    IO.println(averageLatency(new int[] {120, 80, 100}));
    IO.println(averageLatency(new int[] {}));
}
// Output:
// [admin, editor]
// 0
// avg 100.0 ms
// no requests yet

Repository, service and handler each handle absence once

Optional stops at the service boundary; the DTO and the HTTP layer never see it. This is the same shape a Spring service and an exception handler use.

Example · java
class NotFoundException extends RuntimeException {
    NotFoundException(String message) {
        super(message);
    }
}

record Product(String sku, String name, long pricePence) {}

record ProductDto(String sku, String name, String price) {}

final Map<String, Product> table = Map.of("SKU-1", new Product("SKU-1", "Mug", 899));

// Repository: may find nothing, so it says so in the type.
Optional<Product> findBySku(String sku) {
    return Optional.ofNullable(table.get(sku));
}

// Service: absence is an error here, so it becomes an exception.
ProductDto getProduct(String sku) {
    return findBySku(sku)
            .map(p -> new ProductDto(p.sku(), p.name(), String.format("%d.%02d", p.pricePence() / 100, p.pricePence() % 100)))
            .orElseThrow(() -> new NotFoundException("product " + sku + " not found"));
}

// Handler: one place maps the exception to an HTTP status.
String handle(String sku) {
    try {
        return "200 " + getProduct(sku);
    } catch (NotFoundException e) {
        return "404 " + e.getMessage();
    }
}

void main() {
    IO.println(handle("SKU-1"));
    IO.println(handle("SKU-2"));
}
// Output:
// 200 ProductDto[sku=SKU-1, name=Mug, price=8.99]
// 404 product SKU-2 not found

Discussion

  • Be the first to comment on this lesson.