Learning Java from Scratch with Arbiter — Streams API: Lazy Pipelines, reduce, groupingBy, and Optional
Day 14 of my Arbiter Learning Journey | Why a stream does nothing until a terminal operation asks, how groupingBy can total while it groups, and why orElse runs its fallback even when nothing is missing
Before we begin, here’s the source code for today’s lesson:
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public class java_14StreamsOptional {
public static void main(String[] args) {
List<String> words = List.of("apple", "kiwi", "banana", "fig", "cherry", "date");
String str = words.stream()
.filter(word -> word.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
int total = words.stream()
.mapToInt(String::length)
.reduce(0, Integer::sum);
System.out.println(str);
System.out.println("Total characters: " + total);
System.out.println();
List<Transaction> transactions = List.of(
new Transaction("Alice", "Groceries", 45.20),
new Transaction("Bob", "Electronics", 299.99),
new Transaction("Alice", "Electronics", 150.00),
new Transaction("Carol", "Groceries", 60.00),
new Transaction("Alice", "Groceries", 12.50),
new Transaction("Bob", "Groceries", 22.30)
);
Map<String, List<Transaction>> customerTransactions = transactions.stream()
.collect(Collectors.groupingBy(Transaction::customer));
System.out.println("--- Customer Totals ---");
System.out.printf("Alice = %.2f%n", totalFor(customerTransactions, "Alice"));
System.out.printf("Bob = %.2f%n", totalFor(customerTransactions, "Bob"));
System.out.printf("Dave = %.2f%n", totalFor(customerTransactions, "Dave"));
Map<String, Double> categoryTotals = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::category,
Collectors.summingDouble(Transaction::amount)
));
System.out.println("\n--- Category Totals ---");
categoryTotals.forEach((category, totalCount) ->
System.out.printf("%s = %.2f%n", category, totalCount)
);
}
record Transaction(String customer, String category, double amount) {}
public static double totalFor(Map<String, List<Transaction>> byCustomer, String customer) {
return Optional.ofNullable(byCustomer.get(customer))
.map(list -> list.stream()
.mapToDouble(Transaction::amount)
.sum()
).orElseGet(() -> {
System.out.println("No transactions found for " + customer);
return 0.0;
});
}
}Last time, lambdas and method references turned behavior into values you could store, pass, and combine. Today, those values get somewhere to go. The Streams API takes a collection and runs it through a chain of operations, like filter, map, reduce, and collect, where every step is just a lambda or a method reference from Day 13. And Optional gives a "maybe there's a value here" result a proper type, instead of a null waiting to blow up.
The interesting part isn’t how short the code gets. It’s three questions hiding behind it. When does a stream actually do its work? How do you turn a list of records into totals per group in one pass? And what’s the real difference between two methods, orElse and orElseGet, that return exactly the same value?
This is Day 14 of my Arbiter Learning Journey, where I’m learning Java from the ground up before eventually building Arbiter, a complete microservices-based test management platform.
In Day 13, I covered lambdas, functional interfaces, method references, and composing behavior with .and() and .andThen(), along with how a fallback can quietly swallow the reason something failed. If you want the theory behind it (lambda calculus, target typing, and how lambdas compile), the Day 13 Guide Theory article covers it.
Today’s Topics
- Stream pipelines: a source, intermediate operations, and one terminal operation
- Intermediate operations:
filterandmap, and why they're lazy - Terminal operations:
collect(Collectors.joining(...))andreduce(...) - Primitive streams:
mapToIntandmapToDouble - Grouping:
Collectors.groupingBy, with and without a downstream collector - Records: a compact data class, and why
Transaction::customerworks Optional:ofNullable,map, andorElseGetversusorElse
A Stream Is a Plan Until a Terminal Operation Runs It
The lesson opens with a pipeline over six words:
List<String> words = List.of("apple", "kiwi", "banana", "fig", "cherry", "date");
String str = words.stream()
.filter(word -> word.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.joining(", "));Every stream pipeline has the same three parts:
- A source:
words.stream()turns the list into a stream of its elements - Intermediate operations:
filterkeeps only words longer than 3 characters, andmapturns each one into upper case. Each returns a new stream - A terminal operation:
collect(Collectors.joining(", "))produces an actual result, here oneStringwith the words joined by commas
The lambdas are exactly the ones from Day 13. word -> word.length() > 3 is a Predicate<String>, and String::toUpperCase is a Function<String, String> written as a method reference.
Output:
APPLE, KIWI, BANANA, CHERRY, DATEfig is the only word with 3 characters or fewer, so it's the only one missing.
Here’s the part that’s easy to miss. filter and map don't do anything when they're called. They only record a step. The work happens when the terminal operation asks for results. I checked this by adding a print inside each lambda and running the pipeline twice: once ending at .map(...), and once ending at .collect(...):
-- no terminal op
-- with terminal op
filter apple
map apple
filter kiwi
map kiwi
filter banana
map banana
filter fig
filter cherry
map cherry
filter date
map date
APPLE, KIWI, BANANA, CHERRY, DATEWithout a terminal operation, the pipeline compiled, ran, and printed nothing. Not a single lambda was called. With one, two more things become visible:
- Elements go through one at a time, not one stage at a time.
applepasses throughfilterandmapbeforekiwieven reachesfilter. The stream isn't building a filtered list and then mapping it. fignever reachesmap. It fails the filter, sotoUpperCaseis never called on it.
That’s what “lazy” means here: the stream does exactly the work the terminal operation needs, and no more.
One more rule from the same check: a stream can only be used once. Calling a second terminal operation on the same stream object throws:
java.lang.IllegalStateException: stream has already been operated upon or closedThat’s why the lesson calls words.stream() again for the next pipeline instead of reusing the first one.
reduce: Folding a Stream Down to One Value
The second pipeline adds up the length of every word:
int total = words.stream()
.mapToInt(String::length)
.reduce(0, Integer::sum);mapToInt(String::length) turns the Stream<String> into an IntStream, a stream of plain int values instead of boxed Integer objects. Then reduce(0, Integer::sum) folds them into one number. It starts with 0, the identity, and combines it with each element in turn using Integer::sum:
0 + 5 (apple) = 5
5 + 4 (kiwi) = 9
9 + 6 (banana) = 15
15 + 3 (fig) = 18
18 + 6 (cherry) = 24
24 + 4 (date) = 28Output:
Total characters: 28Notice that fig counts here. This is a fresh stream over the original, unfiltered words list. The first pipeline didn't change words at all. Streams never modify their source, and two pipelines over the same list don't share anything.
IntStream also has .sum(), which does the same thing in one call. Writing it as reduce shows what sum actually is: a fold with 0 as the starting value and + as the combining step.
groupingBy: Grouping and Totaling in One Pass
The second half of the lesson works with transactions. Each one is a record:
record Transaction(String customer, String category, double amount) {}A record is a compact, immutable data class. That one line gives you a constructor, private final fields, and an accessor for each component, named after the component itself: customer(), category(), and amount(). There's no get prefix, which is why the method references below read as Transaction::customer rather than Transaction::getCustomer.
Six transactions go into a list, and the first grouping splits them by customer:
Map<String, List<Transaction>> customerTransactions = transactions.stream()
.collect(Collectors.groupingBy(Transaction::customer));groupingBy takes a classifier, a function that says which group each element belongs to. Here, Transaction::customer puts every transaction in a bucket under its customer's name. The result is a Map<String, List<Transaction>>: Alice gets three transactions, Bob gets two, and Carol gets one.
The second grouping does more in the same call:
Map<String, Double> categoryTotals = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::category,
Collectors.summingDouble(Transaction::amount)
));The second argument is a downstream collector. It decides what each bucket holds. Without it, each bucket is a List of the matching elements. With Collectors.summingDouble(Transaction::amount), each bucket is a running total instead, so the result is Map<String, Double> directly. There's no separate step afterward that loops over the lists to add them up.
Get Someone’s stories in your inbox
Join Medium for free to get updates from this writer.
Tracing the data by hand:
- Groceries: 45.20 + 60.00 + 12.50 + 22.30 = 140.00
- Electronics: 299.99 + 150.00 = 449.99
Output:
--- Category Totals ---
Groceries = 140.00
Electronics = 449.99One thing worth knowing: groupingBy returns a HashMap (I printed getClass() to check), so it makes no promise about the order the categories come out in. Groceries printing first here isn't something to rely on. If order matters, groupingBy has an overload that takes a map factory, like TreeMap::new.
Optional: Handling a Missing Value Without a null Check
Totals per customer go through a helper method:
public static double totalFor(Map<String, List<Transaction>> byCustomer, String customer) {
return Optional.ofNullable(byCustomer.get(customer))
.map(list -> list.stream()
.mapToDouble(Transaction::amount)
.sum()
).orElseGet(() -> {
System.out.println("No transactions found for " + customer);
return 0.0;
});
}byCustomer.get(customer) returns the customer's list if they're in the map, and null if they aren't. Dave never appears in the data, so for him it's null. Instead of writing if (list == null), the method wraps the result:
Optional.ofNullable(...)produces anOptionalholding the list if there is one, or an emptyOptionalif the value wasnull.map(...)runs only if a value is present. It turns the list into its summed amount (withmapToDoubleandsum, a stream inside theOptional). If theOptionalis empty,mapskips the lambda and passes the emptyOptionalalong.orElseGet(...)unwraps the result. If there's a value, it returns it. If not, it calls theSupplierlambda, which prints a message and returns0.0
Output:
--- Customer Totals ---
Alice = 207.70
Bob = 322.29
No transactions found for Dave
Dave = 0.00The “No transactions found” line appears exactly once, and only for Dave. That’s the proof the Supplier ran only when it was needed. For Alice and Bob, it was never called.
There’s no isPresent() and no get() anywhere in this method. That's the point. Optional is meant to be used through methods like map and orElseGet, not as a null check with extra steps.
Running It
Here’s the complete output of the program, exactly as it ran:
APPLE, KIWI, BANANA, CHERRY, DATE
Total characters: 28
--- Customer Totals ---
Alice = 207.70
Bob = 322.29
No transactions found for Dave
Dave = 0.00
--- Category Totals ---
Groceries = 140.00
Electronics = 449.99Reading it in groups:
- Line 1: a lazy
filter→map→collectpipeline over the words - Line 2:
mapToIntandreduceadding up every word's length, unfiltered - Lines 4–8:
groupingByby customer, withOptionalhandling the customer who isn't there - Lines 10–12:
groupingByby category with asummingDoubledownstream collector
The Quiet Trap: orElse Runs Its Fallback Every Time
Optional has two methods that look interchangeable: orElse(value) and orElseGet(supplier). Both return the value if there is one, and the fallback if there isn't. So I swapped the lesson's orElseGet for orElse, with the fallback moved into a method that prints the same message:
static double fallback(String c) {
System.out.println("No transactions found for " + c);
return 0.0;
}
double t = Optional.ofNullable(m.get(c))
.map(l -> l.stream().mapToDouble(Transaction::amount).sum())
.orElse(fallback(c));Here’s what printed:
No transactions found for Alice
Alice = 207.70
No transactions found for Bob
Bob = 322.29
No transactions found for Dave
Dave = 0.00Every total is still correct. Alice still gets 207.70, Bob still gets 322.29. But the program now claims Alice and Bob have no transactions, right before printing their totals.
The reason is ordinary Java, not anything special about Optional. orElse takes a value, and Java evaluates a method's arguments before the method runs. So fallback(c) is called for every customer, every time, whether or not the Optional is empty. Its return value is then thrown away whenever there was a real total. orElseGet takes a Supplier, a lambda, and only calls it when the Optional is actually empty.
That’s what makes it quiet. Nothing fails, and the numbers are right. Here the side effect is a misleading log line. In a real system, it could be a database query, a network call, or a new object created for every lookup and discarded every time. The rule of thumb:
orElse(...)for a fallback that's a plain value or already exists, like0.0or a constantorElseGet(...)for a fallback that does work, has side effects, or costs something to build
It’s the same family as Day 13’s swallowed exception: everything behaves correctly on paper, and the cost hides in a place you don’t think to look.
Key Takeaway
Streams describe what should happen to data, and the terminal operation decides when it actually does:
- A pipeline is source → intermediate operations → terminal operation. Without a terminal operation, nothing runs
- Intermediate operations are lazy, and elements pass through the whole pipeline one at a time
- A stream can be used only once, and it never modifies its source
reducefolds a stream into one value from an identity and a combining functiongroupingBysplits elements into buckets by a classifier, and a downstream collector decides what each bucket holdsOptionalreplacesnullchecks withmapandorElseGet, andorElseevaluates its fallback even when the value is present
And like the last few lessons, the dangerous case is the one that runs without complaint. Two methods that return the same answer can do very different amounts of work to get there.
The Arbiter Journey Continues
One concept at a time, this series is building toward Arbiter, a microservices-based test management platform using Java, Spring Boot, databases, testing frameworks, Docker, and AWS. Today’s lesson is close to code Arbiter will actually contain. Turning a list of test results into pass, fail, and skipped counts per suite is a groupingBy with a downstream collector, the same shape as the category totals here. Converting entities into API response objects is a map over a stream. Spring Data JPA repositories commonly return Optional from lookups like findById, so "test case not found" becomes an empty Optional rather than a null. And the orElse trap matters directly: a fallback that quietly runs a query on every lookup is exactly the kind of cost that only shows up once a service is under load.
Watch the complete concept in detail
Follow along with the complete Arbiter playlist
GitHub Repository
You can follow the complete learning journey, source code, explanations, and future lessons here:
Follow the Complete Arbiter Series
Every Guide and Guide Theory article in this series, collected in one place:
Explore My Other Learning Series
If you’re interested in my other programming and technology learning journeys, you may also enjoy:
What’s Next?
In Day 15, we move into Multithreading: Thread and Runnable. So far, every program in this series has done one thing at a time, in order. Next, Java starts running work side by side, and Runnable, a functional interface with a single run() method, means today's lambdas are already the way to hand that work to a thread.
Happy coding! ☕
Companion theory article: For the theoretical background behind this lesson, see the Day 14 Guide Theory piece, published alongside this one. For the previous lesson’s theory, see Day 13 Guide Theory.










