Low-Level Design (Object-Oriented Design) Module 16 – Case studies: infrastructure components
Low-level design: in-memory file system
Design an in-memory file system in code: files and folders as a Composite, POSIX path resolution, moves that refuse their own subtree, and find-style search.
What you will learn
- Model files, directories and paths with the Composite pattern
- Resolve absolute and relative paths with '.', '..' and repeated slashes
- Implement mkdir, write, read, ls, mv and rm so that a failed call changes nothing
- Search files with composable filters, as the find utility does
Before you start
What you’ll learn · Before you start
What you will learn
- Model files, directories and paths with the Composite pattern
- Resolve absolute and relative paths with '.', '..' and repeated slashes
- Implement mkdir, write, read, ls, mv and rm so that a failed call changes nothing
- Search files with composable filters, as the find utility does
Before you start
On this page
Designing an in-memory file system is a low-level design (LLD) exercise in classes: hold a tree of directories
and files in memory, and run a small shell over it that makes directories, writes and reads files, lists, moves,
removes and searches. Five decisions carry the model in this case study. Files and directories share one interface, the
Composite pattern, so a size or a search treats a whole tree and a single file alike. A directory maps each child’s
name to the child, so a name lives in exactly one place. Every operation turns its path into nodes with one resolver
that walks the tree component by component, as POSIX describes pathname resolution. A move checks everything before
it changes anything, and never puts a directory inside itself. And a search is built from small filter objects, the
way find combines its tests.
The prompt, the requirements and the first design
The prompt: “Design an in-memory file system with a shell-like API.” Four questions shape the answer. One user, or permissions too? Paths relative to a current directory? Links? Several threads? Here the answers are one user, both kinds of path, no links yet and one thread first, and they become the requirements:
- F1
mkdir(path)creates a directory; withparents, also the missing ones above it, likemkdir -p. - F2
write(path, text)creates or replaces a file,read(path)returns its text andls(path)lists names. - F3
mv(src, dst)renames or moves, intodstwhen it is a directory, never into the moved directory itself. - F4
rm(path)removes a file or an empty directory;recursiveremoves a whole tree. - F5
find(path, filter)lists the paths belowpaththat match tests of name, type, size and age. - N1 Paths are absolute or relative;
.,..and repeated slashes mean what POSIX says. - N2 A failed call changes nothing and names its error as POSIX does:
ENOENT,ENOTDIR,EINVAL. - N3 Time comes from a clock the file system is given, so tests choose it.
N1 decides more than it seems. A tempting shortcut cleans the path as a string, as Python’s posixpath.normpath()
does, and only then looks it up. The run below resolves eight paths from /home/asha, where notes.txt is a file
and src a directory, both ways:
"""Cleaning a path as a string, against walking it through the tree (lesson lld/in-memory-file-system).
posixpath.normpath() removes repeated slashes, "." and "name/.." by looking at the string alone. The resolver of
this lesson walks the tree one component at a time, as POSIX pathname resolution does, so it notices when a name
is missing or is a file. The tree is a dict of dicts, a str is a file's content, and the current directory is
/home/asha."""
import posixpath
TREE = {"home": {"asha": {"notes.txt": "buy milk", "src": {"app.py": "print('hi')"}}}, "tmp": {}}
CWD = ["home", "asha"]
def node_at(names):
node = TREE
for name in names:
node = node[name]
return node
def walk(path):
"""The absolute path that path names, or the error resolution stops with."""
names = [] if path.startswith("/") else list(CWD)
node = node_at(names)
for part in path.split("/"):
if not part: # repeated, leading and trailing slashes
continue
if not isinstance(node, dict):
return f"ENOTDIR: /{'/'.join(names)} is a file"
if part == "..":
names = names[:-1] # at the root, ".." is the root
elif part != ".":
if part not in node:
return f"ENOENT: /{'/'.join(names)} has no {part}"
names.append(part)
node = node_at(names)
if path.endswith("/") and not isinstance(node, dict):
return "ENOTDIR: a trailing slash needs a directory"
return "/" + "/".join(names)
PATHS = [
"/home//asha/./src/",
"src/../notes.txt",
"/../../tmp",
"notes.txt/..",
"missing/../notes.txt",
"src/app.py/../../notes.txt",
"notes.txt/",
"//tmp",
]
print(f"{'path':28} {'cleaned as a string':22} walked through the tree")
differ = 0
for path in PATHS:
cleaned = posixpath.normpath(posixpath.join("/home/asha", path))
walked = walk(path)
differ += cleaned != walked
print(f"{path:28} {cleaned:22} {walked}")
print(f"{differ} of {len(PATHS)} paths get a different answer from the string and from the tree") Output
path cleaned as a string walked through the tree /home//asha/./src/ /home/asha/src /home/asha/src src/../notes.txt /home/asha/notes.txt /home/asha/notes.txt /../../tmp /tmp /tmp notes.txt/.. /home/asha ENOTDIR: /home/asha/notes.txt is a file missing/../notes.txt /home/asha/notes.txt ENOENT: /home/asha has no missing src/app.py/../../notes.txt /home/asha/notes.txt ENOTDIR: /home/asha/src/app.py is a file notes.txt/ /home/asha/notes.txt ENOTDIR: a trailing slash needs a directory //tmp //tmp /tmp 5 of 8 paths get a different answer from the string and from the tree
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lexical.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Five answers differ. notes.txt/.. names nothing, because .. needs a directory before it; the string version
answers /home/asha anyway. missing/../notes.txt fails because missing does not exist, and notes.txt/ because
a trailing slash names a directory. The last line is a choice, not a bug: POSIX lets an implementation give exactly
two leading slashes a meaning of its own, and this design reads them as the root.
The in-memory file system's class diagram
Text description of the diagram
The diagram shows seven boxes in UML notation. Each box names a class, then lists its attributes and, below a line, its operations.
- FileSystem holds the root and the current directory, both Directories, and a clock that returns seconds. Its operations are resolve(path), returning a Node; mkdir, write, read, ls and cd; mv(src, dst) and rm(path); and find(path, match, skip), returning paths. An arrow labelled "root, cwd" leads to Directory.
- Node has a name, a parent Directory and a modified time, and the operations path(), returning a string, and size(), returning bytes.
- File and Directory each point to Node with a hollow triangle: both are kinds of Node. File adds its data, as text. Directory adds its children, a map from a name to a Node.
- A line from Directory to Node has a filled diamond at Directory's end, labelled 1, and an arrow at Node's end, labelled 0..*: a directory is composed of zero or more child nodes, and each node belongs to exactly one directory.
- An arrow labelled "find() asks" leads from FileSystem to Filter, whose operation matches(node, now) returns a boolean.
- Two boxes point to Filter with hollow triangles: the primaries Name, Type, Size and Modified, whose value is a pattern, f or d, a size such as n or nc, or a number of days; and the combinators And, Or and Not, whose operands are one or two other Filters.
The design makes six choices, each with one main option:
- Composite nodes:
FileandDirectoryshareNode, so size and search treat a tree and a leaf alike. - Names in the parent: a directory maps names to children, so a rename changes one entry.
- Parent links: each node knows its parent, so
..and the subtree check walk upwards. - One resolver: every operation resolves its path with the same walk, component by component.
- Checks before changes: a move or a removal validates everything first, so a failure leaves the tree as it was.
- Filters as objects: each search test is an object with
matches(), combined byAnd,OrandNot.
The deep dives that decide whether this design holds:
- How does one resolver serve every operation, including those that create the last component?
- What does
mvcheck, in which order, and what does a careless move do to the tree? - Should a directory compute its size or cache it, and what does each cost?
- How do
find’s tests become filter objects, with its rounding, its precedence and pruning? - What changes when two threads rename at once?
- What do symbolic links add to resolution and to recursive calls?
An answer is judged on the requirements, the split between nodes, resolver and operations, the move rules, the size trade-off, the search design and the failure handling.
One resolver for every operation Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Moves: six checks, then two changes
- Two careless moves, reproduced
- A directory’s size: computed or cached, with numbers
- find’s tests as filter objects
- Two threads, and symbolic links later
- Designs this one rejects
- What each level is expected to cover
- A 45-minute self-mock
- Practise: symbolic links with loop detection
- Symbolic links in the resolver, with loop detection
- Check yourself
- Interview questions
- Why do file system designs model files and directories with the Composite pattern?
- mv /projects /projects/archive/old must fail, while mv /projects/archive /old must work. How does your design tell the two apart, what does the check cost, and what would a move without it do to the tree?
- du on the root of your in-memory file system takes 2 seconds with 5 million files, and a quota check needs the size of a user's folder on every write. Would you cache directory sizes? Give the costs in numbers and say how you keep the totals right.
- Two threads run mv /a/x /b/x/y and mv /b/x /a/x/z at the same moment on your in-memory file system. Each move passes its subtree check. What can go wrong, and how do you lock so that it cannot?
- Key takeaways
1 diagram · 4 runnable examples (drills.py, filters.py, fs.py, search.py, session.py, sizes.py) · 1 exercise with sample tests · 3 interview answers · about 29 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
References
- POSIX.1-2024 Base Definitions, 4.16 Pathname Resolution (The Open Group and IEEE)
- POSIX.1-2024 Base Definitions, 3 Definitions (3.254 Pathname) (The Open Group and IEEE)
- POSIX.1-2024: rename() (The Open Group and IEEE)
- POSIX.1-2024: rmdir() (The Open Group and IEEE)
- POSIX.1-2024: find (The Open Group and IEEE)
- POSIX.1-2024: limits.h (SYMLOOP_MAX) (The Open Group and IEEE)
- os.path: normpath (Python Software Foundation)
- pathlib: Object-oriented filesystem paths (Python Software Foundation)
- fnmatch: Unix filename pattern matching (Python Software Foundation)
- Directory Locking (The Linux Kernel documentation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress