Object-Oriented Design Interview

Course Content

Object-Oriented Design Interview

14 sections · 29 lessons

File Search: composable filters, traversal and extensions


The file tree is in place, and the traversal over it has no type checks. Now the problem's real test: the search criteria. This lesson builds the filter design the round is scoring, writes the walk that uses it, and then shows how the finished design absorbs each extension question with new classes rather than edits.

The filter design

This is the insight the problem is testing. The same structure used for the file tree applies a second time, now to the search criteria.

Criteria composed into a treeANDext is .logORover 1 MBafter Jan 1
A composite filter is a filter, so the traversal calls matches() once and never knows the depth.

The naive filter, and how it decays

Most first attempts produce something shaped like this:

Java
public class SearchCriteria {    private String name;    private String extension;    private Long minSize;    private Instant modifiedAfter;}public boolean matches(FileEntry f, SearchCriteria c) {    if (c.name != null && !f.getName().equals(c.name)) return false;    if (c.extension != null && !f.extension().equals(c.extension)) return false;    if (c.minSize != null && f.size() < c.minSize) return false;    if (c.modifiedAfter != null && f.getModifiedAt().isBefore(c.modifiedAfter)) return false;    return true;}

It works, and it fails three ways.

Adding a criterion edits two places — a field on SearchCriteria and a branch in matches. That violates requirement N1 directly.

It can only express AND. ".log files OR anything over 100 MB" has nowhere to go. Adding OR means adding a mode flag, and then nesting is impossible.

Every criterion is optional and null-checked, so the class carries four nulls to express one condition, and nothing stops a caller setting minSize above maxSize.

The repair: one interface, one method

Java
public interface Filter {    boolean matches(FileSystemEntry entry);}

That is the whole contract. Each criterion becomes a small class that holds its own parameters:

Java
public class NameFilter implements Filter {    private final Pattern pattern;                     // compiled from a glob    private final boolean caseSensitive;    public boolean matches(FileSystemEntry e) {        String n = caseSensitive ? e.getName() : e.getName().toLowerCase();        return pattern.matcher(n).matches();    }}public class SizeFilter implements Filter {    private final long min, max;                       // Long.MAX_VALUE for "no upper bound"    public boolean matches(FileSystemEntry e) {        long s = e.size();        return s >= min && s <= max;    }}public class ModifiedAfterFilter implements Filter {    private final Instant cutoff;    public boolean matches(FileSystemEntry e) { return e.getModifiedAt().isAfter(cutoff); }}

Each class holds exactly the parameters it needs. No nulls. No shared class to edit.

Composite, the second time

Now the move that makes this design good. The combinators are themselves filters, and they hold filters:

Java
public class AndFilter implements Filter {    private final List<Filter> filters;    public boolean matches(FileSystemEntry e) {        return filters.stream().allMatch(f -> f.matches(e));    // short-circuits    }}public class OrFilter implements Filter {    private final List<Filter> filters;    public boolean matches(FileSystemEntry e) {        return filters.stream().anyMatch(f -> f.matches(e));    }}public class NotFilter implements Filter {    private final Filter inner;    public boolean matches(FileSystemEntry e) { return !inner.matches(e); }}

Twelve lines, and the design is now complete. Any boolean expression over any criteria is a tree of these objects:

Java
// (*.log OR *.txt) AND larger than 10 MB AND NOT modified in the last dayFilter query = new AndFilter(List.of(    new OrFilter(List.of(new ExtensionFilter("log"), new ExtensionFilter("txt"))),    new SizeFilter(10L * 1024 * 1024, Long.MAX_VALUE),    new NotFilter(new ModifiedAfterFilter(clock.now().minus(Duration.ofDays(1))))));

FileSearcher sees one Filter. It has no idea whether it is holding a name check or a twelve-node expression tree, and it will never need to change again.

Why this is the same pattern twice

File treeFilter tree
Common interfaceFileSystemEntryFilter
LeafFileEntryNameFilter, SizeFilter, …
ContainerDirectory holds entriesAndFilter/OrFilter hold filters
Recursionsize() sums childrenmatches() asks children
What it buysNo type checks in traversalNo branching in the searcher

Say this out loud. "This is Composite again — the same shape as the file tree, applied to the criteria. A combinator is a filter that holds filters, so the searcher only ever sees one filter." Naming the repeat is what makes the answer look like understanding rather than recall.

Traversal and filtering, in code

Three things to write: the recursive walk, the iterative version for deep trees, and the optimisation about which directories to skip.

Where the filter is applied, and the bugVisit adirectoryList its entriesRecurseinto subdirsTest each fileCollect matchesPruning a directory because it fails a file filter is the subtle bug — a .log file can live inside src/.
A filter that fails a directory must not stop the walk; only a directory-aware filter may prune.

The recursive walk

With Composite in place, this is as short as promised:

Java
public class FileSearcher {    public List<FileSystemEntry> search(Directory root, Filter filter) {        List<FileSystemEntry> results = new ArrayList<>();        walk(root, filter, results);        return results;    }    private void walk(FileSystemEntry entry, Filter filter, List<FileSystemEntry> results) {        if (filter.matches(entry)) results.add(entry);        if (entry instanceof Directory dir) {            for (FileSystemEntry child : dir.getChildren()) walk(child, filter, results);        }    }}

One instanceof remains, and it is honest: only a directory has children to descend into. An alternative is a children() method on FileSystemEntry that returns an empty list for files, which removes the check at the cost of giving files a method that means nothing for them. Both are defensible; mention the trade-off and pick one.

The iterative version, for deep trees

Recursion depth equals tree depth. A stack overflow on a pathological tree — or an infinite one created by a symbolic link cycle — turns a search into a crash. An explicit stack removes the limit:

Java
public List<FileSystemEntry> searchIterative(Directory root, Filter filter) {    List<FileSystemEntry> results = new ArrayList<>();    Deque<FileSystemEntry> stack = new ArrayDeque<>();    stack.push(root);    while (!stack.isEmpty()) {        FileSystemEntry entry = stack.pop();        if (filter.matches(entry)) results.add(entry);        if (entry instanceof Directory dir) {            for (FileSystemEntry child : dir.getChildren()) stack.push(child);        }    }    return results;}

Same traversal, heap memory instead of stack memory. Swap push/pop for a queue's add/poll and you get breadth-first order, which finds shallow matches sooner — worth mentioning when the requirement is "first ten matches".

Where to apply the filter, and the subtle bug

Requirement N3 said not to descend directories that cannot match. The obvious implementation is wrong:

Java
// WRONGif (entry instanceof Directory dir && filter.matches(dir)) {    for (FileSystemEntry child : dir.getChildren()) walk(child, filter, results);}

A filter for *.log does not match the directory /var/log/archive, so this skips the directory and finds nothing inside it. A directory failing the filter says nothing about its children.

The correct version separates two different questions — does this entry match? and could anything under here match? — with a second, optional interface:

Java
public interface PruningFilter extends Filter {    boolean couldContainMatch(Directory dir);   // false ⇒ safe to skip the whole subtree}// In the walk:if (entry instanceof Directory dir) {    if (filter instanceof PruningFilter p && !p.couldContainMatch(dir)) return;  // prune    for (FileSystemEntry child : dir.getChildren()) walk(child, filter, results);}

Only filters that can honestly answer the second question implement it. A path-prefix filter can: nothing under /home can be inside /var/log. A size filter cannot — a small directory can contain a huge file. A name filter cannot.

That distinction is the interesting part of the traversal, and stating it — "a filter's verdict on a directory tells you nothing about its children unless the filter is about the path" — is the sentence worth saying.

Extensions

1. Content search (grep)

"Find .log files containing the word timeout" adds a filter that reads bytes:

What the model absorbs, and what it does notAbsorbed by the interface• Content search is one more Filter• New criteria combine with the old ones• Any AND and OR nesting already worksNeeds genuinely new work• Symlink cycles need a visited set• Streaming needs an iterator, not a list• Visitor, for operations beyond search
Grep costing nothing is the proof the filter abstraction was drawn at the right place.
Java
public class ContentFilter implements Filter {    private final Pattern pattern;    public boolean matches(FileSystemEntry e) {        if (e.isDirectory()) return false;        return contentReader.linesOf(e).anyMatch(line -> pattern.matcher(line).find());    }}

The design absorbs it with no changes — that is the payoff for the filter design above. But say the cost out loud: this filter is thousands of times more expensive than a name check, because it reads the file. So order matters inside AndFilter: evaluate cheap filters first so the expensive one runs on the few files that survive.

That is a two-line change with a large effect, and it is a good thing to volunteer:

Java
public class AndFilter implements Filter {    private final List<Filter> filters;   // sorted by estimated cost, cheapest first}

Adding an estimatedCost() method to Filter lets AndFilter sort automatically. Mention it; implement it only if there is time.

2. Symbolic links and cycles

A symbolic link is a filesystem entry that points at another entry. Two consequences:

  • A link to a parent directory creates an infinite tree. /a/b/link → /a means the walk never ends.
  • The same file can be reached by several paths, so results contain duplicates.

Both are fixed by tracking visited identities — not paths, since the whole problem is that one file has several paths. Use the filesystem's inode number, or object identity in an in-memory model:

Java
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());if (!visited.add(entry)) return;    // already walked this one

Real find defaults to not following symbolic links for exactly this reason, and offers a flag to follow them. Mentioning that default is a nice domain touch.

3. Very large directories and streaming

Returning List<FileSystemEntry> means the whole result set is in memory before the caller sees anything. For a search matching two million files, that is a problem, and it also prevents the caller from stopping early.

Return a lazy stream or accept a callback instead:

Java
public void search(Directory root, Filter filter, Consumer<FileSystemEntry> onMatch)// orpublic Stream<FileSystemEntry> searchLazily(Directory root, Filter filter)

The callback version is simpler and enough to make the point: the caller can stop by throwing, memory is constant, and the first result arrives immediately rather than after the full walk. This is the answer to "what if there are a hundred million files?" — which is the scale follow-up this problem attracts.

4. Visitor, for operations beyond search

If the requirement grows to "delete matches", "count by extension", "compute total size", then search is one operation among several over the same tree. Rather than adding a method to FileSystemEntry per operation, add a Visitor:

Java
public interface FileSystemVisitor<R> {    R visitFile(FileEntry file);    R visitDirectory(Directory dir);}

Be honest about the trade-off, because it goes the opposite way to Composite: Visitor makes new operations cheap and new entry types expensive, since every visitor must gain a method when a type is added. In a filesystem there are exactly two entry types and they will never change, so that trade is favourable here. In a design where new types arrive regularly it would be the wrong choice.