Collectors.groupingBy e partitioningBy com exemplos

1. Visão geral

Ao trabalhar com Stream, você frequentemente precisa agrupar elementos por um critério. Exemplo: pedidos por cidade, total de valor por cliente, ou separar pedidos grandes e pequenos. Collectors.groupingBy e Collectors.partitioningBy foram criados exatamente para isso.

Este artigo passa por cada forma de uso comum, com output específico. Você saberá como combinar downstream collectors, escolher o tipo de Map a retornar, e evitar alguns erros comuns.

2. Dados de exemplo

Todos os exemplos do artigo usam o mesmo conjunto de pedidos:

import java.util.List;

enum OrderStatus { PENDING, PAID, CANCELLED }

record Order(String id, String customer, String city, OrderStatus status, long amount) {}

class OrderFixtures {
    static List<Order> sample() {
        return List.of(
            new Order("O1", "An",   "Hà Nội",      OrderStatus.PAID,      500_000),
            new Order("O2", "Bình", "Đà Nẵng",     OrderStatus.PAID,      1_200_000),
            new Order("O3", "An",   "Hà Nội",      OrderStatus.CANCELLED, 300_000),
            new Order("O4", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      2_000_000),
            new Order("O5", "Bình", "Đà Nẵng",     OrderStatus.PENDING,   150_000),
            new Order("O6", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      800_000)
        );
    }
}

amount é calculado em VND e usa o tipo long para simplificar. Em um sistema real, você deveria usar BigDecimal para valores monetários.

Os exemplos abaixo assumem que você já tem import static java.util.stream.Collectors.*; e List<Order> orders = OrderFixtures.sample();.

3. groupingBy básico

A forma mais simples aceita apenas uma função de classificação (classifier):

Map<String, List<Order>> byCity = orders.stream()
        .collect(groupingBy(Order::city));

byCity.forEach((city, list) ->
        System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]

Order::city determina a chave. Cada chave mapeia para uma List contendo os pedidos com o mesmo valor. Por baixo dos panos, esta versão é equivalente a groupingBy(Order::city, HashMap::new, toList()).

Nota: O Map retornado é um HashMap, então a ordem das chaves não é garantida. A saída na sua máquina pode ser diferente. Se você precisa de ordem, veja a seção 5.

4. Downstream collector

O segundo parâmetro de groupingBy é outro Collector, chamado de downstream. Ele decide como combinar elementos do mesmo grupo. Isto é o que torna groupingBy verdadeiramente poderoso.

4.1. Contagem e soma

Map<OrderStatus, Long> countByStatus = orders.stream()
        .collect(groupingBy(Order::status, counting()));

Map<String, Long> totalByCustomer = orders.stream()
        .collect(groupingBy(Order::customer, summingLong(Order::amount)));

System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}

counting() sempre retorna Long, não Integer. Você precisa se lembrar disso ao declarar o tipo da variável. Além de summingLong, há também summingInt, summingDouble, averagingLong, e summarizingLong. O último collector retorna LongSummaryStatistics, que contém count, sum, min, max, e average de uma vez.

4.2. Transformar elementos com mapping

Se você precisa apenas de um atributo em vez do objeto inteiro, use mapping:

Map<String, Set<String>> customersByCity = orders.stream()
        .collect(groupingBy(Order::city, mapping(Order::customer, toSet())));

System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}

mapping(Order::customer, toSet()) pega o nome do cliente de cada pedido, depois coleta em um Set para remover duplicatas. An tem dois pedidos em Hà Nội mas aparece apenas uma vez.

4.3. Pegar o maior elemento no grupo

maxBy retorna Optional<Order>, porque em teoria um grupo pode estar vazio. Em groupingBy, um grupo só existe quando há pelo menos um elemento. Portanto você pode “desembrulhar” o Optional usando collectingAndThen:

Map<String, Order> biggestByCustomer = orders.stream()
        .collect(groupingBy(
                Order::customer,
                collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
        ));

biggestByCustomer.forEach((customer, order) ->
        System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)

collectingAndThen executa o collector interno primeiro, depois aplica uma função de finalização (Optional::get) ao resultado.

Há outra abordagem comum: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). Esta abordagem dá resultados semelhantes e não precisa passar por Optional.

4.4. Filtrar dentro do grupo com filtering

Collectors.filtering está disponível desde Java 9. É diferente de chamar .filter() antes de agrupar:

Map<String, Long> cancelledPerCustomer = orders.stream()
        .collect(groupingBy(Order::customer,
                filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));

Map<String, Long> filteredFirst = orders.stream()
        .filter(o -> o.status() == OrderStatus.CANCELLED)
        .collect(groupingBy(Order::customer, counting()));

System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}

Com filtering, todo cliente ainda aparece no resultado, mesmo que a contagem seja 0. Quando você .filter() primeiro, Bình e Chi desaparecem completamente. Escolha baseado no seu objetivo: se seu relatório precisa de uma lista completa, use filtering.

De forma similar, flatMapping (Java 9) ajuda a achatar uma collection dentro de cada grupo.

5. Escolher o tipo de Map a retornar

A versão de três parâmetros permite você passar um Supplier que cria o Map:

Map<String, Long> sortedTotals = orders.stream()
        .collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));

Map<OrderStatus, Long> statusCounts = orders.stream()
        .collect(groupingBy(Order::status,
                () -> new EnumMap<>(OrderStatus.class), counting()));

System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}

TreeMap ordena as chaves por ordem natural. EnumMap mantém a ordem das chaves conforme declaradas no enum e é mais rápida que HashMap quando as chaves são enums. Se você quer manter a ordem de primeira aparição na stream, use LinkedHashMap::new.

Nota: TreeMap com String ordena por código Unicode, não por regras vietnamitas. Por exemplo “Đà Nẵng” virá depois de “Hồ Chí Minh”. Quando você precisa de ordenação vietnamita apropriada, passe () -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).

6. Agrupamento aninhado

Como downstream é qualquer collector, você pode colocar outro groupingBy dentro dele:

Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
        .collect(groupingBy(Order::city, TreeMap::new,
                groupingBy(Order::status, counting())));

System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}

O resultado é um Map de dois níveis. Isto é muito conveniente para relatórios estilo pivot. Porém, além de dois níveis, o tipo de dado fica difícil de ler. Nesse caso, considere usar uma chave composta, por exemplo record CityStatus(String city, OrderStatus status), depois groupingBy(o -> new CityStatus(o.city(), o.status())).

7. partitioningBy

partitioningBy é um caso especial de agrupamento. O classifier é um Predicate, e a chave é sempre Boolean:

Map<Boolean, List<String>> bigOrders = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000,
                mapping(Order::id, toList())));

Map<Boolean, Long> bigOrderCount = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));

System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("Pedidos grandes: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
Pedidos grandes: [O2, O4]

partitioningBy também aceita downstream collectors assim como groupingBy. Porém, ele não tem uma versão que aceita um Supplier para Map, porque o Map retornado é uma implementação interna com exatamente duas chaves.

7.1. Diferença importante: a chave sempre existe

Este é o ponto que faz muitos escolherem incorretamente entre os dois collectors:

List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();

Map<Boolean, List<Order>> partitioned = smallOnly.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000));

Map<Boolean, List<Order>> grouped = smallOnly.stream()
        .collect(groupingBy(o -> o.amount() >= 1_000_000));

System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null

partitioningBy sempre tem tanto true quanto false, mesmo se um lado estiver vazio. groupingBy só cria uma chave quando há pelo menos um elemento. Chamar grouped.get(true).size() aqui lançaria NullPointerException.

Portanto, quando o critério é verdadeiro/falso, use partitioningBy. A intenção fica mais clara, e você não precisa se preocupar com chaves faltando.

8. Escrevendo testes para lógica de agrupamento

Adicione JUnit 5 e AssertJ ao pom.xml. Se seu projeto usa Spring Boot 3.x, spring-boot-starter-test já tem ambos:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Coloque a lógica de relatório em um serviço separado para facilitar testes:

class OrderReportService {

    Map<String, Long> paidTotalByCustomer(List<Order> orders) {
        return orders.stream()
                .collect(groupingBy(Order::customer, TreeMap::new,
                        filtering(o -> o.status() == OrderStatus.PAID,
                                summingLong(Order::amount))));
    }

    Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
        return orders.stream()
                .collect(partitioningBy(o -> o.amount() >= threshold, counting()));
    }
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class OrderReportServiceTest {

    private final OrderReportService service = new OrderReportService();

    @Test
    void shouldSumOnlyPaidOrdersPerCustomer() {
        var result = service.paidTotalByCustomer(OrderFixtures.sample());

        assertThat(result)
                .containsExactly(
                        entry("An", 500_000L),
                        entry("Bình", 1_200_000L),
                        entry("Chi", 2_800_000L));
    }

    @Test
    void shouldAlwaysContainBothPartitions() {
        var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);

        assertThat(result)
                .containsEntry(true, 0L)
                .containsEntry(false, 6L);
    }
}

O primeiro teste usa containsExactly porque o resultado é um TreeMap, então a ordem é fixa. An é somado apenas 500.000, porque o pedido O3 foi cancelado. Bình é somado apenas 1.200.000, porque O5 ainda está aguardando pagamento.

O segundo teste confirma que mesmo se nenhum pedido exceder o limite, a chave true ainda existe com valor 0.

9. Erros comuns

Chave nula. groupingBy não aceita um classifier que retorna null:

var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key

Você tem duas formas de lidar com isso. A primeira é substituir null por um valor padrão: groupingBy(o -> Objects.requireNonNullElse(o.city(), "Desconhecido")). A segunda é filtrar os pedidos sem cidade antes de agrupar.

Tipo de retorno errado. counting() retorna Long. averagingLong retorna Double. maxBy retorna Optional. Se você declarar o tipo errado, receberá um erro de compilação difícil de ler. Em caso de dúvida, use var ou deixe a IDE inferir o tipo.

Usar groupingBy quando cada chave tem apenas um valor. Se o id é único, groupingBy(Order::id) cria um Map<String, List<Order>> com listas de um elemento. Neste caso use toMap(Order::id, Function.identity()).

Modificar o Map resultante. O Javadoc não garante o tipo, mutabilidade ou thread-safety do Map retornado pela versão padrão. Se você planeja modificar o Map depois, passe um Supplier explicitamente, por exemplo HashMap::new.

Nota: Com streams paralelos, groupingBy deve mesclar muitos sub-Maps, então pode ser lento. groupingByConcurrent escreve diretamente em um ConcurrentHashMap e é mais rápido. Em troca, não preserva a ordem dos elementos dentro de cada grupo. Com streams sequenciais, apenas use groupingBy.

10. Conclusão

groupingBy agrupa elementos por qualquer chave. partitioningBy particiona em dois lados por um Predicate e sempre retorna tanto true quanto false. O poder real está nos downstream collectors: counting, summingLong, mapping, filtering, collectingAndThen, ou outro groupingBy aninhado.

Você deve se lembrar de três pontos ao usar estes dois collectors:

  • Escolha o tipo de Map apropriado (TreeMap, EnumMap, LinkedHashMap) quando você precisa de ordenação.
  • Trate chaves nulas antes de agrupar.
  • Prefira partitioningBy para critérios verdadeiro/falso.

Com estes pontos em mente, a maioria da sua lógica de relatório será apenas algumas linhas de código Stream fácil de ler e fácil de testar.

javastreamcollectorsgroupingByjava 21