Collectors.groupingBy y partitioningBy con ejemplos
1. Descripción general
Cuando trabajas con Stream, a menudo necesitas agrupar elementos según un criterio. Por ejemplo: pedidos por ciudad, total por cliente, o separar pedidos grandes de los pequeños. Collectors.groupingBy y Collectors.partitioningBy fueron creados exactamente para esto.
Este artículo recorre los usos más comunes con outputs específicos. Aprenderás a combinar downstream collectors, elegir el tipo de Map que devuelves, y evitar errores frecuentes.
2. Datos de muestra
Todos los ejemplos en este artículo utilizan un conjunto común 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 está en VND y usa tipo long por simplicidad. En un sistema real, deberías usar BigDecimal para moneda.
Los ejemplos a continuación asumen que ya tienes import static java.util.stream.Collectors.*; y List<Order> orders = OrderFixtures.sample();.
3. groupingBy básico
La forma más simple solo toma una función clasificadora (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 la clave. Cada clave se asigna a una List que contiene los pedidos con ese mismo valor. Internamente, esta versión es equivalente a groupingBy(Order::city, HashMap::new, toList()).
Nota: El
Mapdevuelto es unHashMap, por lo que el orden de las claves no está garantizado. La salida en tu máquina podría ser diferente. Si necesitas orden, ver la sección 5.
4. Downstream collector
El segundo parámetro de groupingBy es otro Collector, llamado downstream. Define cómo se combinan los elementos dentro del mismo grupo. Esta es la parte que hace que groupingBy sea realmente poderoso.
4.1. Conteo y suma
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() siempre devuelve Long, no Integer. Recuerda esto al declarar el tipo de variable. Además de summingLong, tienes summingInt, summingDouble, averagingLong y summarizingLong. Este último devuelve LongSummaryStatistics, que contiene a la vez count, sum, min, max y average.
4.2. Transformar elementos con mapping
Si solo necesitas un atributo en lugar del objeto completo, usa 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()) extrae el nombre del cliente de cada pedido, luego lo agrupa en un Set para eliminar duplicados. An tiene dos pedidos en Hà Nội pero aparece solo una vez.
4.3. Obtener el elemento más grande en el grupo
maxBy devuelve Optional<Order>, en teoría porque el grupo podría estar vacío. En groupingBy, un grupo solo existe si hay al menos un elemento. Por lo tanto, puedes “abrir” el Optional con 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 ejecuta primero el collector interno, luego aplica la función de finalización (Optional::get) al resultado.
Hay otra forma también común: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). Este enfoque da el mismo resultado sin pasar por Optional.
4.4. Filtrar dentro del grupo con filtering
Collectors.filtering existe desde Java 9. Es diferente a llamar .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}
Con filtering, todos los clientes siguen presentes en el resultado, incluso si el conteo es 0. Cuando llamas .filter() antes, Bình y Chi desaparecen completamente. Elige según tus necesidades: si el reporte necesita la lista completa, usa filtering.
De manera similar, flatMapping (Java 9) ayuda a aplanar una colección dentro de cada grupo.
5. Elegir el tipo de Map a devolver
La versión de tres parámetros permite pasar un Supplier que crea el 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 las claves según su orden natural. EnumMap mantiene las claves en el orden de declaración del enum y es más rápido que HashMap cuando la clave es un enum. Si quieres preservar el orden de aparición en el stream, usa LinkedHashMap::new.
Nota:
TreeMapconStringordena según códigos Unicode, no según reglas vietnamitas. Por ejemplo, “Đà Nẵng” vendría después de “Hồ Chí Minh”. Cuando necesites ordenar correctamente en vietnamita, pasa() -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).
6. Agrupamiento anidado
Ya que downstream es cualquier collector, puedes poner otro groupingBy dentro:
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}}
El resultado es un Map de dos niveles. Es muy conveniente para reportes tipo pivot. Sin embargo, más de dos niveles hace que el tipo de datos sea difícil de leer. En ese caso, considera usar una clave compuesta, por ejemplo record CityStatus(String city, OrderStatus status), luego groupingBy(o -> new CityStatus(o.city(), o.status())).
7. partitioningBy
partitioningBy es un caso especial de agrupamiento. El classifier es un Predicate y la clave es siempre 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 también acepta un downstream collector exactamente como groupingBy. Pero no tiene una versión que acepte un Supplier para el Map, ya que el Map devuelto es una implementación interna que tiene exactamente dos claves.
7.1. Diferencia importante: las claves siempre existen
Este es el punto que hace que muchas personas elijan incorrectamente entre los dos 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 siempre tiene tanto true como false, incluso si un lado está vacío. groupingBy solo crea una clave si hay al menos un elemento. Llamar a grouped.get(true).size() aquí lanzaría NullPointerException.
Por lo tanto, cuando el criterio es verdadero/falso, usa partitioningBy. La intención del código es más clara y no tienes que preocuparte por claves faltantes.
8. Escribiendo pruebas para la lógica de agrupamiento
Agrega JUnit 5 y AssertJ a pom.xml. Si tu proyecto usa Spring Boot 3.x, spring-boot-starter-test ya incluye ambos:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Coloca la lógica del reporte en un service separado para facilitar las pruebas:
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);
}
}
El primer test usa containsExactly porque el resultado es un TreeMap, así que el orden es fijo. An solo se suma a 500.000 porque el pedido O3 fue cancelado. Bình solo se suma a 1.200.000 porque O5 todavía está pendiente de pago.
El segundo test confirma que incluso si no hay pedidos que superen el umbral, la clave true sigue existiendo con valor 0.
9. Errores comunes
Clave nula. groupingBy no acepta un classifier que devuelva 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
Tienes dos formas de manejarlo. La primera es reemplazar null con un valor predeterminado: groupingBy(o -> Objects.requireNonNullElse(o.city(), "Desconocida")). La segunda es filtrar los pedidos sin ciudad antes de agrupar.
Confundir el tipo de retorno. counting() devuelve Long. averagingLong devuelve Double. maxBy devuelve Optional. Si declaras el tipo incorrecto, obtendrás un error de compilación difícil de leer. Cuando dudes, usa var o deja que el IDE infiera el tipo.
Usar groupingBy cuando cada clave tiene solo un valor. Si el id es único, groupingBy(Order::id) crea un Map<String, List<Order>> donde todas las listas tienen un elemento. En este caso usa toMap(Order::id, Function.identity()).
Modificar el Map resultante. El Javadoc no garantiza el tipo, mutabilidad o thread-safety del Map devuelto por la versión predeterminada. Si planeas modificar el Map después, pasa un Supplier explícito, por ejemplo HashMap::new.
Nota: Con parallel streams,
groupingBydebe fusionar varios Maps parciales, por lo que puede ser lento.groupingByConcurrentescribe directamente en unConcurrentHashMapy es más rápido. A cambio, no preserva el orden de los elementos dentro de cada grupo. Con streams secuenciales, usagroupingBy.
10. Conclusión
groupingBy agrupa elementos según una clave arbitraria. partitioningBy divide exactamente en dos lados según un Predicate y siempre devuelve tanto true como false. El verdadero poder reside en el downstream collector: counting, summingLong, mapping, filtering, collectingAndThen o un groupingBy anidado dentro.
Deberías recordar tres puntos al usar estos dos collectors:
- Elige el tipo de
Mapapropiado (TreeMap,EnumMap,LinkedHashMap) cuando necesites orden. - Maneja las claves nulas antes de agrupar.
- Prefiere
partitioningBypara criterios verdadero/falso.
Dominando estos puntos, la mayoría de la lógica de reporte estará limitada a unas pocas líneas de Stream fáciles de leer y probar.