انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-036-L07

عرض عملي: بناء خط معالجة تقرير المبيعات

  • الدرس 7 من 8
  • 10 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

This is a demo lesson. Instead of learning a new tool, you will build something with the tools from Lessons 1 to 6: a small sales report from a list of orders. It prints revenue by category, the top three customers, and the average order value per month. Along the way you will make one planned mistake, reusing a stream, and fix it.

Think of the whole program as one factory floor with three belts. Every belt starts from the same loading dock, the list of orders, and each ends at a different packing worker: one totals revenue per category, one ranks customers, one averages per month. The loading dock is never emptied, which is why you can start as many belts as you like from it, but each belt only once.

Why this matters: this is exactly the shape of the module lab (BEG-036-LAB1) and of countless real report endpoints. Doing it once, slowly, shows how small readable pipelines compose.

This lesson builds on all earlier lessons of the module, plus LocalDate and YearMonth from BEG-033. You can type along in any JDK 21 setup (menus differ between IDEs, so use whatever you normally use to create and run a class).

Learning Objectives

By the end of this lesson you will be able to:

  • Model orders as a record with a customer, category, amount, and date.
  • Compute revenue per category with groupingBy and summingDouble.
  • Compute the top three customers by grouping, then sorting the entries.
  • Compute an average per month by grouping on YearMonth.
  • Reproduce and explain the IllegalStateException caused by reusing a stream, and fix it.
  • Print each result with a clear heading and a stable order.

Written Explanation

Step 1: the data and the record

An order is a value: it is defined by its fields, so a record is a good fit (previewed in BEG-026):

java
record Order(String customer, String category, double amount, LocalDate date) { }

A record gives you accessors named after the fields: o.customer(), o.category(), o.amount(), o.date(). The sample data has eight orders over three months.

A note on money, as in Lesson 5: the amounts are double so that summingDouble can be used. A production system would use another representation for currency. All amounts here are chosen to be exactly representable, so the printed output is tidy.

Step 2: revenue by category

java
Map<String, Double> revenue = orders.stream().collect(
    Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));

This is the pattern from Lesson 5: group by category, total the amounts. To print it sorted from highest revenue to lowest, stream the entries:

java
revenue.entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

forEach is the one terminal operation where printing is natural: it is the last station and it only displays.

Step 3: top three customers

The same two moves, with limit(3) added:

java
Map<String, Double> byCustomer = orders.stream().collect(
    Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::amount)));
byCustomer.entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .limit(3)
    .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

If two customers could tie, add a second sort key (.thenComparing(Map.Entry.comparingByKey()) on the comparator) so the output is the same on every run. The sample data has no ties among the top three, so the simple version is enough.

Step 4: average order value per month

To group by month we need a key per month. YearMonth.from(o.date()) gives a value such as 2025-02, which is a good map key: it carries the year, so January 2025 and January 2026 are never mixed. (Using only the month name would merge different years, a classic bug.)

java
Map<YearMonth, Double> avgByMonth = orders.stream().collect(
    Collectors.groupingBy(o -> YearMonth.from(o.date()),
                          Collectors.averagingDouble(Order::amount)));
new TreeMap<>(avgByMonth).forEach((m, v) -> System.out.println(m + ": " + v));

YearMonth values compare naturally, so a TreeMap copy prints the months in calendar order. The forEach on a map takes a two-argument lambda (key, value).

Step 5: the planned mistake

A developer wants to compute "orders of at least 20" once and use it in two ways, so they keep the stream in a variable:

java
Stream<Order> big = orders.stream().filter(o -> o.amount() >= 20);
System.out.println(big.count());                                  // works
System.out.println(big.map(Order::customer).distinct().toList()); // fails

The second terminal operation fails with IllegalStateException: stream has already been operated upon or closed. A stream is a one-time belt. The fix is to keep the list, which can be streamed as often as needed:

java
List<Order> big = orders.stream().filter(o -> o.amount() >= 20).toList();
big.size();
big.stream().map(Order::customer).distinct().toList();

Now there are two cheap pipelines from a stored result.

Step 6: print with headings

Print a heading line before each result. A plain System.out.println("== Revenue by category =="); is enough. Report code that mixes results with no headings is hard to check.

Readability check

Look back at the three pipelines. Each has at most four stations, no side effects, and one lambda per line. The only place a loop could win here is if you needed all three results in a single pass over a huge list. For eight orders, three pipelines are perfectly fine, and reading them is quick.

Examples

Example 1: the whole program (simple)

Create a file SalesReport.java and type this in. Compile and run it with your usual setup (for example, java SalesReport.java works in JDK 11 and later for a single-file program).

java
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.*;
import java.util.stream.*;

public class SalesReport {
    record Order(String customer, String category, double amount, LocalDate date) { }

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("Amal", "Books", 12.50, LocalDate.of(2025, 1, 5)),
            new Order("Bo", "Toys", 30.00, LocalDate.of(2025, 1, 12)),
            new Order("Amal", "Toys", 20.00, LocalDate.of(2025, 2, 3)),
            new Order("Chen", "Books", 7.50, LocalDate.of(2025, 2, 14)),
            new Order("Bo", "Books", 10.00, LocalDate.of(2025, 2, 20)),
            new Order("Dina", "Games", 60.00, LocalDate.of(2025, 2, 27)),
            new Order("Chen", "Games", 45.00, LocalDate.of(2025, 3, 2)),
            new Order("Dina", "Books", 15.00, LocalDate.of(2025, 3, 9)));

        System.out.println("== Revenue by category ==");
        Map<String, Double> revenue = orders.stream().collect(
            Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));
        revenue.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

        System.out.println("== Top 3 customers ==");
        Map<String, Double> byCustomer = orders.stream().collect(
            Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::amount)));
        byCustomer.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(3)
            .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

        System.out.println("== Average order value per month ==");
        Map<YearMonth, Double> avgByMonth = orders.stream().collect(
            Collectors.groupingBy(o -> YearMonth.from(o.date()),
                                  Collectors.averagingDouble(Order::amount)));
        new TreeMap<>(avgByMonth).forEach((m, v) -> System.out.println(m + ": " + v));
    }
}

Real output:

text
== Revenue by category ==
Games: 105.0
Toys: 50.0
Books: 45.0
== Top 3 customers ==
Dina: 75.0
Chen: 52.5
Bo: 40.0
== Average order value per month ==
2025-01: 21.25
2025-02: 24.375
2025-03: 30.0

Check one line by hand: Dina has 60 + 15 = 75. February has four orders (20, 7.5, 10, 60), total 97.5, average 24.375. Always verify a report with at least one hand calculation.

Example 2: the planned mistake and its fix (practical)

Add this to main:

java
Stream<Order> big = orders.stream().filter(o -> o.amount() >= 20);
System.out.println(big.count());
try {
    System.out.println(big.map(Order::customer).distinct().toList());
} catch (IllegalStateException e) {
    System.out.println("IllegalStateException: " + e.getMessage());
}
List<Order> bigList = orders.stream().filter(o -> o.amount() >= 20).toList();
System.out.println(bigList.size() + " " + bigList.stream().map(Order::customer).distinct().toList());

Real output:

text
4
IllegalStateException: stream has already been operated upon or closed
4 [Bo, Amal, Dina, Chen]

The count, 4, is correct (30, 20, 60, 45). The second pipeline fails because the stream was consumed by count(). The fix on the last two lines uses the stored list twice.

Example 3: a small extension: orders per month (realistic)

Management asks, "how many orders per month?". It is the same groupingBy with counting() instead of averagingDouble:

java
Map<YearMonth, Long> perMonth = orders.stream().collect(
    Collectors.groupingBy(o -> YearMonth.from(o.date()), Collectors.counting()));
System.out.println(new TreeMap<>(perMonth));

Real output:

text
{2025-01=2, 2025-02=4, 2025-03=2}

Changing the question meant changing only the downstream collector. That is the payoff of a small, composable design.

Backend Insight

A report endpoint such as GET /reports/sales is exactly this program with the list coming from a database and the result written as JSON. Three habits from the demo carry over:

  • Keep results in collections. Return a Map or a list of small result records. Never return an already-consumed stream.
  • Sort for determinism. A map's order is unspecified; a report that changes row order between runs causes confusing bug reports and flaky tests. Always sort before output.
  • Prefer the database for volume. With ten million orders you would run GROUP BY in SQL and map the few result rows. The stream version is what you write for the data already in memory, and it is also a handy specification of what the SQL must return, which helps when you test.

Test the pipelines with a tiny known list like the one above and check results by hand, as we did for Dina and February. Tests built this way are cheap and catch most mistakes in grouping keys.

Common Mistakes

Mistake 1: Reusing a stream variable

Seen in Step 5. Store a list, not a stream.

Mistake 2: Grouping by month name only

o.date().getMonth() ignores the year, so January 2025 and January 2026 merge. Use YearMonth.from(o.date()).

Mistake 3: Printing an unsorted map

The order of a HashMap-style result is not part of its contract. Sort entries or copy into a TreeMap before printing.

Mistake 4: Ranking in ascending order by accident

comparingByValue() alone sorts smallest first. For a "top" list you need reversed() (or Comparator.reverseOrder() on the values).

Mistake 5: Not checking a result by hand

A pipeline that compiles can still group by the wrong field. Check at least one total with a calculator.

Exercises

Try each exercise yourself before opening the solutions. Use the eight-order list from Example 1.

Level A: Foundation

A1. Which two collectors do you combine to get "revenue per category"?

أظهر الحل A1

A1. Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)).

A2. Why is YearMonth a better grouping key than the month number for orders spanning two years?

أظهر الحل A2

A2. The month number alone (1 to 12) mixes the same month of different years. YearMonth includes the year, so each calendar month stays separate.

A3. Write a pipeline that prints the customer name of the single largest order. (Hint: max with a comparator; it returns an Optional, so end with map and orElse.)

أظهر الحل A3

A3.

java
String who = orders.stream()
    .max(Comparator.comparingDouble(Order::amount))
    .map(Order::customer)
    .orElse("none");
// Dina

Level B: Application

B1. Compute the total amount of all orders placed in February 2025.

أظهر الحل B1

B1.

java
double feb = orders.stream()
    .filter(o -> YearMonth.from(o.date()).equals(YearMonth.of(2025, 2)))
    .map(Order::amount)
    .reduce(0.0, Double::sum);   // 97.5

B2. Compute the overall average order value, using reduce and size() (no collector).

أظهر الحل B2

B2.

java
double avg = orders.stream().map(Order::amount).reduce(0.0, Double::sum) / orders.size();
// 25.0

B3. Print the number of orders per category, sorted by category name.

أظهر الحل B3

B3.

java
Map<String, Long> n = orders.stream()
    .collect(Collectors.groupingBy(Order::category, Collectors.counting()));
new TreeMap<>(n).forEach((k, v) -> System.out.println(k + ": " + v));
// Books: 4, Games: 2, Toys: 2 (one per line)

Level C: Challenge

C1. Print the top two months by total revenue, as month: total. Reuse the pattern of the top customers.

أظهر الحل C1

C1.

java
Map<YearMonth, Double> byMonth = orders.stream().collect(
    Collectors.groupingBy(o -> YearMonth.from(o.date()),
                          Collectors.summingDouble(Order::amount)));
byMonth.entrySet().stream()
    .sorted(Map.Entry.<YearMonth, Double>comparingByValue().reversed())
    .limit(2)
    .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

For the sample data: February is 97.5, March 60.0, January 42.5, so this prints 2025-02: 97.5 then 2025-03: 60.0.

C2. The report must show the top three customers, but with the customer name in alphabetical order when two totals tie. Show the comparator you need and explain why the simple version is not enough.

أظهر الحل C2

C2.

java
Comparator<Map.Entry<String, Double>> rank =
    Map.Entry.<String, Double>comparingByValue().reversed()
        .thenComparing(Map.Entry.comparingByKey());

Use .sorted(rank). The simple version only compares values; for tied totals the order of the tied entries then depends on the map's iteration order, which is not guaranteed. The second key makes the order fully determined.

Mini Challenge

Extend the report with one more section of your choice: for example the number of distinct customers per category, or the largest single order per month. Print it with a heading in a stable order. Before running, write the expected output by hand for the eight sample orders, then compare. Check your work: your prediction matched, the pipeline is no longer than five stations, and you used no outside variables inside a lambda.

Summary & Next Lesson

Key takeaways

  • A report is a few small pipelines over the same list. Keep the list, not the stream.
  • groupingBy plus summingDouble, counting, or averagingDouble answers most "per group" questions.
  • Rank by sorting the entries of the grouped map, then limit.
  • Group by YearMonth, not by month number alone.
  • Sort before printing, and verify at least one number by hand.

Next lesson

The last lesson, "Demo: rewriting an unreadable stream as a loop", shows the opposite skill: stepping back from a stream that has gone too far, and explaining the change.

Self-check

  1. Why does reusing a stream variable fail?
  2. What should you store instead?
  3. How do you make a "top 3" list from a grouped map?
  4. Why group by YearMonth instead of month number?
  5. Why sort before printing a grouped result?
أظهر الإجابات
  1. The first terminal operation consumes the stream, and a second one throws IllegalStateException.
  2. The list (the source or the collected result), then create a new stream each time.
  3. Stream the entries, sort by value in reverse, then limit(3).
  4. It includes the year, so months of different years are not merged.
  5. A grouped map has no guaranteed order, so output could change between runs.

References

  • Collectors, Java SE 21 API. Supports: groupingBy with a downstream collector, summingDouble, averagingDouble, counting, and that the grouped map's type and order are not guaranteed.
  • java.util.stream package summary, Java SE 21 API. Supports: a stream is consumed once and a new stream must be obtained from the source to traverse again.
  • All outputs and the exact exception message were produced by compiling and running the program with JDK 21. The properties of YearMonth (carries the year, natural ordering) were verified by running the code and were not checked against a fetched page here.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.