You have a list of paid orders and you need to send each one to a notifier, or write a log line, or persist an audit row. There is no new list to build. The work is the side effect. Putting that work in map pretends you are transforming values. You are not.

Consumer<T> is the slot for “do something with this, then move on.”

A Consumer accepts a value in order to do something, not to return a new one. The series hub owns the glossary; here we only care about accept, andThen, and the difference between a deliberate terminal side effect and peek as business logic.

The loop that was already a Consumer

This is honest. It is also the shape forEach names:

for (Order order : paid) {
    notifier.notifyPaid(order);
    audit.record(order.id(), "PAID");
}

As a Consumer, the same work is a value you can pass, compose, and test:

Consumer<Order> notify = notifier::notifyPaid;
Consumer<Order> trail = order -> audit.record(order.id(), "PAID");
Consumer<Order> afterPay = notify.andThen(trail);

paid.forEach(afterPay);

Iterable.forEach takes a Consumer. So does Stream.forEach. The loop did not get more functional. It got a name for the slot it was already filling.

The contract

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) { … }
}

accept is the SAM. It returns void on purpose. If you need a result, you wanted a Function.

MethodJob
accept(t)Run the side effect
andThen(after)This consumer first, then after, same input

andThen does not feed one’s output into the next — there is no output. Both consumers see the original t. If notify throws, trail does not run.

Consumer<Order> afterPay = ((Consumer<Order>) notifier::notifyPaid)
        .andThen(order -> audit.record(order.id(), "PAID"));

A method reference has no andThen until you assign it to a Consumer<Order> (as in the first snippet) or cast. Target typing is on the hub; the rule here is: name the slot, then compose.

Where the JDK already asks for Consumer

APISlotTypical lambda
Iterable.forEachConsumer<? super T>notifier::notifyPaid
Stream.forEach / forEachOrderedConsumer<? super T>System.out::println
Optional.ifPresentConsumer<? super T>notifier::notifyPaid
Optional.ifPresentOrElseConsumer + Runnablepresent vs empty
Stream.peekConsumer<? super T>debug logging only
Map.forEachBiConsumersee Bi-arity

Optional.ifPresent is the “do this when the value is there” form. It is not a map:

findPaid(orderId).ifPresent(notifier::notifyPaid);

ifPresentOrElse (Java 9) adds the empty path as a Runnable. The hub names Runnable once and stops.

forEach vs peek vs map

Three slots people mix up:

SlotTypeWhen it runsWhat it is for
mapFunctionWhen a terminal op pulls a valueTransform
forEachConsumerAs the terminal opDeliberate side effect
peekConsumerWhen a later stage pulls a valueDebug / trace

peek is not a quiet forEach. It is an intermediate op. If nothing downstream pulls the element — findFirst after a filter, a short-circuit match — peek may not see it. If you need “notify every paid order,” that is forEach (or a loop). Streams Advanced already bans peek as business logic; this is why the type is a Consumer anyway.

// debug: see what survived the filter
orders.stream()
        .filter(Order::active)
        .peek(o -> log.debug("active {}", o.id()))
        .map(Order::id)
        .toList();

// production: the side effect is the point
paid.forEach(notifier::notifyPaid);

Do not use forEach to build a list. That is map + toList. A Consumer that adds to an outer ArrayList is the mutable-capture smell the hub already named — and under parallelStream() it is also a race.

Side effects are allowed. Hidden ones are not.

A Consumer is the honest place for I/O, logging, and mutation you can name. The rule is not “no side effects in lambdas.” The rule is put the side effect in a slot that means side effect, and keep Function / Predicate pure enough that map and filter can run more than once, or not at all, without changing the world.

The lab is still Order / LineItem / DiscountPolicy / PaymentGateway. PaymentGateway is the named side-effect collaborator: checkout calls charge, tests fake that type. A Consumer is a fine adapter onto a one-arg method (notifier::notifyPaid). gateway::charge still needs the amount — that is a BiConsumer. It is a poor replacement for a domain collaborator you need to fake in tests.

Pitfalls

Checked exceptions. accept does not declare them. Wrap at the boundary. Same story as Function and Predicate.

Exceptions in andThen. If the first consumer throws, the second does not run. If you need “always audit, even when notify fails,” do not express that with andThen. Write a single accept with a finally, or two explicit calls.

Parallel forEach. Stream.forEach does not promise encounter order. forEachOrdered does, and pays for it. Prefer collect when the goal is a collection; prefer a sequential loop when order and side effects both matter.

Capturing a sink. list.forEach(sink::add) looks like a Consumer and is a leftover for loop. Use addAll, or a Stream collect, unless you are genuinely forwarding into an API that only takes a Consumer.

Cheat sheet

SAM          void accept(T t)
Default      andThen(after)     both see the same t; first throw skips second

JDK slots    Iterable.forEach / Stream.forEach / forEachOrdered
             Optional.ifPresent / ifPresentOrElse
             Stream.peek        debug only

forEach      terminal side effect     the work is the point
peek         intermediate             may not run; not business logic
map          Function                 you wanted a result

Do           notifier::notifyPaid in a forEach slot
Don't        peek to send email       forEach to build a List
             mutate shared sink in parallel forEach
             Consumer as a stand-in for PaymentGateway

Do:

  • Fill forEach / ifPresent with a Consumer when the work is the side effect.
  • Compose independent effects with andThen when the first failing should skip the rest.
  • Keep peek for tracing.

Don’t:

  • Use peek (or map) to send mail, write rows, or charge cards.
  • Build collections in a Consumer that adds to an outer list.
  • Treat Consumer<Order> as a substitute for a named PaymentGateway seam.

Wrap-up

Consumer<T> is the JDK name for “do something with this.” accept is the SAM; andThen runs two effects on the same value. You meet it in forEach and ifPresent. Use it when the side effect is the point. The next shape produces a value without taking one — lazy creation, defaults, factories.

Next optional step in the series Produce a value when asked, so callers can wait until they need one. Supplier: Produce a Value When Asked