Skip to main content

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:

  1. When is a for-loop clearer?
  2. What goes wrong with checked exceptions in lambdas?
  3. Why is parallel() not a free speedup?
  4. What happens if I mix streams with Spring transactions?
  5. 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 (i and i+1)
  • early return of 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 UncheckedIOException at 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 forEach is undefined in parallel
  • each save may 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 (findFirst vs findAny, forEach vs forEachOrdered)

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

IdeaStream-friendly?
Map a small in-memory page to DTOsYes
findAll().parallel().map(this::touchLazy)No
@Transactional + stream().parallel()No
Files.lines in an import jobYes, with try-with-resources, sequential
WebFlux FluxA 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