LLD Dojo

Syllabus · C4

Composition over inheritance — wanting the code is not needing the type

The idea

Wanting the base's code is not the same as needing to be the base

"Prefer composition" loses at a whiteboard, because a preference cannot be applied. Here is a test with two questions instead.

Q1 · Substitution. Name a caller that takes the base type and would be handed your new class. If you cannot name one, you wanted the base's code, not its type. That is a field.

Q2 · Direction of control. Does the base own a sequence, call down into the subclass at points it alone picks, and hold a field the subclass shares? If not, the shared thing is a contract, so make it an interface. If so, it is an abstract class with a final skeleton.

Try it where the answer is already on disk. Nothing in corpus/lru-cache accepts an LruCache; every caller takes LruCacheApi. So LfuCache extends LruCache is a subtype nobody asks for, and corpus/lru-cache/reference/src/EvictionPolicy.java is the field instead.

One axis does not settle it. contrast/ measures the second eviction rule at 3 lines against 3, a tie. Then a second axis arrives, a write-through store, and a third rule after it. That rule costs the subclass design two new classes against composition's one, and the write-through code lands in three files against one. Two independent axes cost N times M subclasses and N plus M parts. Run node lessons/C4/contrast/measure.mjs.

Then worked/ breaks a subclass by editing only its base, and when-not.md defends the one abstract class the corpus keeps.


Worked walkthrough

What each line guarantees, and what breaks without it

Fourteen files, two designs of the same cache. MiniCache holds its eviction rule as a field. BulkCache publishes it as a protected hook. Both compile clean under -Xlint:all and both answer the same contract, CacheApi.

Run it:

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

Every number below is that program's output, unedited.


The test, before the code

You have heard "prefer composition over inheritance". At a whiteboard that sentence loses to an interviewer who says "surely LfuCache extends LruCache". A preference cannot be applied and a slogan cannot be defended. Here is the version you can run in your head in about ten seconds, and it has two questions rather than one.

Q1 · Substitution. Name a caller that takes the base type and would be handed your new class. If you cannot name one, you want the base's code, not its type. That is a field. Q2 · Direction of control. Does the base own a sequence, call down into the subclass at points it alone picks, and hold a field the subclass shares? If not, the thing being shared is a contract, so it is an interface and your class implements it. If so, it is an abstract class with a final skeleton.

Two questions, three outcomes: a field, an interface, or an abstract class. when-not.md walks the whole corpus through it, including the one problem where the answer really is the abstract class.

Apply it to the cache before reading further. Is there a caller anywhere that takes BulkCache? Look at CacheApi.java — every parameter type in this lesson is the interface. So an `LfuCache extends BulkCache` would be a subtype nothing asks for, and what it actually wanted was BulkCache's map, its capacity check and its bulk loop. Q1 says field, and the corpus agrees: corpus/lru-cache/reference/src/EvictionPolicy.java is that field.


MiniCache.java — the composed design

public final class MiniCache implements CacheApi {

    private final Map<String, Object> entries = new HashMap<>();
    private final EvictionPolicy policy;

final on the class is the design decision, not a formality. Every axis this cache varies along is a constructor parameter, so a subclass could only ever be somebody routing around a parameter that does not exist yet. Deleting final here does not add a feature; it adds a way to break one.

A plain HashMap, not a LinkedHashMap. BulkCache needs insertion order because order is its eviction rule. MiniCache does not, because the order lives in the policy. That is one fewer thing the map is quietly responsible for, and it is why swapping in LfuEvictionPolicy needs no change here at all.

        if (entries.size() == capacity) {
            String victim = policy.victim();
            entries.remove(victim);
            policy.onRemove(victim);
        }
        entries.put(key, value);
        policy.onInsert(key);

Evict before inserting, and only for a key that is not already present. Reverse the two and the cache is briefly at capacity + 1, and the contract in corpus/lru-cache/contract/LruCacheApi.java forbids exactly that: "no caller of this interface, on any thread, can ever observe size() > capacity()". On one thread the window is invisible. The real problem is that policy.victim() can then return the key you are inserting, so the write disappears and get answers null for something you stored a moment ago.

policy.onRemove(victim) on the same three lines as the map removal. The policy's bookkeeping and the map are two structures holding one fact. Miss this call and the policy keeps recommending a key that is no longer there, so the next eviction removes nothing and the map grows past capacity. This is composition's real price: the wiring is yours to get right, and nothing enforces it. Hold that thought for OrderStage in when-not.md, which is the design that does enforce it.

    @Override
    public Object get(String key) {
        Object value = entries.get(key);
        if (value == null) {
            return null;
        }
        policy.onAccess(key);
        return value;
    }

onAccess after the null check, never before. A miss must not refresh anything. Move the call above the check and a get for a key that was never in the cache teaches LruEvictionPolicy about that key. The policy then nominates it as a victim, so victim() returns a key the map has never held. entries.remove(thatKey) is a silent no-op, and the cache is over capacity from then on.


BulkCache.java — the same behaviour, arranged as a base class

This is not a straw man. It is shorter than the composed version, it has no interface and no wiring, and under a twelve-minute clock it is what a competent engineer writes. Everything wrong with it is invisible in the file.

    protected final Map<String, Object> entries = new LinkedHashMap<>();
    ...
    protected String victim() {
        return entries.keySet().iterator().next();
    }

protected is a second public API, and the class declaration does not say so. These two members are a promise to every subclass in every codebase, forever, including subclasses written by people who never open this file. The promise is specific: entries are a Map whose iteration order carries eviction meaning. MiniCache freely swaps its LinkedHashMap for a HashMap plus a separate order structure. Here that same swap is a breaking change to code you cannot see.

Block 5 counts what each design published:

  MiniCache   5 public, 0 protected, 5 published in total  []
  BulkCache   5 public, 2 protected, 7 published in total  [victim(), entries]

Seven against five. The two extra were never designed as API, never reviewed as API, and are not mentioned in any contract file. C5 is where minimal public surface is argued in full; the point here is narrower. Choosing inheritance as your extension mechanism publishes members, and it publishes them at the same moment and with the same permanence as public would.


Block 4 — protected defeating the one invariant the contract promises

HotKeyCache is ten lines and does what a real requirement asks: pin the region setting so it never gets evicted.

    public void pin(String key, Object value) {
        entries.put(key, value);
    }

No cast, no reflection, and javac -Xlint:all prints nothing for the file. Block 4:

  capacity                         : 3
  size                             : 4
  size() > capacity()              : true
  the pin bypassed victim(), so the eviction rule was never asked

The contract says no caller can ever observe size() > capacity(). A subclass is not a caller, and that is the whole loophole. MiniCache cannot be broken this way, and not because its author was careful — because entries is private and the class is final, so the code that would break it does not compile.


Block 2 — the fragile base class, built and run

Here is the failure that actually costs people days, and it is worth watching it happen rather than reading a warning about it.

CountingBulkCache extends BulkCache and overrides put to count writes. Nothing about it is careless. Its author read the base, saw that putAll loops calling put, and wrote the single override that covers both paths. Overriding putAll too would have double-counted every bulk write.

Then somebody commits an optimisation to the base. BulkCacheNext is that commit: putAll does the work in one pass instead of dispatching per entry. The diff touches one method. No signature changed, no behaviour of the base changed, and no test of the base failed.

CountingBulkCacheNext is CountingBulkCache with one word changed, the name after extends. Its author did not review the commit and was not told about it. Block 2:

  release 1, putAll loops over put : writes() reports 3 of 3
  release 2, putAll does it itself : writes() reports 0 of 3
  both caches hold the same data   : true
  no compile error, no warning, no exception, no failing base test

Read the last line as the finding. Nothing tells you. javac -Xlint:all compiles all fourteen files with no output at all — that is the compile step above, and it is silent. The cache still holds the right three entries. The only thing that changed is a number nobody was watching, and the code that produces it is byte-identical.

The reason fits in one sentence, and it is worth being able to say it at a whiteboard. A subclass depends on which of its base's methods call which other of its base's methods. That is not part of the base's contract. putAll calling put was never documented, never tested and never promised. It was true, and a subclass built on it.

Block 3 — the same change, absorbed

CountingCache implements CacheApi and holds one:

    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

put, not delegate.putAll. That is the load-bearing line in the file. The wrapper owns its own loop, so the count is a function of what this class was asked to do. How the thing behind it chose to do that does not enter into it. Block 3 runs the wrapper over both releases:

  wrapping release 1               : writes() reports 3 of 3
  wrapping release 2               : writes() reports 3 of 3

Same wrapper, same answer, twice. The delegate's internals are not reachable from here, so they cannot be depended on by accident.

And the price is in the same file, three methods of it:

    @Override public Object get(String key)  { return delegate.get(key); }
    @Override public int size()              { return delegate.size(); }
    @Override public int capacity()          { return delegate.capacity(); }

Three one-line forwards that a subclass would have inherited. That is the trade, stated plainly: forwarding boilerplate in exchange for a class that cannot be broken by a change it never saw. Widen CacheApi to twenty methods and the trade gets worse, which is C5's argument for keeping interfaces narrow.

The related trap, already measured elsewhere

There is a second way a base class reaches into a subclass, and it is worse than this one because it happens during construction. A base constructor calling an overridable method lands on the override while every subclass field is still null.

J4 measured it rather than describing it. lessons/J4/worked/src/SpotNumbering.java produces

new PrefixedNumbering(3, "S").spotIds() -> [null1, null2, null3]

and lessons/J4/worked/NOTES.md has the one compiler flag that sees it, javac -Xlint:this-escape, with the warning it prints and the line it points at. Read that file rather than re-deriving it here. The design conclusion is the same as this block's. J4 states it: "a base class that calls back into its subclass is a design worth avoiding rather than a hazard worth managing."


Blocks 1 and 6 — the argument that wins whiteboards

Block 1 is the composed seam doing its ordinary job. One MiniCache class, two eviction rules, and the caller picks:

  LRU  evicted the untouched key   : beta gone? true
  LFU  evicted the least-used key  : beta gone? true, alpha kept? true

LfuEvictionPolicy is one new file. That is not a claim about this lesson's code — it is what the corpus measured. corpus/lru-cache/curveballs/01-least-frequently-used/budget.json records reference_diff: 0, and its note says why the number is the interesting part:

This is the corpus's stated test of whether the eviction-policy seam is real: if it were not, this number would not be zero.

Now the part that decides a whiteboard argument. One axis of variation is not enough to settle inheritance against composition, because a single new subclass is also one new file. contrast/ measures that honestly and it comes out close. The pressure arrives with the second axis.

corpus/lru-cache has three curveballs, and they are three axes that vary for unrelated reasons: least-frequently-used eviction, per-entry expiry, and a write-through backing store. Nothing about choosing an eviction rule tells you whether writes are mirrored. Block 6 builds every combination of two of those axes from the parts already on disk:

  LRU, no store        size 3, mirrored 0
  LFU, no store        size 3, mirrored 0
  LRU, write-through   size 3, mirrored 3
  LFU, write-through   size 3, mirrored 3
  2 eviction rules and 2 store modes: 4 combinations from 3 parts
  a third eviction rule makes it 6 combinations from 4 parts

Four behaviours, zero new classes. The four wirings are four constructor calls in Main.

Under one subclass per combination the count is the product rather than the sum. Two by two is four classes; add a third eviction rule and it is six; add the expiry axis and it is twelve. Composition pays the sum: three parts, then four, then six. The sentence to have ready is short.

Two independent axes cost N times M subclasses and N plus M composed parts, and the interviewer will ask about the second axis.

The projection past four is arithmetic, and the four is measured — it is block 6's output above, and contrast/measure.mjs counts the classes in both trees for the same two axes.

What this lesson does not claim

That inheritance is always wrong. Across the 20 corpus problems there are 87 interfaces and 6 abstract classes, counted directly:

grep -rn "^\s*\(public \)\?\(sealed \)\?interface " corpus/*/reference/src/*.java corpus/*/contract/*.java | wc -l
grep -rn "abstract class" corpus/*/reference/src/*.java corpus/*/contract/*.java

Five of the six abstract classes are exception bases. The sixth is corpus/food-ordering/reference/src/OrderStage.java, and it is legitimate. Inheritance earns its place about once in twenty problems, which is rare enough to need a reason and common enough that "never inherit" is refuted by the corpus's own code. when-not.md is that argument, and it is the half of this lesson an interviewer is more likely to probe.


When not to

When inheritance is the right answer

A lesson that cannot name where the disfavoured option wins is a slogan with citations. This file is the other half of the test, and it is the half an interviewer is more likely to probe, because anybody can recite the first half.

Start with how often it comes up. Across the 20 corpus problems:

grep -rn "^\s*\(public \)\?\(sealed \)\?interface " corpus/*/reference/src/*.java corpus/*/contract/*.java | wc -l
87

grep -rn "abstract class" corpus/*/reference/src/*.java corpus/*/contract/*.java
corpus/food-ordering/reference/src/OrderStage.java:58
corpus/file-system/contract/FileSystemException.java:13
corpus/library/contract/LibraryException.java:36
corpus/notification-service/contract/DeliveryException.java:9
corpus/trip-state-machine/contract/TripException.java:14
corpus/vending-machine/contract/VendingMachineException.java:33

87 interfaces, 6 abstract classes, and five of the six are exception bases. One domain abstract class in twenty problems. That is rare enough to need a reason and common enough that "never inherit" is refuted by the corpus's own code.

The test has three outcomes, not two

From idea.md, with the branch the language forces added:

Q1 · Substitution. Name a caller that takes the base type and would be handed your new class. No such caller means you wanted the code, not the type. That is a field. Q2 · Direction of control. Does the base own a sequence, call down into the subclass at points it alone picks, and hold a field the subclass shares? If not, the shared thing is a contract, so make it an interface. If so, it is an abstract class with a final skeleton. The carve-out. A family of exceptions is inheritance whatever the two answers say, because catch is the one dispatch in Java that cannot be given an interface.

Two questions, four outcomes: a field, an interface, an abstract class, or a Throwable. The rest of this file runs the corpus through it.


OrderStage — both questions pass, so it is legitimately an abstract class

corpus/food-ordering/reference/src/OrderStage.java. Read it before reading this section; it argues its own case in its javadoc, and the argument is the one to be able to give out loud.

Q1 passes, and the evidence is a signature. corpus/food-ordering/reference/src/Lifecycle.java holds List<OrderStage> chain and a Map<OrderState, OrderStage> byName, and its first(), after(OrderState) and named(OrderState) all return OrderStage. So CancelledStage is handed to callers on account of being an OrderStage, at three call sites, none of which knows any concrete stage exists. That is substitution being used rather than assumed.

Q2 passes, and the evidence is one keyword. enter is final:

    public final StageEvent enter(Order order, Clock clock, Notifier notifier) {
        Instant at = clock.instant();       // 3 · the timestamp, read once and up front
        check(order, at);                   // 1 · validate the transition
        order.moveTo(state);                // 2 · apply it
        onEntered(order, at);               // 4 · this stage's own work
        StageEvent event = new StageEvent(state, at, tell(order, notifier));   // 5 · notify
        order.record(event);
        return event;
    }

The base owns the sequence and calls down into check and onEntered at points it alone picks. It also holds two final fields every stage shares, state and audiences. Both halves of Q2, in one method and two fields.

What that buys is an invariant no composed arrangement can offer. The file says it plainly:

"Every stage is timestamped" [...] and "a stage is recorded before anybody is told" are true of stages that do not exist yet. True without one stage subclass containing a line about clocks, notification order or history.

A subclass cannot arrange to be timestamped before it is applied, cannot notify twice, and cannot notify before its own work has run. Not because it is well behaved, but because it does not call any of those things. final on enter is what makes the property structural.

The payoff is measurable in the file count. Three of the six stages override nothing at all: ACCEPTED, PREPARING and OUT_FOR_DELIVERY are three constructor calls to PlainStage, whose javadoc makes the argument the right way round:

Half the lifecycle costs one line each. Without the skeleton, each of those three would be a class or a branch that had to remember to read the clock, record an event and notify [...] in the right order.

The one stage with real behaviour, CancelledStage, overrides both hooks and is the entire cancellation requirement: a Set of two states in check, and order.charge(0) in onEntered.

And the file distinguishes itself from a policy, which is the distinction being tested. Both seams exist in that design on purpose so they can be compared:

A policy is chosen by its caller, answers one question, and has no idea when it will be asked or what else is going on. The caller keeps control of the sequence. Swapping one changes an answer. A template method owns the sequence and calls down into its subclasses at points it alone decides. Overriding a hook changes a step.

That sentence is Q2 in the corpus's own words. DeliveryFeePolicy and Tariff are fields in the same problem, and OrderStage is not, and the difference is which of them is allowed to decide when it runs.

The bad example, and it is the one this lesson risks causing

Composition applied where the sequence had to be unforgeable. worked/ and faded/ already contain the proof, so it does not have to be imagined.

MiniCache composes its eviction rule, which means the three calls into the policy are wiring, and nothing enforces them. faded/'s third gap is that wiring. Fill it with an answer a careful person writes:

        if (entries.size() == capacity) {
            entries.remove(policy.victim());
        }

It compiles under javac -Xlint:all with no warnings. It evicts. It looks finished. What it omits is policy.onRemove(victim), so the policy keeps nominating a key the map no longer holds, the next removal is a silent no-op, and the cache runs over capacity:

GapTest.evictionKeepsTheTwoStructuresInStep
  expected: <2> but was: <3>

Capacity 2, size 3. The invariant corpus/lru-cache/contract/LruCacheApi.java calls unobservable, and one missing line got there. Four of the five gaps in faded/ are this same category — a collaborator that has to be called at the right moment, with nothing checking that it was. That is composition's standing cost, and OrderStage is the shape that pays it once in the base instead of once per implementation.

So the honest version of this lesson's claim is narrower than the slogan. Composition moves a guarantee from the compiler to the wiring. Where the guarantee is "which rule is in force", that is a good trade. Where the guarantee is "these five steps always happen in this order", it is not, and OrderStage is what the corpus reached for instead.

The five exception bases — where the language decides, not the design

corpus/library/contract/LibraryException.java is the fullest of the five, so take that one.

Q1 passes, and the javadoc is explicit about why the base exists:

Six things can go wrong at the desk and the caller cares which. A self-service kiosk says "we don't have that book" for one and "you're at your limit" for another; the finance system watches only OutstandingFinesException. A single exception type with six different messages would force every caller to read prose [...] so each refusal is its own type, and they share a root so a caller that does not care can catch one thing.

"A caller that does not care can catch one thing" is Q1's answer written out. `catch (LibraryException e)` is the caller that takes the base type and is handed all six subclasses.

Q2 fails. Nothing calls down. There is no final skeleton, no hook, no sequence. By the test as stated so far, the shared thing is a contract, so it should be an interface.

It cannot be, and the compiler says so rather than a style guide. Give a family of exceptions a marker interface and try to catch it:

interface CacheFault { String key(); }

final class MissingKey extends RuntimeException implements CacheFault { … }

        try {
            throw new MissingKey("alpha");
        } catch (CacheFault fault) {
Probe.java:13: error: incompatible types: CacheFault cannot be converted to Throwable
        } catch (CacheFault fault) {
                 ^
1 error

A catch parameter has to be a Throwable subtype, and Throwable is a class. So the type a handler dispatches on has to be reached by extends, and there is no arrangement of interfaces that gets you there. That is why five of the corpus's six abstract classes are exception bases: not a design preference, an inheritance the language requires.

Two more properties make these bases honest rather than merely legal. The base holds real shared state, the message and the cause, and it hands it up through the one defensible use of protected:

    protected LibraryException(String message) {
        super(message);
    }

protected on a constructor publishes nothing to a caller. It says the base is never instantiated directly. The file then says it in words:

You never construct this class — it is abstract on purpose, because "something went wrong at the library" is not a thing that happens. One of the six specific things does. And the subclass set is closed and given, all seven files in contract/, so

there is no open hierarchy for a stranger to extend badly.

Sealed hierarchies are inheritance over values, so the test does not apply

Worth naming, because it is the easiest way to misread the two questions. corpus/trip-state-machine records the decision in its own problem.json:

No inheritance for reuse anywhere in the design, deliberately — the closed sets are sealed hierarchies for exhaustiveness, not for shared behaviour.

A sealed hierarchy exists so a switch can be checked for completeness. It splits over values, and Q2 asks about shared code, so applying the test to Node, FileNode and DirectoryNode gives an answer to a question nobody asked. C1's when-not.md makes the same point from the decomposition side, and J11 is where sealed types are the subject.

Where the test says compose, checked against 93 real decisions

The 87 interfaces are one answer repeated. Q1 passes for each of them, because callers do take the interface type. Q2 fails, because an interface holds no field and calls down into nothing. So the test says interface, 87 times, and the corpus wrote interface 87 times.

The policies are the other pattern, and there Q1 fails outright. Nothing in corpus/lru-cache accepts an LruCache, nothing in corpus/elevator accepts a Lift, nothing in corpus/parking-lot accepts a ParkingLot. Every caller takes the contract interface. So a variant that "extends" one of those is a subtype no call site asks for, and what it wanted was the class's code. EvictionPolicy, DispatchPolicy, StopOrder, FloorAccess, DeliveryFeePolicy and Tariff are all that answer.

corpus/movie-booking/problem.json records the same reasoning as a rejection, against this syllabus item:

No Show subclasses for a VIP-only show or a members-only show [...] that variation is pricing and eligibility, both already behind PricingPolicy. Subclassing Show would duplicate a decision the seam already makes.

One case where Q1 passes and the answer is still a field. corpus/trip-state-machine's no-show timeout added TripGuard as a collaborator. Q1 does pass here: TripFactory returns Trip, so a NoShowTrip extends Trip would be accepted wherever a Trip is. Q2 is what refuses it. Trip has no hook and no protected member, so there is nothing to override. You would have to add a hook first, and that is a decision to be argued rather than a subclass to be written. curveballs/03-no-show-timeout/reference-patch/PATCH.md argues it and rejects two cheaper-looking placements:

A fourth component on Edge would have put the rule where the rules are, and it costs more, not less. All seven existing rows grow a no-op guard [...] An if in Trip.handle measures fewer lines and is the wrong answer [...] Trip holds the sequence and not one rule a requirement could reach.

Be precise about what that document does and does not say. Neither rejected alternative is literally a subclass, and the patch never considers subclassing Trip at all. That absence is the evidence. Its author wrote a page defending where one new rule should live, and extending the class that owns the sequence was not on the list of options. Measured cost of the answer they chose: reference_diff: 28, 19 of it in Trip, from budget.json.

The threshold, from both sides

Reach for an abstract class when all three hold together.

OrderStage satisfies all three. One design in twenty problems did.

Accept inheritance without argument for a family of exceptions, because catch leaves no choice, and for a sealed hierarchy, because that is variance over values.

Compose otherwise, and know what it costs. contrast/'s fourth requirement is composition losing. A rule that needed a fact about the value cost 22 lines across 5 files against 3 lines in 1 file, because a collaborator only knows what it is handed. The design that absorbed it cheaply was the one with a protected field.

That last sentence is the one to keep. protected entries is why requirement 4 was cheap and why block 4 of worked/src/Main.java reports size 4, capacity 3. Those are the same fact, and choosing inheritance means accepting both halves of it.


The contrast pair

Four requirements, in an interviewer's words

Two designs of one cache, both passing BaseTest 5 of 5, both compiling clean under javac -Xlint:all with no warnings.

Every number below comes from node lessons/C4/contrast/measure.mjs. Four instruments, because the first one cannot see what this lesson claims.


Requirement 1 · "Some caches should evict the least used key, not the least recently used"

corpus/lru-cache/curveballs/01-least-frequently-used, whose budget.json records reference_diff: 0. Checked by LfuTest, and both designs pass it.

a second eviction rule    a  -> a-lfu    diffLines   3  touched 1  new 1
a second eviction rule    b  -> b-lfu    diffLines   3  touched 1  new 1

An exact tie, and it is the most useful number in this file. One new class either way: LfuCache extends BaseCache in a, LfuEvictionPolicy implements EvictionPolicy in b. Three lines in Wiring.java either way, all of them additions.

If you have been taught to reach for composition, this is the measurement that should slow you down. A single axis of variation does not separate these designs. Somebody who argues at a whiteboard that inheritance costs more here is wrong, and an interviewer who has written both will know it.

Requirement 2 · "Every write also goes to a store, on both of those rules"

corpus/lru-cache/curveballs/03-write-through-backing-store. Checked by StoreTest. Read the "on both" part as the whole requirement. Mirroring writes has nothing to do with which key gets evicted, so this is a second axis rather than a third variant.

write-through, on both rules   a-lfu -> a-store   diffLines   6  touched 1  new 3
write-through, on both rules   b-lfu -> b-store   diffLines   6  touched 1  new 2

Tied again on the graded instrument. Six lines in Wiring.java on both sides, and no pre-existing file opened beyond it.

The difference is in what the new files are. b-store adds BackingStore and one WriteThroughCache that wraps any CacheApi. a-store adds BackingStore and two subclasses, WriteThroughLruCache extends LruCache and WriteThroughLfuCache extends LfuCache, whose bodies are the same eleven lines twice. extends names one class at the point the file is written, so one write-through subclass cannot cover two eviction rules.

Instrument 3 counts that:

a-store     2  [WriteThroughLfuCache.java, WriteThroughLruCache.java]
b-store     1  [WriteThroughCache.java]

Two copies is not yet expensive. It is the trajectory that matters, and requirement 3 is where the trajectory shows up.

Requirement 3 · "One more rule: evict in arrival order, and it needs the store too"

Same shape as requirement 1, asked after the second axis exists. Checked by FifoTest, and both designs pass.

a THIRD eviction rule   a-store -> a-fifo   diffLines   6  touched 1  new 2
a THIRD eviction rule   b-store -> b-fifo   diffLines   6  touched 1  new 1

Tied on lines for the third time. Now instrument 2, which is the one that separates them:

1st extra eviction rule, no 2nd axis yet    a +1 class(es)   b +1 class(es)
2nd extra eviction rule, 2nd axis present   a +2 class(es)   b +1 class(es)

One new rule cost a two classes and b one. a-fifo needs FifoCache and WriteThroughFifoCache, because a rule without the store and the same rule with it are two positions in a class hierarchy. b-fifo needs FifoEvictionPolicy, and the existing WriteThroughCache wraps it with no change at all.

Instrument 3 again:

a-fifo      3  [WriteThroughFifoCache.java, WriteThroughLfuCache.java, WriteThroughLruCache.java]
b-fifo      1  [WriteThroughCache.java]

One requirement, implemented in three files against one. That is the whole combinatorial argument in a number you can quote. Two independent axes cost N times M subclasses and N plus M composed parts. With three rules and two store modes it is six leaf classes against four parts, and the six behaviours are six wirings in b.

Instrument 4 is the honest counterweight, and it says the standing cost is close:

size of a        4 file(s)   98 normalised lines
size of b        5 file(s)  101 normalised lines
size of a-fifo  10 file(s)  216 normalised lines
size of b-fifo   9 file(s)  205 normalised lines

At the start b is one file and three lines bigger. After three rules and two axes it is one file and eleven lines smaller. The composed design does not start ahead. It stops growing.

Requirement 4 · "Evict the biggest entry" — and this one goes the other way

The eviction rule now needs a fact about the value, not only the key. Checked by SizedTest, and both designs pass.

eviction needs the value   a-lfu -> a-sized   diffLines   3  touched 1  new 1
eviction needs the value   b-lfu -> b-sized   diffLines  22  touched 5  new 1
      [EvictionPolicy.java +2/-2, LfuEvictionPolicy.java +2/-2, LruEvictionPolicy.java +2/-2,
       MiniCache.java +4/-3, Wiring.java +3/-0]

Composition costs 22 lines across 5 files against 3 lines in 1 file. That is the largest gap in this lesson, and it is against the design the lesson recommends.

The reason is exactly the property that made composition win requirements 2 and 3. A collaborator knows only what it is handed. EvictionPolicy.victim() was handed keys, so a rule about values has nowhere to read one, and the fix is to widen the interface: onInsert(String, int) and onAccess(String, int). Both existing implementations change signature, MiniCache computes and passes a weight at three call sites, and none of those four files had any other reason to be opened.

a-sized pays 3 lines because LargestFirstCache extends BaseCache reads protected entries directly. The live entry map, values and all, was already in scope. The thing that makes inheritance dangerous is the same thing that makes it cheap here, which is why worked/ builds the protected failure rather than describing it.

There is a cheaper-looking composed answer, and it is worth rejecting out loud. Hand the policy the map: String victim(Map<String, Object> entries). That measures smaller and it re-creates protected on purpose, giving every policy implementation a live reference to the cache's internals. corpus/elevator/reference/src/StopOrder.java faced the identical choice and refused it in its contract. It passes "a floor, a direction and a read-only set rather than a lift". Its javadoc gives the reason: a collaborator holding the live object is how the lock cycle gets built.

Which instrument decided this, stated plainly

measureChange is the function that scores D4 in a graded attempt, and on the three on-axis changes it reported 3 against 3, 6 against 6, and 6 against 6. Three ties. A lesson argued on that instrument alone would have to conclude that inheritance and composition are interchangeable here.

The instrument that separates them is classes added per new value on an axis: 2 against 1, once a second axis exists. The supporting one is duplication: 3 files carrying one rule against 1.

The wrong-way requirement was measured on measureChange and lost there, 22 against 3. It is reported as it came out. Nothing was swapped for a requirement that would have flattered the composed design.


Worked source

The 14 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/BackingStore.java15 lines

/**
 * Somewhere a write also has to go. This is
 * {@code corpus/lru-cache/curveballs/03-write-through-backing-store} reduced to the one method the
 * requirement needs.
 *
 * <p>It exists in this lesson to be the <b>second axis</b>. An eviction rule and a write-through
 * store vary for entirely unrelated reasons: nothing about choosing least-frequently-used tells you
 * whether writes are mirrored, and nothing about mirroring writes tells you what gets evicted.
 * Block 6 of {@link Main} builds every combination of the two and counts how many classes it took.
 */
public interface BackingStore {

    /** Mirror one write. Called after the cache has accepted it. */
    void write(String key, Object value);
}

worked/src/BulkCache.java89 lines

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * <b>The base class, release one.</b> Same behaviour as {@link MiniCache}, arranged the other way:
 * the eviction rule is a {@code protected} hook a subclass overrides, and the entry map is
 * {@code protected} so an override can read it.
 *
 * <p>This is not a straw man. It is shorter than the composed version, it has no policy interface
 * and no wiring, and under a twelve-minute clock it is the design a competent engineer reaches for.
 * Everything wrong with it is invisible in this file.
 *
 * <p>Two things are published here that were never meant as public API, and the class declaration
 * does not say so. See {@link #entries} and {@link #victim()}, then block 4 and block 5 of
 * {@link Main}.
 */
public class BulkCache implements CacheApi {

    /**
     * <b>The second public API.</b> {@code protected} means every subclass, in every codebase,
     * forever — including subclasses written by people who never read this file. It is a promise
     * that entries are a {@code Map} with insertion order carrying eviction meaning, and any
     * change to that representation is a breaking change to code you cannot see.
     */
    protected final Map<String, Object> entries = new LinkedHashMap<>();

    private final int capacity;

    public BulkCache(int capacity) {
        if (capacity < 1) {
            throw new IllegalArgumentException("capacity must be at least 1, got " + capacity);
        }
        this.capacity = capacity;
    }

    @Override
    public void put(String key, Object value) {
        if (entries.containsKey(key)) {
            entries.remove(key);
            entries.put(key, value);
            return;
        }
        if (entries.size() == capacity) {
            entries.remove(victim());
        }
        entries.put(key, value);
    }

    /**
     * Release one implements the bulk write as a loop over {@link #put}. That is the obvious
     * implementation and nothing documents it, because at the time it was written there was
     * nothing to document — no subclass existed.
     */
    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public Object get(String key) {
        Object value = entries.remove(key);
        if (value == null) {
            return null;
        }
        entries.put(key, value);
        return value;
    }

    @Override
    public int size() {
        return entries.size();
    }

    @Override
    public int capacity() {
        return capacity;
    }

    /**
     * The extension point: which key leaves when the cache is full. Least recently used by default,
     * because {@link #entries} is a {@link LinkedHashMap} that {@link #get} and {@link #put} keep in
     * access order.
     */
    protected String victim() {
        return entries.keySet().iterator().next();
    }
}

worked/src/BulkCacheNext.java81 lines

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * <b>The base class, release two.</b> {@link BulkCache} after one commit: {@link #putAll} does the
 * work itself instead of calling {@link #put} once per entry. Nothing else in the file differs.
 *
 * <p>A second class name is a limitation of keeping both releases in one directory. Read it as the
 * same file on a later day. Diff it against {@link BulkCache} and the change is confined to
 * {@code putAll}.
 *
 * <p>The commit message would have been honest. The new loop does the same three things the old one
 * did — evict if full, drop the stale mapping, insert — in one pass, without going through a
 * virtual call per entry. No behaviour of {@code BulkCache} changed. No signature changed. No test
 * of {@code BulkCache} failed.
 *
 * <p>{@link CountingBulkCacheNext} is what it broke, and that file is byte-identical to
 * {@link CountingBulkCache} apart from the word after {@code extends}.
 */
public class BulkCacheNext implements CacheApi {

    protected final Map<String, Object> entries = new LinkedHashMap<>();

    private final int capacity;

    public BulkCacheNext(int capacity) {
        if (capacity < 1) {
            throw new IllegalArgumentException("capacity must be at least 1, got " + capacity);
        }
        this.capacity = capacity;
    }

    @Override
    public void put(String key, Object value) {
        if (entries.containsKey(key)) {
            entries.remove(key);
            entries.put(key, value);
            return;
        }
        if (entries.size() == capacity) {
            entries.remove(victim());
        }
        entries.put(key, value);
    }

    /** One pass, no per-entry dispatch. The optimisation, and the whole of the change. */
    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            if (!entries.containsKey(entry.getKey()) && entries.size() == capacity) {
                entries.remove(victim());
            }
            entries.remove(entry.getKey());
            entries.put(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public Object get(String key) {
        Object value = entries.remove(key);
        if (value == null) {
            return null;
        }
        entries.put(key, value);
        return value;
    }

    @Override
    public int size() {
        return entries.size();
    }

    @Override
    public int capacity() {
        return capacity;
    }

    protected String victim() {
        return entries.keySet().iterator().next();
    }
}

worked/src/CacheApi.java36 lines

import java.util.Map;

/**
 * The contract, trimmed from {@code corpus/lru-cache/contract/LruCacheApi.java} down to the four
 * operations this lesson needs plus a bulk write.
 *
 * <p>Read the type of the parameter every caller in this lesson takes. It is this interface, never
 * a class. That is the fact the whole lesson turns on: if no caller anywhere names a concrete cache
 * class, then a new cache variant gains nothing from being a subtype of an existing one, because
 * there is no call site that would accept it on account of its superclass.
 *
 * <p>The invariant to keep in view: {@code size()} is never greater than {@code capacity()}. The
 * real contract states it as a promise to every caller on every thread — <i>"no caller of this
 * interface, on any thread, can ever observe {@code size() > capacity()}"</i>. Block 4 of
 * {@link Main} shows a subclass observing exactly that, with no cast and no reflection.
 */
public interface CacheApi {

    /** Stores {@code value} under {@code key}, evicting one entry first if the cache is full. */
    void put(String key, Object value);

    /**
     * Stores every entry. Present on the contract rather than left to callers because whether a
     * bulk write is one call or many is the kind of internal decision blocks 2 and 3 are about.
     */
    void putAll(Map<String, Object> incoming);

    /** The value stored under {@code key}, or {@code null} if there is none. */
    Object get(String key);

    /** How many entries are stored right now. Never greater than {@link #capacity()}. */
    int size();

    /** Fixed for the life of the instance. */
    int capacity();
}

worked/src/CountingBulkCache.java31 lines

/**
 * Counts writes by extending the base and overriding {@code put}. Written against
 * {@link BulkCache}, release one, where it is correct.
 *
 * <p>Nothing here is careless. The author looked at {@code BulkCache}, saw that
 * {@code putAll} calls {@code put}, and wrote the one override that covers both. Overriding
 * {@code putAll} as well would have double-counted every bulk write in release one.
 *
 * <p>So the count is correct only while the base keeps routing bulk writes through {@code put}.
 * That is a fact about the base's <b>implementation</b>, which nothing in the base promises and
 * nothing in the compiler checks. {@link CountingBulkCacheNext} is this file with one word changed.
 */
public final class CountingBulkCache extends BulkCache {

    private int writes;

    public CountingBulkCache(int capacity) {
        super(capacity);
    }

    @Override
    public void put(String key, Object value) {
        writes++;
        super.put(key, value);
    }

    /** How many single-key writes have reached this cache. */
    public int writes() {
        return writes;
    }
}

worked/src/CountingBulkCacheNext.java26 lines

/**
 * {@link CountingBulkCache} against release two of the base. The body below is byte-identical to
 * that file's body, and the difference is the word after {@code extends}.
 *
 * <p>Its author did not change it, review it, or hear about the commit. Block 2 of {@link Main}
 * prints what it now reports.
 */
public final class CountingBulkCacheNext extends BulkCacheNext {

    private int writes;

    public CountingBulkCacheNext(int capacity) {
        super(capacity);
    }

    @Override
    public void put(String key, Object value) {
        writes++;
        super.put(key, value);
    }

    /** How many single-key writes have reached this cache. */
    public int writes() {
        return writes;
    }
}

worked/src/CountingCache.java58 lines

import java.util.Map;
import java.util.Objects;

/**
 * Counts writes, by wrapping any {@link CacheApi} rather than extending one.
 *
 * <p>The load-bearing line is in {@link #putAll}: it loops calling <b>its own</b> {@code put}, and
 * never calls {@code delegate.putAll}. So the count is a function of what this class was asked to
 * do, not of how the thing behind it chose to do it. Blocks 2 and 3 of {@link Main} run this and the
 * subclass version against the same two versions of the same cache and print both answers.
 *
 * <p>The price is here in the file and worth seeing: five methods, and three of them are one-line
 * forwards that exist only because the interface has five members. A subclass would have inherited
 * those three. That is composition's real cost, and it is the trade the lesson asks you to make
 * deliberately rather than by habit.
 */
public final class CountingCache implements CacheApi {

    private final CacheApi delegate;
    private int writes;

    public CountingCache(CacheApi delegate) {
        this.delegate = Objects.requireNonNull(delegate, "delegate");
    }

    @Override
    public void put(String key, Object value) {
        writes++;
        delegate.put(key, value);
    }

    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public Object get(String key) {
        return delegate.get(key);
    }

    @Override
    public int size() {
        return delegate.size();
    }

    @Override
    public int capacity() {
        return delegate.capacity();
    }

    /** How many single-key writes have gone through this wrapper. */
    public int writes() {
        return writes;
    }
}

worked/src/EvictionPolicy.java32 lines

/**
 * Which key leaves when the cache is full. Copied in shape from
 * {@code corpus/lru-cache/reference/src/EvictionPolicy.java}, which is the corpus's own answer to
 * this lesson's question.
 *
 * <p>Four methods, and none of them is a cache operation. The policy is told what happened and
 * asked one question. It holds no values, no capacity and no reference to the cache, so there is
 * nothing here a policy could break even by trying.
 *
 * <p>Compare that with the {@code protected String victim()} hook on {@link BulkCache}. Same
 * question, same answer type. The difference is that an override of {@code victim()} runs with
 * {@code protected} access to the cache's live entry map, and an implementation of this interface
 * runs with access to what it was told.
 */
public interface EvictionPolicy {

    /** A brand-new key was just stored. Never called for an overwrite of an existing key. */
    void onInsert(String key);

    /** This key's standing was just refreshed by a read or a same-key overwrite. */
    void onAccess(String key);

    /** This key has left the cache — evicted, or removed for any other reason. */
    void onRemove(String key);

    /**
     * Which key should be evicted right now, among the ones this policy currently knows about.
     * Asked only when the cache is at capacity and a brand-new key needs room, so it is never
     * asked while tracking nothing.
     */
    String victim();
}

worked/src/HotKeyCache.java25 lines

/**
 * A configuration cache that pins one key so it is never evicted. Ten lines, and it does what it
 * says.
 *
 * <p>It is written this way because {@link BulkCache#entries} is {@code protected}, so the shortest
 * route to "never evict this" is to put the entry in the map without telling the eviction rule.
 * There is no cast here, no reflection and no warning: {@code javac -Xlint:all} prints nothing for
 * this file.
 *
 * <p>Block 4 of {@link Main} then reads {@code size()} and {@code capacity()} off the result. The
 * contract in {@code corpus/lru-cache/contract/LruCacheApi.java} says no caller can ever observe
 * {@code size() > capacity()}. A subclass is not a caller, and that is the loophole
 * {@code protected} opens.
 */
public final class HotKeyCache extends BulkCache {

    public HotKeyCache(int capacity) {
        super(capacity);
    }

    /** Pin an entry the eviction rule will never be asked about. */
    public void pin(String key, Object value) {
        entries.put(key, value);
    }
}

worked/src/LfuEvictionPolicy.java46 lines

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Evict whichever tracked key has been touched fewest times, oldest insertion first on a tie.
 * This is the whole of {@code corpus/lru-cache/curveballs/01-least-frequently-used}, whose
 * {@code budget.json} records {@code reference_diff: 0} — one new file, and not a line changed in
 * any file that already existed.
 *
 * <p>The tie-break is why the counts live in a {@link LinkedHashMap} rather than a {@code HashMap}.
 * Iteration order is insertion order, so the first key found at the lowest count is the one that
 * has been in the cache longest. A {@code HashMap} would answer the same question differently on
 * two runs, and the eviction a test asserts would depend on hash order.
 */
public final class LfuEvictionPolicy implements EvictionPolicy {

    private final Map<String, Integer> uses = new LinkedHashMap<>();

    @Override
    public void onInsert(String key) {
        uses.put(key, 1);
    }

    @Override
    public void onAccess(String key) {
        uses.merge(key, 1, Integer::sum);
    }

    @Override
    public void onRemove(String key) {
        uses.remove(key);
    }

    @Override
    public String victim() {
        String coldest = null;
        int fewest = Integer.MAX_VALUE;
        for (Map.Entry<String, Integer> use : uses.entrySet()) {
            if (use.getValue() < fewest) {
                fewest = use.getValue();
                coldest = use.getKey();
            }
        }
        return coldest;
    }
}

worked/src/LruEvictionPolicy.java36 lines

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Evict whichever tracked key was touched longest ago. Taken from
 * {@code corpus/lru-cache/reference/src/LruEvictionPolicy.java}.
 *
 * <p>The {@link LinkedHashMap} is standing in for an ordered set: {@link #onAccess} removes and
 * re-inserts so the key moves to the end, and {@link #victim()} takes the first key left in
 * iteration order. The boolean value is never read.
 */
public final class LruEvictionPolicy implements EvictionPolicy {

    private final Map<String, Boolean> order = new LinkedHashMap<>();

    @Override
    public void onInsert(String key) {
        order.put(key, Boolean.TRUE);
    }

    @Override
    public void onAccess(String key) {
        order.remove(key);
        order.put(key, Boolean.TRUE);
    }

    @Override
    public void onRemove(String key) {
        order.remove(key);
    }

    @Override
    public String victim() {
        return order.keySet().iterator().next();
    }
}

worked/src/MiniCache.java74 lines

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * The composed cache. One class, one eviction rule held as a field, no subclasses and no
 * {@code protected} member.
 *
 * <p><b>final on purpose.</b> There is nothing to extend here, and saying so is the design
 * decision rather than a formality: every axis this cache is expected to vary along is a
 * constructor parameter, so a subclass could only be somebody working around a missing parameter.
 *
 * <p><b>The whole public surface is {@link CacheApi} plus a constructor.</b> Count the members a
 * future change is not allowed to break: five methods, all of them already on the contract. Block 5
 * of {@link Main} prints that count next to {@link BulkCache}'s, and the gap is the point.
 */
public final class MiniCache implements CacheApi {

    private final Map<String, Object> entries = new HashMap<>();
    private final EvictionPolicy policy;
    private final int capacity;

    public MiniCache(int capacity, EvictionPolicy policy) {
        if (capacity < 1) {
            throw new IllegalArgumentException("capacity must be at least 1, got " + capacity);
        }
        this.capacity = capacity;
        this.policy = Objects.requireNonNull(policy, "policy");
    }

    @Override
    public void put(String key, Object value) {
        Objects.requireNonNull(key, "key");
        if (entries.containsKey(key)) {
            entries.put(key, value);
            policy.onAccess(key);
            return;
        }
        if (entries.size() == capacity) {
            String victim = policy.victim();
            entries.remove(victim);
            policy.onRemove(victim);
        }
        entries.put(key, value);
        policy.onInsert(key);
    }

    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public Object get(String key) {
        Object value = entries.get(key);
        if (value == null) {
            return null;
        }
        policy.onAccess(key);
        return value;
    }

    @Override
    public int size() {
        return entries.size();
    }

    @Override
    public int capacity() {
        return capacity;
    }
}

worked/src/WriteThroughCache.java52 lines

import java.util.Map;
import java.util.Objects;

/**
 * Mirrors every write to a {@link BackingStore}, wrapping any {@link CacheApi}.
 *
 * <p>Read the constructor parameter type. It takes {@link CacheApi}, so it wraps a
 * least-recently-used cache, a least-frequently-used one, a {@link CountingCache} that is itself
 * wrapping one of those, or something written next year. That is the second axis composing with the
 * first, and it needed no knowledge of what eviction rules exist.
 *
 * <p>The subclass version of this cannot make that claim, because {@code extends} names one class at
 * the point the file is written. {@code contrast/} measures what that costs.
 */
public final class WriteThroughCache implements CacheApi {

    private final CacheApi delegate;
    private final BackingStore store;

    public WriteThroughCache(CacheApi delegate, BackingStore store) {
        this.delegate = Objects.requireNonNull(delegate, "delegate");
        this.store = Objects.requireNonNull(store, "store");
    }

    @Override
    public void put(String key, Object value) {
        delegate.put(key, value);
        store.write(key, value);
    }

    @Override
    public void putAll(Map<String, Object> incoming) {
        for (Map.Entry<String, Object> entry : incoming.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public Object get(String key) {
        return delegate.get(key);
    }

    @Override
    public int size() {
        return delegate.size();
    }

    @Override
    public int capacity() {
        return delegate.capacity();
    }
}

worked/src/Main.java187 lines

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Every number in {@code idea.md}, {@code worked/NOTES.md} and {@code when-not.md} that does not
 * come from {@code contrast/measure.mjs} comes from here. Run it:
 *
 * <pre>
 *   javac -Xlint:all -d out lessons/C4/worked/src/*.java
 *   java -cp out Main
 * </pre>
 */
public final class Main {

    public static void main(String[] args) {
        compositionSelectsTheRule();
        fragileBaseClass();
        theSameChangeUnderComposition();
        protectedIsASecondPublicApi();
        countThePublishedSurface();
        twoAxes();
    }

    /** 1 · the eviction rule as a field: same class, two behaviours. */
    private static void compositionSelectsTheRule() {
        System.out.println("--- 1. one MiniCache class, two eviction rules, chosen by the caller");

        MiniCache recent = new MiniCache(3, new LruEvictionPolicy());
        recent.putAll(threeEntries());
        recent.get("alpha");
        recent.get("alpha");
        recent.put("delta", "4");
        System.out.printf("  LRU  evicted the untouched key   : beta gone? %s%n",
                recent.get("beta") == null);

        MiniCache frequent = new MiniCache(3, new LfuEvictionPolicy());
        frequent.putAll(threeEntries());
        frequent.get("alpha");
        frequent.get("alpha");
        frequent.put("delta", "4");
        System.out.printf("  LFU  evicted the least-used key  : beta gone? %s, alpha kept? %s%n",
                frequent.get("beta") == null, frequent.get("alpha") != null);
        System.out.println();
    }

    /**
     * 2 · the fragile base class. Two releases of one base, one subclass body, two answers.
     */
    private static void fragileBaseClass() {
        System.out.println("--- 2. the base changed, the subclass did not, the answer did");

        CountingBulkCache one = new CountingBulkCache(3);
        one.putAll(threeEntries());
        System.out.printf("  release 1, putAll loops over put : writes() reports %d of 3%n",
                one.writes());

        CountingBulkCacheNext two = new CountingBulkCacheNext(3);
        two.putAll(threeEntries());
        System.out.printf("  release 2, putAll does it itself : writes() reports %d of 3%n",
                two.writes());

        System.out.printf("  both caches hold the same data   : %s%n",
                one.size() == two.size() && two.get("alpha") != null);
        System.out.println("  no compile error, no warning, no exception, no failing base test");
        System.out.println();
    }

    /** 3 · the same two releases behind a wrapper that owns its own loop. */
    private static void theSameChangeUnderComposition() {
        System.out.println("--- 3. the same two releases, counted by composition instead");

        CountingCache one = new CountingCache(new BulkCache(3));
        one.putAll(threeEntries());
        CountingCache two = new CountingCache(new BulkCacheNext(3));
        two.putAll(threeEntries());

        System.out.printf("  wrapping release 1               : writes() reports %d of 3%n",
                one.writes());
        System.out.printf("  wrapping release 2               : writes() reports %d of 3%n",
                two.writes());
        System.out.println("  CountingCache.putAll calls its own put, so putAll's internals "
                + "cannot reach it");
        System.out.println();
    }

    /** 4 · protected access defeating the one invariant the contract promises. */
    private static void protectedIsASecondPublicApi() {
        System.out.println("--- 4. protected access, and the invariant the contract calls "
                + "unobservable");

        HotKeyCache pinned = new HotKeyCache(3);
        pinned.putAll(threeEntries());
        pinned.pin("region", "eu-west-1");

        System.out.printf("  capacity                         : %d%n", pinned.capacity());
        System.out.printf("  size                             : %d%n", pinned.size());
        System.out.printf("  size() > capacity()              : %s%n",
                pinned.size() > pinned.capacity());
        System.out.println("  the pin bypassed victim(), so the eviction rule was never asked");
        System.out.println();
    }

    /**
     * 5 · what each design published. A {@code protected} member is API to every subclass in every
     * codebase, so it is counted alongside the public ones.
     */
    private static void countThePublishedSurface() {
        System.out.println("--- 5. members a future change is not allowed to break");
        for (Class<?> type : List.of(MiniCache.class, BulkCache.class)) {
            int publicCount = 0;
            int protectedCount = 0;
            List<String> protectedNames = new ArrayList<>();
            for (Method method : type.getDeclaredMethods()) {
                if (Modifier.isPublic(method.getModifiers())) {
                    publicCount++;
                } else if (Modifier.isProtected(method.getModifiers())) {
                    protectedCount++;
                    protectedNames.add(method.getName() + "()");
                }
            }
            for (Field field : type.getDeclaredFields()) {
                if (Modifier.isPublic(field.getModifiers())) {
                    publicCount++;
                } else if (Modifier.isProtected(field.getModifiers())) {
                    protectedCount++;
                    protectedNames.add(field.getName());
                }
            }
            System.out.printf("  %-11s %d public, %d protected, %d published in total  %s%n",
                    type.getSimpleName(), publicCount, protectedCount,
                    publicCount + protectedCount, protectedNames);
        }
        System.out.printf("  %-11s %s%n", "MiniCache", "is final, so nothing can subclass it");
        System.out.printf("  %-11s %s%n", "BulkCache", "is not, so both protected members are live");
        System.out.println();
    }

    /**
     * 6 · two axes that vary for unrelated reasons, and every combination of them built from the
     * parts already on disk.
     */
    private static void twoAxes() {
        System.out.println("--- 6. two independent axes, four behaviours, no new class");

        List<String> mirrored = new ArrayList<>();
        BackingStore store = (key, value) -> mirrored.add(key);

        Map<String, CacheApi> wirings = new LinkedHashMap<>();
        wirings.put("LRU, no store       ", new MiniCache(3, new LruEvictionPolicy()));
        wirings.put("LFU, no store       ", new MiniCache(3, new LfuEvictionPolicy()));
        wirings.put("LRU, write-through  ",
                new WriteThroughCache(new MiniCache(3, new LruEvictionPolicy()), store));
        wirings.put("LFU, write-through  ",
                new WriteThroughCache(new MiniCache(3, new LfuEvictionPolicy()), store));

        for (Map.Entry<String, CacheApi> wiring : wirings.entrySet()) {
            mirrored.clear();
            wiring.getValue().putAll(threeEntries());
            System.out.printf("  %s size %d, mirrored %d%n",
                    wiring.getKey(), wiring.getValue().size(), mirrored.size());
        }

        int policies = 2;
        int stores = 2;
        System.out.printf("  %d eviction rules and %d store modes: %d combinations from %d parts%n",
                policies, stores, policies * stores, policies + stores - 1);
        System.out.println("  a third eviction rule makes it 6 combinations from 4 parts");
        System.out.println("  under one subclass per combination it is 6 classes, and every one of "
                + "them names its base in the file that declares it");
    }

    private static Map<String, Object> threeEntries() {
        Map<String, Object> entries = new LinkedHashMap<>();
        entries.put("alpha", "1");
        entries.put("beta", "2");
        entries.put("gamma", "3");
        return entries;
    }

    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.

← C3 · Dependency inversion and injection — the clock is a dependency C5 · Interface segregation and minimal public surface →

← all lessons