LLD Dojo

Syllabus · D4

Iteration, pagination and bulk-operation shape

The idea

How a method hands back many things, and takes many in

A List is a snapshot: copy it out and the caller reads it safely while the source keeps changing. A Stream or a live Iterator walks the source right now, cheaper for one pass but dangerous the moment something else mutates it mid-walk. corpus/shopping-cart/contract/CartPage.java states the snapshot promise as data: "later changes to the cart do not show up in a page already handed out." Pick the promise on purpose, because the caller will assume one or the other.

Pagination makes the same choice per page. Offset remembers a position: skip 40, take 20. Cursor, or keyset, pagination remembers an entry instead: give me what comes after this one. A position is a fact about the collection's arrangement, so it moves when the collection does; an entry's key, like seq in this lesson's Feed, does not.

This lesson's demo makes that concrete. Three posts sit in the feed as a, b, c. A cursor read, page(null, 2), returns [c, b] with nextCursor=2. A fourth post, d, lands at the front, and page(2, 2) returns exactly [a] — no repeat, no gap. Read by offset instead: offsetPage(0, 2) also returns [c, b], but after the same insert offsetPage(2, 2) returns [b, a]. b comes back twice: position 2 points at a different entry once d shifted everything back one slot. Nothing threw; the method answered cleanly and lied about what the caller had read. A cursor needs a stable sort key, since resorting the same rows by a different column breaks the promise the same way an insert does.

Bulk operations raise the question error handling raises: what comes back when only part of a request succeeds? Feed.postAll posts three texts and returns three PostResult values, because the caller needs to know which text failed, not only that one did. corpus/shopping-cart/reference/src/Cart.java's addAll chooses the opposite on purpose. It validates every line first and only then touches the cart, because a half-filled basket is not a state its caller, a repeat-order button, can reason about. A void return, or a bare count, throws away that fact. Pick all-or-nothing when a partial version of the state is not honest, and a result per item once the requests are independent enough that partial success matters.


Worked walkthrough

NOTES — a feed, paged two ways, and a bulk post that keeps every result

Run it first

.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main

javac prints nothing at all — no errors, no warnings. Real output, verbatim:

--- 1. bulk post: one PostResult per request, partial success
  "hello" -> seq 1
  "  " -> failed: text must be non-blank
  "world" -> seq 2
--- 2. cursor pagination: stable across an insert
  page(null, 2)            : [c, b]  nextCursor=2 hasMore=true
  post("d"), then page(2, 2): [a]  hasMore=false
--- 3. offset pagination: the same insert changes the answer
  offsetPage(0, 2)         : [c, b]
  post("d"), then offsetPage(2, 2): [b, a]  <- repeats "b", which offsetPage(0, 2) already returned

Block 1 — postAll: partial success, one result per request

if (text == null || text.isBlank()) {
    results.add(PostResult.failed(text, "text must be non-blank"));
} else {
    results.add(PostResult.ok(text, post(text)));
}

corpus/shopping-cart/reference/src/Cart.java makes the opposite choice for its own bulk method, on purpose. addAll validates every request in one pass and only then touches the cart. Its own decision log says why: "the caller is a 'buy it again' button and a half-filled basket is not a state a customer can reason about." A feed is not a cart. Three independent posts have nothing to roll back into a consistent whole. There is no "half-posted" state to protect a caller from, so the useful answer names which one failed and lets the other two stand. A void return, or even a count of successes, would have thrown that fact away. The caller would have had to re-fetch the feed and diff it to find out which text never landed.

Block 2 — page: a page anchored to an entry, not a position

int start = 0;
if (cursor != null) {
    while (start < entries.size() && entries.get(start).seq() >= cursor) {
        start++;
    }
}
int end = Math.min(start + size, entries.size());

nextCursor is the seq of this page's own last entry. Entry's own javadoc argues for exactly this: "a page that remembers 'the last seq I handed out' is remembering a fact about one entry." Block 2's output is the argument made concrete. page(null, 2) hands back [c, b] with nextCursor=2. A new post, d, lands at the front, and page(2, 2) still returns exactly [a]: the one entry the caller had not seen yet, no repeats, no gaps. The cursor named an entry that kept its identity. The insert changed positions, not identities, so the cursor never noticed.

Block 3 — offsetPage: the same insert, read by position instead

int from = Math.min(offset, entries.size());
int to = Math.min(from + size, entries.size());
return List.copyOf(entries.subList(from, to));

Same feed, same insert, different anchor. offsetPage(0, 2) returns [c, b], identical to block 2's first page, as it should. Then d lands at the front, and offsetPage(2, 2) returns [b, a]. "b" came back twice. Nobody asked for that. The caller read page 0, got b, moved on to page 1, and got b again, because position 2 pointed at a different entry once d pushed everything back by one slot. Nothing crashed and nothing threw. The method returned a clean, well-typed answer that happened to be a lie about what the caller had already seen. That is the whole case against offset pagination on anything that can grow between two calls: it fails quietly, in data, not in a stack trace.

corpus/shopping-cart runs the same arithmetic on a bounded cart, deliberately

corpus/shopping-cart/reference/src/CartLines.java pages a shopping cart with plain (pageIndex, pageSize) offsets, and its own decision log calls this out by name: "the paging arithmetic is in long on purpose... an overflowed window index reads the wrong lines rather than failing." A cart's lines only change when its own owner calls add or remove between two of their own page reads — nobody else is posting into it mid-scroll. That is exactly the condition under which offset pagination is fine, and when-not.md names it directly. This lesson's Feed is the other case: something else can post at any time, so a position stops meaning anything the moment it does.

The choice, stated once

QuestionAnswer hereWhy
Hand back many things — as what?List<Entry> / List<PostResult>, copied, not a live IteratorA caller reading a page must not see it change under it mid-read; Page and PostResult are both immutable snapshots
Page by position or by identity?Cursor (seq), not offsetAn insert changes positions; it does not change which entry is which
One bulk request fails — then what?A PostResult per item, not void and not a countIndependent posts have nothing to roll back into; the caller needs to know which one failed
What decides all-or-nothing versus per-item?Whether a partial outcome leaves a state the caller can reason aboutshopping-cart.addAll needs atomicity because a half-filled cart is not; a feed's bulk post does not

When not to

A cursor and a per-item result both cost something. Sometimes the cost buys nothing

worked/ argues for a cursor over an offset, and a result-per-item over a bare count. Feed can change out from under a caller mid-scroll, and its bulk post has nothing to roll back into. Neither argument survives once those two facts stop being true.

Offset pagination on a bounded, single-owner collection

corpus/shopping-cart/reference/src/CartLines.java pages a cart with plain page(pageIndex, pageSize) offsets, not a cursor, and its own decision log defends the choice rather than apologising for it. The paging arithmetic is deliberately done in long so that pageIndex * pageSize cannot overflow, because, in the log's own words, "an overflowed window index reads the wrong lines rather than failing." Nowhere does the log reach for a cursor.

A cart's lines only change when the cart's own owner calls add, remove, or setQuantity. Nobody else posts into someone else's cart between two of that owner's own page reads. Offset lies when the collection changes underneath the pagination; here it cannot, because only the one caller doing the paging can also do the changing. Reaching for seq-style cursors on a shopping cart's line items would add a field and a comparator contract. It would also add a sentence in the decision log, defending a threat that does not exist in this problem.

The same holds for any bounded, in-memory list a single caller owns end to end: a settings screen's list of ten toggles, a wizard's fixed set of steps. If nothing else can write to the collection between two calls, an offset is not lying about anything. It is an index, and treating it as suspect is solving a problem the caller does not have.

A per-item result where the operation is genuinely atomic

corpus/shopping-cart/reference/src/Cart.java's addAll is the mirror case on the bulk side. It resolves and merges every request in one pass, and only then touches the cart, on purpose. The caller is "a 'buy it again' button," and a half-filled basket is not a state a customer can reason about. A List<PostResult>-style return here would report finer-grained failure but describe a world that never exists. Either every request lands or the cart stays untouched, so there is no "item three failed" to report separately from "the whole call failed."

Building the richer result type would hand back the same information a plain thrown exception already carries. The cost is a type nobody needed, and a caller who now checks each entry instead of catching once.

The question that decides it

Not "is this a bulk operation" or "is this a list." Ask whether the collection can be touched by something other than the caller's own next request, and whether a partial outcome leaves a state the caller can actually reason about. Where the answer to both is no, the plain offset and the plain all-or-nothing call are not the weaker design. They are the one with nothing spare in it.


Worked source

The 5 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/Entry.java21 lines

import java.util.Objects;

/**
 * One posted item. {@code seq} is assigned once, when the entry is created, and never changes
 * afterwards — it is the stable key a page boundary can be anchored to.
 *
 * Position inside whatever list happens to hold this entry is not a key: a list can be resized,
 * spliced or reordered at any time, and none of that touches {@code seq}. A page that remembers
 * "the last seq I handed out" is remembering a fact about one entry. A page that remembers "I was
 * up to position 40" is remembering a fact about a list that may not look the same on the next
 * call.
 */
public record Entry(long seq, String text) {

    public Entry {
        Objects.requireNonNull(text, "text");
        if (text.isBlank()) {
            throw new IllegalArgumentException("an entry needs non-blank text, not \"" + text + "\"");
        }
    }
}

worked/src/Feed.java83 lines

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * A feed of posted entries, newest first — the shape a scrolling timeline uses: it keeps growing
 * while a caller is still paging through it.
 *
 * <p>{@link #page} anchors every page to a {@code seq}, never a position, so a post landing at the
 * front between two calls cannot shift what the next call sees. {@link #offsetPage} exists only
 * as the counter-example — see {@code worked/NOTES.md} for the run where the same insert makes it
 * repeat an entry the caller already saw.
 */
public final class Feed {

    private final List<Entry> entries = new ArrayList<>();
    private long nextSeq = 1;

    public Entry post(String text) {
        Entry entry = new Entry(nextSeq++, text);
        entries.add(0, entry);
        return entry;
    }

    /**
     * Bulk post with a result per request, not a count and not a {@code void}. One bad text does
     * not roll back the others: it fails on its own, in place, and every other request still goes
     * through. {@code when-not.md} covers the operation where that would be the wrong call.
     *
     * @return one {@link PostResult} per request, in the order given
     */
    public List<PostResult> postAll(List<String> texts) {
        Objects.requireNonNull(texts, "texts");
        List<PostResult> results = new ArrayList<>();
        for (String text : texts) {
            if (text == null || text.isBlank()) {
                results.add(PostResult.failed(text, "text must be non-blank"));
            } else {
                results.add(PostResult.ok(text, post(text)));
            }
        }
        return List.copyOf(results);
    }

    /**
     * Cursor (keyset) pagination: entries older than {@code cursor}, or the newest page when
     * {@code cursor} is null. The window is a snapshot copied out now; the cursor itself is the
     * only thing carried forward, and it names an entry, not a position.
     */
    public Page page(Long cursor, int size) {
        if (size < 1) {
            throw new IllegalArgumentException("a page holds at least one entry, not " + size);
        }
        int start = 0;
        if (cursor != null) {
            while (start < entries.size() && entries.get(start).seq() >= cursor) {
                start++;
            }
        }
        int end = Math.min(start + size, entries.size());
        List<Entry> window = List.copyOf(entries.subList(start, end));
        boolean hasMore = end < entries.size();
        Long nextCursor = hasMore ? window.get(window.size() - 1).seq() : null;
        return new Page(window, nextCursor, hasMore);
    }

    /**
     * Offset pagination, kept only to be measured against {@link #page}. {@code offset} names a
     * position in whatever the feed looks like right now, and a post landing at the front changes
     * what every later position holds. See {@code worked/NOTES.md} for the run that shows it lying.
     */
    public List<Entry> offsetPage(int offset, int size) {
        if (offset < 0) {
            throw new IllegalArgumentException("offset must be >= 0, not " + offset);
        }
        if (size < 1) {
            throw new IllegalArgumentException("a page holds at least one entry, not " + size);
        }
        int from = Math.min(offset, entries.size());
        int to = Math.min(from + size, entries.size());
        return List.copyOf(entries.subList(from, to));
    }
}

worked/src/Page.java21 lines

import java.util.List;
import java.util.Objects;

/**
 * One window onto a feed, anchored to a seq rather than a position.
 *
 * {@code nextCursor} is the seq of this page's own last entry — pass it back in to continue. It
 * is {@code null} exactly when {@code hasMore} is false, because there is nothing to resume from.
 * Unlike an offset page, this record never reports how many entries the feed holds in total: a
 * live feed's total is a different number on every call, and a cursor page does not need it to
 * keep going.
 */
public record Page(List<Entry> entries, Long nextCursor, boolean hasMore) {

    public Page {
        entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
        if (hasMore && nextCursor == null) {
            throw new IllegalStateException("hasMore is true but there is no cursor to resume from");
        }
    }
}

worked/src/PostResult.java20 lines

/**
 * One request's outcome from a partial-success bulk post. Exactly one of {@code entry} or
 * {@code reason} is set. A caller reading a {@code List<PostResult>} can tell which request in
 * the batch failed and why — the information a bare {@code void}, or even a count of successes,
 * throws away.
 */
public record PostResult(String requestedText, Entry entry, String reason) {

    public static PostResult ok(String requestedText, Entry entry) {
        return new PostResult(requestedText, entry, null);
    }

    public static PostResult failed(String requestedText, String reason) {
        return new PostResult(requestedText, null, reason);
    }

    public boolean succeeded() {
        return entry != null;
    }
}

worked/src/Main.java44 lines

import java.util.List;

/** Real, captured output pasted verbatim into worked/NOTES.md comes from running this. */
public class Main {

    public static void main(String[] args) {
        System.out.println("--- 1. bulk post: one PostResult per request, partial success");
        Feed feed = new Feed();
        List<PostResult> results = feed.postAll(List.of("hello", "  ", "world"));
        for (PostResult r : results) {
            System.out.println("  \"" + r.requestedText() + "\" -> "
                    + (r.succeeded() ? "seq " + r.entry().seq() : "failed: " + r.reason()));
        }

        System.out.println("--- 2. cursor pagination: stable across an insert");
        Feed live = new Feed();
        live.post("a");
        live.post("b");
        live.post("c");
        Page page0 = live.page(null, 2);
        System.out.println("  page(null, 2)            : " + textsOf(page0.entries())
                + "  nextCursor=" + page0.nextCursor() + " hasMore=" + page0.hasMore());
        live.post("d"); // lands at the front, between the two page reads
        Page page1 = live.page(page0.nextCursor(), 2);
        System.out.println("  post(\"d\"), then page(" + page0.nextCursor() + ", 2): " + textsOf(page1.entries())
                + "  hasMore=" + page1.hasMore());

        System.out.println("--- 3. offset pagination: the same insert changes the answer");
        Feed offsetFeed = new Feed();
        offsetFeed.post("a");
        offsetFeed.post("b");
        offsetFeed.post("c");
        List<Entry> offsetPage0 = offsetFeed.offsetPage(0, 2);
        System.out.println("  offsetPage(0, 2)         : " + textsOf(offsetPage0));
        offsetFeed.post("d"); // the same insert, same point in the sequence
        List<Entry> offsetPage1 = offsetFeed.offsetPage(2, 2);
        System.out.println("  post(\"d\"), then offsetPage(2, 2): " + textsOf(offsetPage1)
                + "  <- repeats \"b\", which offsetPage(0, 2) already returned");
    }

    private static String textsOf(List<Entry> entries) {
        return entries.stream().map(Entry::text).toList().toString();
    }
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← D3 · Immutability boundaries at API edges E1 · Identifying shared mutable state — the two-question census →

← all lessons