CompletableFuture: ejecutar de forma asincrónica y combinar resultados

1. Descripción general

Una API a menudo debe llamar a múltiples servicios: obtener información del cliente, pedidos, tarifas de envío. Si los llamas uno tras otro, el tiempo total equivale a la suma de las latencias de cada llamada. CompletableFuture permite ejecutar llamadas independientes en paralelo y luego combinar los resultados cuando todos se hayan completado.

Después de leer este artículo, sabrás cómo crear tareas asincrónicas, encadenar pasos de procesamiento (thenApply, thenCompose) y combinar resultados (thenCombine, allOf, anyOf). También aprenderás a manejar errores, establecer timeouts y escribir pruebas para código asincrónico.

2. Preparar ejemplos

Usamos tres servicios simulados. Cada uno “duerme” durante un tiempo para simular latencia de red:

import java.math.BigDecimal;
import java.util.List;

record Customer(long id, String name, String city) {}
record Order(long id, long customerId, BigDecimal total) {}

final class Delay {
    static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        }
    }
}

class CustomerService {
    Customer findById(long id) {
        Delay.sleep(300);
        return new Customer(id, "Lan", "Hue");
    }
}

class OrderService {
    List<Order> findByCustomer(long customerId) {
        Delay.sleep(400);
        return List.of(
            new Order(1, customerId, new BigDecimal("150000")),
            new Order(2, customerId, new BigDecimal("90000")));
    }
}

class ShippingService {
    BigDecimal feeFor(String city) {
        Delay.sleep(200);
        return "Hue".equals(city) ? new BigDecimal("30000") : new BigDecimal("45000");
    }
}

Estos tres servicios usan solo JDK. No necesitas agregar ninguna librería adicional para ejecutarlos.

3. Crear tareas asincrónicas

3.1. supplyAsync y runAsync

supplyAsync ejecuta un Supplier y devuelve CompletableFuture<T>. runAsync ejecuta un Runnable sin resultado y devuelve CompletableFuture<Void>:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CreateDemo {
    public static void main(String[] args) {
        var customerService = new CustomerService();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            CompletableFuture<Customer> customerFuture =
                CompletableFuture.supplyAsync(() -> customerService.findById(42L), executor);

            CompletableFuture<Void> auditFuture =
                CompletableFuture.runAsync(() -> System.out.println("Registrar acceso"), executor);

            System.out.println("Thread principal no está bloqueado");
            System.out.println(customerFuture.join());
            auditFuture.join();
        }
    }
}
Thread principal no está bloqueado
Registrar acceso
Customer[id=42, name=Lan, city=Hue]

El orden de las dos primeras líneas puede cambiar, ya que se ejecutan en dos threads diferentes. Debes prestar atención a varios puntos:

  • El segundo parámetro es un Executor. Si lo omites, la tarea se ejecuta en ForkJoinPool.commonPool().
  • Executors.newVirtualThreadPerTaskExecutor() (Java 21) crea un virtual thread para cada tarea. Esta opción es adecuada para tareas de E/S como llamadas HTTP o consultas a bases de datos.
  • ExecutorService es AutoCloseable desde Java 19. El bloque try-with-resources esperará a que las tareas terminen antes de cerrar el executor.
  • join() bloquea el thread actual hasta obtener un resultado. Si la tarea falla, join() lanza CompletionException (unchecked). En cambio, get() lanza ExecutionException (checked).

3.2. Completar manualmente un future

A veces el resultado proviene de un callback en lugar de un lambda. En ese caso, creas un future vacío y lo completas manualmente:

CompletableFuture<String> paymentResult = new CompletableFuture<>();

// En algún lugar, por ejemplo en un callback de la puerta de pago:
paymentResult.complete("PAID");

System.out.println(paymentResult.join()); // PAID

Si quieres reportar un error, llama a completeExceptionally(new RuntimeException("Tarjeta rechazada")). Cuando ya tienes un valor, CompletableFuture.completedFuture(value) es la forma más concisa de crear un future completado.

4. Encadenar pasos de procesamiento

4.1. thenApply y thenAccept

thenApply transforma el resultado, similar a map de Stream. thenAccept consume el resultado sin devolver nada:

CompletableFuture<String> greeting = CompletableFuture
    .supplyAsync(() -> customerService.findById(42L), executor)
    .thenApply(Customer::name)
    .thenApply(name -> "Hola, " + name);

greeting.thenAccept(System.out::println).join();
Hola, Lan

Cada llamada a thenApply devuelve un nuevo CompletableFuture. El future original no se modifica.

4.2. thenCompose cuando el siguiente paso también es asincrónico

Para obtener los pedidos, necesitamos el customerId del paso anterior. Si usamos thenApply con una función que devuelve un future, el resultado será CompletableFuture<CompletableFuture<List<Order>>>. thenCompose “aplana” este tipo anidado, similar a flatMap:

CompletableFuture<List<Order>> ordersFuture = CompletableFuture
    .supplyAsync(() -> customerService.findById(42L), executor)
    .thenCompose(customer -> CompletableFuture.supplyAsync(
        () -> orderService.findByCustomer(customer.id()), executor));

System.out.println(ordersFuture.join().size()); // 2

Estos dos pasos dependen uno del otro, así que se ejecutan secuencialmente. El tiempo total es aproximadamente 700 ms. thenCompose no lo hace más rápido. Te ayuda a encadenar pasos sin bloquear ningún thread.

5. Combinar resultados

Con tareas independientes, CompletableFuture realmente brilla.

5.1. thenCombine para dos futures

Supongamos que ya conocemos la ciudad del cliente y queremos obtener simultáneamente la lista de pedidos y la tarifa de envío:

long start = System.currentTimeMillis();

CompletableFuture<List<Order>> orders =
    CompletableFuture.supplyAsync(() -> orderService.findByCustomer(42L), executor);
CompletableFuture<BigDecimal> shippingFee =
    CompletableFuture.supplyAsync(() -> shippingService.feeFor("Hue"), executor);

CompletableFuture<BigDecimal> grandTotal = orders.thenCombine(shippingFee,
    (orderList, fee) -> orderList.stream()
        .map(Order::total)
        .reduce(BigDecimal.ZERO, BigDecimal::add)
        .add(fee));

System.out.println(grandTotal.join());
System.out.println("Tardó " + (System.currentTimeMillis() - start) + " ms");
270000
Tardó 405 ms

Las dos llamadas se ejecutan en paralelo. El tiempo total es aproximadamente el de la llamada más lenta (400 ms), en lugar de 600 ms si las hubieras llamado secuencialmente. La función en el segundo parámetro de thenCombine solo se ejecuta cuando ambos futures se hayan completado.

5.2. allOf para múltiples futures

Cuando tienes más de dos tareas, usas allOf. Devuelve CompletableFuture<Void>, así que debes obtener el resultado de cada future hijo manualmente:

List<String> cities = List.of("Hue", "Hanoi", "Danang");

List<CompletableFuture<BigDecimal>> fees = cities.stream()
    .map(city -> CompletableFuture.supplyAsync(() -> shippingService.feeFor(city), executor))
    .toList();

CompletableFuture<List<BigDecimal>> allFees = CompletableFuture
    .allOf(fees.toArray(CompletableFuture[]::new))
    .thenApply(ignored -> fees.stream()
        .map(CompletableFuture::join)
        .toList());

System.out.println(allFees.join());
[30000, 45000, 45000]

Dentro de thenApply, todos los futures hijos se han completado. Por lo tanto, join() devuelve inmediatamente sin bloquear. El orden de los resultados mantiene el orden en cities.

5.3. anyOf obtiene el resultado más rápido

anyOf se completa tan pronto como uno de los futures hijos se completa. Esta técnica es útil cuando llamas a múltiples réplicas del mismo servicio:

CompletableFuture<Object> fastest = CompletableFuture.anyOf(
    CompletableFuture.supplyAsync(() -> { Delay.sleep(500); return "replica-A"; }, executor),
    CompletableFuture.supplyAsync(() -> { Delay.sleep(100); return "replica-B"; }, executor));

System.out.println(fastest.join()); // replica-B

El tipo devuelto es CompletableFuture<Object>, así que debes hacer cast manualmente. Nota adicional: si el future más rápido termina con un error, anyOf también termina con ese error.

6. Manejar errores y timeouts

6.1. exceptionally y handle

Una excepción en cualquier paso se propaga a los pasos siguientes. Esos pasos se omiten hasta que encuentres un paso que maneje el error:

CompletableFuture<BigDecimal> fee = CompletableFuture
    .supplyAsync(() -> {
        throw new IllegalStateException("Servicio de envío caído");
    }, executor)
    .thenApply(value -> (BigDecimal) value)          // se omite
    .exceptionally(ex -> {
        System.out.println("Error: " + ex.getMessage());
        return new BigDecimal("50000");              // valor de fallback
    });

System.out.println(fee.join());
Error: java.lang.IllegalStateException: Servicio de envío caído
50000

El parámetro ex suele ser CompletionException que envuelve la excepción original. Por eso el mensaje tiene el prefijo del nombre de la clase. Usa ex.getCause() para obtener el error real.

handle recibe tanto el resultado como el error. Siempre se ejecuta, independientemente de si la tarea tiene éxito o falla:

CompletableFuture<String> status = CompletableFuture
    .supplyAsync(() -> orderService.findByCustomer(42L), executor)
    .handle((orderList, ex) -> ex == null
        ? "Hay " + orderList.size() + " pedidos"
        : "No se pudieron cargar los pedidos");

Además, existe whenComplete. También recibe tanto el resultado como el error, pero no cambia el resultado. Este método es adecuado para registrar eventos.

6.2. orTimeout y completeOnTimeout

Nunca debes esperar un servicio externo indefinidamente. Java 9+ tiene dos métodos para esto:

import java.util.concurrent.TimeUnit;

CompletableFuture<BigDecimal> withDefault = CompletableFuture
    .supplyAsync(() -> { Delay.sleep(2000); return new BigDecimal("30000"); }, executor)
    .completeOnTimeout(new BigDecimal("45000"), 500, TimeUnit.MILLISECONDS);

System.out.println(withDefault.join()); // 45000 después de ~500 ms

CompletableFuture<BigDecimal> strict = CompletableFuture
    .supplyAsync(() -> { Delay.sleep(2000); return new BigDecimal("30000"); }, executor)
    .orTimeout(500, TimeUnit.MILLISECONDS);
// strict.join() lanza CompletionException con causa TimeoutException

completeOnTimeout devuelve un valor por defecto cuando expira el tiempo. orTimeout completa el future con TimeoutException.

Nota: El timeout solo completa el future. No detiene la tarea subyacente. El thread sigue “durmiendo” 2 segundos. cancel(true) tampoco interrumpe el thread en ejecución. Si necesitas cancelar de verdad, debes verificar manualmente un flag de cancelación dentro de la tarea o usar el timeout del cliente HTTP.

7. Escribir pruebas

Si usas Spring Boot, spring-boot-starter-test ya incluye JUnit 5 y AssertJ. Para un proyecto Java puro, agrega:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Primero, agrupa la lógica en una clase para facilitar las pruebas:

class CheckoutService {
    private final OrderService orderService;
    private final ShippingService shippingService;
    private final Executor executor;

    CheckoutService(OrderService o, ShippingService s, Executor executor) {
        this.orderService = o;
        this.shippingService = s;
        this.executor = executor;
    }

    CompletableFuture<BigDecimal> grandTotal(long customerId, String city) {
        var orders = CompletableFuture.supplyAsync(() -> orderService.findByCustomer(customerId), executor);
        var fee = CompletableFuture.supplyAsync(() -> shippingService.feeFor(city), executor);
        return orders.thenCombine(fee, (list, f) -> list.stream()
            .map(Order::total)
            .reduce(BigDecimal.ZERO, BigDecimal::add)
            .add(f));
    }
}

Luego escribe pruebas para casos de éxito y fracaso:

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.time.Duration;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;

class CheckoutServiceTest {

    @Test
    void shouldCombineOrdersAndShippingFee() {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var service = new CheckoutService(new OrderService(), new ShippingService(), executor);

            assertThat(service.grandTotal(42L, "Hue"))
                .succeedsWithin(Duration.ofSeconds(2))
                .isEqualTo(new BigDecimal("270000"));
        }
    }

    @Test
    void shouldPropagateShippingFailure() {
        ShippingService broken = new ShippingService() {
            @Override
            BigDecimal feeFor(String city) {
                throw new IllegalStateException("caído");
            }
        };

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var service = new CheckoutService(new OrderService(), broken, executor);

            assertThatThrownBy(() -> service.grandTotal(42L, "Hue").join())
                .isInstanceOf(CompletionException.class)
                .hasCauseInstanceOf(IllegalStateException.class);
        }
    }
}

succeedsWithin espera un máximo de 2 segundos y verifica el valor. De esta manera, la prueba no se cuelga si hay un error en el código. En la segunda prueba, el error del ShippingService se propaga a través de thenCombine y se envuelve en CompletionException.

En las pruebas, puedes pasar Runnable::run como Executor para que todo se ejecute de forma sincrónica en el thread de la prueba. Esto hace que las pruebas sean rápidas y los resultados estables.

8. Errores comunes

  • Ejecutar E/S en commonPool. Este pool tiene un número de threads igual al número de CPUs menos uno, y se comparte en toda la aplicación (incluso para streams paralelos). Algunas llamadas HTTP lentas pueden bloquear todo. Con E/S, debes pasar un executor separado, idealmente virtual threads.
  • Llamar a join() demasiado pronto. Escribir supplyAsync(a).join() y luego supplyAsync(b).join() sigue siendo secuencial. Necesitas iniciar todos los futures primero, luego combinarlos o esperarlos.
  • Consumir una excepción. Si nadie llama a join(), get() o adjunta exceptionally/whenComplete, el error desaparecerá sin dejar rastro.
  • Confundir thenApply con thenApplyAsync. thenApply podría ejecutarse en el thread que completó el paso anterior, o en el thread que realiza la llamada si el future ya está listo. Si el siguiente paso es pesado o blocking, usa la variante ...Async(fn, executor).
  • No establecer timeouts. Un servicio que se cuelga hará que tu request también se cuelgue.
  • El programa se cierra antes de tiempo. Los threads de commonPool son daemon. Si main termina antes de que se completen las tareas, los resultados nunca se imprimirán.

Nota: No debes usar CompletableFuture cuando solo hay una llamada única y esperas el resultado inmediatamente. Desde Java 21, con código ejecutándose en virtual threads, escribir de forma sincrónica normal es a menudo más fácil de leer sin perder rendimiento. CompletableFuture solo vale la pena cuando necesitas ejecutar varias tareas independientes en paralelo, o necesitas un pipeline no bloqueante.

9. Conclusión

CompletableFuture te permite ejecutar tareas independientes en paralelo y combinar resultados sin tener que administrar threads manualmente. Resumen rápido:

  • Crear tareas: supplyAsync/runAsync, siempre pasa un executor apropiado.
  • Encadenar pasos: thenApply cuando transformas un valor, thenCompose cuando el siguiente paso devuelve un future.
  • Combinar: thenCombine para dos futures, allOf para múltiples futures, anyOf para obtener el resultado más rápido.
  • Errores y timeouts: exceptionally, handle, orTimeout, completeOnTimeout.

En las pruebas, usa succeedsWithin de AssertJ para verificar resultados sin preocuparte de que las pruebas se cuelguen.

javacompletablefutureconcurrencyasynchronousjava-21