LLD Dojo

Syllabus · A7

Collection encapsulation — no mutable internals escape

The idea

The method that fails is not the method that is wrong

Cart enforces five rules: one line per sku, a positive quantity, a total that matches the price, insertion order, and no standing offer over 40 percent. LeakyCart in worked/src enforces all five, method for method. Every guard is present. Then somebody calls the getter.

before   lines=2 items=3 subtotal=3048
after    lines=3 items=8 subtotal=7543
threw    nothing

That is leaky.lines().add(LineItem.of(MUG, 5)). Two mug lines, and a customer billed for seven mugs having chosen two. itemCount() returned 8 and itemCount() is correct: it counted the cart it was handed. The defect is in an accessor that computes nothing.

Nobody adds a line on purpose. What people do is sort. A receipt wants the dearest line first, List.sort sorts in place, and MUG SOCKS LAMP becomes LAMP SOCKS MUG in the cart itself. The report is right, the basket page is wrong, and they live in different files.

Three options, priced in when-not.md. List.copyOf on the way out costs 8 bytes per element plus 56, measured: 136 bytes at ten lines, 40056 at five thousand. A cached unmodifiable view costs zero bytes and hands the caller something that changes under them. Answering contains and itemCount instead costs nothing, and it is usually the right move.

Two halves people miss. A constructor that stores the caller's list is equally open, and worked/ shows a 100 percent coupon arriving after validation passed. And List.copyOf is shallow, so mutable elements still escape.


Worked walkthrough

NOTES — two carts, five invariants, and the two lines that decide whether they hold

Run it first. Every quoted line below is real output.

javac -Xlint:all -d out lessons/A7/worked/src/*.java
java -cp out Main

Cart and LeakyCart enforce the same five rules:

  1. at most one line per sku code
  2. every line holds at least one item
  3. every line's total is its unit price times its quantity
  4. lines read back in the order their skus were first added
  5. no standing offer on this cart takes more than 40 percent off

Scenario 0 is the two of them side by side on honest input:

Cart       lines=2 items=3 subtotal=3048 total=2743
LeakyCart  lines=2 items=3 subtotal=3048 total=2743

Same answers, to the minor unit. A base suite cannot tell them apart, and that is the shape of this defect: it is invisible until somebody uses the accessor.

LineItem.java — the invariant that makes a snapshot worth taking

long expected = sku.unitPriceMinor() * quantity;
if (lineTotalMinor != expected) {
    throw new IllegalArgumentException(
            "line total for " + sku.code() + " must be " + expected + ...);
}

What this holds: a LineItem in existence has a total that matches its own arithmetic. What breaks without it: the cart would have to re-derive every total on read to trust it, and Cart.lines() could no longer be described as safe to hand out. The reason Cart can copy references and stop is that a LineItem reached through the copy has nothing to change and nothing to lie about.

Note what invariant 3 is not protecting against. Scenario 1's escaping caller adds LineItem.of(MUG, 5), which is a perfectly legal line. Its quantity is positive, its total matches, its compact constructor is satisfied. Rules 2 and 3 belong to LineItem and they hold. The rule that is broken is rule 1, and rule 1 is about the relationship between lines. No element type can hold it. Only the class that owns the collection can, and only while it still owns it.

Cart.java — the two lines that matter

this.standingOffers = List.copyOf(standingOffers);
...
public List<LineItem> lines() {
    return List.copyOf(byCode.values());
}

What the constructor line holds: the offers this cart was validated with are the offers it will use for the rest of its life. What breaks without it: the loop above it, the one that rejects an offer over 40 percent, becomes a statement about the past. Scenario 5 is that in full.

What lines() holds: whatever a caller does to the returned list, this cart still satisfies all five rules. What breaks without it: every one of the five, and none of them noisily. Scenario 1:

before   lines=2 items=3 subtotal=3048
after    lines=3 items=8 subtotal=7543
threw    nothing
mug lines now 2, and invariant 1 says at most 1
the customer is charged for 7 mugs having chosen 2

The bug is not in itemCount(). Read it: it iterates the lines and adds up quantities, which is exactly right, and it returns 8 because there are 8 items in the cart it is looking at. The bug is not in subtotalMinor() either. The failing method is correct and the leaking method is the defect, and that gap is the whole reason this item is on the syllabus.

Scenario 2 is the identical statement against Cart:

threw    java.lang.UnsupportedOperationException
  message  null
  blamed   java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
after    lines=1 items=2

No message, which lessons/J7/from-cpp.md already covers. What matters here is the third line. The top frame is inside java.base, one call from the mistake, and the cart is untouched.

LeakyCart.linesCopied() — the half-fix, and it is worse

public List<LineItem> linesCopied() {
    return new ArrayList<>(lines);
}

This is the answer people reach for first, and it protects the cart completely. Scenario 2b:

before   lines=1 items=2
copy.add returned  true
copy size          2
cart              lines=1 items=2
threw    nothing, and the caller has been told the socks are in the basket

What it holds: the cart's five invariants, all of them. Nothing done to that list can reach the map. What it destroys: the evidence. add returned true, the caller's list has two entries, and the basket has one. A caller who reads cart.lines() as "the cart's lines" has been told their change landed. List.copyOf refuses and points at the line; this hands back a shrug.

lessons/A5/ measured the same finding on a different item. A design that clamped a negative remaining to zero produced remaining=0, 100% of quota used, which cannot be told apart from an honestly exhausted quota. Refuse rather than absorb. A copy that is also unmodifiable does both jobs, and it is one method call.

The accessor that people actually write, and it is not add

Nobody adds a line to somebody else's cart on purpose. What people do is sort. A receipt view wants the dearest line first, List.sort sorts in place, and scenario 3 is the result:

cart order before  MUG SOCKS LAMP
receipt order      LAMP SOCKS MUG
cart order after   LAMP SOCKS MUG   <- the cart was sorted too

Invariant 4 is gone and the report is correct. Nothing failed. The next person to open the basket page sees their items in an order they never chose, and the bug lives in a reporting module that does not import Cart.

Against Cart, the same sort call throws from ImmutableCollections.uoe and the order after is MUG SOCKS LAMP. The author of the receipt finds out inside their own unit test, in the minute they wrote it.

MutableLineCart.java — the copy that is not deep

This is the cart written by somebody who has learned the first half. lines() returns List.copyOf. Both of the things a reviewer checks are true: adding to the snapshot throws, and a snapshot taken earlier does not learn about later changes.

before   subtotal=1798 invariantHolds=true
adding to the snapshot   java.lang.UnsupportedOperationException
after    subtotal=898101 invariantHolds=false
threw    nothing; the ceiling is 20 and the line holds 999

snapshot.get(0).setQuantity(999) did that. List.copyOf copies references, so both lists point at the same MutableLine objects.

Where the invariant went. MutableLine.setQuantity refuses a quantity below one, which is all it knows about. MAX_PER_LINE is a fact about a cart, so the line cannot check it, so a caller holding a line walks straight past it. The cart's own add checks the ceiling on every path. It is still 999.

The threshold, and it is the argument for A2. A shallow copy is sufficient exactly when the elements are immutable. Cart holds LineItem, a record with no setter, so List.copyOf is the end of the problem. MutableLineCart holds a class with a setter, so the copy buys one layer and the caller reaches through it.

This shape is in the corpus rather than invented. corpus/file-system/reference/src/FileNode.java is a class rather than a record because write replaces its content, and lessons/J11/worked/NOTES.md records the second reason too: a record DirectoryNode(TreeMap<String, Node> children) would hand the live map to every caller through its generated accessor. So DirectoryNode exposes childNames(), which is List.copyOf(children.keySet()) — a copy of String keys, and String is immutable, so that copy is enough. Handing back List.copyOf(children.values()) would not be, because a FileNode reached through it can still be written to.

The way in, which is the half people forget

Scenario 5. Both constructors ran the same validation loop over the same list and both passed:

both constructed and validated; subtotal=4599
Cart total       4139
LeakyCart total  4139
caller adds STAFF100 to the list it still holds
Cart total       4139   offers=1
LeakyCart total  0   offers=2
constructing a Cart from that list now: IllegalArgumentException
  message  a standing offer takes at most 40 percent off, and STAFF100 takes 100

Read the last two lines together. The list that LeakyCart is now discounting by 100 percent is a list Cart's constructor refuses outright. The validation was not weaker; it ran, and it was correct, and it was made irrelevant by an assignment three lines later.

corpus/shopping-cart/reference/src/Cart.java has the fixed version at line 53:

this.standingPromotions = List.copyOf(standingPromotions);

One call, in a constructor, on a parameter nobody thinks of as dangerous.

corpus/tic-tac-toe does the same thing one level further in, and it is worth reading because a record makes the boundary automatic. contract/GameState.java holds List<Optional<Mark>> cells and its compact constructor ends with:

cells = List.copyOf(cells);

Then look at who calls it. reference/src/TicTacToe.java builds a fresh ArrayList, fills it from the grid, and hands it to new GameState(...). The producer does not have to remember to copy, because the record copies whatever it is given. Its javadoc states the resulting contract in one line: "A snapshot, not a view: handing one out never lets a caller change the game." And reference/src/StandardGrid.java keeps the actual board as a Mark[][] that no method returns. An array has no unmodifiable wrapper in the JDK, so a design that hands one out has no option 2 and no option 3. The only safe move with an array field is to not expose it.

liveLines() — the middle option, and its price

Scenario 6 is the view. Collections.unmodifiableCollection(byCode.values()) allocates one small wrapper and no copy of the contents. The caller cannot change it:

view.clear()             java.lang.UnsupportedOperationException

Then read the two sizes:

view size 2   snapshot size 2
after cart.add(LAMP, 1)
view size 3   snapshot size 2

The caller who took the view is holding a moving target. If they cached it, computed a subtotal from it, and rendered it, they may have used three different versions of the cart. And a loop already running over one does this:

adding while iterating   java.util.ConcurrentModificationException
  blamed  java.base/java.util.LinkedHashMap$LinkedHashIterator.nextNode(LinkedHashMap.java:1023)

That trace is a single thread. Two threads is corpus/lru-cache's territory and E1's.

Say which one you are handing back. corpus/shopping-cart/contract/ShoppingCartApi.java uses the word: "What comes back is a snapshot, and it cannot be used to change the cart." Then it names both checks the grader runs — adding to what you handed out fails, and a snapshot taken earlier does not change afterwards. A view passes the first and fails the second, on purpose, and a contract that does not say which one it means has left the caller to guess.

standingOffers() — the copy deliberately not taken

public List<Coupon> standingOffers() {
    return standingOffers;
}

The field is already List.copyOf of records. There is nothing left to defend, so copying again allocates for nothing. corpus/rate-limiter/reference/DECISION_LOG.md makes the same call about rulesFor and states the reasoning: "the list is already immutable, Rule is a record, and the caller cannot reach anything mutable through it."

Two conditions, both required. An immutable list of mutable elements fails the second one, and that is MutableLineCart again.

contains, lineCount, itemCount, forEachLine — the answer candidates skip

Scenario 7:

contains(MUG)   true
lineCount()     2
itemCount()     5
forEachLine     MUG SOCKS
nothing was copied and nothing was handed over

Four questions answered, no collection across the boundary, nothing allocated. Most callers of a lines() accessor want one of these four and iterate a whole list to get it.

corpus/parking-lot/contract/ParkingLotApi.java is built this way and has no collection in it at all. Its three methods are park, unpark, and availableSpots(VehicleType), which returns an int. There is a SpotGrid behind it holding the occupancy, and nothing in the contract can reach it. corpus/logger/reference/DECISION_LOG.md names the same decision and the reason: "Nothing in LoggerApi hands back the registrations, and nothing needs to."

That is also a C5 decision, and the two items point at each other. A method that hands back a collection is a wide public surface and a mutable-state hazard at once.

What this design does not do

No deep copy anywhere. LineItem and Coupon are records over String and primitives, so there is no second layer to copy and a deep copy would allocate for a hazard that does not exist. If a line ever gained a mutable field, the fix is to make that field a value type, not to write a clone method. when-not.md costs out the alternative.


When not to

When not to copy

Copying on the way out is not a habit to apply to every accessor. It has a price. It has a requirement that makes it the wrong answer outright. And there is a version of it that is worse than having no boundary at all.

What a copy costs, measured

From node lessons/A7/contrast/measure.mjs, the Bench.java block:

operation                 lines       reps   bytes/call    ns/call
lines()  copy                10      40000        136.0      111.4
lines()  copy               100      40000        856.0      274.7
lines()  copy              5000       4000      40056.0    12587.9
liveLines()  window        5000       4000          0.0        7.0
sum over liveLines()       5000       4000          0.0     2690.1
itemCount()                5000       4000          0.0     2810.8

List.copyOf costs 8 bytes per element plus 56, which is one Object[] and one ListN wrapper. The bytes/call column is identical on every run. The timing is not: the 5000-line row read 12587 nanoseconds in the run shown, and 11187, 12609 and 15667 in three further runs.

So the honest statement is not "a copy is cheap". It is: a copy is 8 bytes per element and about 12 microseconds at five thousand elements, and on a page re-rendered per keystroke that is a profile entry. For a cart with ten lines it is 136 bytes, which is nothing, and the interviewer will not ask. The number only matters when the read path is hot.

The requirement that makes copying wrong, measured

contrast/ has that requirement:

Checkout re-renders the whole basket on every keystroke, and our biggest baskets run to five thousand lines. That copy is at the top of the CPU profile.

render 5000 lines, no copies  a  -> a-render   diffLines   0  touched 0  new 1
render 5000 lines, no copies  b  -> b-render   diffLines  11  touched 1  new 1  [Cart.java +11/-0]

The leaky design absorbed it for nothing. Its live list is already what a zero-allocation render path wants, so the change is one new file and no edits. The encapsulated design pays 11 lines inside Cart.java for two imports, a cached wrapper field, and a second accessor.

The 11 lines are not the real cost. The real cost is that b-render/Cart.java now has two accessors with different contracts, and every caller has to know which one they hold. lines() is a moment. liveLines() is a window that changes underneath them, and a loop already running over one throws ConcurrentModificationException. worked/src/Main.java scenario 6 prints the frame:

adding while iterating   java.util.ConcurrentModificationException
  blamed  java.base/java.util.LinkedHashMap$LinkedHashIterator.nextNode(LinkedHashMap.java:1023)

A design with one accessor never has to answer the question. curveball.md says the rest.

The version that is worse than no boundary

public List<LineItem> linesCopied() {
    return new ArrayList<>(lines);
}

This protects the cart completely. All five invariants hold, nothing done to that list reaches the map, and it is the answer most people reach for first. Scenario 2b in worked/:

before   lines=1 items=2
copy.add returned  true
copy size          2
cart              lines=1 items=2
threw    nothing, and the caller has been told the socks are in the basket

add returned true. The caller's list has two entries and the basket has one. A refusal is evidence; a change that quietly goes nowhere is the absence of evidence. The caller will look for their bug in the cart, because the cart is what lost the item, and the cart is correct.

lessons/A5/ measured the same shape on a different item. A design that clamped a negative remaining to zero produced an audit line reading remaining=0, 100% of quota used, which cannot be told apart from an honestly exhausted quota. Repair destroys the evidence. List.copyOf does the copy and the refusal in one call, so there is no reason to pick the half.

The concrete bad example: defending against a mutation that cannot happen

This is what over-application of the item looks like. Applied beside worked/src, it compiles: javac 21 with -Xlint:all prints nothing and exits 0.

public List<LineItem> lines() {
    List<LineItem> out = new ArrayList<>();
    for (LineItem line : byCode.values()) {
        Sku skuCopy = new Sku(line.sku().code(), line.sku().name(), line.sku().unitPriceMinor());
        out.add(new LineItem(skuCopy, line.quantity(), line.lineTotalMinor()));
    }
    return Collections.unmodifiableList(out);
}

What a reviewer sees. Three allocations per line instead of one for the whole list, to defend against a mutation the record keyword already makes impossible. And it changes nothing observable, which is the tell. Run it:

deep copies equal?      true
same reference?         false
equal to the original?  true

Every copy is equals to every other copy and to the original, because LineItem and Sku are records and a record compares by component. No caller can detect that the copy happened. The code exists to protect against a scenario that has no expression in the language, and the next person adds a copy() method to Sku so the pattern can be reused.

The fix for a mutable element is to make the element immutable, not to clone it. That is A2, and lessons/J11/worked/NOTES.md walks the same decision for FileNode and DirectoryNode, which are deliberately not records because their state genuinely changes. When you cannot make an element immutable, do not hand it out at all.

The second bad example: copying an argument you do not keep

public long cheapestMinor(List<Sku> catalogue) {
    List<Sku> safe = List.copyOf(catalogue);
    long cheapest = Long.MAX_VALUE;
    for (Sku sku : safe) {
        cheapest = Math.min(cheapest, sku.unitPriceMinor());
    }
    return cheapest;
}

Nothing is stored, so nothing needs defending. The copy allocates once per call in proportion to the argument, and it also changes the method's failure mode. List.copyOf refuses null elements, so a read-only calculation now throws where it used to work:

Quote.cheapestMinor threw java.lang.NullPointerException at java.base/java.util.Objects.requireNonNull(Objects.java:233)

Copy what you store, not what you read. Cart's constructor is the shape to copy: it iterates the caller's list to validate it, and copies it once, at the point where it becomes state.

The axis this lesson deliberately does not defend

Four places in worked/src/Cart.java where nothing is copied, on purpose.

standingOffers() returns the field itself. The field is already List.copyOf of records, so a second copy would allocate for a hazard that no longer exists. corpus/rate-limiter/reference/DECISION_LOG.md makes the identical call about rulesFor and states both conditions: "the list is already immutable, Rule is a record, and the caller cannot reach anything mutable through it." Both halves are required. An immutable list of mutable elements fails the second, and that is MutableLineCart.

lineCount(), itemCount(), subtotalMinor() and contains() return an int, a long or a boolean. There is nothing to copy because there is nothing to hand over. Rewriting contains to return a List<String> of codes so callers can search it themselves would manufacture the problem this lesson is about.

The constructor's validation loop reads the caller's list without copying it first. Reading a collection needs no defence. Storing one does.

corpus/file-system's DirectoryNode.childNames() copies String keys and stops there. It is List.copyOf(children.keySet()), and a deeper copy of a String is not a thing that exists. What that method deliberately does not do is hand back children.values(), because a FileNode reached through it can still be written to.

The concurrency link, in one line

An escaped collection is shared mutable state, and corpus/lru-cache measured what that looks like under load. Change its listeners field from CopyOnWriteArrayList to a plain ArrayList and tests_concurrency/LruCacheStressTest catches a ConcurrentModificationException. Its decision log records six independent full-suite runs at CROWD = 200, five of them catching it on the first repetition. That is E1, E2 and E4, and they own it.

The threshold

Four questions, in this order. Stop at the first one that fits.

  1. Does the caller need the elements, or a number, a boolean, or one pass over them? If it is the second, answer the question. contains, lineCount, itemCount, forEachLine. No collection crosses the boundary, nothing is allocated, and the invariant cannot be reached. corpus/parking-lot/contract/ParkingLotApi.java answers availableSpots(VehicleType) with an int and mentions no collection anywhere. This is the option candidates skip.
  2. Do they need the elements, and is a moment in time an acceptable answer? List.copyOf. Pay the 8 bytes per element. Say the word snapshot in the contract, the way corpus/shopping-cart/contract/ShoppingCartApi.java does.
  3. Do they need the elements on a read path where the copy shows in a profile? A cached unmodifiable view, documented as live, and only for that caller. Both accessors then exist and both are named honestly.
  4. Are the elements mutable? Make them immutable first. If you cannot, option 1 is the only safe one, because 2 and 3 both hand the elements over.

On the way in: copy at the assignment, never at the read. On the way out, two things are never the answer. A modifiable copy, because it loses the change instead of refusing it. And a deep copy of immutable elements, because there is nothing there to copy.

What the grader sees

leaked-mutable-state is a D1 defect tag, and D1's static checks name it directly: "mutable collections leaked from getters". It routes to this lesson at the faded stage. D3 level 3 also wants a minimal seam set, and a cart carrying lines(), linesCopied(), liveLines() and a deep copy has four ways to ask one question. One accessor, chosen from the four options above and named for what it is, scores higher than three.


The contrast pair

The measured pair: one line apart, and a line count cannot tell them apart

Two carts. a/Cart.java and b/Cart.java differ by exactly one line:

public List<LineItem> lines() {
    return lines;                    // a/
    return List.copyOf(lines);       // b/
}

Everything else is byte-identical, including the class javadoc. Both are 3 files and 75 normalised lines, so the boundary costs nothing up front. That is unusual for a technique on this syllabus, and it matters: unlike a policy interface, this one has no fixed price to argue about.

a/ is not a straw man. Its add scans for the sku, merges the quantity in place, and keeps the line's position, so one line per sku and first-added order both hold. BaseTest passes 4/4 against every tree in this directory, a/ included. A base suite cannot see this defect at all.

Change one, in the interviewer's words

Finance wants a receipt view — same lines, but dearest first, so the expensive stuff is at the top. The reporting module is theirs, not ours. Hand it the lines and let it sort them how it likes.

Change two, in the interviewer's words

Checkout re-renders the whole basket on every keystroke, and our biggest baskets run to five thousand lines. That copy is at the top of the CPU profile. Get the render path down to no allocation per call.

The numbers

node lessons/A7/contrast/measure.mjs

Everything below is one run. The bytes/call column and every diffLines figure come out identical on every run. The ns/call column moves, so three further runs are quoted under that table.

The instrument that can see it

A diff cannot see the difference between a design you can misuse silently and one you cannot. So Probe.java measures something else: how far a leaked reference travels before anything notices, in calls, and whether what goes wrong is thrown or merely wrong. Each scenario runs two carts through identical honest calls. Only subject is leaked from, control is what the cart should say, so the expected value is computed rather than remembered.

---------- a/ ----------
scenario 2  a receipt sorts the handle it was given
  escape     handle.sort(dearestFirst)
  detected   no
  calls      6 of 6 honest calls completed after the escape
  control    lines=3 items=6 subtotal=9796 order=MUG SOCKS LAMP
  subject    lines=3 items=6 subtotal=9796 order=LAMP MUG SOCKS
  mode       silently wrong answer, and no exception anywhere

---------- b/ ----------
scenario 2  a receipt sorts the handle it was given
  escape     handle.sort(dearestFirst)
  detected   yes, at the escaping statement, after 0 further call(s)
  threw      java.lang.UnsupportedOperationException
  message    null
  blamed     java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
  calls      6 of 6 honest calls completed after the escape
  control    lines=3 items=6 subtotal=9796 order=MUG SOCKS LAMP
  subject    lines=3 items=6 subtotal=9796 order=MUG SOCKS LAMP
  mode       no damage: the cart still answers exactly as an unleaked one does
escapea/b/
handle.add(...)undetected, 6/6 calls, subtotal 14291 against 9796thrown, 0 further calls
handle.sort(...)undetected, order LAMP MUG SOCKS against MUG SOCKS LAMPthrown, 0 further calls
handle.clear()undetected, 2 lines against 3, subtotal 2149 against 9796thrown, 0 further calls
stashed, used 6 calls laterundetected, the mutation still landsthrown, 6 calls after lines()

Four escapes, four silent wrong answers, zero exceptions. The distance from cause to symptom is not six calls; it is unbounded, because nothing in a/ ever raises the subject. Scenario 4 is the one worth sitting with: the handle is stashed, six honest calls run, and the mutation still lands. A returned reference has no lifetime. It is wired to the cart until the caller drops it.

In b/ all four are refused at the offending statement, the stack trace points one frame into java.base, and the subject and control lines are identical afterwards. The cart is not damaged, and it is not repaired either. Nothing needed repairing.

The suites

a                  BaseTest 4/4  green true
b                  BaseTest 4/4  green true
a-receipt          BaseTest 4/4  green true
b-receipt-naive    BaseTest 4/4  green true
b-receipt          BaseTest 4/4  green true
a-render           BaseTest 4/4  green true
b-render           BaseTest 4/4  green true

a-receipt          ReceiptTest 1/2  green false
                   FAIL rendering a receipt does not reorder the basket -- expected: <[MUG, SOCKS, LAMP]> but was: <[LAMP, SOCKS, MUG]>
b-receipt-naive    ReceiptTest 0/2  green false
                   FAIL rendering a receipt does not reorder the basket -- java.lang.UnsupportedOperationException
                   FAIL the receipt lists the dearest line first -- java.lang.UnsupportedOperationException
b-receipt          ReceiptTest 2/2  green true

Read the three receipt trees as three moments in one afternoon.

a-receipt/Receipt.java is the draft anybody writes: take cart.lines(), sort it, format it. The receipt is correct. receiptIsDearestFirst passes. The basket page reads lines() too, and it now shows the customer's items in an order they never chose. That second assertion is one nobody writes until a design has taught them to.

b-receipt-naive/ is the identical file against the encapsulated cart. It throws immediately, in the receipt author's own unit test, in the minute they wrote it.

b-receipt/ is what they push. The difference is one line:

List<LineItem> lines = new ArrayList<>(cart.lines());

The allocation cost, so nobody pretends copying is free

operation                 lines       reps   bytes/call    ns/call
lines()  copy                10      40000        136.0      111.4
liveLines()  window          10      40000          0.0       19.8
sum over lines()             10      40000        168.0      104.7
sum over liveLines()         10      40000         56.0      219.0
itemCount()                  10      40000         32.0       80.0

lines()  copy               100      40000        856.0      274.7
liveLines()  window         100      40000          0.0        4.5
sum over lines()            100      40000        832.0      162.2
sum over liveLines()        100      40000          0.0       64.8
itemCount()                 100      40000          0.0       42.0

lines()  copy              5000       4000      40056.0    12587.9
liveLines()  window        5000       4000          0.0        7.0
sum over lines()           5000       4000      40032.0    10086.0
sum over liveLines()       5000       4000          0.0     2690.1
itemCount()                5000       4000          0.0     2810.8

List.copyOf costs 8 bytes per line plus 56, which is one Object[] and one ListN wrapper. 136 at ten lines, 856 at a hundred, 40056 at five thousand. The timing for that last row read 12587 nanoseconds above, and 11187, 12609 and 15667 in three further runs. Twelve microseconds per keystroke is a real number, and the interviewer is right to notice it.

Two rows in the ten-line block report 32 and 56 bytes for code that allocates nothing at 100 and 5000 lines. That is the iterator failing to be scalar-replaced at that inlining depth, not a cost of the design. Do not build an argument on the ten-line block.

And the D4 instrument, reported honestly

finance wants a receipt       a                -> a-receipt         diffLines   0  touched 0  new 1
finance wants a receipt       b                -> b-receipt-naive   diffLines   0  touched 0  new 1
fixing the receipt            b-receipt-naive  -> b-receipt         diffLines   2  touched 1  new 0  [Receipt.java +1/-1]
render 5000 lines, no copies  a                -> a-render          diffLines   0  touched 0  new 1
render 5000 lines, no copies  b                -> b-render          diffLines  11  touched 1  new 1  [Cart.java +11/-0]

The receipt change is a tie at zero. One new file, no existing file touched, in both designs. measureChange is the function that scores D4 in a graded attempt, and on this requirement it reports that encapsulation bought nothing. If you came here expecting the boundary to shrink a diff, it does not. The whole benefit is in the two rows above: a-receipt ships a corrupted basket, b-receipt-naive cannot be shipped, and the fix is two lines.

The change that goes the wrong way

The render requirement costs a/ nothing: one new file, no edits. The live list it already hands out is exactly what a zero-allocation render path wants. b/ pays 11 lines inside Cart.java: a Collection import, a Collections import, a cached wrapper field, and a liveLines() accessor.

// Built once, not per call: a fresh wrapper on every read would defeat the point.
private final Collection<LineItem> liveLines = Collections.unmodifiableCollection(lines);

The field is the interesting half. Collections.unmodifiableCollection(lines) inside the accessor would allocate a wrapper per keystroke, which is smaller than a copy and still not zero. Caching it is what makes the 0.0 bytes/call row real.

And the 11 lines are not the whole price. b-render/ now has two accessors with different contracts, and a caller has to know which one they took. The one who took the window is holding something that changes underneath them, and a loop already running over one throws ConcurrentModificationExceptionlessons/A7/worked/src/Main.java scenario 6 triggers it and prints the frame. a/ has one accessor and that question does not arise, because its single accessor is already the dangerous one.

So the honest summary of the wrong-way direction: when the requirement is throughput on a read path, the leak is the feature. That is what when-not.md is about, and it is why the third option, not handing back a collection at all, is worth reaching for before either of these two.

The alternatives, so the choice is a choice

Could b/ have absorbed the render requirement without touching Cart? Yes, and it is the better answer. sum over liveLines() at 5000 lines is 0 bytes and about 2.7 to 4.3 microseconds. A forEachLine(Consumer<LineItem>) or a purpose-built visibleSubtotalMinor(long) on the cart is the same cost with no window escaping at all, and it is one method rather than a second contract. That is not what was measured here, because the 11 lines are the price of the reflex to widen the accessor you already have. corpus/parking-lot/contract/ParkingLotApi.java took the other road and has no collection in it anywhere.

Was a-receipt written by somebody careless? No. List.sort sorts in place and has since Java 8, cart.lines() reads like a getter, and nothing in a/Cart.java says otherwise. The design offered a footgun and somebody found it. That is what a design is for.

Is a/ therefore always wrong? No. corpus/lru-cache/reference/src/LruCache.java holds its values map and never hands it out in any form. Its decision log states the reason under "Iteration order is not exposed, on purpose (A7, C5)": exposing the key set would leak whichever EvictionPolicy is installed. That is a cart-shaped problem answered by option three, not by copying.

What this pair does not show

Two things, both covered elsewhere in the lesson.

A copy is shallow. Both carts here hold LineItem records, so List.copyOf is the end of the problem. worked/src/MutableLineCart.java is the same accessor over a class with a setter, and its subtotal goes from 1798 to 898101 through a snapshot that refuses add.

The way in is the same defect. a/ and b/ take no constructor arguments, so neither can leak inward. worked/src does: LeakyCart's constructor validates the offers it is handed and keeps the caller's list, and the caller adds a 100 percent coupon afterwards. Its total goes to 0 on a list Cart's constructor refuses outright.


Worked source

The 7 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/Cart.java177 lines

import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;

/**
 * A shopping cart that keeps its own promises.
 *
 * <p>Five invariants, all of them enforced here and none of them enforced by a call site:
 * <ol>
 *   <li>at most one line per sku code;</li>
 *   <li>every line holds at least one item;</li>
 *   <li>every line's total is its unit price times its quantity;</li>
 *   <li>lines read back in the order their skus were first added;</li>
 *   <li>no standing offer on this cart takes more than {@value #MAX_STANDING_PERCENT} percent off.</li>
 * </ol>
 *
 * <p>{@code LeakyCart} in this directory enforces the same five in exactly the same way, method for
 * method, and can still be talked out of all five. The difference is two lines, and neither of them
 * is in a method that computes anything.
 *
 * <p>Three ways to answer a question about the lines are shown here on purpose, because they cost
 * different things and a real contract picks one:
 * <ul>
 *   <li>{@link #lines()} — a snapshot. Cheap to reason about, allocates on every call.</li>
 *   <li>{@link #liveLines()} — an unmodifiable window. No copy, and it changes under the caller.</li>
 *   <li>{@link #contains}, {@link #lineCount}, {@link #itemCount}, {@link #forEachLine} — no
 *       collection crosses the boundary at all. Usually the right answer.</li>
 * </ul>
 * {@code corpus/shopping-cart/contract/ShoppingCartApi.java} ships the first. {@code
 * corpus/parking-lot/contract/ParkingLotApi.java} ships the third and never mentions a collection.
 */
public final class Cart {

    /** A standing offer is a marketing discount, not a staff code; 40 is the cart's own ceiling. */
    public static final int MAX_STANDING_PERCENT = 40;

    private final Map<String, LineItem> byCode = new LinkedHashMap<>();
    private final List<Coupon> standingOffers;

    /**
     * Validates the offers it is given, then keeps a copy of them.
     *
     * The copy is the half people forget. Checking the list proves something about the list as it
     * is right now; copying it is what makes that proof still true a second later.
     */
    public Cart(List<Coupon> standingOffers) {
        Objects.requireNonNull(standingOffers, "standingOffers");
        for (Coupon offer : standingOffers) {
            Objects.requireNonNull(offer, "a standing offer cannot be null");
            if (offer.percentOff() > MAX_STANDING_PERCENT) {
                throw new IllegalArgumentException("a standing offer takes at most "
                        + MAX_STANDING_PERCENT + " percent off, and " + offer.code()
                        + " takes " + offer.percentOff());
            }
        }
        this.standingOffers = List.copyOf(standingOffers);
    }

    /** Adds to whatever quantity this sku already has on the cart. */
    public void add(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        requirePositive(quantity);
        LineItem existing = byCode.get(sku.code());
        int total = existing == null ? quantity : existing.quantity() + quantity;
        byCode.put(sku.code(), LineItem.of(sku, total));
    }

    /** Sets the quantity outright. Zero removes the line. */
    public void setQuantity(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        if (quantity < 0) {
            throw new IllegalArgumentException("a quantity cannot be negative: " + quantity);
        }
        if (quantity == 0) {
            byCode.remove(sku.code());
        } else {
            byCode.put(sku.code(), LineItem.of(sku, quantity));
        }
    }

    public void clear() {
        byCode.clear();
    }

    /**
     * Every line, in order, as a snapshot.
     *
     * {@code List.copyOf} is doing two jobs and it is worth separating them. It severs the caller
     * from this map, so nothing done to the returned list can reach the cart. And the list it
     * returns refuses mutation itself, so a caller who tries finds out at the point of the mistake
     * rather than three screens later.
     */
    public List<LineItem> lines() {
        return List.copyOf(byCode.values());
    }

    /**
     * A live, unmodifiable window onto the lines.
     *
     * No copy, so nothing is allocated per call. The price is that the caller is holding something
     * that changes underneath them: add a line and every window handed out earlier reports the new
     * size, and any loop already running over one throws {@code ConcurrentModificationException}.
     * {@code Main} triggers both.
     */
    public Collection<LineItem> liveLines() {
        return Collections.unmodifiableCollection(byCode.values());
    }

    /** The question most callers are actually asking. No collection, no copy, no window. */
    public boolean contains(String skuCode) {
        Objects.requireNonNull(skuCode, "skuCode");
        return byCode.containsKey(skuCode);
    }

    /** How many lines. The basket page's line count. */
    public int lineCount() {
        return byCode.size();
    }

    /** Quantities added up — the badge on the basket icon, not the line count. */
    public int itemCount() {
        int items = 0;
        for (LineItem line : byCode.values()) {
            items += line.quantity();
        }
        return items;
    }

    /** Shows every line to a visitor. Iteration without handing over the thing being iterated. */
    public void forEachLine(Consumer<LineItem> visitor) {
        Objects.requireNonNull(visitor, "visitor");
        for (LineItem line : byCode.values()) {
            visitor.accept(line);
        }
    }

    /**
     * The standing offers, returned <b>without a second copy</b>.
     *
     * The field is already an immutable list of records, so there is nothing left to defend
     * against and copying again would allocate for nothing.
     * {@code corpus/rate-limiter/reference/DECISION_LOG.md} makes the same call about
     * {@code rulesFor} and says so in as many words.
     */
    public List<Coupon> standingOffers() {
        return standingOffers;
    }

    public long subtotalMinor() {
        long subtotal = 0L;
        for (LineItem line : byCode.values()) {
            subtotal += line.lineTotalMinor();
        }
        return subtotal;
    }

    public long totalMinor() {
        int percent = 0;
        for (Coupon offer : standingOffers) {
            percent += offer.percentOff();
        }
        percent = Math.min(percent, 100);
        long subtotal = subtotalMinor();
        long discount = (subtotal * percent + 50L) / 100L;   // half up, the retail convention
        return subtotal - discount;
    }

    private static void requirePositive(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("a quantity must be positive, not " + quantity);
        }
    }
}

worked/src/Coupon.java23 lines

import java.util.Objects;

/**
 * A discount code and what it takes off.
 *
 * <p>A coupon on its own may be worth anything from 1 to 100 percent — a full-price giveaway is a
 * legal coupon, and staff codes really do exist. The ceiling that matters to a cart is a
 * <i>cart</i> rule, not a coupon rule, so it lives in {@code Cart}'s constructor. That split is
 * what makes the way-in escape in {@code LeakyCart} worth looking at: the cart checks the list it
 * is handed, and then keeps a reference to a list the caller can still add to.
 */
public record Coupon(String code, int percentOff) {

    public Coupon {
        Objects.requireNonNull(code, "code");
        if (code.isBlank()) {
            throw new IllegalArgumentException("a coupon code cannot be blank");
        }
        if (percentOff < 1 || percentOff > 100) {
            throw new IllegalArgumentException("a coupon takes 1 to 100 percent off, not " + percentOff);
        }
    }
}

worked/src/LeakyCart.java137 lines

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

/**
 * The same cart, written by somebody with twelve minutes left, and it is not a straw man.
 *
 * <p>Read {@link #add} and {@link #setQuantity}. They enforce all five of {@code Cart}'s invariants:
 * one line per sku, a positive quantity, a derived line total, insertion order, and a 40 percent
 * ceiling on the offers the constructor is handed. Every arithmetic method is correct. Every guard
 * is present. A reviewer skimming the methods finds nothing wrong, because nothing in a method is
 * wrong.
 *
 * <p>Two lines are:
 * <pre>
 *   this.standingOffers = standingOffers;   // the caller keeps a handle on our state
 *   return lines;                           // the caller is handed our state
 * </pre>
 *
 * <p>The backing {@link ArrayList} is not the mistake either. A list means {@link #add} has to scan
 * for the sku and merge, which is what the code below does; a {@code LinkedHashMap} keyed on the
 * code would be better, and {@code corpus/shopping-cart/reference/src/CartLines.java} says why at
 * length. But a map-backed cart leaks exactly as badly the moment its accessor returns
 * {@code byCode.values()}, which is also a live view. The container is a separate question from the
 * boundary.
 */
public final class LeakyCart {

    public static final int MAX_STANDING_PERCENT = 40;

    private final List<LineItem> lines = new ArrayList<>();
    private final List<Coupon> standingOffers;

    /** Checks every offer, exactly as {@code Cart} does, and then keeps the caller's list. */
    public LeakyCart(List<Coupon> standingOffers) {
        Objects.requireNonNull(standingOffers, "standingOffers");
        for (Coupon offer : standingOffers) {
            Objects.requireNonNull(offer, "a standing offer cannot be null");
            if (offer.percentOff() > MAX_STANDING_PERCENT) {
                throw new IllegalArgumentException("a standing offer takes at most "
                        + MAX_STANDING_PERCENT + " percent off, and " + offer.code()
                        + " takes " + offer.percentOff());
            }
        }
        this.standingOffers = standingOffers;
    }

    /** Scan, merge, keep the position: one line per sku, in first-added order. */
    public void add(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        if (quantity <= 0) {
            throw new IllegalArgumentException("a quantity must be positive, not " + quantity);
        }
        for (int i = 0; i < lines.size(); i++) {
            if (lines.get(i).sku().code().equals(sku.code())) {
                lines.set(i, LineItem.of(sku, lines.get(i).quantity() + quantity));
                return;
            }
        }
        lines.add(LineItem.of(sku, quantity));
    }

    public void setQuantity(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        if (quantity < 0) {
            throw new IllegalArgumentException("a quantity cannot be negative: " + quantity);
        }
        for (int i = 0; i < lines.size(); i++) {
            if (lines.get(i).sku().code().equals(sku.code())) {
                if (quantity == 0) {
                    lines.remove(i);
                } else {
                    lines.set(i, LineItem.of(sku, quantity));
                }
                return;
            }
        }
        if (quantity > 0) {
            lines.add(LineItem.of(sku, quantity));
        }
    }

    public void clear() {
        lines.clear();
    }

    /** The accessor that voids all five invariants. Nothing else in this file has to be wrong. */
    public List<LineItem> lines() {
        return lines;
    }

    /**
     * The half-fix: a copy the caller can still write to.
     *
     * Severs the caller from this list, so nothing they do reaches the cart. It is worse than
     * {@link #lines()} for one reason. {@code add} returns {@code true}, so the caller is told the
     * change landed, and it did not. See {@code when-not.md}.
     */
    public List<LineItem> linesCopied() {
        return new ArrayList<>(lines);
    }

    public int lineCount() {
        return lines.size();
    }

    public int itemCount() {
        int items = 0;
        for (LineItem line : lines) {
            items += line.quantity();
        }
        return items;
    }

    public List<Coupon> standingOffers() {
        return standingOffers;
    }

    public long subtotalMinor() {
        long subtotal = 0L;
        for (LineItem line : lines) {
            subtotal += line.lineTotalMinor();
        }
        return subtotal;
    }

    public long totalMinor() {
        int percent = 0;
        for (Coupon offer : standingOffers) {
            percent += offer.percentOff();
        }
        percent = Math.min(percent, 100);
        long subtotal = subtotalMinor();
        long discount = (subtotal * percent + 50L) / 100L;
        return subtotal - discount;
    }
}

worked/src/LineItem.java32 lines

import java.util.Objects;

/**
 * One line of a cart: a sku, how many, and what those cost together.
 *
 * <p>Two invariants live in the compact constructor, so a wrong line cannot be built at all:
 * a quantity is at least one, and {@code lineTotalMinor} is exactly the unit price times the
 * quantity. That second one is the invariant a leaked collection destroys, and it is stated here
 * rather than checked inside {@code Cart} so that {@code Cart} handing a line out is safe by
 * construction rather than safe by convention.
 */
public record LineItem(Sku sku, int quantity, long lineTotalMinor) {

    public LineItem {
        Objects.requireNonNull(sku, "sku");
        if (quantity < 1) {
            throw new IllegalArgumentException("a line holds at least one item, not " + quantity);
        }
        long expected = sku.unitPriceMinor() * quantity;
        if (lineTotalMinor != expected) {
            throw new IllegalArgumentException(
                    "line total for " + sku.code() + " must be " + expected
                            + " (" + sku.unitPriceMinor() + " x " + quantity + "), not " + lineTotalMinor);
        }
    }

    /** The only sane way to build one: the total is derived, never supplied. */
    public static LineItem of(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        return new LineItem(sku, quantity, sku.unitPriceMinor() * quantity);
    }
}

worked/src/MutableLineCart.java122 lines

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

/**
 * The cart written by somebody who has already learned the first half of this lesson.
 *
 * <p>{@link #lines()} returns {@code List.copyOf}. Adding to what it hands out throws. A snapshot
 * taken before a change does not learn about the change. Both of the things a reviewer checks for
 * are true, and this cart is still open.
 *
 * <p>The reason is that its lines are a class with a setter rather than a record. {@code
 * List.copyOf} copies the <i>references</i>; both lists then point at the same {@link MutableLine}
 * objects, so a caller who cannot touch the list can still reach through it. {@code Main} sets a
 * quantity of 999 through a snapshot and watches this cart's total move.
 *
 * <p>A mutable line is not a silly thing to write. {@code add} for a sku already in the cart becomes
 * {@code line.setQuantity(line.quantity() + n)} with no allocation, which is the obvious
 * optimisation, and it is why {@code corpus/file-system} has this shape for real:
 * {@code reference/src/FileNode.java} is a class rather than a record because {@code write} replaces
 * its content, and {@code lessons/J11/worked/NOTES.md} records both reasons those two node types are
 * not records. A {@code List.copyOf} of live {@code FileNode}s would be exactly this bug.
 */
public final class MutableLineCart {

    /** A cart rule, not a line rule: nobody buys twenty-one of anything on this site. */
    public static final int MAX_PER_LINE = 20;

    private final List<MutableLine> lines = new ArrayList<>();

    public void add(Sku sku, int quantity) {
        Objects.requireNonNull(sku, "sku");
        if (quantity <= 0) {
            throw new IllegalArgumentException("a quantity must be positive, not " + quantity);
        }
        for (MutableLine line : lines) {
            if (line.sku().code().equals(sku.code())) {
                requireWithinLimit(line.quantity() + quantity);
                line.setQuantity(line.quantity() + quantity);
                return;
            }
        }
        requireWithinLimit(quantity);
        lines.add(new MutableLine(sku, quantity));
    }

    /** A snapshot: unmodifiable, severed from this list, and still not safe. */
    public List<MutableLine> lines() {
        return List.copyOf(lines);
    }

    public int lineCount() {
        return lines.size();
    }

    public long subtotalMinor() {
        long subtotal = 0L;
        for (MutableLine line : lines) {
            subtotal += line.lineTotalMinor();
        }
        return subtotal;
    }

    /** True when every line still obeys the per-line ceiling this cart claims to enforce. */
    public boolean invariantHolds() {
        for (MutableLine line : lines) {
            if (line.quantity() < 1 || line.quantity() > MAX_PER_LINE) {
                return false;
            }
        }
        return true;
    }

    private static void requireWithinLimit(int quantity) {
        if (quantity > MAX_PER_LINE) {
            throw new IllegalArgumentException(
                    "a line holds at most " + MAX_PER_LINE + " items, not " + quantity);
        }
    }

    /**
     * A line with a setter, guarding its own invariant and not the cart's.
     *
     * It refuses a quantity below one, which is all it knows about. {@code MAX_PER_LINE} is a fact
     * about a cart, so this class cannot check it, so a caller holding one of these can walk
     * straight past it.
     */
    public static final class MutableLine {

        private final Sku sku;
        private int quantity;

        MutableLine(Sku sku, int quantity) {
            this.sku = Objects.requireNonNull(sku, "sku");
            setQuantity(quantity);
        }

        public Sku sku() {
            return sku;
        }

        public int quantity() {
            return quantity;
        }

        public long lineTotalMinor() {
            return sku.unitPriceMinor() * quantity;
        }

        public void setQuantity(int quantity) {
            if (quantity < 1) {
                throw new IllegalArgumentException("a line holds at least one item, not " + quantity);
            }
            this.quantity = quantity;
        }

        @Override
        public String toString() {
            return sku.code() + " x" + quantity + " = " + lineTotalMinor();
        }
    }
}

worked/src/Sku.java23 lines

import java.util.Objects;

/**
 * A catalogue entry: a code, a name, and what one of them costs in minor units.
 *
 * <p>Drawn from {@code corpus/shopping-cart/contract/Sku.java}. It is a record here for the reason
 * this lesson is about: a snapshot of lines is only as safe as the things inside it, and a record
 * has no setter for a caller to reach. See {@code MutableLineCart} in this directory for what the
 * same snapshot is worth when the elements do have setters.
 */
public record Sku(String code, String name, long unitPriceMinor) {

    public Sku {
        Objects.requireNonNull(code, "code");
        Objects.requireNonNull(name, "name");
        if (code.isBlank()) {
            throw new IllegalArgumentException("a sku code cannot be blank");
        }
        if (unitPriceMinor < 0L) {
            throw new IllegalArgumentException("a unit price cannot be negative, got " + unitPriceMinor);
        }
    }
}

worked/src/Main.java283 lines

import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;

/**
 * Every line of output quoted in this lesson's prose comes from here.
 *
 *   javac -Xlint:all -d out lessons/A7/worked/src/*.java && java -cp out Main
 *
 * The order is deliberate. Each scenario fixes the previous one's problem and then fails somewhere
 * new, which is the order the design was actually arrived at.
 */
public final class Main {

    private static final Sku MUG = new Sku("MUG", "Enamel mug", 899L);
    private static final Sku SOCKS = new Sku("SOCKS", "Wool socks", 1250L);
    private static final Sku LAMP = new Sku("LAMP", "Desk lamp", 4599L);
    private static final Sku PEN = new Sku("PEN", "Fineliner", 299L);

    public static void main(String[] args) {
        honestUse();
        leakOnTheWayOut();
        theSameMoveAgainstCart();
        theCopyThatSwallowsTheMistake();
        aReportSortsWhatItWasHanded();
        theCopyThatIsNotDeep();
        leakOnTheWayIn();
        theViewAndItsPrice();
        notExposingItAtAll();
    }

    /** Scenario 0. Both carts agree about everything a test is likely to ask. */
    private static void honestUse() {
        head("0  honest use: the two designs are indistinguishable");
        Cart cart = new Cart(List.of(new Coupon("SAVE10", 10)));
        cart.add(MUG, 2);
        cart.add(SOCKS, 1);
        say("Cart       lines=" + cart.lineCount() + " items=" + cart.itemCount()
                + " subtotal=" + cart.subtotalMinor() + " total=" + cart.totalMinor());

        LeakyCart leaky = new LeakyCart(List.of(new Coupon("SAVE10", 10)));
        leaky.add(MUG, 2);
        leaky.add(SOCKS, 1);
        say("LeakyCart  lines=" + leaky.lineCount() + " items=" + leaky.itemCount()
                + " subtotal=" + leaky.subtotalMinor() + " total=" + leaky.totalMinor());
    }

    /**
     * Scenario 1. One accessor, and invariant 1 is gone. Nothing throws and nothing logs.
     *
     * The added line is a legal {@code LineItem} — its own compact constructor is satisfied, the
     * total matches the price, the quantity is positive. Every rule that {@code LineItem} owns still
     * holds. The rule that is broken is the one only the cart could have held.
     */
    private static void leakOnTheWayOut() {
        head("1  the accessor: LeakyCart.lines() hands over the ArrayList");
        LeakyCart leaky = new LeakyCart(List.of());
        leaky.add(MUG, 2);
        leaky.add(SOCKS, 1);
        say("before   lines=" + leaky.lineCount() + " items=" + leaky.itemCount()
                + " subtotal=" + leaky.subtotalMinor());

        List<LineItem> handedOut = leaky.lines();
        handedOut.add(LineItem.of(MUG, 5));

        say("after    lines=" + leaky.lineCount() + " items=" + leaky.itemCount()
                + " subtotal=" + leaky.subtotalMinor());
        say("threw    nothing");
        say("mug lines now " + countLines(leaky, "MUG") + ", and invariant 1 says at most 1");
        say("the customer is charged for 7 mugs having chosen 2");
    }

    /** Scenario 2. The identical statement, against the cart that copies on the way out. */
    private static void theSameMoveAgainstCart() {
        head("2  the same statement against Cart");
        Cart cart = new Cart(List.of());
        cart.add(MUG, 2);
        try {
            cart.lines().add(LineItem.of(MUG, 5));
            say("no exception, which would mean this lesson is wrong");
        } catch (RuntimeException thrown) {
            report(thrown);
        }
        say("after    lines=" + cart.lineCount() + " items=" + cart.itemCount());
    }

    /**
     * Scenario 2b. The half-fix, and it is worse than the leak in one specific way.
     *
     * {@code new ArrayList<>(lines)} severs the caller from the cart, so the cart is safe. The
     * caller is not: {@code add} answers {@code true} and the item is nowhere. A refusal is
     * evidence. A change that quietly goes nowhere is the absence of evidence.
     */
    private static void theCopyThatSwallowsTheMistake() {
        head("2b  a modifiable copy: the change is lost rather than refused");
        LeakyCart leaky = new LeakyCart(List.of());
        leaky.add(MUG, 2);
        say("before   lines=" + leaky.lineCount() + " items=" + leaky.itemCount());

        List<LineItem> copy = leaky.linesCopied();
        boolean accepted = copy.add(LineItem.of(SOCKS, 1));

        say("copy.add returned  " + accepted);
        say("copy size          " + copy.size());
        say("cart              lines=" + leaky.lineCount() + " items=" + leaky.itemCount());
        say("threw    nothing, and the caller has been told the socks are in the basket");
    }

    /**
     * Scenario 3. Nobody adds a line on purpose. What people do is sort.
     *
     * A receipt view wants the most expensive line first. {@code List.sort} is in place, so sorting
     * what you were handed reorders the cart itself, and invariant 4 is gone. The report is correct.
     */
    private static void aReportSortsWhatItWasHanded() {
        head("3  a receipt sorts the list it was given");
        Comparator<LineItem> dearestFirst = Comparator.comparingLong(LineItem::lineTotalMinor).reversed();

        LeakyCart leaky = new LeakyCart(List.of());
        leaky.add(MUG, 1);
        leaky.add(SOCKS, 1);
        leaky.add(LAMP, 1);
        say("cart order before  " + codes(leaky.lines()));
        List<LineItem> forTheReceipt = leaky.lines();
        forTheReceipt.sort(dearestFirst);
        say("receipt order      " + codes(forTheReceipt));
        say("cart order after   " + codes(leaky.lines()) + "   <- the cart was sorted too");

        Cart cart = new Cart(List.of());
        cart.add(MUG, 1);
        cart.add(SOCKS, 1);
        cart.add(LAMP, 1);
        try {
            cart.lines().sort(dearestFirst);
        } catch (RuntimeException thrown) {
            report(thrown);
        }
        say("cart order after   " + codes(cart.lines()));
    }

    /** Scenario 4. The copy is real, the list refuses mutation, and the cart still moves. */
    private static void theCopyThatIsNotDeep() {
        head("4  List.copyOf over mutable elements");
        MutableLineCart cart = new MutableLineCart();
        cart.add(MUG, 2);
        say("before   subtotal=" + cart.subtotalMinor() + " invariantHolds=" + cart.invariantHolds());

        List<MutableLineCart.MutableLine> snapshot = cart.lines();
        try {
            snapshot.add(new MutableLineCart.MutableLine(LAMP, 1));
        } catch (RuntimeException thrown) {
            say("adding to the snapshot   " + thrown.getClass().getName());
        }
        snapshot.get(0).setQuantity(999);

        say("after    subtotal=" + cart.subtotalMinor() + " invariantHolds=" + cart.invariantHolds());
        say("threw    nothing; the ceiling is " + MutableLineCart.MAX_PER_LINE + " and the line holds 999");
    }

    /**
     * Scenario 5. The half people forget.
     *
     * Both constructors ran the same loop over the same list and both were satisfied. Then the
     * caller added a coupon. The cart that copied is unaffected; the cart that kept the reference
     * has a 100 percent offer it never validated and would have refused.
     */
    private static void leakOnTheWayIn() {
        head("5  escape on the way in: the constructor's own check, walked past");
        List<Coupon> offers = new ArrayList<>();
        offers.add(new Coupon("SAVE10", 10));

        Cart cart = new Cart(offers);
        LeakyCart leaky = new LeakyCart(offers);
        cart.add(LAMP, 1);
        leaky.add(LAMP, 1);
        say("both constructed and validated; subtotal=" + cart.subtotalMinor());
        say("Cart total       " + cart.totalMinor());
        say("LeakyCart total  " + leaky.totalMinor());

        offers.add(new Coupon("STAFF100", 100));

        say("caller adds STAFF100 to the list it still holds");
        say("Cart total       " + cart.totalMinor() + "   offers=" + cart.standingOffers().size());
        say("LeakyCart total  " + leaky.totalMinor() + "   offers=" + leaky.standingOffers().size());
        try {
            new Cart(offers);
        } catch (RuntimeException thrown) {
            say("constructing a Cart from that list now: " + thrown.getClass().getSimpleName());
            say("  message  " + thrown.getMessage());
        }
    }

    /** Scenario 6. The view costs nothing and hands the caller a moving target. */
    private static void theViewAndItsPrice() {
        head("6  the unmodifiable view: no copy, and it changes under you");
        Cart cart = new Cart(List.of());
        cart.add(MUG, 1);
        cart.add(SOCKS, 1);

        Collection<LineItem> view = cart.liveLines();
        List<LineItem> snapshot = cart.lines();
        say("view size " + view.size() + "   snapshot size " + snapshot.size());

        cart.add(LAMP, 1);
        say("after cart.add(LAMP, 1)");
        say("view size " + view.size() + "   snapshot size " + snapshot.size());

        try {
            view.clear();
        } catch (RuntimeException thrown) {
            say("view.clear()             " + thrown.getClass().getName());
        }
        try {
            for (LineItem line : view) {
                if (line.sku().code().equals("MUG")) {
                    cart.add(PEN, 1);
                }
            }
        } catch (RuntimeException thrown) {
            say("adding while iterating   " + thrown.getClass().getName());
            say("  blamed  " + firstFrame(thrown));
        }
    }

    /** Scenario 7. The answer that skips the question. */
    private static void notExposingItAtAll() {
        head("7  not handing back a collection at all");
        Cart cart = new Cart(List.of());
        cart.add(MUG, 2);
        cart.add(SOCKS, 3);
        say("contains(MUG)   " + cart.contains("MUG"));
        say("lineCount()     " + cart.lineCount());
        say("itemCount()     " + cart.itemCount());
        StringBuilder rendered = new StringBuilder();
        cart.forEachLine(line -> rendered.append(line.sku().code()).append(' '));
        say("forEachLine     " + rendered.toString().trim());
        say("nothing was copied and nothing was handed over");
        say("corpus/parking-lot answers availableSpots(VehicleType) with an int for this reason");
    }

    // ---------------------------------------------------------------- plumbing

    private static int countLines(LeakyCart cart, String code) {
        int seen = 0;
        for (LineItem line : cart.lines()) {
            if (line.sku().code().equals(code)) {
                seen++;
            }
        }
        return seen;
    }

    private static String codes(List<LineItem> lines) {
        StringBuilder out = new StringBuilder();
        for (LineItem line : lines) {
            out.append(line.sku().code()).append(' ');
        }
        return out.toString().trim();
    }

    private static void report(RuntimeException thrown) {
        say("threw    " + thrown.getClass().getName());
        say("  message  " + thrown.getMessage());
        say("  blamed   " + firstFrame(thrown));
    }

    private static String firstFrame(Throwable thrown) {
        StackTraceElement[] frames = thrown.getStackTrace();
        return frames.length == 0 ? "(no frames)" : frames[0].toString();
    }

    private static void head(String title) {
        System.out.println();
        System.out.println("== " + title + " ==");
    }

    private static void say(String line) {
        System.out.println("  " + line);
    }

    private Main() {}
}

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.

← A6 · Domain exception hierarchy and error signalling — what is actually yours to design B1 · Policy interface — the Strategy seam, and when it is not worth its file →

← all lessons