LLD Dojo

Single Responsibility Principle

Core. Expect to meet this one, and expect to be asked for it by name.

Start with the problem

A file system needs to know whether a path is legal before it does anything else with it. The first version writes itself, and it puts the check right where the operation needs it.

public void mkdir(String path) {
    if (path == null || !path.startsWith("/")) {
        throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
    }
    // ... create the directory
}

For a single operation this is fine, and a reviewer would not object to it. The rule about what a path looks like lives next to the one place that currently needs it.

Then a second operation arrives, and it needs the same rule. write has to reject a malformed path exactly the way mkdir does, so the check gets copied.

public void write(String path, String content) {
    if (path == null || !path.startsWith("/")) {
        throw new IllegalArgumentException("path must be absolute (start with '/'): " + path);
    }
    // ... store the content
}

This still works, and at two copies keeping them in sync by hand costs little. Six operations later, ls, read, delete, mv and exists each carry their own copy too, because each one was written the same way the first one was.

Watch where it goes

Now a requirement lands: a reserved character has to be rejected everywhere a path is accepted. That means finding every copy of the check and editing all of them the same way.

Two costs appear, and the second is the one worth naming in an interview. The first cost is that the same lines exist six times, which is tedious to read but not yet dangerous. The second cost is that nothing forces the six copies to agree. A reviewer adding a seventh operation copies whichever version happens to be nearby, so the six drift apart one commit at a time. The day that drift reaches mkdir and write, one of them will accept a path the other refuses.

That is the real reason to act. Six operations disagreeing about what a legal path looks like is a correctness bug, not a style complaint.

The move

Path syntax and tree walking are two different questions, and they change for two different reasons. Whether "/a//b" is well-formed does not depend on what is stored at /a. So give the well-formedness question its own class.

final class PathSyntax {
    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);
        }
        // ... split into segments, reject an empty one
    }

    private PathSyntax() {}
}

Every operation now asks PathSyntax.segments(path) instead of carrying its own copy of the rule. The reserved-character requirement is one edit, in one file, and every caller picks it up because every caller was already going through it.

That is the idea behind the formal statement of this principle: a class should have one reason to change. PathSyntax changes when the definition of a well-formed path changes, and for no other reason. mkdir, write and the rest change when the sequence of their own operation changes, and they no longer have an opinion on path syntax at all.

One way to make that concrete under a clock, used in the C-track lesson this principle anchors to, is a fill-in-the-blank sentence: <Class> changes when ____. If the blank needs an "and," the class is carrying two reasons and should split at it.

What modern Java changes here

A sealed hierarchy takes some of this pressure off by construction. file-system's Node is sealed interface Node permits FileNode, DirectoryNode, and the one place that asks "what kind of node is this" is a switch expression the compiler checks for completeness. A third kind of node added later is a compile error at that one switch, not a silent wrong answer at whichever of six methods forgot to handle it. The responsibility "know every kind of node that exists" lives in one exhaustive switch instead of being re-litigated wherever an instanceof chain happens to appear.

The version of this that is wrong

Taken literally, "one reason to change" produces a class per method. Split PathSyntax's two methods apart, and it looks like more discipline, not less.

final class PathParser {
    static List<String> segments(String path) { return PathSyntax.segments(path); }
}

final class PathFormatter {
    static String join(List<String> segments) { return PathSyntax.join(segments); }
}

Run the fill-in-the-blank test on both. PathParser changes when what makes a path well-formed changes. So does PathFormatter, for the identical reason: joining segments back together uses the same notion of a legal separator that splitting them apart does. One blank, two classes, which means they are one class, and the split bought nothing but two extra files to open the next time either rule changes.

STANDARD v1.0 penalises exactly this. A minimal seam set is graded as highly as a correct decomposition, and a speculative split with no second reason to exist scores below the single class it replaced. The failure tag is over-engineered (premature interface).

Where this lives in the app

Syllabus item C1 is the full lesson: the fill-in-the-blank test in detail, and the six-row decision log for corpus/file-system. A measured contrast pair there shows what an over-split design costs before any requirement even arrives.

All reference pages