Stream API: map, filter, reduce e collect através de exemplos

1. Visão geral

A Stream API surgiu no Java 8 e se tornou uma ferramenta indispensável ao trabalhar com collections. Este artigo ajuda você a dominar as 4 operações mais importantes: map, filter, reduce e collect através de exemplos práticos com Order e Customer. Ao final da leitura, você saberá quando usar cada operação, como combiná-las e evitar alguns erros comuns ao trabalhar com streams.

2. Preparando dados de amostra

Antes de explorar cada operação, você precisa de um conjunto de dados para manipular ao longo do artigo.

public record Order(String id, String product, int quantity, double price, String status) {
    public double total() {
        return quantity * price;
    }
}
import java.util.List;

public class OrderData {
    public static List<Order> sample() {
        return List.of(
            new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
            new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
            new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
            new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
            new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
        );
    }
}

Order é um record – conciso, imutável, perfeito para demonstração de stream. O método total() calcula o valor total de cada order e será reutilizado muitas vezes abaixo.

3. filter: filtrando elementos por condição

filter recebe um Predicate<T> e mantém apenas os elementos que satisfazem a condição.

List<Order> completedOrders = OrderData.sample().stream()
    .filter(order -> order.status().equals("COMPLETED"))
    .toList();

completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
  • stream() converte List<Order> em um Stream<Order>.
  • filter mantém apenas os orders com status igual a COMPLETED.
  • toList() (Java 16+) agrupa o resultado em uma List imutável, substituindo collect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor

Observação: filter não altera os dados originais. Stream é lazy – se você não tiver uma operação terminal (como toList(), forEach()), os filter/map anteriores não serão executados.

3.1. Combinando múltiplas condições de filter

List<Order> expensiveCompleted = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .toList();

Dois filter consecutivos são equivalentes a uma condição AND. Você pode combiná-los em um único filter com &&, mas separá-los deixa o código mais legível quando as condições são complexas.

4. map: transformando elementos

map recebe uma Function<T, R> e transforma cada elemento do tipo T em um elemento do tipo R.

List<String> productNames = OrderData.sample().stream()
    .map(Order::product)
    .distinct()
    .toList();

System.out.println(productNames);
  • Order::product é uma method reference, equivalente a order -> order.product().
  • distinct() remove elementos duplicados (baseado em equals()).
[Laptop, Mouse, Keyboard, Monitor]

4.1. map retornando um tipo completamente diferente

O ponto forte do map é transformar tipos de dados, não apenas extrair um campo.

List<Double> totals = OrderData.sample().stream()
    .map(Order::total)
    .toList();

System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]

Observação: Não confunda map com forEach. map retorna um novo stream sem side effects; forEach é usado para executar ações (imprimir, salvar em BD…) e não retorna nada.

5. reduce: agregando múltiplos elementos em um valor

reduce agrega todo o stream em um único valor, baseado em identity (valor inicial) e um BinaryOperator.

double totalRevenue = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .map(Order::total)
    .reduce(0.0, Double::sum);

System.out.println(totalRevenue);
  • 0.0 é a identity – o valor inicial quando o stream está vazio.
  • Double::sum é o accumulator, acumulando cada valor no resultado.
1.8950000E7

5.1. reduce sem identity

Se você não quiser fornecer uma identity, reduce retorna Optional<T> porque o stream pode estar vazio.

Optional<Order> mostExpensive = OrderData.sample().stream()
    .reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);

mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7

Observação: Para encontrar max/min, Java já fornece Stream.max(Comparator) e Stream.min(Comparator) – mais concisas e claras que reduce. Use reduce apenas quando a lógica de agregação for mais complexa que uma simples comparação.

6. collect: agrupando resultados em estruturas de dados

collect é mais flexível que toList(), permitindo agrupar resultados em Map, Set ou realizar groupBy/partition.

6.1. Agrupando em Map

import java.util.Map;
import java.util.stream.Collectors;

Map<String, Double> totalByOrderId = OrderData.sample().stream()
    .collect(Collectors.toMap(Order::id, Order::total));

System.out.println(totalByOrderId);
  • Collectors.toMap recebe duas funções: key extractor e value extractor.
  • Se houver keys duplicadas, toMap lançará IllegalStateException – você precisa passar uma merge function adicional se os dados puderem ter keys duplicadas.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}

6.2. groupingBy – agrupando por condição

Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(Order::status));

ordersByStatus.forEach((status, orders) ->
    System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)

6.3. groupingBy combinado com downstream collector

Você pode calcular a receita total por cada status de agrupamento sem precisar agrupar primeiro e depois fazer um loop novamente.

Map<String, Double> revenueByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(
        Order::status,
        Collectors.summingDouble(Order::total)
    ));

System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}

groupingBy com downstream collector (summingDouble, counting, averagingDouble…) ajuda você a evitar ter que escrever reduce manualmente para cada grupo.

7. Combinando as quatro operações em um pipeline

Este é um exemplo prático: obter uma lista de nomes de produtos de orders completadas, com valor > 1 milhão, ordenada em ordem decrescente pelo total.

List<String> topProducts = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .sorted(Comparator.comparingDouble(Order::total).reversed())
    .map(Order::product)
    .toList();

System.out.println(topProducts);
[Laptop, Monitor]

A ordem das operações aqui é importante: filter primeiro para reduzir o número de elementos a processar, sorted após filtragem para evitar ordenação desnecessária, map no final para extrair apenas o campo necessário.

8. Escrevendo testes para o pipeline

Sempre escreva testes para a lógica de negócio que está em um stream pipeline, não deixe “oculta” em uma longa linha de código sem verificação.

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderAnalyticsTest {

    @Test
    void shouldCalculateTotalRevenueForCompletedOrders() {
        double totalRevenue = OrderData.sample().stream()
            .filter(o -> o.status().equals("COMPLETED"))
            .map(Order::total)
            .reduce(0.0, Double::sum);

        assertThat(totalRevenue).isEqualTo(18_950_000.0);
    }

    @Test
    void shouldGroupOrdersByStatus() {
        var grouped = OrderData.sample().stream()
            .collect(java.util.stream.Collectors.groupingBy(Order::status));

        assertThat(grouped)
            .containsKey("COMPLETED")
            .extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
            .hasSize(3);
    }
}

O primeiro teste verifica se reduce calcula corretamente o total de receita. O segundo teste verifica se groupingBy cria o número correto de elementos no grupo COMPLETED.

9. Erros comuns

  • Usar stream quando um loop tradicional é mais claro: se a lógica tem muitos side effects (logging, atualizar variáveis externas, lançar exceções com condições complexas), um loop for tradicional é mais legível que stream.
  • Reutilizar um Stream já consumido: um stream pode ser executado apenas uma vez. Chamar collect() ou forEach() duas vezes no mesmo stream lançará IllegalStateException: stream has already been operated upon or closed.
  • Esquecer a identity apropriada em reduce: usar a identity errada (por exemplo, 1 em vez de 0 ao calcular uma soma) resultará em resultado incorreto mas sem nenhum erro reportado.
  • toMap com keys duplicadas: como mencionado na seção 6.1, você precisa adicionar uma merge function: Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1).
  • Side effects em map: evite alterar estado externo dentro da função passada para map/filter. Streams podem ser executados em paralelo e side effects causarão race conditions difíceis de debugar.

Observação: Se seu pipeline tiver mais de 4-5 operações encadeadas e for difícil de ler, divida-o em métodos separados com nomes significativos (filterCompleted, calculateRevenue…) em vez de colocar tudo em uma chain longa.

10. Conclusão

filter, map, reduce e collect são as ferramentas fundamentais para processar dados de forma funcional em Java. filter ajuda você a selecionar, map transforma, reduce agrega em um único valor, e collect flexivelmente agrupa resultados em estruturas mais complexas como Map ou agrupa por condição. Dominando a ordem de operações e evitando os erros comuns acima, você será capaz de escrever pipelines de processamento de dados concisos, claros e fáceis de testar.

javastream-apiprogramação-funcionaljava21collections