Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 24

Custom Collectors for Stream Aggregation

Recommended resources

Hello! Welcome to the final lesson in our module on Functional Java.

In our last session, we critically analyzed the trade-offs of parallel streams, establishing that they are a powerful optimization but must be used with care, especially in server-side applications. We saw that all stream pipelines end with a terminal operation, many of which, like collect(), are designed for complex aggregations.

Today, we will dive into one of the most powerful and advanced features of the Stream API. Your learning outcome is to implement a custom collector for a specialized stream aggregation scenario. While the standard methods in java.util.stream.Collectors are versatile, knowing how to create your own collector is a hallmark of a senior Java developer. It allows you to perform complex, multi-faceted data aggregations in a single, efficient pass over a stream, a skill that is highly valued in interviews and real-world data processing tasks.

1. Why Standard Collectors Aren't Always Enough

Imagine you need to process a stream of data and extract multiple pieces of information simultaneously. For example, from a list of transactions, you might want to calculate the total value, find the highest-value transaction, and count the number of transactions, all at once.

The naive approach is to stream over the collection three times:

  1. stream().mapToDouble(Transaction::getValue).sum()
  2. stream().mapToDouble(Transaction::getValue).max()
  3. stream().count()

This is inefficient. Since streams can only be consumed once, this requires iterating over the source data multiple times. A custom collector allows you to achieve the same result in a single pass, which is significantly more performant, especially for large datasets.


2. The Anatomy of the Collector Interface

To build our own collector, we must implement the java.util.stream.Collector interface. It may look intimidating at first, but it's composed of five distinct functional pieces that work together.

public interface Collector<T, A, R>

The generic types are:

  • T: The type of input elements in the stream (e.g., String, Transaction).
  • A: The type of the mutable accumulator object used during collection (e.g., StringBuilder, a custom Statistics class).
  • R: The type of the final result (e.g., String, a custom Summary object). Often, A and R are the same.
Internal Working of a Java Stream Collector
This diagram illustrates the internal workflow of a `Collector`. The `supplier` (1) creates a container. The `accumulator` (2) adds stream elements to it. The `combiner` (3) merges containers in parallel execution. Finally, the optional `finisher` (4) can transform the container into the final result (5).

To understand the role of each of the five methods, let's look at a clear definition.

Introduction to writing custom collectors in Java 8

The article 'Introduction to writing custom collectors in Java 8' by Tomasz Nurkiewicz provides an excellent breakdown of the Collector interface and its methods.

Read the section under the heading 'interface Collector<T,A,R> {' down to just before the code block for ImmutableSetCollector. Focus on understanding the purpose of the five methods: supplier, accumulator, combiner, finisher, and characteristics.

Here is a summary of those five essential methods:

  1. supplier(): Provides new instances of the mutable container. It's a Supplier<A> that gets called at the beginning of the collection process.
  2. accumulator(): Adds a new element to an existing container. It's a BiConsumer<A, T> that takes the container and the stream element.
  3. combiner(): Merges two containers into one. It's a BinaryOperator<A> and is crucial for parallel streams, where the data is split, processed in separate containers, and then merged.
  4. finisher(): Performs an optional final transformation from the intermediate container A to the final result type R. It's a Function<A, R>. If no final transformation is needed (A and R are the same), this can be Function.identity().
  5. characteristics(): A Set<Characteristics> that provides hints to the framework to optimize execution. Common characteristics are:
    • IDENTITY_FINISH: Indicates the finisher does nothing, so the A -> R cast is safe.
    • UNORDERED: The result is not affected by the order of elements in the stream.
    • CONCURRENT: The accumulator can be modified by multiple threads simultaneously (requires a thread-safe accumulator).

3. A Practical Example: The Shopping Cart Collector

Now, let's apply this theory to a practical, real-world scenario you might encounter in an e-commerce backend.

Problem: We have a shopping cart and need to display its contents. This requires two pieces of information: a list of CartRow objects (for display) and the total price of the cart. We want to get both in a single pass over the cart's items.

The following article walks through solving this exact problem by creating a custom PriceAndRowsCollector.

A Real-World Example of a Stream Collector

The article 'A Real-World Example of a Stream Collector' on foojay.io provides a complete, step-by-step guide to building a custom collector for an e-commerce use case.

Read the article from the beginning to the end. It's short and very focused. Pay close attention to: The Problem: Why iterating the stream twice is inefficient. The Container: The structure of the PriceAndRows class that will hold our intermediate and final results. The Implementation: How each of the Collector interface's methods (supplier, accumulator, etc.) is implemented for the PriceAndRowsCollector. The Usage: How cleanly the final collector integrates into a stream pipeline.

Let's summarize the implementation from the article:

  • T (Input Type): Map.Entry<Product, Integer> - an entry from the cart's product map.
  • A (Accumulator Type): PriceAndRows - a mutable class holding BigDecimal price and List<CartRow> rows.
  • R (Result Type): PriceAndRows - the same as the accumulator.

And the five methods:

  • supplier(): () -> new PriceAndRows() - creates an empty container with price zero and an empty list of rows.
  • accumulator(): (priceAndRows, entry) -> { ... } - creates a CartRow from the map entry, adds its price to the total, and adds the row to the list.
  • combiner(): (c1, c2) -> { ... } - combines two PriceAndRows objects by adding their prices and concatenating their row lists. This is what enables parallel processing.
  • finisher(): Function.identity() - since our accumulator PriceAndRows is the desired final result, no transformation is needed.
  • characteristics(): Set.of(Characteristics.IDENTITY_FINISH) - informs the stream framework that the finisher is a no-op.

4. Solving an Interview Problem with a Custom Collector

In a senior-level interview, you might be asked to solve a problem that is a perfect fit for a custom collector. Let's practice one.

Problem: Given a Stream<Integer>, write a collector that calculates the minimum, maximum, sum, and count of the elements in a single pass.

Let's design this step-by-step:

Step 1: Define the Accumulator/Result Class
We need a container to hold the state. A Java record or class is perfect for this. Let's call it IntStatistics.

public class IntStatistics {
    private int min = Integer.MAX_VALUE;
    private int max = Integer.MIN_VALUE;
    private long sum = 0;
    private long count = 0;

    // Getters...

    public double getAverage() {
        return count > 0 ? (double) sum / count : 0.0;
    }
}

Step 2: Implement the Collector Interface

Now we can create the collector itself. For cleaner usage, it's common practice to provide a static factory method, just like Collectors.toList().

import java.util.Collections;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;

public class IntStatisticsCollector implements Collector<Integer, IntStatistics, IntStatistics> {

    @Override
    public Supplier<IntStatistics> supplier() {
        // 1. Create the container
        return IntStatistics::new;
    }

    @Override
    public BiConsumer<IntStatistics, Integer> accumulator() {
        // 2. Add an element to the container
        return (stats, i) -> {
            stats.min = Math.min(stats.min, i);
            stats.max = Math.max(stats.max, i);
            stats.sum += i;
            stats.count++;
        };
    }

    @Override
    public BinaryOperator<IntStatistics> combiner() {
        // 3. Merge two containers
        return (stats1, stats2) -> {
            IntStatistics combined = new IntStatistics();
            combined.min = Math.min(stats1.min, stats2.min);
            combined.max = Math.max(stats1.max, stats2.max);
            combined.sum = stats1.sum + stats2.sum;
            combined.count = stats1.count + stats2.count;
            return combined;
        };
    }

    @Override
    public Function<IntStatistics, IntStatistics> finisher() {
        // 4. Perform final transformation (none needed here)
        return Function.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        // 5. Specify characteristics
        return Collections.singleton(Characteristics.IDENTITY_FINISH);
    }
    
    // Static factory method for convenience
    public static IntStatisticsCollector statistics() {
        return new IntStatisticsCollector();
    }
}

Step 3: Use the Collector
Using it is now simple and expressive:

List<Integer> numbers = List.of(10, 4, 25, 18, 9, 42, 18);

IntStatistics stats = numbers.stream()
                             .collect(IntStatisticsCollector.statistics());

System.out.println("Min: " + stats.getMin()); // Output: 4
System.out.println("Max: " + stats.getMax()); // Output: 42
System.out.println("Sum: " + stats.getSum()); // Output: 126
System.out.println("Count: " + stats.getCount()); // Output: 7
System.out.println("Average: " + stats.getAverage()); // Output: 18.0

This is far more efficient than streaming over the list four times. The built-in IntStream.summaryStatistics() does exactly this for primitive streams, but building it yourself demonstrates a profound understanding of the API.

Test your understanding!

Consider a collector that takes a Stream<String> and produces a single String where the elements are joined by a comma, enclosed in brackets (e.g., "[a, b, c]"). The standard Collectors.joining() can do parts of this, but let's imagine you need a custom implementation.

How would you implement the finisher() method for this collector if your accumulator (A) is a StringBuilder and your final result (R) is a String?

Show answer

The accumulator would have built a string like "a, b, c". The finisher needs to add the brackets.

@Override
public Function<StringBuilder, String> finisher() {
    return sb -> {
        if (sb.length() > 0) {
            // If we added a trailing comma, remove it before closing the bracket.
            // A better accumulator would avoid this.
            // For simplicity, let's assume no trailing comma.
        }
        sb.insert(0, "["); // Prepend '['
        sb.append("]");   // Append ']'
        return sb.toString();
    };
}

In this case, A is StringBuilder and R is String, so IDENTITY_FINISH would not be one of the characteristics. The finisher performs a crucial final step.


Conclusion

You have now reached the end of our module on functional programming with Java Streams. Mastering custom collectors is a significant step that sets you apart. It shows you can reason about data flow, efficiency, and concurrency at a deep level.

Key Takeaways for Your Interview:

  • Explain the 'Why': Articulate that custom collectors are for performing complex, multi-result aggregations in a single pass, which is more efficient than multiple traversals.
  • Know the 5 Components: Be ready to describe the role of supplier, accumulator, combiner, finisher, and characteristics.
  • Explain the combiner: Emphasize that the combiner is what makes a collector work correctly with parallel streams.
  • Have a Go-To Example: The shopping cart or the statistics collector are both excellent, practical examples to have in your back pocket to demonstrate your knowledge.

Preview of the Next Module:
Our work on parallel streams and the combiner function has been a great warm-up for our next topic. We are moving from functional programming into an even more critical area for senior backend engineers: Advanced Java Concurrency.

In our next lesson, we will start by building a solid foundation, where you will explain the Java Memory Model (JMM) and the thread lifecycle. This is the theoretical bedrock upon which all of Java's concurrency features, from synchronized to volatile to CompletableFuture, are built. See you there