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:
| Part | JDK type | Job |
|---|---|---|
| Iterable | Iterable<T> | ”I can be walked.” Produces an iterator. |
| Iterator | Iterator<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
Listand nobody cares about the layout.for (LineItem line : order.items())on a public list that will remain a list is the language. ALineItemIteratorwrappingArrayList.iterator()is a type that does nothing. - The caller needs random access. A packing algorithm that must
get(i)andget(i + 1)to pair items is using a list on purpose. Do not hide indexes behind an iterator and then addasList()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
Listto look designed. Review comments that say “this should be an Iterator” on a field that is alreadyList<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), notItemIteratorImpl. - Put index math and
isBundleinside the iterator. Callers seeLineItem. - Test the walk without
OrderProcessor; testprocesswith a fakeStock. - Prefer foreach / enhanced-for. Call
iterator()by hand only when you must remove or peek.
Don’t:
- Return
List<LineItem>fromOrderand 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)inprocessafter introducingbillableLines(). - Expose
Dequeor the bundle tree onOrder“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.