Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 008 / 100

Java Optional done right: a hands-on guide for engineers

Optional is a return type for maybe-missing values, not a replacement for null everywhere.

Expona Admin7 min read

Introduction

Optional is one of the most misunderstood features in modern Java. We've all seen codebases where Optional is sprinkled everywhere—in entity fields, method parameters, collections, and places it was never meant to be. The truth is simpler: Java Optional done right: a hands-on guide for engineers means treating Optional as a return type for computations that might not have a value, not as a replacement for defensive null checks everywhere.

In this guide, we'll walk through the patterns that work in production systems and show you the mistakes we've learned to avoid. We're building on real experiences from e-commerce platforms, SaaS backends, and microservices where Optional handling directly impacts reliability and maintainability.

Return Optional from finders, never accept it as a parameter

The primary use case for Optional is as a return type from methods that search or fetch values. Think of repository queries, configuration lookups, or cache misses. When a method might legitimately return nothing, Optional signals that intent clearly to callers.

Here's how it looks in a typical user repository:

java

public class UserRepository {
  private final JdbcTemplate jdbc;

  public Optional<User> findById(Long id) {
    return jdbc.query(
      "SELECT * FROM users WHERE id = ?",
      new Object[]{id},
      rs -> {
        rs.next();
        return new User(rs.getLong("id"), rs.getString("name"));
      }
    ).stream().findFirst();
  }

  public Optional<User> findByEmail(String email) {
    return jdbc.query(
      "SELECT * FROM users WHERE email = ?",
      new Object[]{email},
      (rs, row) -> new User(rs.getLong("id"), rs.getString("name"))
    ).stream().findFirst();
  }
}

This is clean. The caller immediately knows that findById might return nothing. They handle it explicitly, right where it matters.

Now, the opposite pattern—accepting Optional as a parameter—creates friction:

java

// Avoid this
public void updateUser(Long id, Optional<String> newName) {
  if (newName.isPresent()) {
    // update logic
  }
}

// Better
public void updateUser(Long id, String newName) {
  if (newName == null) {
    throw new IllegalArgumentException("newName cannot be null");
  }
  // update logic
}

// Best for truly optional fields
public void updateUser(Long id, Optional<String> newName) {
  newName.ifPresent(name -> {
    // update logic
  });
}

Why? Because the caller has to construct an Optional to pass it in, and Optional wasn't designed for that. If a method has optional inputs, either make them nullable with clear null-checks (Java doesn't have non-nullable types), use overloads, or use a builder pattern. Optional's strength is in the return type, where it makes the "maybe value" contract explicit.

Use orElseThrow for required values

When you need a value and it must exist, orElseThrow is your friend. It's explicit, intentional, and makes failure obvious.

In a Spring service fetching a user from the database:

java

@Service
public class UserService {
  private final UserRepository repo;

  public UserDto getUserOrFail(Long userId) {
    User user = repo.findById(userId)
      .orElseThrow(() -> new UserNotFoundException("User " + userId + " not found"));
    return UserDto.from(user);
  }
}

The error message is clear, the exception type is specific, and anyone reading the code knows that failure is expected and handled. Compare that to silent failures or ambiguous null dereferences.

There's also orElseThrow() with no argument, which throws NoSuchElementException. Use that only in contexts where you're certain a value exists (like after filtering a stream), not for user-facing APIs where a custom exception message helps debugging:

java

// Acceptable in tests or certain internal flows
User admin = users.stream()
  .filter(u -> u.hasRole("ADMIN"))
  .findFirst()
  .orElseThrow();

// Better for production APIs
User admin = users.stream()
  .filter(u -> u.hasRole("ADMIN"))
  .findFirst()
  .orElseThrow(() -> new SecurityException("No admin user found"));

Use map and flatMap instead of isPresent + get

The imperative pattern of if (optional.isPresent()) { optional.get() } is the enemy of readable code. Instead, we use functional transformations. map transforms a present value; flatMap chains operations that themselves return Optional.

Consider a scenario where we fetch a user and then load their profile settings:

java

// Avoid this
public Optional<ProfileSettings> getUserSettings(Long userId) {
  Optional<User> user = userRepo.findById(userId);
  if (user.isPresent()) {
    Optional<ProfileSettings> settings = settingsRepo.findByUserId(user.get().getId());
    if (settings.isPresent()) {
      return settings;
    }
  }
  return Optional.empty();
}

// Better
public Optional<ProfileSettings> getUserSettings(Long userId) {
  return userRepo.findById(userId)
    .flatMap(user -> settingsRepo.findByUserId(user.getId()));
}

With flatMap, we chain without the noise. If either step returns empty, the whole chain returns empty. If both succeed, we get the final value. It's declarative and concise.

For simple transformations, map does the work:

java

public Optional<String> getUserEmail(Long userId) {
  return userRepo.findById(userId)
    .map(User::getEmail);
}

public Optional<UserDto> getUserDto(Long userId) {
  return userRepo.findById(userId)
    .map(UserDto::from);
}

This is especially powerful in REST controllers or query handlers where you're chaining multiple operations and the absence of a value should bubble up gracefully.

Never put Optional in fields or collections

Optional was designed to be a return type, not a persistent container. Putting it in entity fields, JPA entities, or collections defeats its purpose and adds serialization overhead.

Here's the anti-pattern:

java

// Avoid in entity or DTO
@Entity
public class User {
  @Id
  private Long id;
  private String email;
  private Optional<String> phone;  // Wrong
  private Optional<LocalDate> lastLogin;  // Wrong
}

// Also avoid in collections
public List<Optional<User>> searchUsers(String query) {
  return userRepo.search(query);  // Wrong
}

Why? Because:

  • Serialization is messy. Optional serializes with extra wrapper fields. JSON becomes bloated.
  • Nullability is clearer at the field level. Use nullable annotations like @Nullable from Spring or Jackson's @JsonInclude.
  • Database and ORM mappings don't understand Optional. JPA doesn't know how to map an Optional to a column; you'll fight the framework.

The right approach for optional entity fields is simple nullability:

java

@Entity
public class User {
  @Id
  private Long id;
  
  @Column(nullable = false)
  private String email;
  
  @Nullable
  @Column(nullable = true)
  private String phone;
  
  @Nullable
  private LocalDate lastLogin;
}

For collections, filter or map instead:

java

// Instead of List<Optional<User>>
public List<User> searchUsers(String query) {
  return userRepo.search(query);  // Direct list
}

// If you need to handle partial results
public List<User> getActiveUsers(List<Long> ids) {
  return userRepo.findAllById(ids);  // Only returns what exists
}

Common mistakes

Calling get() without checking. This defeats the entire purpose of Optional and leaves you with NullPointerException. Always use orElseThrow, orElse, or chain with map.

Using Optional.of() when you're not sure the value is present. Optional.of(null) throws immediately. Use Optional.ofNullable(value) if unsure, or better yet, return from a method that guarantees non-null.

Returning Optional<List> or Optional<Set>. An empty list is not the same as a missing value. Return an empty collection directly, not wrapped in Optional.

java

// Avoid
public Optional<List<Order>> getUserOrders(Long userId) {
  return userRepo.findById(userId)
    .map(user -> orderRepo.findByUserId(user.getId()));
}

// Better
public List<Order> getUserOrders(Long userId) {
  return userRepo.findById(userId)
    .map(user -> orderRepo.findByUserId(user.getId()))
    .orElseGet(Collections::emptyList);
}

Overusing orElse with expensive operations. orElse evaluates its argument eagerly; use orElseGet for lazy evaluation, especially when creating new objects or hitting the database.

java

// This fetches user even if optional is present
user.orElse(userRepo.findDefault());

// This only fetches if needed
user.orElseGet(() -> userRepo.findDefault());

Key takeaways

  • Optional is a return type for computations that might not have a value. Use it in finder methods, queries, and operations that can legitimately fail.
  • Never use Optional as a method parameter or entity field. It adds complexity without benefit.
  • Use orElseThrow when a value is required; always provide a meaningful exception message.
  • Chain operations with map and flatMap instead of imperative isPresent checks.
  • Distinguish between absent values (return Optional) and empty collections (return empty List). They're not the same thing.
  • Be mindful of eager vs. lazy evaluation: use orElseGet when the fallback is expensive.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles