Optional en Java : bien l'utiliser et éviter les anti-patterns
1. Aperçu
Optional est apparu avec Java 8 pour représenter une valeur “qui peut ne pas exister” de manière plus explicite que null. Mais beaucoup de développeurs l’utilisent mal : ils abusent d’Optional en tant que champ, paramètre, ou appellent .get() comme s’il n’était jamais vide. Cet article vous aide à comprendre quand utiliser Optional, les bons patterns, et énumère les anti-patterns courants à éviter.
Après la lecture, vous saurez comment concevoir une API qui retourne Optional de manière appropriée, traiter les valeurs vides élégamment avec map/filter/orElseGet, et identifier immédiatement les bouts de code “faux Optional” qui ont besoin d’être refactorisés.
2. Qu’est-ce que Optional et quand l’utiliser
Optional<T> est un conteneur qui peut contenir ou non une valeur de type T. Son objectif principal : clarifier dans la signature de la méthode que la valeur retournée peut ne pas exister, forcer l’appelant à gérer ce cas au lieu d’oublier une vérification null.
public class CustomerRepository {
private final Map<Long, Customer> store = new HashMap<>();
public Optional<Customer> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
}
findById retourne Optional<Customer> au lieu de Customer (qui pourrait être null). L’appelant voit immédiatement à partir de la signature qu’il doit gérer le cas de non-trouvé, sans avoir besoin de lire la Javadoc.
Remarque :
Optionalest conçu pour les valeurs retournées, et non pour tous les endroits où il pourrait y avoir null dans le système.
3. Les façons de créer Optional
Optional<String> present = Optional.of("Hello"); // valeur garantie différente de null
Optional<String> empty = Optional.empty(); // vide
Optional<String> nullable = Optional.ofNullable(null); // peut être null, se transforme automatiquement en empty
Optional.of(value)lève uneNullPointerExceptionimmédiatement sivalueestnull— à utiliser quand vous êtes sûr que la valeur n’est pas null.Optional.ofNullable(value)est plus sûr, adapté quand la valeur provient d’une source non contrôlée (résultat de requête, map.get…).Optional.empty()crée une instance vide, à utiliser quand vous savez à l’avance qu’il n’y a pas de valeur.
4. Traiter la valeur à l’intérieur correctement
4.1. map, filter, flatMap
public record Order(Long id, BigDecimal amount, String couponCode) {}
public class DiscountService {
public Optional<BigDecimal> calculateDiscount(Order order) {
return Optional.ofNullable(order.couponCode())
.filter(code -> code.startsWith("SALE"))
.map(code -> order.amount().multiply(BigDecimal.valueOf(0.1)));
}
}
filter ne conserve la valeur que si la condition est vraie, sinon retourne Optional.empty(). map transforme la valeur à l’intérieur si elle existe, ne fait rien si elle est vide. Cette chaîne évite complètement les if-else imbriqués pour vérifier null.
4.2. orElse, orElseGet, orElseThrow
public class PaymentService {
private final CustomerRepository repository;
public PaymentService(CustomerRepository repository) {
this.repository = repository;
}
public Customer resolveCustomer(Long id) {
return repository.findById(id)
.orElseThrow(() -> new CustomerNotFoundException(id));
}
public String resolveCustomerName(Long id) {
return repository.findById(id)
.map(Customer::name)
.orElse("Guest");
}
}
orElseThrow(supplier)lève une exception significative quand rien n’est trouvé — à utiliser là où la valeur est obligatoire.orElse("Guest")à utiliser quand la valeur par défaut est une constante simple, toujours évaluée même si Optional a une valeur.orElseGet(supplier)à utiliser quand la valeur par défaut est coûteuse à calculer — appelle le supplier uniquement quand Optional est vide.
// Incorrect : appelle toujours buildDefaultCustomer() que Optional ait une valeur ou non
repository.findById(id).orElse(buildDefaultCustomer());
// Correct : appelle uniquement si vraiment nécessaire
repository.findById(id).orElseGet(this::buildDefaultCustomer);
4.3. ifPresent et ifPresentOrElse
public class NotificationService {
public void notifyCustomer(Optional<Customer> customer) {
customer.ifPresentOrElse(
c -> System.out.println("Envoyer un email à " + c.email()),
() -> System.out.println("Pas de client auquel envoyer")
);
}
}
ifPresentOrElse (à partir de Java 9) combine les deux branches avec/sans valeur dans un seul appel, au lieu de if (optional.isPresent()) {...} else {...} qui est verbeux.
5. Tester le comportement avec JUnit 5 + AssertJ
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
class DiscountServiceTest {
private final DiscountService service = new DiscountService();
@Test
void shouldReturnDiscountWhenCouponValid() {
Order order = new Order(1L, BigDecimal.valueOf(100), "SALE10");
Optional<BigDecimal> discount = service.calculateDiscount(order);
assertThat(discount)
.isPresent()
.get()
.isEqualTo(BigDecimal.valueOf(10.0));
}
@Test
void shouldReturnEmptyWhenNoCoupon() {
Order order = new Order(2L, BigDecimal.valueOf(100), null);
Optional<BigDecimal> discount = service.calculateDiscount(order);
assertThat(discount).isEmpty();
}
}
AssertJ a ses propres assertions spécialisées pour Optional : isPresent(), isEmpty(), hasValue(...). Utilisez-les au lieu d’appeler .get() puis de comparer, votre code de test sera plus naturel à lire.
6. Les anti-patterns courants
6.1. Appeler .get() sans vérification
// Incorrect : si Optional est vide, lève NoSuchElementException,
// élimine complètement la raison d'utiliser Optional
Customer customer = repository.findById(id).get();
Appeler .get() directement rend Optional pas différent de null enrobé dans une couche supplémentaire — vous pouvez toujours crash à l’exécution, mais vous gaspillez une ligne supplémentaire. Utilisez orElseThrow() (avec une exception explicite) ou traitez via map/ifPresent.
6.2. Utiliser Optional comme champ dans une entité/DTO
// Incorrect : Optional n'est pas Serializable, ne devrait pas apparaître dans une entité JPA
@Entity
public class Customer {
private Optional<String> middleName; // à éviter
}
Optional n’implémente pas Serializable, n’est pas conçu pour être un champ ou paramètre de constructeur. Si middleName peut être null, laissez-le en tant que String et null — enrobez-le dans Optional uniquement quand vous le retournez d’un getter si nécessaire :
// Correct : le champ reste String, enrobez-le dans Optional au getter si vous le souhaitez
public Optional<String> getMiddleName() {
return Optional.ofNullable(middleName);
}
6.3. Utiliser Optional comme paramètre de méthode
// Incorrect : oblige l'appelant à enrober lui-même Optional.of/ofNullable avant d'appeler
public void updateAddress(Optional<String> newAddress) { ... }
Cette approche repousse la complexité vers l’appelant et n’offre aucun avantage par rapport à la surcharge ou @Nullable. Utilisez plutôt la surcharge ou passez une String avec une vérification null ordinaire :
// Correct
public void updateAddress(String newAddress) {
this.address = newAddress; // l'appelant décide lui-même de passer null ou non
}
6.4. Vérifier isPresent() puis get() comme l’ancien if-null
// Incorrect : réécrit la vérification null à l'ancienne, ne tire aucun avantage d'Optional
if (repository.findById(id).isPresent()) {
Customer c = repository.findById(id).get(); // appelle la requête 2 fois !
System.out.println(c.name());
}
Ce code est à la fois verbeux et appelle findById deux fois si c’est une vraie requête. Utilisez plutôt :
// Correct
repository.findById(id).ifPresent(c -> System.out.println(c.name()));
6.5. Utiliser Optional pour une collection
// Incorrect : une collection a déjà la notion de "vide" (liste vide), pas besoin d'Optional
public Optional<List<Order>> findOrdersByCustomer(Long customerId) { ... }
Retourner Optional<List<Order>> oblige l’appelant à gérer deux niveaux de vide : Optional vide et liste vide — c’est redondant. Retournez toujours une List vide quand il n’y a pas de données :
// Correct
public List<Order> findOrdersByCustomer(Long customerId) {
return orders.getOrDefault(customerId, List.of());
}
6.6. Utiliser Optional dans un hot path haute performance
Optional crée un objet wrapper supplémentaire, ce n’est pas un coût majeur dans la plupart des applications, mais dans du code s’exécutant des millions de fois par seconde (par exemple, des boucles traitant de grands streams de données), le boxing continu peut affecter le GC. Dans ces cas, envisagez une vérification null directe ou une valeur sentinel.
Remarque : Évitez la premature-optimization — considérez cela uniquement après avoir profilé et constaté que
Optionalest réellement le goulot d’étranglement.
7. Tableau récapitulatif de quand utiliser/ne pas utiliser
| Situation | Utiliser Optional ? |
|---|---|
| Valeur retournée d’une méthode, peut ne pas exister | ✅ Oui |
| Champ dans une entité/DTO | ❌ Non |
| Paramètre de constructeur/méthode | ❌ Non |
| Retourner une collection (List, Set, Map) | ❌ Non — retourner une collection vide |
| Variable locale dans une méthode pour éviter les vérifications null répétées | ⚠️ Ça dépend — à considérer, parfois if/else est plus clair |
8. Conclusion
Optional est un excellent outil pour clarifier le contrat d’une API et éviter les NullPointerException au niveau de l’appelant, mais il ne montre sa vraie valeur que s’il est utilisé au bon endroit : les valeurs retournées par une méthode. Évitez de le transformer en champ, paramètre, ou conteneur pour une collection — dans ces cas, Optional rend simplement le code plus confus sans rien résoudre. Quand vous écrivez du code nouveau, posez-vous la question : “Est-ce que Optional ici aide l’appelant à gérer plus clairement, ou c’est juste null enrobé ?” — la réponse vous dira si vous devez le garder ou l’abandonner.