Skip to main content

Week 7 Day 4 — collect, groupingBy, and reduce

Goal

Today I want terminal operations that build collections and maps without mutating a shared HashMap inside forEach.

Main questions:

  1. Why collect instead of forEach + add?
  2. What does Collectors.toMap do on a duplicate key?
  3. How does groupingBy work?
  4. When is reduce the right tool?
  5. Why primitive streams exist?

1. collect is the usual terminal

List<OrderResponse> body = orders.stream()
.map(OrderResponse::from)
.toList(); // unmodifiable, Java 16+

List<OrderResponse> mutable = orders.stream()
.map(OrderResponse::from)
.collect(Collectors.toCollection(ArrayList::new));

Set<String> skus = lines.stream()
.map(Line::sku)
.collect(Collectors.toSet());

toList() (Java 16) is unmodifiable. Collectors.toList() is a mutable ArrayList (not specified as safe to mutate, but historically an ArrayList). For an API I own, I pick on purpose.

forEach(list::add) is a side-effect terminal. It breaks on parallel streams and is harder to read. collect is the reduction the API was built for.

Memory sentence:

Collect into a new collection. Do not forEach into a list I captured.


2. toMap and key collisions

Map<Long, Order> byId = orders.stream()
.collect(Collectors.toMap(Order::id, Function.identity()));

Duplicate ids throw IllegalStateException. When duplicates are possible, I pass a merge function:

Collectors.toMap(
Order::customerId,
Function.identity(),
(first, second) -> first.createdAt().isAfter(second.createdAt()) ? first : second
);

(a, b) -> b means last wins. I say that out loud; I do not leave the two-arg toMap if keys can collide.

Collectors.toMap also takes a map supplier: LinkedHashMap::new to keep encounter order.


3. groupingBy

Map<Status, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status));

Default downstream is toList(). Downstream collectors change the value type:

Map<Status, Long> counts = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));

Map<Status, Set<String>> skus = orders.stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.mapping(Order::sku, Collectors.toSet())
));

groupingBy is a HashMap. I pass TreeMap::new as the map supplier if I need sorted keys.

partitioningBy(predicate) is groupingBy into true/false.


4. reduce and count / min / max

Money total = lines.stream()
.map(Line::price)
.reduce(Money.zeroEur(), Money::plus);

Optional<Order> newest = orders.stream()
.max(Comparator.comparing(Order::createdAt));

Identity + accumulator must be associative if I ever go parallel. Money::plus is. list.add is not a good reducer (mutates).

Specialized terminals: count, min, max, sum on primitive streams. Prefer those over a handwritten reduce when they exist.


5. Primitive streams

Stream<Integer> boxes. IntStream / LongStream / DoubleStream do not.

long sum = orders.stream()
.mapToLong(Order::cents)
.sum();

mapToLong then sum / average / summaryStatistics is the backend-friendly shape for totals. I convert back with boxed() only when I need a Stream<Long>.


6. Spring connection

  • Building a DTO list for a controller: stream().map(Dto::from).toList().
  • Deduping by id after a join that duplicated parents: toMap(Order::id, Function.identity(), (a, b) -> a).values().
  • Do not groupingBy a million rows in the app. GROUP BY in SQL, or a projection query.
  • JdbcTemplate already returns a List. Stream after the query for small in-memory shaping, not as a second database.

7. Common traps

Trap 1: Two-arg toMap on a stream that can have duplicate keys.

Trap 2: forEach(map::put) instead of toMap.

Trap 3: groupingBy on a huge findAll.

Trap 4: reduce that mutates a shared ArrayList identity — broken in parallel, ugly in sequential.

Trap 5: Stream<Integer> + map(i -> i + 1) in a tight numeric loop. Use IntStream.


Practice Questions and Answers

Question 1

What happens if toMap sees the same key twice?

Answer:

The two-argument Collectors.toMap throws IllegalStateException. I add a merge function: last wins, first wins, or a real combine. I do not wait for production to find the duplicate.


Question 2

groupingBy vs toMap?

Answer:

toMap is one value per key (collision is my problem). groupingBy is many values per key, as a list by default, or as whatever downstream collector I pass (counting, mapping, reducing).


Question 3

Why prefer collect over forEach + add?

Answer:

collect is a reduction: it creates the container, is parallel-safe with the right collector, and reads as “this pipeline’s result is a list.” forEach + add captures a mutable list, races in parallel, and hides the result type.


Question 4

When do I use reduce?

Answer:

When I combine elements into one value with an associative function (Money::plus). For collections I use collect. For min/max/count/sum I use the dedicated terminal ops.


Question 5

Why mapToLong before sum?

Answer:

Stream<Long> boxes every element. mapToLong gives an LongStream whose sum is a primitive total. Less allocation, clearer numeric intent.


Memory sentences

Collect into a new collection. Do not forEach into a captured list.

toMap needs a merge function if keys can collide.

groupingBy is many per key; push large grouping to SQL.

Next: Week 7 Day 5 — When Not to Use Streams