Learning pathsPricingChecking sign-in…
← LLD problem library

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.

Namespace ownership

There are no symlinks, permissions or live handles in this local tree model.

  1. Absolute lexical Path value
  2. ↓ Reject root escape and wrong kinds

    Directory traversal under owner
  3. ↓ One parent per reachable node

    Directory child map or DataFile bytes
  4. ↓ No mutable byte alias escapes

    Immutable/copy result
Atomic move

Target is an exact new name; existing-target overwrite and directory-container shorthand are not supported.

  1. Resolve source and both parents
  2. ↓ Validate before mutation

    Check root, cycle, hint and collision
  3. ↓ One monitor spans transfer

    Remove old name and insert new name
  4. ↓ 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.

filesystem: relationship map

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.

filesystem relationshipsfilesystem 1 to fs_node 0..*: owns nodes. fs_node.filesystem_id → filesystem.id.owns nodes1 parent · 0..* childrenfs_nodeid [PK]filesystem_id [FK]parent_id [FK]filesystemid [PK]version
fs_node: relationship map

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.

fs_node relationshipsfs_node 0..1 to fs_node 0..*: contains children. fs_node.parent_id → fs_node.id. fs_node 1 to file_content 0..1: owns file data. file_content.node_id → fs_node.id.contains children0..1 parent · 0..* childrenfs_nodeid [PK]filesystem_id [FK]parent_id [FK]owns file data1 parent · 0..1 childrenfile_contentnode_id [PK]bytessizefs_nodeid [PK]filesystem_id [FK]parent_id [FK]

Entity definitions and field contracts

filesystem

Namespace root owner.

FieldType / keyMeaning and reference
iduuid · PKFilesystem identity
versionbigint · VALUENamespace revision

Code mapping: FileSystem object.

  • One root directory per filesystem.

fs_node

File or directory entry.

FieldType / keyMeaning and reference
iduuid · PKNode identity
filesystem_iduuid · FKNamespace · filesystem.id
parent_iduuid? · FKNull only for root · fs_node.id
nametext · VALUEOne lexical path component
kindenum · VALUEFILE 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.

FieldType / keyMeaning and reference
node_iduuid · PK,FKFile node · fs_node.id
bytesbytes · VALUECopied content
sizebigint · VALUEByte 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 → childMultiplicityFK / why
filesystem → fs_node

owns nodes

1 parent per child; 0..* children per parentfs_node.filesystem_id → filesystem.id

Every node belongs to one namespace.

fs_node → fs_node

contains children

0..1 parent per child; 0..* children per parentfs_node.parent_id → fs_node.id

Root 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 parentfile_content.node_id → fs_node.id

FK/PK gives at most one content row; type constraint determines whether one is required.

Transaction boundaries

  1. Create: validate normalized absolute path, parent directory and absent sibling, then insert atomically.
  2. Move: resolve both paths, reject root/cycle/overwrite and change parent/name together. Recursive delete removes namespace descendants under one owner.

Indexes and uniqueness

  1. Unique parent/name with explicit root constraint; index filesystem_id,parent_id for sorted child listing.
  2. An ancestor/closure index can accelerate subtree operations at additional write cost; not required by local tree.

Schema traps and failure cases

  1. Foreign keys alone allow directory cycles.
  2. Cascade delete may traverse data but cannot replace permission/live-handle policies absent from this scoped model.

Trace the records through one operation

  1. Create /docs and /docs/a with copied bytes.
  2. Move a to /archive/a after validating destination ownership.
  3. 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.

Solution.java Java 21
import java.util.*;
/** Absolute-path in-memory tree. No symlinks, permissions, handles or persistence. */
public final class Solution {
    private Solution() {}
    private abstract static sealed class Node permits Directory,DataFile {}
    private static final class Directory extends Node { final Map<String,Node> children=new TreeMap<>(); }
    private static final class DataFile extends Node { final byte[] data; DataFile(byte[] data){this.data=data.clone();} }
    public record Stat(boolean directory,int bytes,int children){}
    private record Path(List<String> parts,boolean directoryHint){public Path{parts=List.copyOf(parts);}}
    public static final class FileSystem {
        private final Directory root=new Directory();
        private static Path parse(String raw){
            if(raw==null||!raw.startsWith("/")||raw.indexOf('\0')>=0)throw new IllegalArgumentException("absolute path");
            var parts=new ArrayList<String>();for(String part:raw.split("/")){
                if(part.isEmpty()||part.equals("."))continue;
                if(part.equals("..")){if(parts.isEmpty())throw new IllegalArgumentException("path escapes root");parts.removeLast();}
                else parts.add(part);
            }return new Path(parts,raw.endsWith("/"));
        }
        private Node resolve(List<String> parts){Node node=root;for(String part:parts){if(!(node instanceof Directory d))throw new IllegalStateException("not a directory");node=d.children.get(part);if(node==null)throw new IllegalArgumentException("not found");}return node;}
        private Node resolve(Path path){var node=resolve(path.parts());if(path.directoryHint()&&!(node instanceof Directory))throw new IllegalStateException("trailing slash needs directory");return node;}
        private Directory parent(Path path){if(path.parts().isEmpty())throw new IllegalArgumentException("root operation");var parent=resolve(path.parts().subList(0,path.parts().size()-1));if(!(parent instanceof Directory d))throw new IllegalStateException("parent not directory");return d;}
        private static String name(Path path){return path.parts().getLast();}
        public synchronized void mkdir(String raw){
            var path=parse(raw);if(path.parts().isEmpty())return;var parent=parent(path);var old=parent.children.get(name(path));if(old instanceof Directory)return;if(old!=null)throw new IllegalStateException("file exists");parent.children.put(name(path),new Directory());
        }
        public synchronized void write(String raw,byte[] data){
            Objects.requireNonNull(data);if(data.length>1_048_576)throw new IllegalArgumentException("file byte limit");var path=parse(raw);if(path.directoryHint())throw new IllegalArgumentException("file trailing slash");var parent=parent(path);if(parent.children.get(name(path)) instanceof Directory)throw new IllegalStateException("directory exists");var replacement=new DataFile(data);parent.children.put(name(path),replacement);
        }
        public synchronized byte[] read(String raw){var node=resolve(parse(raw));if(!(node instanceof DataFile file))throw new IllegalStateException("not file");return file.data.clone();}
        public synchronized List<String> list(String raw){var node=resolve(parse(raw));if(!(node instanceof Directory dir))throw new IllegalStateException("not directory");return List.copyOf(dir.children.keySet());}
        public synchronized Stat stat(String raw){var node=resolve(parse(raw));return node instanceof Directory dir?new Stat(true,0,dir.children.size()):new Stat(false,((DataFile)node).data.length,0);}
        public synchronized void move(String source,String target){
            var from=parse(source);var to=parse(target);var node=resolve(from);var oldParent=parent(from);var newParent=parent(to);
            if(to.directoryHint()&&!(node instanceof Directory))throw new IllegalStateException("target trailing slash");
            if(from.parts().equals(to.parts()))return;
            if(node instanceof Directory&&to.parts().size()>from.parts().size()&&to.parts().subList(0,from.parts().size()).equals(from.parts()))throw new IllegalArgumentException("directory cycle");
            if(newParent.children.containsKey(name(to)))throw new IllegalStateException("target exists");
            oldParent.children.remove(name(from));newParent.children.put(name(to),node);
        }
        public synchronized void delete(String raw,boolean recursive){
            var path=parse(raw);var node=resolve(path);var parent=parent(path);
            if(node instanceof Directory dir&&!recursive&&!dir.children.isEmpty())throw new IllegalStateException("directory not empty");parent.children.remove(name(path));
        }
    }
}

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
ActionState after actionInvariant
Write copied bytes to /docs/aCaller mutation cannot alter fileFile owns its byte storage
Move to /archive/aOne destination name; old name absentNamespace transfer is one atomic operation
Try overwrite via moveRejected with source intactValidate 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
ActionState after actionInvariant
Move /docs under /docs/subRejected before tree mutationA directory cannot become its own ancestor
Delete root or escape via ..RejectedRoot 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

Choose a deep dive

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.

Solution.java
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.

Open focused practice →

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.