Week 7 Day 5 — When Not to Use Streams
Goal
Today I want judgment: streams declare a transformation; loops stay better for control flow, checked exceptions, and side effects.
Main questions:
- When is a for-loop clearer?
- What goes wrong with checked exceptions in lambdas?
- Why is
parallel()not a free speedup? - What happens if I mix streams with Spring transactions?
- How do I debug a pipeline?
1. Streams declare; loops control
I use a stream when I can read the pipeline in one breath: filter, map, collect.
I use a loop when I need:
- an index (
iandi+1) - early
returnof a complex result mid-algorithm - several local variables updated together
- checked exceptions from I/O
- mutation of exactly one element in a way that is clearer as assignment
// clear as a stream
List<String> names = users.stream().map(User::name).toList();
// clearer as a loop
for (int i = 0; i < lines.size(); i++) {
if (overlap(lines.get(i), lines.get(i + 1))) {
return Optional.of(i);
}
}
Memory sentence:
Streams are for declaring a transformation. Loops stay better for control flow and side effects.
2. Checked exceptions
Function.apply does not declare throws IOException. This does not compile:
paths.stream().map(Files::readString); // checked exception
Options:
- wrap in
UncheckedIOExceptionat a small helper - do not use a stream; write a loop with
try - handle at a method that is allowed to throw, outside the pipeline
A 15-line try/catch inside map is a sign I wanted a loop. Week 4’s translation still applies: wrap at an adapter, keep the cause.
3. Side effects and forEach
orders.stream().forEach(repository::save); // looks cute, hides the transaction story
Problems:
- order of
forEachis undefined in parallel - each
savemay be its own flush behavior - exceptions in the middle leave a partial batch
- I cannot use
break
A service method with @Transactional and a for-loop of saves is honest. Better: a batch API. forEach on a stream is for isolated side effects (logging a debug line), not for persistence.
Mutating a captured List or int[] counter from forEach is the same smell. Use collect / count.
4. Parallel streams
.parallel() uses ForkJoinPool.commonPool() by default — shared with other parallel streams and some JDK work.
When they can help: large, CPU-bound, splittable, associative reductions. Few shared writes.
When they hurt:
- small lists (overhead > work)
- blocking I/O (ties up common pool threads)
- HTTP request threads starving
- Spring
@Transactional: the persistence context is thread-bound. Parallel tasks do not see the session. Lazy loads fail. Saves race. - order-sensitive pipelines (
findFirstvsfindAny,forEachvsforEachOrdered)
I do not put .parallel() on a REST request path unless I have measured it and I do not touch JPA in the workers.
Memory sentence:
parallel()is not a free speedup. It is a different thread pool, and it does not carry the Spring transaction.
5. Spring connection
| Idea | Stream-friendly? |
|---|---|
| Map a small in-memory page to DTOs | Yes |
findAll().parallel().map(this::touchLazy) | No |
@Transactional + stream().parallel() | No |
Files.lines in an import job | Yes, with try-with-resources, sequential |
WebFlux Flux | A different API. Do not mix the words in an interview without saying so |
Debugging: extract methods (Order::isOpen, this::toDto) so stack traces have names. Temporary peek is fine locally. Prefer a test on the extracted method over peek in production.
6. Common traps
Trap 1: Rewriting every loop as a stream to look modern.
Trap 2: .parallel() on a 20-element list in a controller.
Trap 3: Catching nothing and letting a lambda throw a checked exception — it will not compile, so people swallow inside the lambda.
Trap 4: Infinite Stream.iterate without limit.
Trap 5: Calling stream() on a Hibernate lazy bag after the transaction ended.
Practice Questions and Answers
Question 1
When should I not use streams?
Answer:
When the logic needs indexes or complex control flow, when lambdas would wrap checked exceptions poorly, when I would mutate shared state in forEach, or when the pipeline is harder to read than a loop. Parallel streams are a separate decision, not a default.
Question 2
Why is stream.forEach a bad substitute for a for-loop with checked exceptions?
Answer:
The consumer cannot throw checked exceptions. I end up with try/catch inside the lambda, often swallowing or wrapping badly. A loop can declare throws IOException or translate once per iteration with a clear cause.
Question 3
Why can @Transactional plus parallel() break JPA?
Answer:
The persistence context is bound to the thread that opened the transaction. Parallel stream tasks run on ForkJoinPool threads with no session. Lazy loads throw. Writes are not in the same unit of work. Keep JPA work sequential on the transactional thread.
Question 4
When is a parallel stream reasonable?
Answer:
Large in-memory data, CPU-heavy independent work, associative collect/reduce, no JPA, no blocking HTTP, measured against a sequential baseline. Not on the default request thread as a first try.
Question 5
How do I debug a pipeline?
Answer:
Extract predicates and mappers to named methods and unit-test them. Use peek only while diagnosing. Check whether a terminal op actually runs. For data bugs, print a small limit sample in a test, not in production logs for every element.
Memory sentences
Streams declare a transformation. Loops stay better for control flow and side effects.
parallel()is not a free speedup, and it does not carry the Spring transaction.
Wrap checked I/O at an adapter, or do not stream it.
Next: Week 7 Review