The ticket says “bundles: a line item can contain child SKUs, and we bill the leaves.” Order.items() returns List<LineItem>. OrderProcessor already totals, holds inventory, and prints receipts with items().get(i). Warehouse wants a Map<String, LineItem> keyed by SKU. Bundles want a small tree. Every get(i) is a bet that the layout stays a list forever. You also just taught checkout the difference between ArrayList and whatever comes next.

That is Iterator’s entire complaint. From the Design Patterns Roadmap: callers walk a collection without seeing its structure — list, map, or tree.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. Java’s Iterable / Iterator is the pattern. We do not re-lecture the three families. We hide how line items are stored because that storage is about to change.

The get(i) that leaked the list

Here is the processor after Strategy already swapped discounts. The charge path is clean. The walk is not:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;
    private final Stock stock;

    public OrderProcessor(
            PaymentGateway gateway,
            DiscountPolicy discount,
            OrderRepository orders,
            Stock stock) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
        this.stock = stock;
    }

    public void process(Order order) {
        List<LineItem> items = order.items();
        for (int i = 0; i < items.size(); i++) {
            LineItem item = items.get(i);
            if (!item.bundle().isEmpty()) {
                for (int j = 0; j < item.bundle().size(); j++) {
                    stock.hold(item.bundle().get(j));
                }
            } else {
                stock.hold(item);
            }
        }
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
    }
}

Order leaked an ArrayList. Nested get taught every caller that bundles are a list-on-a-list. Now price the warehouse ticket — and the tree behind it:

Store items in Map by SKU     -> size()/get(i) no longer compile
Bill bundle leaves only       -> every nested loop must change together
Print a receipt without stock -> copy the same two loops into a second class
Test hold without a list      -> construct ArrayList even when the fixture is a map

Four costs, and none of them are about charging a card. The processor has become a co-owner of how Order lays out SKUs.

Note: The problem is not List. List is a fine internal type. The problem is returning it (or indexing it) from a policy class whose job is “hold, then charge.” Traversal belongs on the collection. Index arithmetic belongs inside an iterator you can replace.

What Iterator actually is

Two parts, one promise — and in Java they already have names:

PartJDK typeJob
IterableIterable<T>”I can be walked.” Produces an iterator.
IteratorIterator<T>hasNext / next. Holds the cursor. Hides the layout.

The caller says foreach. The collection decides what “next” means. If process still calls items().get(i), you have not iterated; you have reached into a list.

You do not need a class named Iterator of your own when List is honest and public. You need one when the structure is not the contract — a map of SKUs, a tree of bundles, a concatenation of catalog lines plus gift SKUs:

public interface Iterable<T> {

    Iterator<T> iterator();
}

That is the seam. for (LineItem line : order.billableLines()) is the whole pattern at the call site. Enhanced-for is compiler sugar for iterator(). You are not inventing a walk. You are choosing not to expose ArrayList.

The processor stops seeing indexes

Give Order a walk that means “billable SKUs,” not “the field I stored”:

public record Order(
        String id, String customerEmail, List<LineItem> items, BigDecimal total) {

    public Iterable<LineItem> billableLines() {
        return new FlatteningLines(items);
    }
}

items can stay a list inside the record for construction and tests. Callers that need to traverse use billableLines(). OrderProcessor drops both index loops:

public void process(Order order) {
    for (LineItem line : order.billableLines()) {
        stock.hold(line);
    }
    BigDecimal payable = discount.payable(order);
    PaymentResult result = gateway.charge(order, payable);
    if (!result.approved()) {
        throw new PaymentDeclinedException(order.id(), result.failureReason());
    }
    orders.markPaid(order.id(), result.reference());
}

Warehouse can change Order’s field to Map<String, LineItem> tomorrow. process does not change if billableLines() still yields the same LineItem sequence. Receipts use the same iterable. They do not import FlatteningLines.

Note: Keep items() off the processor. If you leave public List<LineItem> items() on the record “for convenience,” someone will get(i) again. A package-visible list for builders and tests is fine. A public list is the leak returning.

A custom iterator over bundles

Java’s ArrayList.iterator() is the pattern for a flat list. Bundles are why you write your own: you do not want OrderProcessor to know there is a tree.

LineItem holds optional children. A bundle parent is a grouping, not a SKU you hold in inventory:

public record LineItem(
        String sku, int quantity, BigDecimal unitPrice, List<LineItem> bundle) {

    public boolean isBundle() {
        return bundle != null && !bundle.isEmpty();
    }
}

FlatteningLines walks depth-first and yields leaves only. The cursor lives here — a deque — not in process:

public final class FlatteningLines implements Iterable<LineItem> {

    private final List<LineItem> roots;

    public FlatteningLines(List<LineItem> roots) {
        this.roots = List.copyOf(roots);
    }

    @Override
    public Iterator<LineItem> iterator() {
        return new Iter(roots);
    }

    private static final class Iter implements Iterator<LineItem> {

        private final Deque<LineItem> pending = new ArrayDeque<>();

        Iter(List<LineItem> roots) {
            for (int i = roots.size() - 1; i >= 0; i--) {
                pending.push(roots.get(i));
            }
        }

        @Override
        public boolean hasNext() {
            skipBundles();
            return !pending.isEmpty();
        }

        @Override
        public LineItem next() {
            skipBundles();
            if (pending.isEmpty()) {
                throw new NoSuchElementException();
            }
            return pending.pop();
        }

        private void skipBundles() {
            while (!pending.isEmpty() && pending.peek().isBundle()) {
                LineItem bundle = pending.pop();
                List<LineItem> children = bundle.bundle();
                for (int i = children.size() - 1; i >= 0; i--) {
                    pending.push(children.get(i));
                }
            }
        }
    }
}

The get(i) calls still exist. They exist once, inside the iterator, next to the tree. A map-backed Order would ship a different Iterable with the same LineItem element type. That is the scoreboard: layout change edits one walk, not every caller.

If you later need the bundle parents on a packing slip, that is a second iterable (packingLines()), not an if (line.isBundle()) stuffed back into process. Two walks, two meanings. Do not grow a flag on LineItem that every caller must remember.

What the diff looks like now

Same feature request, both designs:

Before — switch to a SKU map / bundle tree
  M OrderProcessor.java       nested get(i), isEmpty, hold-vs-skip
  M ReceiptPrinter.java       the same loops copied
  M OrderProcessorTest.java   fixtures must be ArrayList

After — billableLines()
  A FlatteningLines.java      the only code that knows the tree
  A FlatteningLinesTest.java  leaves vs bundles, no gateway
  M OrderProcessor.java       foreach; no indexes

One iterator plus foreach at the call sites. OrderProcessor is closed against “we stored SKUs differently” and still fully open when the workflow changes — a refund step, a second hold. Those belong in process. How to find the next leaf does not.

Proving the walk without charging a card

The seam pays a second dividend: you can test flattening with records, and you can test process with a fake stock that only sees leaves.

@Test
void bundleYieldsLeavesNotParent() {
    LineItem mug = new LineItem("mug", 1, new BigDecimal("12.00"), List.of());
    LineItem tea = new LineItem("tea", 2, new BigDecimal("4.00"), List.of());
    LineItem kit = new LineItem("kit", 1, BigDecimal.ZERO, List.of(mug, tea));
    Order order = new Order("o-1", "a@b.com", List.of(kit), new BigDecimal("20.00"));

    List<String> skus = new ArrayList<>();
    for (LineItem line : order.billableLines()) {
        skus.add(line.sku());
    }

    assertEquals(List.of("mug", "tea"), skus);
}

A hold test records SKUs from stock.hold and never calls items().get. If processor tests start asserting order.items() instanceof ArrayList, the leak is back.

When the backing structure becomes a TreeMap, add an iterator test for key order. Do not add index arithmetic to OrderProcessorTest.

When Iterator is the wrong move

Skip the custom walk when:

  • You already have a List and nobody cares about the layout. for (LineItem line : order.items()) on a public list that will remain a list is the language. A LineItemIterator wrapping ArrayList.iterator() is a type that does nothing.
  • The caller needs random access. A packing algorithm that must get(i) and get(i + 1) to pair items is using a list on purpose. Do not hide indexes behind an iterator and then add asList() because the caller needed them anyway.
  • There is one loop in one class. Inlining for (int i = 0; …) next to the only consumer is fine until a second consumer appears — or until the structure is about to change. Two copies of nested bundle loops are the usual trigger.
  • You are wrapping List to look designed. Review comments that say “this should be an Iterator” on a field that is already List<LineItem> with a foreach are unactionable.

The healthy trigger is a structure you do not want to publish — a map, a tree, a concat of two sources — and at least one caller that only needs “the next element.” Bundles inside LineItem are that structure. A flat three-line cart is a List.

Iterator also is not Composite. Composite is the tree type (a node that is also a collection). Iterator is how you walk whatever you stored. You can iterate a composite; you do not need Composite to flatten a List of bundles. Do not introduce a LineItemComponent hierarchy so a foreach has somewhere to sit.

Cheat sheet

Iterable   Order.billableLines()     foreach at the call site; no get(i)
Iterator   FlatteningLines.Iter      hasNext/next; owns the deque / cursor
Element    LineItem (leaves)         what Stock and receipts already understand
Storage    List, Map, tree           allowed to change behind the iterable

Trigger to apply: structure will change, or is already a tree/map you must not leak
Trigger to stop:  a List everyone is allowed to see, one loop, no second layout
Scoreboard:       new layout = new iterator (or a new Iterable); process() stays foreach
JDK:              Iterable/Iterator is the pattern — do not rename it LineItemWalker

Do:

  • Name the iterable after the meaning (billableLines, packingLines), not ItemIteratorImpl.
  • Put index math and isBundle inside the iterator. Callers see LineItem.
  • Test the walk without OrderProcessor; test process with a fake Stock.
  • Prefer foreach / enhanced-for. Call iterator() by hand only when you must remove or peek.

Don’t:

  • Return List<LineItem> from Order and then also publish an iterator — the list will win.
  • Write a custom iterator that delegates 1:1 to arrayList.iterator() with no extra rule.
  • Keep nested get(i) in process after introducing billableLines().
  • Expose Deque or the bundle tree on Order “so tests can see internals.” Test through the iterable.

Wrap-up

Iterator is a cursor that knows the structure so callers do not have to. OrderProcessor walked get(i) because Order handed it an ArrayList, and then bundles taught every loop about children. billableLines() plus a flattening iterator makes the next layout an internal change, keeps leaf SKUs unit-testable, and leaves process on foreach — hold, charge, mark paid — which was never a list’s job to describe.

Wave 3 is the set that is easy to over-apply. Bridge is first: split channel wording from vendor SDKs.

Next optional step in the series Stop minting a subclass per channel × provider pairing. Bridge: Split Abstraction from Implementation