LLD-015 · LOW LEVEL DESIGN
In-Memory File System
Model a namespace tree with precise paths, defensive bytes and atomic ownership-preserving moves.
Local Java 21 reference verified: 128 checks. Learner drafts are not executed. Author self-review; independent review pending.
Existing introductory lesson · Version 1
Understand the problem
Design an in-memory file system with directories, byte files, mkdir/read/write/list/stat/move/delete. Prevent cycles and accidental source loss during failed moves. Explain which familiar operating-system behaviors are part of this contract and which are deliberately absent.
Before you start
- Tree ownership and Composite
- Path value normalization
- Defensive copying and aliasing
- Atomic namespace updates
Scope and explicit decisions
- An absolute-path in-memory tree with one root. No host filesystem access, persistence, symlinks, hard links, permissions, users, handles, open-file semantics, locking across processes or POSIX compatibility is implemented.
- Paths must start with slash and contain no NUL. Repeated slashes and . are ignored; .. lexically removes one component and rejects escaping above root. This happens before traversal: /missing/../archive/a resolves as /archive/a even if missing never existed.
- Names are case-sensitive Java strings. Directory listings use TreeMap natural String order (UTF-16 ordering), not locale collation. There is no normalization of Unicode-equivalent names or maximum path length.
- Trailing slash is a directory hint. read(file/) rejects; write always rejects trailing slash. Target paths are exact new names, not a POSIX “move into this existing directory” shorthand.
- mkdir creates only one level and requires an existing directory parent. Existing directory is a no-op; a file collision rejects. mkdir root is a no-op.
- write creates or replaces a file, never a directory. One file is limited to 1,048,576 bytes. Both write and read defensively copy bytes, so callers cannot mutate stored content through aliases. Total tree memory is not globally bounded.
- move requires a nonroot source and nonroot exact target, rejects target overwrite and directory-into-descendant cycles, and preserves node identity/content. Same normalized nonroot source/target is a no-op after type/path validation.
- delete rejects root. Nonrecursive deletion rejects nonempty directories; recursive deletion removes a subtree from the namespace. There are no external handles keeping it visible and no secure byte erasure guarantee.
- One global monitor serializes lookup and mutation. This provides local atomic namespace operations but no durability, crash recovery or real filesystem authorization.
Worked examples
Input: Write bytes to /docs/a, then mutate caller array and move to /archive/a.
Outcome: Stored bytes stay unchanged, old name disappears, and new name contains the same data.
Content ownership and namespace ownership are separate invariants.
Input: Move /docs into /docs/sub/new, or move /archive/a onto existing /archive/b.
Outcome: Both reject before removing the source.
A valid target location must be proven before namespace mutation.
Try before revealing
Hint 1
Which node type can own children, and which owns bytes?
Hint 2
What conditions must a move validate before removing the old name?
Hint 3
Which references would let a caller mutate data outside the filesystem monitor?
Requirements and invariants
R1 — Only directories own children; each reachable node has one parent and the root has none. move protects I1.
R2 — Caller byte-array mutation cannot alter stored file content. write protects I2.
R3 — Rejected moves leave source and target namespaces unchanged. move protects I3.
R4 — Path normalization cannot escape root or silently treat a file as a directory. parse protects I1.
I1: The namespace is an acyclic tree rooted at one Directory; only Directory stores named children.
I2: Each DataFile owns a private byte array, and read returns another copy.
I3: A successful move removes one old name and adds one new name atomically; ordinary validation failure removes neither.
Derive the design
A flat path map is simple until directory semantics matter
A flat map from full path to bytes supports basic file lookup, but moving a directory requires rewriting every descendant path and coordinating collisions. The tree model gives each directory child ownership and moves one subtree reference. File and directory remain distinct capabilities, avoiding methods such as read on a directory that have no valid meaning in this contract.
Alternative: A flat indexed namespace can be efficient with transactional prefix operations or inode/dentry models, but that is a different storage design. An operating-system filesystem already supplies many mature guarantees; this exercise studies the domain representation without claiming to replace it.
Declare compatibility scope before borrowing familiar names
read, mkdir and move evoke OS behavior, but this model chooses exact target paths and lexical normalization without symlinks. State those differences so users do not assume unsupported POSIX semantics.
Choose one authoritative tree owner
Directory child maps define containment. No parent pointer is needed for this reference because traversal finds the parent from the normalized path; externally exposed handles would require a new identity/lifetime model.
Validate a move against the whole relation
A target parent may be inside the source subtree. Component-prefix validation prevents cycles, while collision checks protect no-overwrite semantics. Do this before deleting the original name.
Make array aliasing a correctness test
Java arrays are mutable even if a reference is final. Copy on write and read, then prove the boundary with caller mutations rather than merely labelling the field private.
Responsibilities and ownership
FileSystem
Owns: Root, path traversal and every namespace mutation
Methods: mkdir, write, read, list, stat, move, delete
One local monitor owns compound operations.
Directory
Owns: Sorted child-name→Node map
Methods: Private namespace state
Cannot contain duplicate names; it is not a byte file.
DataFile
Owns: Private copied byte storage
Methods: Private immutable contents
No child map or directory behavior.
Path / Stat
Owns: Canonical components/type hint and immutable observations
Methods: parse / record accessors
Path is a value; normalization policy is not an authorization mechanism.
There are no symlinks, permissions or live handles in this local tree model.
- Absolute lexical Path value
↓ Reject root escape and wrong kinds
Directory traversal under owner↓ One parent per reachable node
Directory child map or DataFile bytes↓ No mutable byte alias escapes
Immutable/copy result
Target is an exact new name; existing-target overwrite and directory-container shorthand are not supported.
- Resolve source and both parents
↓ Validate before mutation
Check root, cycle, hint and collision↓ One monitor spans transfer
Remove old name and insert new name↓ No descendant path rewrites
Same subtree identity at target
Data model, schema and relationships
A filesystem namespace is a tree of directory entries; files own bytes. Parent ownership and sibling-name uniqueness differ from POSIX inode/hard-link semantics, which are outside this reference.
Implementation boundary: Local directory/file nodes own copied bytes and atomic moves. The proposed table mirrors this tree; it does not implement disk durability, permissions, handles or symlinks.
These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Entity definitions and field contracts
filesystem
Namespace root owner.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Filesystem identity |
version | bigint · VALUE | Namespace revision |
Code mapping: FileSystem object.
- One root directory per filesystem.
fs_node
File or directory entry.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Node identity |
filesystem_id | uuid · FK | Namespace · filesystem.id |
parent_id | uuid? · FK | Null only for root · fs_node.id |
name | text · VALUE | One lexical path component |
kind | enum · VALUE | FILE or DIRECTORY |
Code mapping: Node tree.
- Unique(filesystem_id,parent_id,name) for nonroot; separately constrain one root because SQL NULL uniqueness differs. Parent must be directory in same filesystem.
file_content
Owned file bytes.
| Field | Type / key | Meaning and reference |
|---|---|---|
node_id | uuid · PK,FK | File node · fs_node.id |
bytes | bytes · VALUE | Copied content |
size | bigint · VALUE | Byte count |
Code mapping: Private copied byte array.
- Exactly one content record for FILE and none for DIRECTORY; subtype validation required.
Relationship cardinalities and their reasons
| Parent → child | Multiplicity | FK / why |
|---|---|---|
| filesystem → fs_node owns nodes | 1 parent per child; 0..* children per parent | fs_node.filesystem_id → filesystem.idEvery node belongs to one namespace. |
| fs_node → fs_node contains children | 0..1 parent per child; 0..* children per parent | fs_node.parent_id → fs_node.idRoot has no parent. Every other node has one directory parent; cycle prohibition needs traversal/transaction validation. |
| fs_node → file_content owns file data | 1 parent per child; 0..1 children per parent | file_content.node_id → fs_node.idFK/PK gives at most one content row; type constraint determines whether one is required. |
Transaction boundaries
- Create: validate normalized absolute path, parent directory and absent sibling, then insert atomically.
- Move: resolve both paths, reject root/cycle/overwrite and change parent/name together. Recursive delete removes namespace descendants under one owner.
Indexes and uniqueness
- Unique parent/name with explicit root constraint; index filesystem_id,parent_id for sorted child listing.
- An ancestor/closure index can accelerate subtree operations at additional write cost; not required by local tree.
Schema traps and failure cases
- Foreign keys alone allow directory cycles.
- Cascade delete may traverse data but cannot replace permission/live-handle policies absent from this scoped model.
Trace the records through one operation
- Create /docs and /docs/a with copied bytes.
- Move a to /archive/a after validating destination ownership.
- Moving /archive into its descendant rejects before changing any parent pointer.
Method contracts
mkdir(path)
Normalize absolute path, require parent directory and create one level. Existing directory/root is a no-op; file collision rejects.
write(path,bytes); read(path) → copied bytes
Enforce file kind and size limit. write clones before insertion; read clones before returning. Writing over a directory or reading one rejects.
list(path) → immutable sorted names; stat(path) → Stat
list requires directory and returns names only. stat reports type, file byte count or direct child count; no recursive size or metadata history is implied.
move(source,target)
Resolve source and both parents, validate trailing hint, root restrictions, cycle and no-overwrite policy before removing the old name. Hold one monitor for the complete transfer.
delete(path,recursive)
Root rejects. A nonempty directory requires recursive=true; otherwise remove the selected name atomically. No permissions or live handles are modeled.
What, when, why and how
Each concept is attached to the requirement it protects, its implementation, an alternative and a test.
Composite with meaningful type boundaries
- what
- Represent hierarchical containment while preserving differences between directories and files.
- when
- Paths traverse nested directories and operations may target either kind.
- why
- A single mutable “node with optional bytes and children” permits impossible file-directory combinations.
- how
- A sealed Node has Directory with child map and DataFile with bytes. resolve checks Directory at each traversal; operations require the appropriate concrete capability.
Code: Node, Directory, DataFile, resolve · Invariants: I1
Alternatives, cost and failure example
Alternative: Separate interfaces for file/directory capabilities also work. Do not force a universal Node API where half the methods throw unsupported operations by design.
Cost: Traversal costs depend on depth and child-map lookup; the tree naturally supports moving a subtree without rewriting every descendant name.
Counterexample: A file accidentally acquires children after mkdir(file/child).
Test: Write a file, attempt a child mkdir and assert rejection; verify directory stat/list remain distinct from file read.
Canonical path as an explicit value contract
- what
- Parse and normalize path components once before authoritative traversal.
- when
- Inputs contain repeated separators, . or ...
- why
- Duplicated normalization logic can disagree about identity or permit root escape.
- how
- parse returns immutable components plus a trailing-directory hint. .. rejects above root; resolve later enforces node types. Lexical normalization is deliberately not POSIX traversal semantics.
Code: Path, parse, resolve · Invariants: I1
Alternatives, cost and failure example
Alternative: A real filesystem with symlinks needs traversal-aware resolution and authorization; lexical string cleanup alone is not a sandbox.
Cost: Parsing is O(L) in path characters, plus copied components. Name equality/collation is case-sensitive Java String behavior here.
Counterexample: Treat /a and /ab as ancestor/descendant using raw string prefix instead of components.
Test: Test repeated separators, . and .., root escape, trailing slash on a file and lexical removal of a nonexistent component.
Atomic namespace transfer and cycle prevention
- what
- Move one owned subtree reference after validating its exact new location.
- when
- Two callers rename the same source or a directory is moved under itself.
- why
- Removing first can lose the source on target failure; inserting under a descendant creates a cycle.
- how
- move resolves source/parents, compares normalized component prefix for directory cycles and checks target collision, then removes/inserts under one monitor.
Code: move, parent · Invariants: I1, I3
Alternatives, cost and failure example
Alternative: Per-directory locks need deterministic ordering and protection against ancestor changes during traversal. A persistent store needs an actual atomic namespace transaction.
Cost: Global locking serializes large read/write copies too; optimize after measuring contention and preserving ownership invariants.
Counterexample: Move /docs to /docs/sub/new or remove /a before discovering /b exists.
Test: Assert cycle and overwrite rejection leave originals intact; race two destinations for one source and get one winner.
Defensive byte ownership
- what
- Copy mutable data at both input and output boundaries.
- when
- Callers retain arrays after write or mutate arrays returned by read.
- why
- Without copies, external code changes file contents without a validated write or lock.
- how
- DataFile clones incoming bytes, and read clones its private array. Stat/list expose immutable scalars/name lists.
Code: DataFile, write, read · Invariants: I2
Alternatives, cost and failure example
Alternative: Immutable byte containers or ownership-transfer APIs can avoid copies if their contract is enforced. Generic “readonly” documentation is not enough for a mutable Java byte array.
Cost: Read/write are O(B) and allocate copied bytes for file size B. Per-file limit does not bound all files or transient copies.
Counterexample: Caller writes {1,2,3}, then changes its original array to {9,2,3} and stored data changes silently.
Test: Mutate original and returned arrays independently; subsequent reads must preserve original bytes.
Read and run the Java reference
Reveal complete Solution.java
Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.
parse normalizes components before traversal
The directory hint survives normalization. Root escape rejects, while removed lexical components need not exist. This is an explicit contract choice rather than a hidden security guarantee.
resolve enforces kind at every step
A path cannot traverse through DataFile. The final trailing slash additionally requires Directory, so file/ does not silently resolve as a normal file.
move validates both parents and target before unlink
The source remains reachable until every ordinary validation has succeeded. A global monitor prevents another mutation between those checks and the transfer.
write creates a replacement owned buffer
The new DataFile clones data before insertion, so subsequent caller mutation does not touch authoritative contents. read returns another clone.
Complexity and limits
- Parsing costs O(L) for path length L. Traversal at depth D performs TreeMap lookups, roughly O(sum(log children_i)) plus String comparison cost; it is not unconditional O(D) hash lookup.
- list copies C names in existing sorted order: O(C). There is no additional sort because Directory uses TreeMap. stat is O(path traversal) plus constant direct metadata extraction.
- read/write copy B bytes: O(B) time and returned/stored buffer space. The per-file cap is 1 MiB, but total namespace memory is unbounded.
- move changes two directory maps after traversal/cycle checks, independent of descendant count; recursive delete unlinks a subtree, with eventual garbage-collection work proportional to its contents. No secure erasure or handle semantics are provided.
Walk through the actual reference
These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.
Defensive file bytes and atomic rename
Step 1 of 3: Write copied bytes to /docs/a
Caller mutation cannot alter file
Invariant: File owns its byte storage
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Write copied bytes to /docs/a | Caller mutation cannot alter file | File owns its byte storage |
| Move to /archive/a | One destination name; old name absent | Namespace transfer is one atomic operation |
| Try overwrite via move | Rejected with source intact | Validate target before removing source |
Reject directory cycle and unsafe root operations
Step 1 of 2: Move /docs under /docs/sub
Rejected before tree mutation
Invariant: A directory cannot become its own ancestor
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Move /docs under /docs/sub | Rejected before tree mutation | A directory cannot become its own ancestor |
| Delete root or escape via .. | Rejected | Root remains the single tree owner |
Mistakes and counterexamples
Use raw string prefix to detect cycles
Failing case: /a and /ab share text prefix but are different components.
Repair: Compare normalized component sequences.
Delete source before validating target
Failing case: Rename fails on collision after original removal.
Repair: Validate all ordinary failure conditions before atomic transfer.
Return private byte arrays
Failing case: Caller changes file contents without write.
Repair: Defensively copy or enforce an immutable ownership-transfer type.
Treat path normalization as full authorization
Failing case: A later symlink feature bypasses simple lexical assumptions.
Repair: Define traversal/security at the actual resource boundary; this model has no permissions or symlinks.
Engineer → Senior → Staff
Implement a clear namespace and byte contract.
Executed: absolute paths, single-level mkdir, byte read/write, listing, stat and deletion.
Change exercise
Add case/Unicode identity tests without silently introducing locale normalization.
Worked answer guidance
Use the declared String equality/order behavior. If normalization is desired, define it at creation and lookup consistently and handle collisions explicitly.
Interview follow-ups
Why does mkdir not create missing parents?
The contract chooses one-level creation. A mkdir-p extension needs explicit partial-failure and file-collision semantics.
Why clone both input and output arrays?
Either boundary can leak mutation. Private storage alone does not help if its array is returned directly.
Your design attempt
Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.
Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.
Run program · Java 21
No unsaved changes · 208/20000
Self-review
Checks apply to this visit. Self-review is not a passed submission or verified mastery.
Provenance and scope of evidence
Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.
Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.