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:
TreeMapcomStringordena 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,
groupingBydeve mesclar muitos sub-Maps, então pode ser lento.groupingByConcurrentescreve diretamente em umConcurrentHashMape é mais rápido. Em troca, não preserva a ordem dos elementos dentro de cada grupo. Com streams sequenciais, apenas usegroupingBy.
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
partitioningBypara 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.