In the basics you filtered and mapped. Real reporting needs more: totals per customer, counts per status, all the lines across all orders. That’s the job of collectors and flatMap — and once you know them, many a fifty-line loop becomes five lines.
The examples use orders that each contain lines:
record Line(String sku, int qty, double price) { double total() { return qty * price; } }
record Order(String id, String customer, String status, List<Line> lines) {
double total() { return lines.stream().mapToDouble(Line::total).sum(); }
}
groupingBy: the SQL GROUP BY of Java
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer));
The real power is the optional downstream collector — what to do with each group:
// customer -> total spend (sorted by customer thanks to TreeMap::new)
Map<String, Double> spend = orders.stream()
.collect(Collectors.groupingBy(Order::customer, TreeMap::new,
Collectors.summingDouble(Order::total)));
// status -> number of orders
Map<String, Long> perStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));
// customer -> list of order IDs
Map<String, List<String>> idsByCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer,
Collectors.mapping(Order::id, Collectors.toList())));
Useful downstream collectors: counting(), summingInt/Double, averagingDouble, mapping(f, toList()), maxBy(comparator), toSet(), joining(", ").
partitioningBy: exactly two groups
Map<Boolean, List<String>> bigOrSmall = orders.stream()
.collect(Collectors.partitioningBy(o -> o.total() > 200,
Collectors.mapping(Order::id, Collectors.toList())));
// {false=[PO-2], true=[PO-1, PO-3]}
toMap: build lookups
Map<String, Order> byId = orders.stream()
.collect(Collectors.toMap(Order::id, o -> o));
Watch out: toMap throws IllegalStateException on duplicate keys. Pass a merge function to decide what happens: toMap(Line::sku, Line::qty, Integer::sum) adds quantities for the same SKU.
flatMap: from nested to flat
Each order has a list of lines. To work with all lines at once, flatten them:
Map<String, Integer> qtyPerSku = orders.stream()
.flatMap(o -> o.lines().stream()) // Stream<Order> -> Stream<Line>
.collect(Collectors.toMap(Line::sku, Line::qty, Integer::sum, TreeMap::new));
// {AX-200=39, BX-310=2, CX-420=1}
map turns each element into one thing; flatMap turns each element into a stream of things and joins them all together. It’s the stream equivalent of a loop inside a loop.
joining, reduce and statistics
String ids = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
double grandTotal = orders.stream().map(Order::total).reduce(0.0, Double::sum);
DoubleSummaryStatistics stats = orders.stream().mapToDouble(Order::total).summaryStatistics();
stats.getMin(); stats.getMax(); stats.getAverage(); stats.getCount();
reduce combines all elements into one value with an identity (0.0) and a combining function. For sums, mapToDouble(...).sum() is clearer; reach for reduce when combining things that aren’t simple numbers.
teeing (Java 12+): two results in one pass
String report = orders.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.averagingDouble(Order::total),
(count, average) -> count + " orders, average " + String.format("%.2f", average)));
Putting it together: the top-selling SKU
orders.stream()
.flatMap(o -> o.lines().stream())
.collect(Collectors.groupingBy(Line::sku, Collectors.summingInt(Line::qty)))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.println("Top SKU: " + e.getKey()));
Parallel streams — handle with care
orders.parallelStream() splits work across CPU cores. It can help for large, CPU-heavy, independent work. It usually hurts for small lists, I/O (API calls, database queries), or code with shared mutable state. Measure before using it — and for concurrent I/O, prefer CompletableFuture.
The full example
// AdvancedStreams.java — collectors, groupingBy, partitioningBy, flatMap, reduce.
import java.util.*;
import java.util.stream.*;
public class AdvancedStreams {
record Line(String sku, int qty, double price) {
double total() { return qty * price; }
}
record Order(String id, String customer, String status, List<Line> lines) {
double total() { return lines.stream().mapToDouble(Line::total).sum(); }
}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("PO-1", "Acme", "SHIPPED", List.of(new Line("AX-200", 24, 8.5), new Line("BX-310", 2, 40))),
new Order("PO-2", "Globex", "NEW", List.of(new Line("AX-200", 10, 8.5))),
new Order("PO-3", "Acme", "NEW", List.of(new Line("CX-420", 1, 999), new Line("AX-200", 5, 8.5))));
// groupingBy: customer -> list of orders
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer));
System.out.println(byCustomer.keySet());
// groupingBy with a downstream collector: customer -> total spend
Map<String, Double> spend = orders.stream()
.collect(Collectors.groupingBy(Order::customer, TreeMap::new,
Collectors.summingDouble(Order::total)));
System.out.println(spend); // {Acme=1325.5, Globex=85.0}
// counting per status
Map<String, Long> perStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status, TreeMap::new, Collectors.counting()));
System.out.println(perStatus); // {NEW=2, SHIPPED=1}
// partitioningBy: two groups, true and false
Map<Boolean, List<String>> bigOrSmall = orders.stream()
.collect(Collectors.partitioningBy(o -> o.total() > 200,
Collectors.mapping(Order::id, Collectors.toList())));
System.out.println(bigOrSmall); // {false=[PO-2], true=[PO-1, PO-3]}
// flatMap: from orders to all their lines
Map<String, Integer> qtyPerSku = orders.stream()
.flatMap(o -> o.lines().stream())
.collect(Collectors.toMap(Line::sku, Line::qty, Integer::sum, TreeMap::new));
System.out.println(qtyPerSku); // {AX-200=39, BX-310=2, CX-420=1}
// joining
String ids = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
System.out.println(ids); // [PO-1, PO-2, PO-3]
// reduce: combine everything into one value
double grandTotal = orders.stream().map(Order::total).reduce(0.0, Double::sum);
System.out.println("Grand total: " + grandTotal);
// summary statistics in one pass
DoubleSummaryStatistics stats = orders.stream().mapToDouble(Order::total).summaryStatistics();
System.out.printf("min %.1f max %.1f avg %.1f%n", stats.getMin(), stats.getMax(), stats.getAverage());
// teeing (Java 12+): two collectors at once
String report = orders.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.averagingDouble(Order::total),
(count, average) -> count + " orders, average " + String.format("%.2f", average)));
System.out.println(report);
// Most ordered SKU
orders.stream()
.flatMap(o -> o.lines().stream())
.collect(Collectors.groupingBy(Line::sku, Collectors.summingInt(Line::qty)))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.println("Top SKU: " + e.getKey() + " (" + e.getValue() + ")"));
}
}
Try it yourself
Using the same orders, build a Map<String, Set<String>> from customer to the set of SKUs they ordered.
Show a solution
Map<String, Set<String>> skusByCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer, TreeMap::new,
Collectors.flatMapping(o -> o.lines().stream().map(Line::sku),
Collectors.toCollection(TreeSet::new))));
System.out.println(skusByCustomer); // {Acme=[AX-200, BX-310, CX-420], Globex=[AX-200]}
Collectors.flatMapping (Java 9+) is flatMap as a downstream collector.
Key takeaways
groupingBy+ a downstream collector answers “per X, how many / how much / which”.partitioningBysplits into true/false;toMapbuilds lookups (give it a merge function).flatMapflattens nested collections.joining,reduce,summaryStatisticsandteeingsummarise; parallel streams need measurement.