Definition
- Stream pipeline – a sequence of operations: source, lazy intermediate operations (map, filter, sorted), and one terminal operation (collect, count, reduce).
- Collectors – ready-made reductions:
groupingBy,partitioningBy,counting,joining, andreducing. - Lazy evaluation – intermediate operations do nothing until a terminal operation triggers execution.
- Single-use – a stream is consumed after the terminal operation and cannot be reused.
- Money safety – always use
BigDecimalwithreducingfor financial calculations, neversummingDouble.
Imagine the back office at National Bank of India has a huge pile of trade slips on a conveyor belt. Instead of writing a loop to manually sort them, the Stream API lets you describe what you want: group these slips by account, count how many are for each stock, put BUY slips in one tray and SELL slips in another, give me the three biggest trades. You describe the outcome, and the stream does the walking for you.
Understanding the Concepts
What is a stream pipeline?
A stream pipeline has three parts: a source (such as trades.stream()), zero or more intermediate operations, and exactly one terminal operation. Intermediate operations are lazy, meaning nothing runs until the terminal operation is called.
package com.bank.stream;
import java.math.BigDecimal;
import java.util.List;
public class StreamBasics {
record Trade(String id, String accountId, String symbol,
String side, BigDecimal amount) {}
public static void main(String[] args) {
List<Trade> trades = List.of(
new Trade("T1", "ACC001", "HDFC", "BUY", new BigDecimal("50000")),
new Trade("T2", "ACC001", "ICICI", "SELL", new BigDecimal("30000")),
new Trade("T3", "ACC002", "HDFC", "BUY", new BigDecimal("75000")),
new Trade("T4", "ACC002", "SBI", "BUY", new BigDecimal("40000")),
new Trade("T5", "ACC001", "HDFC", "SELL", new BigDecimal("60000"))
);
// Pipeline: source -> filter -> map -> terminal
long buyCount = trades.stream()
.filter(t -> "BUY".equals(t.side()))
.count();
System.out.println("BUY trades: " + buyCount);
}
}
BUY trades: 3
How does groupingBy work?
The groupingBy collector turns a stream into a Map keyed by a classifier function. You can pass a second (downstream) collector to summarise each group instead of collecting raw lists.
package com.bank.stream;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingExample {
record Trade(String id, String accountId, String symbol, BigDecimal amount) {}
public static void main(String[] args) {
List<Trade> trades = List.of(
new Trade("T1", "ACC001", "HDFC", new BigDecimal("50000")),
new Trade("T2", "ACC001", "ICICI", new BigDecimal("30000")),
new Trade("T3", "ACC002", "HDFC", new BigDecimal("75000")),
new Trade("T4", "ACC002", "SBI", new BigDecimal("40000"))
);
// Group by account
Map<String, List<Trade>> byAccount = trades.stream()
.collect(Collectors.groupingBy(Trade::accountId));
byAccount.forEach((k, v) ->
System.out.println(k + ": " + v.size() + " trades"));
// Group by symbol, count per group
Map<String, Long> countBySymbol = trades.stream()
.collect(Collectors.groupingBy(Trade::symbol, Collectors.counting()));
System.out.println("Count by symbol: " + countBySymbol);
// Group by account, total amount using BigDecimal
Map<String, BigDecimal> totalByAccount = trades.stream()
.collect(Collectors.groupingBy(Trade::accountId,
Collectors.reducing(BigDecimal.ZERO, Trade::amount, BigDecimal::add)));
System.out.println("Total by account: " + totalByAccount);
}
}
ACC001: 2 trades
ACC002: 2 trades
Count by symbol: {SBI=1, HDFC=2, ICICI=1}
Total by account: {ACC001=80000, ACC002=115000}
How does partitioningBy work?
The partitioningBy collector splits the stream into exactly two groups based on a boolean predicate. It always returns a Map with both keys present.
package com.bank.stream;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class PartitionExample {
record Trade(String id, String side, int amount) {}
public static void main(String[] args) {
List<Trade> trades = List.of(
new Trade("T1", "BUY", 50000),
new Trade("T2", "SELL", 30000),
new Trade("T3", "BUY", 75000),
new Trade("T4", "SELL", 40000)
);
Map<Boolean, List<Trade>> partition = trades.stream()
.collect(Collectors.partitioningBy(t -> "BUY".equals(t.side())));
System.out.println("BUY: " + partition.get(true).size());
System.out.println("SELL: " + partition.get(false).size());
}
}
BUY: 2
SELL: 2
How do you sort and limit for top-N queries?
Combine sorted with a reversed comparator and limit to find the top-N elements. This is the stream equivalent of "show me the three largest trades."
package com.bank.stream;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
public class TopNTrades {
record Trade(String id, BigDecimal amount) {}
public static void main(String[] args) {
List<Trade> trades = List.of(
new Trade("T1", new BigDecimal("50000")),
new Trade("T2", new BigDecimal("90000")),
new Trade("T3", new BigDecimal("30000")),
new Trade("T4", new BigDecimal("80000")),
new Trade("T5", new BigDecimal("70000"))
);
System.out.println("Top 3 trades:");
trades.stream()
.sorted(Comparator.comparing(Trade::amount).reversed())
.limit(3)
.forEach(t -> System.out.println(" " + t.id() + ": " + t.amount()));
}
}
Top 3 trades:
T2: 90000
T4: 80000
T5: 70000
Why must money use BigDecimal with reducing?
The summingDouble and averagingDouble collectors lose precision because double cannot represent all decimal fractions exactly. In banking, always use BigDecimal with the reducing collector for totals.
package com.bank.stream;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class MoneyPrecision {
public static void main(String[] args) {
List<BigDecimal> amounts = List.of(
new BigDecimal("0.10"),
new BigDecimal("0.20"),
new BigDecimal("0.30")
);
// WRONG: double loses precision
double doubleSum = amounts.stream()
.collect(Collectors.summingDouble(BigDecimal::doubleValue));
System.out.println("Double sum: " + doubleSum);
// CORRECT: BigDecimal reducing
BigDecimal bdSum = amounts.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
System.out.println("BigDecimal sum: " + bdSum);
}
}
Double sum: 0.6000000000000001
BigDecimal sum: 0.60
How do you use joining to build string output?
The joining collector concatenates strings with a separator, useful for building human-readable output from IDs or names.
package com.bank.stream;
import java.util.List;
import java.util.stream.Collectors;
public class JoiningExample {
public static void main(String[] args) {
List<String> tradeIds = List.of("T1", "T2", "T3", "T4");
String result = tradeIds.stream()
.collect(Collectors.joining(", ", "Trades: [", "]"));
System.out.println(result);
}
}
Trades: [T1, T2, T3, T4]
What about distinct, map, and flatMap?
The distinct operation removes duplicates, map transforms each element, and flatMap flattens nested structures.
package com.bank.stream;
import java.util.List;
public class MapAndDistinct {
record Trade(String id, String symbol) {}
public static void main(String[] args) {
List<Trade> trades = List.of(
new Trade("T1", "HDFC"), new Trade("T2", "ICICI"),
new Trade("T3", "HDFC"), new Trade("T4", "SBI")
);
List<String> uniqueSymbols = trades.stream()
.map(Trade::symbol)
.distinct()
.sorted()
.toList();
System.out.println("Unique symbols: " + uniqueSymbols);
}
}
Unique symbols: [HDFC, ICICI, SBI]
What are the common mistakes with streams?
The most common mistakes are: reusing a consumed stream, using summingDouble for money, and mutating the source collection during stream processing.
package com.bank.stream;
import java.util.List;
import java.util.stream.Stream;
public class StreamMistakes {
public static void main(String[] args) {
Stream<String> s = List.of("A", "B", "C").stream();
s.count(); // first terminal op: fine
try {
s.count(); // second terminal op: EXCEPTION
} catch (IllegalStateException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
Error: stream has already been operated upon or closed
When should a senior engineer use streams versus loops?
Streams are best for declarative bulk transformations like grouping, filtering, and aggregating. Loops are better when you need early exit with break, need to modify external state, or when the logic is complex enough that a stream pipeline becomes hard to read. For large datasets, push heavy aggregation into the database with GROUP BY and use streams only to shape the final result.
Visual Overview
Stream Pipeline:
Source Intermediate (lazy) Terminal
┌────────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌─────────┐
│ trades │──►│filter│──►│ map │──►│sorted│──►│ collect │──► Result
│.stream()│ │(BUY) │ │(amt) │ │(desc)│ │(toList) │
└────────┘ └──────┘ └──────┘ └──────┘ └─────────┘
Nothing runs until collect() is called
Collectors:
┌──────────────────┬─────────────────────────────────┐
│ groupingBy │ Map<K, List<V>> │
│ groupingBy+count │ Map<K, Long> │
│ partitioningBy │ Map<Boolean, List<V>> │
│ reducing │ Single aggregate (BigDecimal) │
│ joining │ String concatenation │
│ toList │ Unmodifiable list (Java 16+) │
└──────────────────┴─────────────────────────────────┘
Key Takeaway
The Java 8 Stream API is a declarative pipeline for processing collections. A pipeline has a source, lazy intermediate operations like filter, map, and sorted, and one terminal operation like collect or count that triggers execution. Because intermediates are lazy, the JVM can fuse them into a single pass.
The Collectors utility provides powerful aggregations: groupingBy buckets data by a key, partitioningBy splits on a boolean, counting and reducing summarise groups, and joining builds readable strings. For money arithmetic, always use BigDecimal with reducing, never summingDouble, because double arithmetic loses precision.
In a banking system at the National Bank of India, streams power reporting: grouping trades by account, counting by symbol, totalling amounts with BigDecimal, finding the top three trades by value, and partitioning BUY versus SELL. The one senior-level caution is that streams are single-use and in-memory, so for very large datasets push heavy aggregation into the database.
Streams should be favoured for readability of bulk transformations, but not forced when a simple loop is clearer. The goal is clean, declarative code that a teammate can read and maintain.
Remember one sentence: a stream is a lazy pipeline where intermediates build the recipe and the terminal operation cooks the result, with Collectors doing the grouping, counting, and joining.
---