Skip to Content
Course content

73: Collectors in Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember a junior developer on my team, let's call him Sam, who spent nearly half a day writing a reporting module for an e-commerce backend. He was trying to group orders by their shipping region and then, within each region, find the most expensive item sold. I looked at his PR and saw about 50 lines of nested for loops, manual Map instantiations, and a lot of "if key not present, create new list" boilerplate. It wasn't wrong—it worked—but it was fragile and exhausted to read. I sat down with him and showed him how to use Collectors.groupingBy combined with a downstream collector. We collapsed those 50 lines into a single, readable stream pipeline. Sam looked at me like I'd just performed a magic trick, but it wasn't magic; it was just leveraging the full power of the Collectors class.

Categorizing Data with groupingBy and partitioningBy

Most of us start our Stream journey with .collect(Collectors.toList()), but that's barely scratching the surface. The real heavy lifting happens when you need to reorganize your data. Collectors.groupingBy is your primary tool here. It functions similarly to a SQL GROUP BY clause, taking a classifier function and returning a Map where the keys are the results of that function.

List<Employee> employees = getEmployeeList();

// Simple grouping: Map<Department, List<Employee>>
Map<Department, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

Now, there's a specialized version of this called partitioningBy. I always tell developers to use this instead of groupingBy when the logic is a simple true/false split. It's more efficient and returns a Map<Boolean, List<T>>. If you're splitting a list into "active" and "inactive" users, partitioningBy is the semantically correct choice.

// Splitting employees into those making over 100k and those who aren't
Map<Boolean, List<Employee>> highEarners = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));

Layering Collectors for Complex Reductions

This is where things get interesting. The groupingBy method is overloaded, meaning you can pass a second collector as a "downstream" operation. Instead of just getting a List of items for every key, you can transform that list into something else—a count, a sum, or even another map—before the final map is ever returned.

Imagine you don't want a list of all employees in a department; you just want the average salary for that department. You can nest Collectors.averagingDouble inside your groupingBy. This prevents the overhead of creating intermediate lists that you're just going to throw away anyway.

Map<Department, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDepartment, 
        Collectors.averagingDouble(Employee::getSalary)
    ));

If you need something even more specific, like the name of the highest-paid person per department, you can use Collectors.maxBy. Be careful here, though: maxBy returns an Optional, so your resulting map will be Map<Department, Optional<Employee>>. If that's too clunky, you can wrap the whole thing in Collectors.collectingAndThen to extract the value from the optional immediately.

I've found that once you get comfortable with downstream collectors, you stop thinking in terms of "how do I loop through this" and start thinking in terms of "how do I shape this data." It's a fundamental shift in how you approach data transformation in Java.




📋 Practical Task

Building a Regional Sales Performance Report

You are tasked with analyzing a list of Sale objects. Each Sale has a region (String), a category (String), and an amount (double).

Write a program that uses a single Stream pipeline to produce a Map where:

  • The key is the region.
  • The value is another Map where the key is the category and the value is the total sum of sales for that category in that region.

Requirements:

  • Use Collectors.groupingBy nested within another Collectors.groupingBy.
  • Use Collectors.summingDouble as the final downstream collector.
  • Your final result type should be Map<String, Map<String, Double>>.

Starter Code:

public class Sale {
    private final String region;
    private final String category;
    private final double amount;

    public Sale(String region, String category, double amount) {
        this.region = region;
        this.category = category;
        this.amount = amount;
    }

    public String getRegion() { return region; }
    public String getCategory() { return category; }
    public double getAmount() { return amount; }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.