Optional en Java: úsalo correctamente y evita anti-patrones
1. Descripción general
Optional surgió en Java 8 para representar valores “que posiblemente no existan” de forma más clara que null. Pero muchos desarrolladores lo usan incorrectamente: abusan de Optional como campo, parámetro, o llaman a .get() como si nunca fuera vacío. Este artículo te ayuda a entender cuándo usar Optional, los patrones correctos, y te señala los anti-patrones comunes para evitarlos.
Después de leerlo, sabrás cómo diseñar una API que retorne Optional razonablemente, manejar valores vacíos de forma concisa con map/filter/orElseGet, e identificar inmediatamente el código “pseudo-Optional” que necesita refactorización.
2. ¿Qué es Optional y cuándo usarlo?
Optional<T> es un contenedor que puede o no contener un valor de tipo T. Su propósito principal es aclarar en la firma del método que el valor retornado puede no existir, obligando al llamador a manejarlo en lugar de olvidar verificar 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 retorna Optional<Customer> en lugar de Customer (que podría ser null). El llamador ve la firma y sabe inmediatamente que debe manejar el caso de no encontrarlo, sin necesidad de leer Javadoc.
Nota:
Optionalestá diseñado para valores de retorno, no para todos los lugares donde pueda habernullen el sistema.
3. Formas de crear Optional
Optional<String> present = Optional.of("Hello"); // valor seguro que no es null
Optional<String> empty = Optional.empty(); // vacío
Optional<String> nullable = Optional.ofNullable(null); // puede ser null, se convierte a empty
Optional.of(value)lanzaNullPointerExceptioninmediatamente sivaluees null — úsalo cuando estés seguro de que el valor no es null.Optional.ofNullable(value)es más seguro, apropiado cuando el valor viene de una fuente que no puedes controlar (resultado de consulta, map.get…).Optional.empty()crea una instancia vacía, úsalo cuando sabes de antemano que no hay valor.
4. Manejo correcto del valor interno
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 mantiene el valor solo si la condición es verdadera, si no retorna Optional.empty(). map transforma el valor interno si existe, no hace nada si está vacío. Esta cadena evita por completo if-else anidados para verificar 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)lanza una excepción significativa cuando no se encuentra — úsalo donde el valor es obligatorio.orElse("Guest")úsalo cuando el valor por defecto es una constante simple, siempre se evalúa tenga o no valor elOptional.orElseGet(supplier)úsalo cuando el valor por defecto cuesta calcular — solo llama el supplier cuandoOptionalestá vacío.
// Incorrecto: siempre llama buildDefaultCustomer() tenga o no valor
repository.findById(id).orElse(buildDefaultCustomer());
// Correcto: solo llama cuando realmente lo necesita
repository.findById(id).orElseGet(this::buildDefaultCustomer);
4.3. ifPresent e ifPresentOrElse
public class NotificationService {
public void notifyCustomer(Optional<Customer> customer) {
customer.ifPresentOrElse(
c -> System.out.println("Enviar email a " + c.email()),
() -> System.out.println("No hay cliente para enviar")
);
}
}
ifPresentOrElse (desde Java 9) combina ambas ramas con/sin valor en una única llamada, reemplazando el if (optional.isPresent()) {...} else {...} verboso.
5. Pruebas unitarias con 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 tiene assertions especializadas para Optional: isPresent(), isEmpty(), hasValue(...). Úsalas en lugar de llamar a .get() y luego comparar, el código de prueba se lee más naturalmente.
6. Anti-patrones comunes
6.1. Llamar a .get() sin verificar
// Incorrecto: si Optional está vacío lanzará NoSuchElementException,
// eliminando completamente la razón de usar Optional
Customer customer = repository.findById(id).get();
Llamar a .get() directamente hace que Optional sea simplemente null envuelto en una capa más — aún puedes tener un crash en runtime, pero además gastas una línea extra. Usa orElseThrow() (con una excepción significativa) o manéjalo con map/ifPresent.
6.2. Usar Optional como campo en entity/DTO
// Incorrecto: Optional no es Serializable, no debe aparecer en entity JPA
@Entity
public class Customer {
private Optional<String> middleName; // evitar
}
Optional no implementa Serializable, no está diseñado para ser un campo o parámetro de constructor. Si middleName puede ser null, déjalo como String y null — solo envuelve Optional cuando retornas del getter si es necesario:
// Correcto: el campo sigue siendo String, solo envuelve Optional en el getter si lo necesitas
public Optional<String> getMiddleName() {
return Optional.ofNullable(middleName);
}
6.3. Usar Optional como parámetro de método
// Incorrecto: obliga al llamador a envolver Optional.of/ofNullable antes de llamar
public void updateAddress(Optional<String> newAddress) { ... }
Este enfoque empuja la complejidad al llamador sin aportar beneficio sobre overloading o @Nullable. Usa overloading o pasa String con una verificación null convencional:
// Correcto
public void updateAddress(String newAddress) {
this.address = newAddress; // el llamador decide si pasar null o no
}
6.4. Verificar isPresent() y luego get() como el if-null antiguo
// Incorrecto: reescribe el null-check antiguo, no aprovecha nada de Optional
if (repository.findById(id).isPresent()) {
Customer c = repository.findById(id).get(); // ¡llamada a query 2 veces!
System.out.println(c.name());
}
Este fragmento es verboso y llama a findById dos veces si es una consulta real. Reemplázalo con:
// Correcto
repository.findById(id).ifPresent(c -> System.out.println(c.name()));
6.5. Usar Optional para colecciones
// Incorrecto: la colección ya tiene el concepto de "vacío" (lista vacía), no necesita Optional
public Optional<List<Order>> findOrdersByCustomer(Long customerId) { ... }
Retornar Optional<List<Order>> obliga al llamador a manejar dos niveles de vacío: Optional vacío y lista vacía — redundante. Siempre retorna una List vacía cuando no hay datos:
// Correcto
public List<Order> findOrdersByCustomer(Long customerId) {
return orders.getOrDefault(customerId, List.of());
}
6.6. Usar Optional en rutas críticas de alto rendimiento
Optional crea un objeto wrapper adicional, no es un costo grande en la mayoría de aplicaciones, pero en código que se ejecuta millones de veces por segundo (como bucles procesando streams de datos grandes), el boxing continuo puede afectar el GC. En estos casos, considera verificar null directamente o usar un valor centinela.
Nota: No hagas premature-optimize — solo considera esto cuando ya hayas hecho profile y veas que
Optionales realmente un cuello de botella.
7. Tabla resumen de cuándo usar/no usar
| Situación | ¿Usar Optional? |
|---|---|
| Valor de retorno del método, posiblemente no exista | ✅ Sí |
| Campo en entity/DTO | ❌ No |
| Parámetro de constructor/método | ❌ No |
| Retornar colección (List, Set, Map) | ❌ No — retorna colección vacía |
| Variable local en método para evitar verificaciones null repetidas | ⚠️ Depende — considera, a veces if/else es más claro |
8. Conclusión
Optional es una herramienta excelente para aclarar el contrato de API y evitar NullPointerException en el llamador, pero solo brinda valor cuando se usa en el lugar correcto: el valor de retorno del método. Evita convertirlo en campo, parámetro o contenedor para colecciones — en esos lugares Optional solo embrollo el código sin resolver nada. Cuando escribas código nuevo, pregúntate: “¿Optional aquí ayuda al llamador a manejarlo más claramente, o es solo null empaquetado?” — la respuesta te dirá si debes mantenerlo o descartarlo.