Module 10 · Capstone: Professional Java Project ⏱ 20 min

Milestone 5: Reporting with Streams

By the end of this lesson you will be able to:
  • Build stream pipelines that filter and transform domain objects
  • Use groupingBy and counting to aggregate data
  • Predict the output of multi-stage pipelines and spot the laziness trap

A library does not just store books; it needs answers from them. How many books are overdue? Which genre is most popular? What is the average publication year of the science-fiction collection? Without streams, each question is a hand-written loop with a counter, a condition, and maybe a sort. The code works, but the intention is buried under indexing and temporary variables. Worse, off-by-one errors and forgotten increments hide in every loop.

The Stream API lets you describe the report as a pipeline: source, filter, map, collect. The library walks the data for you, fuses steps, and stops early when it can. In a professional codebase, reports are almost always streams — they are concise, parallelisable, and far less error-prone than loops that mutate shared state.

flowchart LR
  SRC["List<Loan>"] --> FIL["filter\ndaysOverdue > 0"]
  FIL --> MAP["map\ngetTitle"]
  MAP --> COL["collect\ntoList"]
  COL --> OUT["report"]
  style FIL fill:#c2410c,color:#fff
  style MAP fill:#c2410c,color:#fff
A stream pipeline for an overdue-loan report: source, filter, map, collect.

Filtering and mapping a report

A stream pipeline starts with a source — here, a List<Loan>. Intermediate operations describe the transformation: filter keeps only the loans where daysOverdue > 0, and map extracts the book title from each surviving loan. The terminal operation collect(Collectors.toList()) gathers the titles into a new list you can print or return to a web controller.

Each intermediate operation is lazy: it returns a new stream that remembers the step but does not execute it. Nothing happens until collect demands a result. That laziness means a huge list is never fully materialised in memory; elements flow through the pipeline one at a time, which is how streams handle datasets far larger than available RAM without crashing the JVM.

Reading the pipeline

Look at the first code example below. A list of loans becomes a stream, passes through filter to keep only overdue items, then map to extract titles, and finally collect to build a list. Each step is one method call; there are no indices, no manual list creation, and no risk of forgetting to increment a counter. If you need to add another condition later, such as filtering by member type, you insert another filter without rewriting the loop body.

Main.java — filtering overdue loans and collecting their titles. Real, compiling Java.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    static class Loan {
        String title;
        int daysOverdue;
        Loan(String t, int d) {
            title = t;
            daysOverdue = d;
        }
    }

    public static void main(String[] args) {
        List<Loan> loans = Arrays.asList(
            new Loan("Java Basics", 0),
            new Loan("Clean Code", 5),
            new Loan("Design Patterns", 0)
        );
        List<String> overdue = loans.stream()
            .filter(l -> l.daysOverdue > 0)
            .map(l -> l.title)
            .collect(Collectors.toList());
        System.out.println(overdue.size());
        System.out.println(overdue.get(0));
    }
}

Grouping and counting

Some reports need aggregation, not just a flat list. To know which genre is most popular, you group loans by genre and count the members of each group. Collectors.groupingBy builds a Map<String, List<Book>>; Collectors.counting() replaces the list with a count. The result is a Map<String, Long> where each key is a genre and each value is how many books belong to it.

This is where streams surpass manual loops. A loop that groups and counts needs a HashMap, a get-or-create check, and a running total. The stream version says exactly what it produces — a grouped count — and hides the mechanics. The map you get back is an ordinary Java map you can iterate, sort, or pass to another method. When the requirements change, you adjust the collector, not a nest of loops.

flowchart TD
  SRC["books stream"] --> GB["groupingBy(b -> b.genre)"]
  GB --> G1["Fiction\ncount 3"]
  GB --> G2["Science\ncount 2"]
  GB --> G3["History\ncount 1"]
  style GB fill:#c2410c,color:#fff
groupingBy turns a stream of books into a map of genre-to-count.

Reading the grouping pipeline

The second example below turns a list of books into a frequency table. groupingBy uses a lambda to decide the key for each book — here, the genre — and counting() tells the collector what to store as the value. The resulting map answers the question 'how many?' in a single pass over the data. You can replace counting() with averagingInt or maxBy to answer different questions without changing the grouping logic.

Main.java — grouping books by genre and counting each group. Real, compiling Java.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class Main {
    static class Book {
        String title, genre;
        Book(String t, String g) {
            title = t;
            genre = g;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("A", "Fiction"),
            new Book("B", "Science"),
            new Book("C", "Fiction"),
            new Book("D", "Fiction")
        );
        Map<String, Long> counts = books.stream()
            .collect(Collectors.groupingBy(
                b -> b.genre, Collectors.counting()));
        System.out.println(counts.get("Fiction"));
        System.out.println(counts.get("Science"));
    }
}

Chaining multiple operations

Real reports rarely stop at one step. You might filter to a single genre, map to publication years, and compute the average. Each operation returns a new stream, so you chain them left to right, reading the pipeline like a sentence: 'from the books, keep only science fiction, extract the year, then average'. The order matters: filter before map avoids transforming elements you will discard, and filter before sort avoids sorting elements you will discard.

This readability is the whole point of the Stream API. A loop that does the same work hides its purpose in indices and braces; a stream pipeline exposes it in the method names. When you review code at 3 a.m. during an outage, filter, map, and collect tell you immediately what the report is trying to do. That clarity saves time and prevents the wrong report from being deployed.

Main.java — filtering, mapping, sorting, and collecting in one pipeline. Real, compiling Java.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    static class Book {
        String title;
        int year;
        Book(String t, int y) {
            title = t;
            year = y;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("Old Java", 2000),
            new Book("New Java", 2020),
            new Book("Middle Java", 2010)
        );
        List<String> recent = books.stream()
            .filter(b -> b.year >= 2010)
            .map(b -> b.title)
            .sorted()
            .collect(Collectors.toList());
        System.out.println(recent.get(0));
        System.out.println(recent.get(1));
    }
}
flowchart TD
  BUILT["stream.filter.map"] --> Q{"terminal op?"}
  Q -->|"no"| N["nothing runs"]
  Q -->|"yes"| R["pipeline executes"]
  style N fill:#b91c1c,color:#fff
  style R fill:#15803d,color:#fff
Without a terminal operation, the pipeline is a description that never executes.

Spotting the trap

The third example chains two filters, a map, a sort, and a collect. Every intermediate step is lazy, but the final collect forces evaluation. Notice how sorted() comes after map: sorting strings is cheaper than sorting whole book objects, and by that point the genre filter has already removed everything we do not need. Ordering your pipeline to do the cheapest work first is a habit that pays off when the dataset grows from a hundred books to a million.

Exercise

What does this program print? A filter and map pipeline produces a list. Predict the exact output (two lines).

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    static class Book {
        String title;
        int year;
        Book(String t, int y) {
            title = t;
            year = y;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("A", 2000),
            new Book("B", 2020),
            new Book("C", 2015)
        );
        List<String> result = books.stream()
            .filter(b -> b.year >= 2015)
            .map(b -> b.title)
            .collect(Collectors.toList());
        System.out.println(result.size());
        System.out.println(result.get(0));
    }
}
Exercise

Why do professional Java projects prefer streams over hand-written loops for reports?

Exercise

What does this print? groupingBy counts genres. Predict the exact output (two lines).

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class Main {
    static class Book {
        String genre;
        Book(String g) {
            genre = g;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("Fiction"),
            new Book("Fiction"),
            new Book("Science")
        );
        Map<String, Long> counts = books.stream()
            .collect(Collectors.groupingBy(
                b -> b.genre, Collectors.counting()));
        System.out.println(counts.get("Fiction"));
        System.out.println(counts.get("Science"));
    }
}
Exercise

The laziness trap. There is no terminal operation. Predict the exact output (one line).

import java.util.Arrays;
import java.util.List;

public class Main {
    static class Book {
        String title;
        Book(String t) {
            title = t;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("A"),
            new Book("B")
        );
        books.stream()
            .filter(b -> {
                System.out.println("check " + b.title);
                return true;
            })
            .map(b -> b.title);
        System.out.println("done");
    }
}
Exercise

Transfer task. Two filters, a sort, and a collect. Predict the exact output (two lines).

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    static class Book {
        String title, genre;
        int year;
        Book(String t, String g, int y) {
            title = t;
            genre = g;
            year = y;
        }
    }

    public static void main(String[] args) {
        List<Book> books = Arrays.asList(
            new Book("Alpha", "Fiction", 2010),
            new Book("Beta", "Fiction", 2020),
            new Book("Gamma", "Science", 2015)
        );
        List<String> result = books.stream()
            .filter(b -> b.genre.equals("Fiction"))
            .filter(b -> b.year >= 2015)
            .map(b -> b.title)
            .sorted()
            .collect(Collectors.toList());
        System.out.println(result.size());
        System.out.println(result.get(0));
    }
}

Recap

  • A stream pipeline has a source, intermediate operations (filter, map, sorted), and a terminal operation (collect, forEach).
  • Intermediate operations are lazy: they only run when a terminal operation forces evaluation.
  • groupingBy and counting turn streams into aggregated maps without manual loops.
  • Order matters: filter early to reduce the work done by later stages.
  • A stream is single-use: once consumed, it cannot be reused; create a new stream from the source collection instead.

Checkpoint quiz

What happens when a stream pipeline calls filter and map but never calls a terminal operation?

Which collector turns a stream of books into a Map from genre to count?

Go deeper — technical resources