LLD Dojo

Syllabus · C1

Single responsibility — write the sentence, then look for the word "and"

The idea

Write the sentence, then look for the word "and"

"One reason to change" is a property you can admire. It is not a procedure, and under a twelve-minute clock you need a procedure.

Here is one. For each class write a sentence in this exact shape:

<Class> changes when ______.

The blank has to name a requirement an interviewer could hand you mid-round. Then two checks, pointing opposite ways. An "and" in the blank means split at the "and". Two classes sharing a blank means they are one class.

Try it on the question you actually get stuck on. "ParkingLot changes when the lot runs out of room, and when the tariff changes." There is your "and", so fee calculation is not its job.

Now the case worth studying, because the answer is already on disk. corpus/file-system/reference/DECISION_LOG.md opens with the table this test produces: six rows for eight files. Start where you would start, from corpus/file-system/scaffold/InMemoryFileSystem.java, one class holding everything. The sentence reads: changes when what makes a path well-formed changes, and when how the tree is walked changes, and when the sequence of an operation changes. Split at both "and"s. You get PathSyntax, TreeResolver and InMemoryFileSystem, which is the real decomposition, in the log's own words.

That split is why seven of eight operations absorbed symbolic links with zero lines changed. It is also why corpus/rate-limiter shipped a bug when one method skipped the boundary. contrast/ measures both directions.


Worked walkthrough

The change table, and the lines that hold it up

Thirteen files in worked/src including the driver, 487 lines in total, the longest 106. Run it:

.toolchain/jdk-21/bin/javac -Xlint:all -d out lessons/C1/worked/src/*.java && java -cp out Main

javac 21 with -Xlint:all prints nothing. This is a trimmed corpus/file-system/reference/src with six operations instead of eight, so every decision below has a namesake on disk you can check.

The table this design was written from

Filechanges when
MiniFileSystemthe sequence of one of the six operations changes
PathSyntaxwhat makes a path well-formed changes
TreeResolverhow the tree is walked or mutated changes
PathResolvernever
Node, FileNode, DirectoryNodewhat a node is made of changes
exception typesthe contract's failure vocabulary changes

Compare corpus/file-system/reference/DECISION_LOG.md, which opens with the same table for the full eight-operation reference. Three of its blanks are word for word the three here, and PathResolver's is "never" in both.

Applying the test to the scaffold, which is where you start

corpus/file-system/scaffold/InMemoryFileSystem.java is one class. Its javadoc points at the split without naming it. Two things worth deciding first, it says: "where the tree of directories and files actually lives, and where a path gets turned into 'the node at the end of it.'" Then: "Those are different jobs."

Write the sentence for that one class and you cannot finish it without two "and"s:

InMemoryFileSystem changes when what makes a path well-formed changes, and when how the tree is walked changes, and when the sequence of an operation changes.

Split at both. Three classes, and they are the three that exist.

Now run the second check, the one the slogan never mentions. Two classes sharing a blank are one class. That check is why the corpus table has six rows for eight files:

So the collision check fires on genuine duplication and stays quiet on variants. That is the difference between decomposing and multiplying files.

PathSyntax.segments — the line that decides which hierarchy a failure belongs to

        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
        }

IllegalArgumentException and not a FileSystemException, and the choice is load-bearing rather than taste. A relative path names nothing whatever the tree contains. Every member of FileSystemException is an outcome of walking a well-formed path through a tree whose contents decided the answer.

Without that separation, TreeResolver.find cannot be written. Look at what it does:

        try {
            return get(path);
        } catch (FileSystemException notThere) {
            return null;
        }

One catch, and it is exactly right, because the two kinds of failure are already two types. Widen it to RuntimeException and exists("var/log") answers false for a path that could never have named anything. A caller's typo becomes a fact about the file system. faded/GapTest.java asserts that exists still throws there, and that assertion is what catches the wide catch.

Run Main and the last few lines are this boundary printing itself:

relative path     -> IllegalArgumentException: path must be absolute (start with '/'): var/log
missing path      -> NoSuchPathException: no such path: /var/log/absent
exists on missing -> false

Two failures on the same-looking input, two owners, and exists answering rather than throwing for one of them.

PathSyntax.parent — where path arithmetic goes when you are strict about it

    static String parent(String path) {
        List<String> segments = segments(path);
        if (segments.isEmpty()) {
            throw new IllegalArgumentException("the root has no parent");
        }
        return join(segments.subList(0, segments.size() - 1));
    }

MiniFileSystem.write wants the parent directory. The tempting version is path.substring(0, path.lastIndexOf('/')) written inline at the call site. It is one line, and it returns the empty string for /f, which is then refused as not absolute. So a write to a top-level file fails with a message about absolute paths.

That is not the reason to move it, though. The reason is the sentence: write would then change when path syntax changes, and write's blank is already taken. corpus/file-system keeps the same rule by making TreeResolver call PathSyntax.join(segments.subList(...)). Same owner, spelled at three call sites instead of one.

Grep MiniFileSystem.java for substring, split, indexOf or charAt. There are none. That absence is the row holding.

TreeResolver.get — the one place a child link is read

            Node next = dir.child(segment);

That line appears once in the whole design, and makeDirectories has the only other call to child. Nothing in MiniFileSystem calls it at all.

This is the seam corpus/file-system/reference/DECISION_LOG.md credits for three cheap curveballs, and the numbers are on disk:

curveballreference_diffwhy
03-glob-search4find() reused the resolver and the existing public reads. Four lines: an @Override, a signature, a one-line body, a brace
02-storage-quotas20two one-line insertions into write's commit branches, plus a new ledger file
01-shortcuts28the resolver learned to follow a pointer. DECISION_LOG.md names read, ls, kindOf, exists, mkdir, mv and delete as zero lines changed

Seven of eight operations unchanged is the payoff, and it follows directly from that one line appearing once. PATCH.md prices the alternative. Every one of the eight operations would resolve a path by hand, so every one "would need its own copy of the follow-and-detect-a-cycle logic."

contrast/ measures that claim on this smaller code and gets 1 operation changed out of 6 against 6 out of 6.

MiniFileSystem.write — the longest method, and why it is still one row

        Node existing = parent.child(leaf);
        if (existing instanceof DirectoryNode) {
            throw new IsADirectoryException(path);
        } else if (existing instanceof FileNode file) {
            file.write(content);
        } else {
            parent.addChild(leaf, new FileNode(content));
        }

A three-way decision inside the class that is supposed to hold no rules. It stays, and the sentence says why: "may a write replace what is here" is a property of write, not of walking. Move it into TreeResolver and the resolver's blank grows an "and".

The wrong answer here compiles, and is caught behaviourally. Reduce the block to its last line alone. The second write to a path then throws, from DirectoryNode's own invariant guard:

IllegalStateException: directory already has a child named 'app.log'

faded/GapTest.java catches that on its second write.

DirectoryNode.addChild — the invariant on the object, not on the caller

        if (children.putIfAbsent(name, node) != null) {
            throw new IllegalStateException("directory already has a child named '" + name + "'");
        }

Every reachable call site checks first and throws the path-aware PathAlreadyExistsException, which it can because it has the full path and this class has a bare name. So this guard never fires in normal use. It is here so the invariant is true of the object even when a future call site forgets. write's wrong answer above is that future call site, arriving early.

MiniFileSystem.delete — one line, because the shape is somebody else's row

        tree.unbind(path);

No loop, no recursion, and recursive delete works. A subtree is reachable only through its root, so detaching the root detaches the subtree and the rest becomes garbage. That property belongs to the tree, which is why this method is a guard and a call.

DECISION_LOG.md prices the alternative for the flat map: recursive delete is "a third full scan, and the only way to discover 'does this directory have children' is yet another one." contrast/a/TinyFileSystem.java is that design, and its delete really does end with two removeIf scans over everything.

What is deliberately not decomposed here


When not to

When more classes is the worse answer

The change-sentence test has two checks, and the second one is the whole of this file. Two classes sharing a blank are one class. Anybody who has only ever heard "one reason to change" has heard the first check and not the second. That is why the slogan produces over-decomposition as reliably as it produces god classes.

STANDARD v1.0 is symmetric about it on purpose. D1 level 0 is "one god class holding all state and behaviour". D3 level 3 is "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". The standard then says it outright: "level 3 penalises over-abstraction as much as level 0 penalises none." The tag is over-engineered (premature interface).

The concrete bad example

Here is worked/src after somebody has taken the lesson too literally. It compiles: dropped next to worked/src/MiniFileSystem.java and built with javac 21 -Xlint:all, it prints nothing.

/** Splits a path into segments. */
final class PathSplitter {
    static java.util.List<String> split(String path) {
        return PathSyntax.segments(path);
    }
    private PathSplitter() {}
}

/** Validates a path. */
final class PathValidator {
    static void validate(String path) {
        PathSyntax.segments(path);
    }
    private PathValidator() {}
}

/** Joins segments into a path. */
final class PathJoiner {
    static String join(java.util.List<String> segments) {
        return PathSyntax.join(segments);
    }
    private PathJoiner() {}
}

Three files where there was one. Now run the test on them.

PathSplitter changes when what makes a path well-formed changes. PathValidator changes when what makes a path well-formed changes. PathJoiner changes when what makes a path well-formed changes. One blank, three classes. The collision check fires three times, and the verdict is that these are one class, which is PathSyntax.

The tell is visible without the test, too. Every method forwards. A class whose bodies are all return other.thing() has no blank of its own, because it changes exactly when the thing it forwards to changes. It is a file, a name, an import and a hop, in exchange for nothing.

Worse than nothing, actually. A reserved-character rule now has to be added in one place and the three forwarders checked to see whether any of them needs to know. That is three files to read to learn that the answer is no.

What the grader sees

D1 level 2 wants "each class has one reason to change" and gets three classes with the same one. Level 3 wants boundaries where "a new requirement's home is unambiguous", and a new path rule now has four candidate homes. So this scores lower than the version with one PathSyntax, for doing more work.

The cost paid before any requirement arrives, measured

From node lessons/C1/contrast/measure.mjs:

size of a    6 file(s)  150 normalised lines
size of b    12 file(s)  251 normalised lines

Twice the files and two thirds again the lines, for behaviour that is identical. Both pass BaseTest.java 5 of 5. Read the shape rather than the multiplier, since both trees carry comments, but the direction is not in doubt.

That is clock. A 45-minute round scores D2 at 25%, more than D3's 20%, and D2 is capped at 0 when no main exists, because "interviewers run it first". Twelve small files typed instead of six is how a driver does not get written.

The cost paid on a change, measured, and this one goes against decomposition

Also from measure.mjs, the case-folding requirement:

case folding, chosen per instance   a   -> a-nocase      diffLines  26  touched 1  new 0
case folding, chosen per instance   b   -> b-nocase      diffLines  22  touched 3  new 0

Four lines cheaper in the decomposed design, across three pre-existing files instead of one. D4 level 3 is "absorbed by adding files only — zero lines changed in pre-existing files", so on the graded instrument the decomposed design sits further from the top.

The reason generalises past this problem. Making anything configurable costs a line in every layer between the constructor and the class that needs it. DirectoryNode takes a comparator, TreeResolver holds one to pass to the directories it creates, TinyFileSystem picks it. Each hand-off is a signature that had no reason to change and changed anyway.

corpus/file-system/reference/src/PathSyntax.java makes the same bet, and it is worth seeing that it is a bet. Every method is static and the constructor is private, which is right while path rules are global, and is a small rewrite the day they are not.

The two absences that are not decomposition failures

Naming what you deliberately did not split is the cheapest way to show the test was applied rather than remembered.

A sealed hierarchy is one row, not three. Node, FileNode and DirectoryNode share a blank: what a node is made of changes. That collision is not a defect, because a sealed hierarchy splits over values and the test asks about reasons. Merging them would put an EntryKind field and a nullable content field on one class, and then a switch wherever you used to have a type.

An interface has no blank at all. PathResolver's row in corpus/file-system/reference/DECISION_LOG.md is literally "never — it is a seam, not a policy", and Nodes' row is "never". A file with no body has nothing a requirement can force you to edit. So "no two blanks are the same" cannot be used to justify an interface. B1's threshold still applies: name the second implementation and write the one signature both satisfy, or leave it concrete.

What the corpus keeps that this test would flag, and why

corpus/rate-limiter/reference/DECISION_LOG.md gives AlgorithmFactory and StandardAlgorithms a single shared row: "which class enforces which Algorithm constant changes." Two files, one blank. By the collision check they are one class.

They are kept apart anyway, and the log defends it as a seam rather than a decomposition. The same log is candid about the price where the bet was thinner. Scopes was written with "one implementation and one line of body", and its own javadoc "states the price before the benefit".

So the honest rule is narrower than "no two classes may share a blank". A shared blank means the split has to be justified as a seam, on B1's threshold, and not as a responsibility split. If it cannot be, merge them.

The threshold, from both sides

Split when the blank needs an "and", and the second half names a requirement somebody could hand you. "ParkingLot changes when the lot runs out of room, and when the tariff changes" passes. "changes when the code gets messy" is not a requirement and does not count.

Merge when two classes share a blank and neither is a variant of a sealed type nor a seam you can defend on B1's threshold. Two classes that always change together are one class.

Leave it alone when the blank is "never". An interface, an exhaustive switch over a closed enum, or a sealed hierarchy's members are already where they belong.


The contrast pair

Two designs, two requirements, three instruments

a/ is one class. A TreeMap<String, String> of files, a TreeSet<String> of directories, and six operations that each work out for themselves what a path means. b/ is the same six operations over PathSyntax, PathResolver, TreeResolver and the node types.

Both compile under -Xlint:all with nothing printed. Both pass BaseTest.java, 5 of 5. This is a comparison of two working designs.

And a/ is not a straw man. corpus/file-system/reference/DECISION_LOG.md names it as "the plausible wrong answer" and says why it appeals: "write and read are one map operation each, and exists is map.containsKey." It is what gets written with twelve minutes left, and it has one real advantage the numbers below will show.

Why this lesson needs three instruments

corpus/file-system/curveballs/03-glob-search/budget.json records reference_diff: 4. Four lines, because find() reused the resolver and the reads ls already had. That is a diff number doing its job.

But the claim this lesson makes is not "the diff is smaller". It is that a requirement lands in fewer places, and that a class of bug stops being expressible. A line count cannot see either one. lessons/C3/when-not.md and its addendum settled that rule for the whole set: pick the instrument that can see the benefit you are claiming, and say which one you used.

So: three instruments, and one of them goes against b/.

Change one, in the interviewer's words

From corpus/file-system/curveballs/01-shortcuts/REQUIREMENT-CHANGE.md, cut down to what six operations need:

A shortcut is a new kind of entry that points at another absolute path. Reading through one gives you the target's content. ls through one lists the target directory. Writing at a shortcut's own path replaces the shortcut with a file. Deleting one removes the shortcut, not its target. A cycle is refused rather than followed forever.

ShortcutTest.java asserts all of it. Both a-shortcuts/ and b-shortcuts/ pass 11 of 11, base suite included.

Change two, in the interviewer's words

Our Windows customers need paths that compare without case. Whether a file system folds case is chosen when it is created, and existing deployments keep today's behaviour.

NoCaseTest.java asserts that, including that the stored casing survives, so ls reports what was written. Both a-nocase/ and b-nocase/ pass 10 of 10.

The numbers

node lessons/C1/contrast/measure.mjs

Real output, from exactly these directories:

instrument 1 — measureChange(), the function that scores D4

shortcuts   (a third kind of node)  a   -> a-shortcuts   diffLines  66  touched 1  new 1
      [TinyFileSystem.java +59/-7]
shortcuts   (a third kind of node)  b   -> b-shortcuts   diffLines  36  touched 4  new 2
      [Node.java +1/-1, PathResolver.java +2/-0, TinyFileSystem.java +6/-0, TreeResolver.java +25/-1]
case folding, chosen per instance   a   -> a-nocase      diffLines  26  touched 1  new 0
      [TinyFileSystem.java +20/-6]
case folding, chosen per instance   b   -> b-nocase      diffLines  22  touched 3  new 0
      [DirectoryNode.java +5/-1, TinyFileSystem.java +8/-1, TreeResolver.java +5/-2]

instrument 2 — of the six public operations, how many changed at all

shortcuts   (a third kind of node)  a   -> a-shortcuts   changed 6/6  unchanged 0/6  [changed: mkdir, write, read, ls, exists, delete]
shortcuts   (a third kind of node)  b   -> b-shortcuts   changed 1/6  unchanged 5/6  [changed: write]
case folding, chosen per instance   a   -> a-nocase      changed 1/6  unchanged 5/6  [changed: delete]
case folding, chosen per instance   b   -> b-nocase      changed 0/6  unchanged 6/6

instrument 3 — how far correct is from broken in the undecomposed design

one resolution site missed        a-shortcuts -> a-shortcuts-partial  diffLines 7  touched 1
  a-shortcuts-partial passes BaseTest 5/5 and fails one assertion in ShortcutTest.
  There is no b-shortcuts-partial, because there is one place resolution happens.

what each design costs before any requirement arrives

size of a    6 file(s)  150 normalised lines
size of b    12 file(s)  251 normalised lines

Instrument 1, and what it does not settle

On the shortcuts change b/ wins, 36 against 66. That is worth having and it is not the finding, because b/ opened four pre-existing files where a/ opened one. STANDARD v1.0's D4 level 3 is "absorbed by adding files only — zero lines changed in pre-existing files". Neither design reaches it, and on files touched the decomposed one is further away.

On the case-folding change the two are a near tie: 22 against 26, with b/ again touching three files against one.

Read those four rows as a set and the honest summary is that measureChange did not decide this. It never gave the decomposition a decisive win, and on files touched it went against it twice. That is the same result lessons/C3 got for the injected clock: 8 lines in both designs, a flat tie on the graded instrument.

Instrument 2, which is the corpus's own

corpus/file-system/curveballs/01-shortcuts/reference-patch/PATCH.md does not argue from its diff. It argues from what did not change:

Nothing in InMemoryFileSystem's seven other operations changed.

The reason it gives is one sentence long. They "were already written in terms of PathResolver, never in terms of a Map they walked themselves."

Seven of eight. Here it is 5 of 6 unchanged against 0 of 6, and the mechanism is identical. b/ has one line that reads a child link, Node next = dir.child(segment), so there is one place to teach. a/ has six operations that each decide what a path means with dirs.contains, files.get or a prefix scan, so there are six places, and all six changed.

Why that matters is not tidiness or line count. It is the next section.

Instrument 3, and the bug the corpus actually shipped

a-shortcuts-partial/ is a-shortcuts/ with exists left as it was. One site of six, missed. Seven normalised lines from correct. It passes the base suite 5 of 5.

Then:

FAIL exists and read agree about what is at a path through a shortcut
     read() finds it, so exists() must too ==> expected: <true> but was: <false>

read("/home/logs/access.log") returns the content. exists("/home/logs/access.log") returns false. Nothing throws. Two operations answer the same question two ways, and the disagreement is invisible to every test that does not think to compare them.

This is not hypothetical, and it is not this lesson's invention. corpus/rate-limiter/reference/DECISION_LOG.md records it happening:

remaining(key) read budgets.get(key) directly instead of going through scopeOf(key), while tryAcquire went through Scopes properly.

One budget or none either way, so under Scopes.own() the two are indistinguishable. The log records what that meant: "the base suite passed 28/28 with the bug in place." Under a second scope it is plainly wrong.

remaining would tell a caller they had 95 left and the very next tryAcquire would refuse them under a cap it had never consulted.

The verdict is the sentence to carry into a round. "A seam honoured by one of the two methods that answer questions about it is not a seam." It is a coincidence.

That entry also prices the retrofit. Measured against the pre-fix code, curveball 02 would have cost 13 lines for the cheapest correct absorption, or 20 done properly, every one of them inside RateLimiter.

There is no b-shortcuts-partial/ in this directory, and that is the measurement. The bug is a disagreement between two resolution sites. b/ has one. You cannot write the partial version, which is a stronger claim than "it is less likely".

Where the decomposition genuinely cost more

The case-folding change is the one that goes the wrong way, and the reason is worth naming precisely.

a-nocase/ builds its two collections with a comparator chosen in the constructor. One field, one constructor, one prefix helper, one file. Done.

b-nocase/ has to thread the same decision through every layer that constructs something. DirectoryNode takes a comparator. TreeResolver holds one so makeDirectories can pass it to the directories it creates. TinyFileSystem picks it and hands it down. Three pre-existing files opened for a requirement that changed no operation at all.

That is the structural price of a layered decomposition, and it is not specific to this problem. Anything configurable costs a line in every layer between the constructor and the class that needs it. The lines came out slightly cheaper here, 22 against 26, which is honest and is also not the point. The count of files you have to open is what a twelve-minute clock feels.

b/ did get something for it that the numbers do not show: ls reports the casing the caller wrote, because DirectoryNode keys children by their real names and only the comparison folds. a-nocase/ matches that only because its keys are full paths stored as written. Neither design lost behaviour, so the requirement is a fair comparison.

The alternatives, so the choice is a choice

Could a/ have absorbed shortcuts in one place? Yes, and it did — resolve() is a private method, and every one of the six operations calls it. That is the god class growing the collaborator it needed, without giving it a name or a file. Look at what it cost: 59 added lines and 7 removed, and six call sites edited. The collaborator arrived anyway. It arrived late, as a private method, at the moment the requirement was already on the table.

Would splitting check() out of a/ have helped? No, and the asymmetry is the interesting part. check() is already a shared static helper in a/, because nobody writes the same six-line validation six times. The cheap thing to factor out is the pure function. The expensive thing to factor out is the one that owns state, and that is the one the god class never grows on its own.

Is b/ the smallest design that passes the change-sentence test? No, and when-not.md prices the gap. PathResolver has one implementation. Its blank is "never", which is what an interface's blank always is, and that is not a licence to add one wherever you like.

What this pair does not show

Six operations against the corpus's eight, and 251 normalised lines against a real reference. The full-scale version of the same measurement is corpus/file-system/curveballs/01-shortcuts/budget.json: reference_diff: 28 across four files, with seven of eight operations at zero. Same shape, at the size of a real problem, and the same honest admission that 28 is "not one of the cheap ones."


Worked source

The 13 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/DirectoryNode.java47 lines

import java.util.List;
import java.util.TreeMap;

/**
 * A directory's children, keyed by name.
 *
 * The one invariant a directory must never violate — no two children under the same name — is
 * enforced here rather than at every call site. addChild refuses a clash unconditionally, so the
 * invariant holds even for a call site that forgets to check.
 *
 * A TreeMap so that "list the children in name order" is the map's own property, stated once,
 * instead of a sort every ls-shaped caller has to remember.
 */
final class DirectoryNode implements Node {

    private final TreeMap<String, Node> children = new TreeMap<>();

    Node child(String name) {
        return children.get(name);
    }

    boolean hasChild(String name) {
        return children.containsKey(name);
    }

    /** Names in sorted order, exactly as ls hands them to a caller. */
    List<String> childNames() {
        return List.copyOf(children.keySet());
    }

    /**
     * Adds a child under a name this directory does not already have.
     *
     * @throws IllegalStateException if it does. A guard on the object, not the domain exception a
     *     caller sees: every reachable call site checks first and throws PathAlreadyExists with
     *     the full path, which it has and this class does not.
     */
    void addChild(String name, Node node) {
        if (children.putIfAbsent(name, node) != null) {
            throw new IllegalStateException("directory already has a child named '" + name + "'");
        }
    }

    Node removeChild(String name) {
        return children.remove(name);
    }
}

worked/src/FileNode.java17 lines

/** A file's content. Mutable, because a write to an existing path replaces it in place. */
final class FileNode implements Node {

    private String content;

    FileNode(String content) {
        this.content = content;
    }

    String content() {
        return content;
    }

    void write(String content) {
        this.content = content;
    }
}

worked/src/FileSystemException.java15 lines

/**
 * Root of every failure that is an outcome of walking a well-formed path through this tree.
 *
 * A malformed path is not in this hierarchy. It is an IllegalArgumentException from PathSyntax,
 * because it names nothing whatever the tree contains. Keeping the two apart is what makes
 * TreeResolver.find safe to write as one catch.
 */
abstract class FileSystemException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    FileSystemException(String message) {
        super(message);
    }
}

worked/src/IsADirectoryException.java9 lines

/** Something on the path is a directory where a file was needed. */
final class IsADirectoryException extends FileSystemException {

    private static final long serialVersionUID = 1L;

    IsADirectoryException(String path) {
        super("is a directory: " + path);
    }
}

worked/src/MiniFileSystem.java85 lines

import java.util.List;

/**
 * The six operations a caller sees.
 *
 * Its row in the change table: <b>MiniFileSystem changes when the sequence of one of the six
 * operations changes.</b> Every method here is the same three beats — ask the resolver what is
 * where, decide whether that is legal, tell the resolver what to change — and holds no rule a
 * requirement could reach except the ones that are genuinely about an operation's own legality.
 *
 * Two absences are the whole design. There is no path arithmetic: no substring, no split, no
 * indexOf('/'). And there is no child link read anywhere: no method in this file calls
 * DirectoryNode.child. Those two absences are what the change-sentence test bought.
 */
public final class MiniFileSystem {

    private final PathResolver tree;

    public MiniFileSystem() {
        this.tree = new TreeResolver(new DirectoryNode());
    }

    /** Creates path and every missing directory above it. Existing directories are left alone. */
    public void mkdir(String path) {
        tree.makeDirectories(path);
    }

    /**
     * Writes content at path, replacing what is there if it is a file. The parent must exist.
     *
     * This is the longest operation in the class, and every line of it is sequence rather than
     * rule: get the parent, look at what is under the name, replace or attach. The three-way
     * decision is here and not in TreeResolver because "may a write replace this" is a property
     * of write, not of walking.
     */
    public void write(String path, String content) {
        DirectoryNode parent = tree.getDirectory(PathSyntax.parent(path));
        String leaf = PathSyntax.leaf(path);
        Node existing = parent.child(leaf);
        if (existing instanceof DirectoryNode) {
            throw new IsADirectoryException(path);
        } else if (existing instanceof FileNode file) {
            file.write(content);
        } else {
            parent.addChild(leaf, new FileNode(content));
        }
    }

    /** The content of the file at path. */
    public String read(String path) {
        Node node = tree.get(path);
        if (node instanceof FileNode file) {
            return file.content();
        }
        throw new IsADirectoryException(path);
    }

    /** The names of the entries directly under the directory at path, in name order. */
    public List<String> ls(String path) {
        Node node = tree.get(path);
        if (node instanceof DirectoryNode dir) {
            return dir.childNames();
        }
        throw new NotADirectoryException(path);
    }

    /** Whether anything is at path. A missing path is the answer here, not a failure. */
    public boolean exists(String path) {
        return tree.find(path) != null;
    }

    /**
     * Removes path and, if it is a directory, everything beneath it.
     *
     * There is no loop here and no recursion. A subtree is reachable only through its root, so
     * detaching the root detaches the subtree; the rest becomes garbage. That property belongs to
     * the tree, which is why this method is one line and a guard.
     */
    public void delete(String path) {
        if (PathSyntax.segments(path).isEmpty()) {
            throw new IllegalArgumentException("the root cannot be deleted");
        }
        tree.unbind(path);
    }
}

worked/src/NoSuchPathException.java9 lines

/** Some segment of a path — possibly the last — names nothing that exists. */
final class NoSuchPathException extends FileSystemException {

    private static final long serialVersionUID = 1L;

    NoSuchPathException(String path) {
        super("no such path: " + path);
    }
}

worked/src/Node.java10 lines

/**
 * A thing that can sit at a path: a file or a directory, and nothing else.
 *
 * Three types share one row in the change table — <b>they change when what a node is made of
 * changes.</b> That is why the collision half of the change-sentence test does not fire here. A
 * sealed hierarchy splits over values, not over reasons, so its permitted types are one
 * responsibility wearing several shapes. Collapsing them would not remove a duplicate row; it
 * would put an EntryKind field and a nullable content field on one class.
 */
sealed interface Node permits FileNode, DirectoryNode {}

worked/src/NotADirectoryException.java9 lines

/** Something on the path exists but is a file where a directory was needed. */
final class NotADirectoryException extends FileSystemException {

    private static final long serialVersionUID = 1L;

    NotADirectoryException(String path) {
        super("not a directory: " + path);
    }
}

worked/src/PathAlreadyExistsException.java9 lines

/** Something is already at this path, and the operation refuses to replace it. */
final class PathAlreadyExistsException extends FileSystemException {

    private static final long serialVersionUID = 1L;

    PathAlreadyExistsException(String path) {
        super("path already exists: " + path);
    }
}

worked/src/PathResolver.java37 lines

/**
 * What is at this path, and attach or detach a node here.
 *
 * Its row in the change table is <b>never</b>, and an interface having no row is not a trick. A
 * row records what would force you to edit a body, and this file has no bodies. It is the seam
 * between two rows: "how the tree is walked" on one side and "what an operation does with what it
 * found" on the other.
 *
 * The decision this interface exists to make structural: <b>no operation in MiniFileSystem ever
 * holds a Node it found by walking children itself.</b> Every one of them asks here. That is what
 * turned "eight places might need this" into "one place does" when
 * corpus/file-system/curveballs/01-shortcuts asked for symbolic links —
 * reference-patch/PATCH.md names read, ls, kindOf, exists, mkdir, mv and delete as unchanged.
 */
interface PathResolver {

    /** The node at path, or null if nothing is there. Never throws for "not found". */
    Node find(String path);

    /** The node at path, which must exist. */
    Node get(String path);

    /** The directory at path, which must exist and must be a directory. */
    DirectoryNode getDirectory(String path);

    /**
     * Creates every missing directory on path including the leaf, leaving directories that
     * already exist untouched, and returns the directory at path.
     */
    DirectoryNode makeDirectories(String path);

    /** Attaches node as the new leaf at path. The leaf must not already exist. */
    void bind(String path, Node node);

    /** Detaches and returns the node at path, which must exist. */
    Node unbind(String path);
}

worked/src/PathSyntax.java77 lines

import java.util.List;

/**
 * Everything decidable about a path without looking at the tree.
 *
 * Its row in the change table: <b>PathSyntax changes when what makes a path well-formed
 * changes.</b> A reserved character, a length limit, case folding, Windows-style drive letters.
 * None of those are asked for here, and that is the point — none of them reach TreeResolver.
 *
 * Every method is static and the constructor is private, because there is no state a syntax rule
 * could depend on. That is also the honest limit of this class, and when-not.md prices it: a
 * requirement that makes the rules per-instance has to undo the staticness in every layer that
 * constructs anything. See corpus/file-system/reference/src/PathSyntax.java, which is this class.
 */
final class PathSyntax {

    /**
     * The named segments of an absolute path, root first. The root is the empty list.
     *
     * Throws IllegalArgumentException — deliberately not a FileSystemException — because a
     * relative path or an empty segment names nothing no matter what the tree contains. That
     * distinction is what lets TreeResolver.find turn "not there" into null while still letting a
     * caller bug escape.
     */
    static List<String> segments(String path) {
        if (path == null) {
            throw new IllegalArgumentException("path must not be null");
        }
        if (!path.startsWith("/")) {
            throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
        }
        String trimmed = path.length() > 1 && path.endsWith("/")
                ? path.substring(0, path.length() - 1)
                : path;
        if (trimmed.equals("/")) {
            return List.of();
        }
        String[] parts = trimmed.substring(1).split("/", -1);
        for (String part : parts) {
            if (part.isEmpty()) {
                throw new IllegalArgumentException("malformed path (empty segment): " + path);
            }
        }
        return List.of(parts);
    }

    /** The '/'-joined path naming these segments; the empty list is the root. */
    static String join(List<String> segments) {
        return segments.isEmpty() ? "/" : "/" + String.join("/", segments);
    }

    /**
     * The path of the directory holding this path's last segment. "/a/b/c" gives "/a/b", and
     * "/a" gives "/".
     *
     * This lives here rather than in MiniFileSystem.write, which is the operation that wants it,
     * because "where does a path end" is a syntax question and write must not own two rows.
     */
    static String parent(String path) {
        List<String> segments = segments(path);
        if (segments.isEmpty()) {
            throw new IllegalArgumentException("the root has no parent");
        }
        return join(segments.subList(0, segments.size() - 1));
    }

    /** The last named segment of a path. "/a/b/c" gives "c". */
    static String leaf(String path) {
        List<String> segments = segments(path);
        if (segments.isEmpty()) {
            throw new IllegalArgumentException("the root has no name");
        }
        return segments.get(segments.size() - 1);
    }

    private PathSyntax() {}
}

worked/src/TreeResolver.java106 lines

import java.util.List;

/**
 * The only implementation of PathResolver: an in-memory tree rooted at one directory.
 *
 * Its row in the change table: <b>TreeResolver changes when how the tree is walked or mutated
 * changes.</b> Following a pointer, memoising a lookup, taking a lock — all of those are this
 * file and nothing else, because this is the only file that reads a child link.
 *
 * Read it looking for a path-syntax rule and there is none. Every method starts by handing the
 * string to PathSyntax and then works in segments. That is the boundary the two rows sit on.
 */
final class TreeResolver implements PathResolver {

    private final DirectoryNode root;

    TreeResolver(DirectoryNode root) {
        this.root = root;
    }

    @Override
    public Node find(String path) {
        // The catch is FileSystemException and not RuntimeException on purpose. Every
        // domain-shaped "not there" is what get already throws for, and find differs only in
        // returning null instead. A malformed path is still a caller bug when the caller only
        // wanted to know whether something exists, so IllegalArgumentException propagates.
        try {
            return get(path);
        } catch (FileSystemException notThere) {
            return null;
        }
    }

    @Override
    public Node get(String path) {
        Node current = root;
        List<String> walked = List.of();
        for (String segment : PathSyntax.segments(path)) {
            if (!(current instanceof DirectoryNode dir)) {
                throw new NotADirectoryException(PathSyntax.join(walked));
            }
            Node next = dir.child(segment);
            if (next == null) {
                throw new NoSuchPathException(path);
            }
            walked = append(walked, segment);
            current = next;
        }
        return current;
    }

    @Override
    public DirectoryNode getDirectory(String path) {
        Node node = get(path);
        if (node instanceof DirectoryNode dir) {
            return dir;
        }
        throw new NotADirectoryException(path);
    }

    @Override
    public DirectoryNode makeDirectories(String path) {
        DirectoryNode current = root;
        List<String> walked = List.of();
        for (String segment : PathSyntax.segments(path)) {
            walked = append(walked, segment);
            Node existing = current.child(segment);
            if (existing == null) {
                DirectoryNode created = new DirectoryNode();
                current.addChild(segment, created);
                current = created;
            } else if (existing instanceof DirectoryNode dir) {
                current = dir;
            } else {
                throw new NotADirectoryException(PathSyntax.join(walked));
            }
        }
        return current;
    }

    @Override
    public void bind(String path, Node node) {
        DirectoryNode parent = getDirectory(PathSyntax.parent(path));
        String leaf = PathSyntax.leaf(path);
        if (parent.hasChild(leaf)) {
            throw new PathAlreadyExistsException(path);
        }
        parent.addChild(leaf, node);
    }

    @Override
    public Node unbind(String path) {
        DirectoryNode parent = getDirectory(PathSyntax.parent(path));
        Node removed = parent.removeChild(PathSyntax.leaf(path));
        if (removed == null) {
            throw new NoSuchPathException(path);
        }
        return removed;
    }

    private static List<String> append(List<String> segments, String segment) {
        java.util.ArrayList<String> next = new java.util.ArrayList<>(segments);
        next.add(segment);
        return List.copyOf(next);
    }
}

worked/src/Main.java57 lines

import java.util.List;

/**
 * The driver. STANDARD v1.0 caps D2 at 0 when no main exists, on the grounds that interviewers run
 * it first, so it shows the interesting cases rather than the happy path.
 *
 * The last two lines are the point of the lesson: the same string, "logs/", is rejected by
 * PathSyntax before the tree is consulted, and by TreeResolver after. Two failures, two owners,
 * two exception hierarchies.
 */
public final class Main {

    public static void main(String[] args) {
        MiniFileSystem fs = new MiniFileSystem();

        fs.mkdir("/var/log/nginx");
        fs.write("/var/log/nginx/access.log", "GET /health 200");
        fs.write("/var/log/nginx/error.log", "");
        System.out.println("ls /var/log/nginx  -> " + fs.ls("/var/log/nginx"));
        System.out.println("read access.log   -> " + fs.read("/var/log/nginx/access.log"));

        fs.write("/var/log/nginx/access.log", "GET /health 500");
        System.out.println("after re-write    -> " + fs.read("/var/log/nginx/access.log"));

        fs.delete("/var/log/nginx");
        System.out.println("subtree gone      -> " + fs.exists("/var/log/nginx/access.log"));
        System.out.println("parent survives   -> " + fs.ls("/var/log"));

        show("read a directory", () -> fs.read("/var"));
        show("ls a file",        () -> { fs.write("/f", "x"); return fs.ls("/f"); });
        show("delete the root",  () -> { fs.delete("/"); return null; });
        show("relative path",    () -> fs.exists("var/log"));
        show("empty segment",    () -> fs.exists("/var//log"));
        show("missing path",     () -> fs.read("/var/log/absent"));
        System.out.println("exists on missing -> " + fs.exists("/var/log/absent"));
        System.out.println("ls /              -> " + List.copyOf(fs.ls("/")));
    }

    private interface Attempt {
        Object run();
    }

    private static void show(String label, Attempt attempt) {
        try {
            System.out.println(pad(label) + "-> no failure, returned " + attempt.run());
        } catch (RuntimeException failed) {
            System.out.println(pad(label) + "-> " + failed.getClass().getSimpleName()
                    + ": " + failed.getMessage());
        }
    }

    private static String pad(String label) {
        return (label + "                  ").substring(0, 18);
    }

    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.

← B8 · Template method or lifecycle hook — the skeleton that calls down into its subclasses C2 · Open/closed — the seam has to sit on the axis the change moves along →

← all lessons