CompletableFuture: executar de forma assíncrona e combinar resultados

1. Visão geral

Uma API frequentemente precisa chamar múltiplos serviços: obter informações de clientes, pedidos, taxas de envio. Se os chamar sequencialmente, o tempo total será a soma dos atrasos de cada chamada. CompletableFuture permite executar chamadas independentes em paralelo e combinar os resultados quando todas estiverem concluídas.

Depois de ler este artigo, você saberá como criar tarefas assíncronas, encadear etapas de processamento (thenApply, thenCompose) e combinar resultados (thenCombine, allOf, anyOf). Você também aprenderá como tratar erros, definir timeouts e escrever testes para código assíncrono.

2. Preparando o exemplo

Usamos três serviços simulados. Cada serviço “dorme” por um tempo para simular latência de rede:

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");
    }
}

Esses três serviços usam apenas JDK. Você não precisa de bibliotecas adicionais para executá-los.

3. Criando tarefas assíncronas

3.1. supplyAsync e runAsync

supplyAsync executa um Supplier e retorna CompletableFuture<T>. runAsync executa um Runnable sem resultado e retorna 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 acesso"), executor);

            System.out.println("Main thread não é bloqueado");
            System.out.println(customerFuture.join());
            auditFuture.join();
        }
    }
}
Main thread não é bloqueado
Registrar acesso
Customer[id=42, name=Lan, city=Hue]

A ordem das duas primeiras linhas pode variar, pois são executadas em threads diferentes. Você deve estar atento aos seguintes pontos:

  • O segundo parâmetro é um Executor. Se omitido, a tarefa executa em ForkJoinPool.commonPool().
  • Executors.newVirtualThreadPerTaskExecutor() (Java 21) cria uma thread virtual para cada tarefa. Essa escolha é adequada para tarefas de I/O, como chamadas HTTP ou consultas de banco de dados.
  • ExecutorService é AutoCloseable desde Java 19. O bloco try-with-resources aguardará a conclusão das tarefas antes de fechar o executor.
  • join() bloqueia a thread atual até que haja um resultado. Se a tarefa falhar, join() lança CompletionException (unchecked). Já get() lança ExecutionException (checked).

3.2. Completando manualmente um future

Às vezes, o resultado vem de um callback em vez de um lambda. Nesse caso, você cria um future vazio e o completa manualmente:

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

// Em algum lugar, por exemplo, no callback do gateway de pagamento:
paymentResult.complete("PAID");

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

Se quiser reportar um erro, você chama completeExceptionally(new RuntimeException("Cartão recusado")). Quando você já tem um valor disponível, CompletableFuture.completedFuture(value) é a forma mais concisa de criar um future já completado.

4. Encadeando etapas de processamento

4.1. thenApply e thenAccept

thenApply transforma o resultado, semelhante a map de Stream. thenAccept consome o resultado e não retorna nada:

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

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

Cada chamada a thenApply retorna um novo CompletableFuture. O future original não é modificado.

4.2. thenCompose quando a próxima etapa também é assíncrona

Para obter os pedidos, precisamos de customerId da etapa anterior. Se usar thenApply com uma função que retorna um future, o resultado será CompletableFuture<CompletableFuture<List<Order>>>. thenCompose “achata” esse tipo aninhado, semelhante 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

Essas duas etapas são dependentes e ainda executam sequencialmente. O tempo total é aproximadamente 700 ms. thenCompose não acelera. Ele ajuda você a encadear etapas sem bloquear nenhuma thread.

5. Combinando resultados

Com tarefas independentes, CompletableFuture realmente brilha.

5.1. thenCombine para dois futures

Suponha que já conhecemos a cidade do cliente e queremos obter simultaneamente a lista de pedidos e a taxa de envio:

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("Levou " + (System.currentTimeMillis() - start) + " ms");
270000
Levou 405 ms

As duas chamadas executam em paralelo. O tempo total é aproximado ao da chamada mais lenta (400 ms), em vez de 600 ms se fossem sequenciais. A função no segundo parâmetro de thenCombine só é executada quando ambos os futures estão completos.

5.2. allOf para múltiplos futures

Quando há mais de duas tarefas, você usa allOf. Ele retorna CompletableFuture<Void>, então você deve obter manualmente o resultado de cada future filho:

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, todo future filho foi completado. Portanto, join() retorna imediatamente sem bloquear. A ordem dos resultados mantém a mesma ordem de cities.

5.3. anyOf obtém o resultado mais rápido

anyOf completa assim que um dos futures filhos se completa. Isso é útil quando você chama múltiplas réplicas do mesmo serviço:

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

O tipo de retorno é CompletableFuture<Object>, então você precisa fazer o cast manualmente. Observe também: se o future mais rápido terminar com erro, anyOf também terminará com esse erro.

6. Tratando erros e timeouts

6.1. exceptionally e handle

Uma exceção em qualquer etapa será propagada para as etapas seguintes. Essas etapas serão puladas até que encontrem uma etapa de tratamento de erro:

CompletableFuture<BigDecimal> fee = CompletableFuture
    .supplyAsync(() -> {
        throw new IllegalStateException("Serviço de envio fora do ar");
    }, executor)
    .thenApply(value -> (BigDecimal) value)          // pulada
    .exceptionally(ex -> {
        System.out.println("Erro: " + ex.getMessage());
        return new BigDecimal("50000");              // valor padrão
    });

System.out.println(fee.join());
Erro: java.lang.IllegalStateException: Serviço de envio fora do ar
50000

O parâmetro ex geralmente é uma CompletionException que envolve a exceção original. Portanto, a mensagem terá o prefixo do nome da classe. Use ex.getCause() para obter o erro real.

handle recebe tanto o resultado quanto o erro. É sempre executado, independentemente de a tarefa ter sucesso ou não:

CompletableFuture<String> status = CompletableFuture
    .supplyAsync(() -> orderService.findByCustomer(42L), executor)
    .handle((orderList, ex) -> ex == null
        ? "Há " + orderList.size() + " pedidos"
        : "Não foi possível carregar os pedidos");

Também há whenComplete. Ele também recebe tanto resultado quanto erro, mas não altera o resultado. Este método é adequado para logging.

6.2. orTimeout e completeOnTimeout

Você nunca deve esperar indefinidamente por um serviço externo. Java 9+ fornece dois métodos para isso:

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 após ~500 ms

CompletableFuture<BigDecimal> strict = CompletableFuture
    .supplyAsync(() -> { Delay.sleep(2000); return new BigDecimal("30000"); }, executor)
    .orTimeout(500, TimeUnit.MILLISECONDS);
// strict.join() lança CompletionException com cause TimeoutException

completeOnTimeout retorna um valor padrão quando o tempo expira. orTimeout completa o future com uma TimeoutException.

Observação: Timeout apenas completa o future. Ele não interrompe a tarefa subjacente. A thread continuará “dormindo” por 2 segundos. cancel(true) também não interrompe a thread em execução. Se precisar de cancelamento real, você deve verificar manualmente a flag de cancelamento na tarefa ou usar o timeout do próprio cliente HTTP.

7. Escrevendo testes

Se você usar Spring Boot, spring-boot-starter-test já inclui JUnit 5 e AssertJ. Para um projeto Java puro, você adiciona:

<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>

Primeiro, coloque a lógica em uma classe para facilitar os testes:

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));
    }
}

Em seguida, escreva testes para casos bem-sucedidos e de falha:

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("down");
            }
        };

        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 aguarda no máximo 2 segundos e verifica o valor. Isso impede que o teste congele se houver um erro no código. No segundo teste, o erro de ShippingService se propaga através de thenCombine e é envolvido em CompletionException.

No teste, você pode passar Runnable::run como Executor para que tudo execute sincronamente na thread do teste. Isso torna o teste rápido e os resultados estáveis.

8. Erros comuns

  • Executar I/O em commonPool. Esse pool tem um número de threads igual ao número de CPUs menos um e é compartilhado por toda a aplicação (incluindo streams paralelos). Algumas chamadas HTTP lentas podem obstruir tudo. Para I/O, você deve passar um executor separado, preferencialmente virtual thread.
  • Chamar join() muito cedo. Escrever supplyAsync(a).join() e depois supplyAsync(b).join() ainda é sequencial. Você precisa iniciar todos os futures primeiro, depois combiná-los ou aguardá-los.
  • Engolir exceções. Se ninguém chamar join(), get() ou anexar exceptionally/whenComplete, o erro desaparecerá sem deixar rastro.
  • Confundir thenApply com thenApplyAsync. thenApply pode executar na thread que completou a etapa anterior ou na thread chamadora se o future já estiver completo. Se a próxima etapa for pesada ou bloqueante, use a variante ...Async(fn, executor).
  • Não definir timeout. Um serviço travado fará com que sua requisição também trave.
  • Programa sai cedo. As threads de commonPool são daemon. Se main terminar antes que a tarefa esteja completa, o resultado nunca será impresso.

Observação: Você não deve usar CompletableFuture quando há apenas uma chamada e você aguarda imediatamente o resultado. A partir do Java 21, com código executando em virtual threads, escrever código síncrono comum geralmente é mais legível sem perder desempenho. CompletableFuture só vale a pena quando você precisa executar várias tarefas independentes em paralelo ou precisa de um pipeline não-bloqueante.

9. Conclusão

CompletableFuture ajuda você a executar tarefas independentes em paralelo e combinar resultados sem gerenciar threads manualmente. Resumo rápido:

  • Criar tarefas: supplyAsync/runAsync, sempre passe um executor apropriado.
  • Encadear etapas: thenApply quando transformar valor, thenCompose quando a próxima etapa retorna um future.
  • Combinar: thenCombine para dois futures, allOf para múltiplos futures, anyOf para obter o resultado mais rápido.
  • Erros e timeouts: exceptionally, handle, orTimeout, completeOnTimeout.

No teste, você usa succeedsWithin do AssertJ para verificar o resultado sem se preocupar que o teste congele.

javacompletablefutureconcurrencyasynchronousjava-21