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.
repo.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 anif (opt.isPresent()) { ... opt.get() ... }block says in five, and cannot callget()on an empty value by mistake. - Use
orElseGetwhen the default costs something. The argument toorElseis 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,OptionalLongandOptionalDoublefor primitives; they are whatIntStream.max()andaverage()return.
Do not
- Do not return null from a method declared to return Optional. Return
Optional.empty(). - Do not wrap collections. Return an empty
Listinstead ofOptional<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
Serializableand adds an object per field. Keep the field nullable and returnOptional.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
==orsynchronizedon Optionals. They are value-based: compare withequals.
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
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.
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 freeEmpty 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.
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 yetRepository, 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.
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