Design a path-value file system API
Create paths and store values with strict parent rules and invalid-path handling.
- Composite — Treating a tree of parts and wholes uniformly.
- Errors, exceptions and validation — Failing loudly at the boundary and never in the middle.
- Designing method signatures — Inputs, outputs, errors and names that make misuse hard.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
01The design, in the six-step framework
1. Requirements and use cases
A client stores integer values at slash-separated paths. create("/app", 1) succeeds, and create("/app/db", 2) succeeds because its parent exists; create("/x/y", 3) fails because /x does not. Creating an existing path fails too. get returns the value or nothing, set updates only existing paths, and delete removes a leaf. A client can list children and watch a subtree for writes, in the style of a coordination service.
Out of scope: moving subtrees, persistence and ephemeral nodes. The reported new-grad round probed the object model, state transitions, invalid paths and edge cases, so those get explicit treatment.
Constraints: each operation costs the depth of the path; a malformed path is a different outcome from a missing one; a client can rely on a clean rule for what happens at each state.
2. Core entities and relationships
- PathStore: the public API, holding the root and a lock.
- PathNode: a value, children by segment and watchers.
- parse_path: the single place where a path string is validated and split.
- InvalidPath: raised for a path that is malformed, regardless of what the tree holds.
Each node composes its children, forming a composite tree in which every node has the same shape. Unlike the in-memory file system, there is no File and Directory split: any node can hold both a value and children, so a single class is the honest model.
3. Interfaces and key methods
class PathStore:
def create(self, path: str, value: int) -> bool:
"""True if created; False if the path exists or its parent does not."""
def get(self, path: str) -> int | None:
"""The value, or None if the path does not exist."""
def set(self, path: str, value: int) -> bool:
"""Update an existing path only."""
def delete(self, path: str) -> bool:
"""Remove a leaf; False for a missing path or one with children."""
def children(self, path: str) -> list[str] | None: ...
def watch(self, path: str, callback: Callable[[str, int], None]) -> None:
"""Called on writes at or under path."""
Invariants: every non-root node has a parent, which is what "parent must exist" guarantees by construction; every non-root node has an integer value; a path is stored only after passing parse_path; the root cannot be created, read or deleted through the path API.
4. Design patterns and the choices behind them
A clear contract for each state
The interview is mostly about this table, so write it down before coding. Each path is in one of three states relative to the store, and each operation has one defined result. Treating it as a small state machine turns edge cases into a checklist.
| Path state | create | get | set | delete |
|---|---|---|---|---|
| Malformed | raises | raises | raises | raises |
| Parent missing | False | None | False | False |
| Parent exists, path absent | True | None | False | False |
| Exists, no children | False | value | True | True |
| Exists, has children | False | value | True | False |
Exceptions for bugs, return values for outcomes
A failed create is an ordinary result the caller may branch on, so it returns False. A path like //a is a caller bug and raises. Mixing these up in either direction forces callers to wrap normal flow in try blocks or to miss real mistakes (see errors and validation).
A tree instead of a dict of paths
A dict from full path to value handles create, get and set in one line each, and it is a fine first answer: the parent check is a lookup of the prefix. children, the has-children check on delete and subtree watches then need a scan or a second index. The tree handles all of them naturally, and that is the reason to prefer it once those features appear.
Validation in one function
All public methods go through parse_path, which makes the API uniform. Rules such as allowed characters change in exactly one place.
5. Core flow and code
create("/app", 1): parse gives one segment, the parent is the root, the name is free, a PathNode is attached; watchers on the root are collected and called after the lock is released.create("/app/db", 2): parent/appis found and the node attached.create("/x/y", 3):_findfor/xreturns None and create returns False; nothing changes.get("/app/db")returns 2;get("/app/cache")returns None.delete("/app")returns False because a child exists;delete("/app/db")returns True, and thendelete("/app")succeeds.get("app//db")raises InvalidPath before the tree is consulted.
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Callable
class InvalidPath(ValueError):
pass
SEGMENT_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789_-")
def parse_path(path: str) -> list[str]:
"""'/leet/code' -> ['leet', 'code']; rejects '', '/', '//' and trailing slashes."""
if not path or path == "/" or not path.startswith("/") or path.endswith("/"):
raise InvalidPath(path)
parts = path[1:].split("/")
for part in parts:
if not part or not set(part) <= SEGMENT_CHARS:
raise InvalidPath(path)
return parts
@dataclass
class PathNode:
value: int | None = None # None only on the root
children: dict[str, "PathNode"] = field(default_factory=dict)
watchers: list[Callable[[str, int], None]] = field(default_factory=list)
class PathStore:
def __init__(self) -> None:
self._root = PathNode()
self._lock = threading.RLock()
def _find(self, parts: list[str]) -> PathNode | None:
node = self._root
for part in parts:
node = node.children.get(part)
if node is None:
return None
return node
def _notify(self, parts: list[str], value: int) -> list[Callable[[], None]]:
"""Collect callbacks along the path; they run after the lock is released."""
path = "/" + "/".join(parts)
calls, node = [], self._root
for part in [None] + parts:
if part is not None:
node = node.children[part]
calls.extend((lambda cb=cb: cb(path, value)) for cb in node.watchers)
return calls
def create(self, path: str, value: int) -> bool:
parts = parse_path(path)
with self._lock:
parent = self._find(parts[:-1])
if parent is None or parts[-1] in parent.children:
return False
parent.children[parts[-1]] = PathNode(value)
calls = self._notify(parts, value)
for call in calls:
call()
return True
def get(self, path: str) -> int | None:
parts = parse_path(path)
with self._lock:
node = self._find(parts)
return None if node is None else node.value
def set(self, path: str, value: int) -> bool:
parts = parse_path(path)
with self._lock:
node = self._find(parts)
if node is None:
return False
node.value = value
calls = self._notify(parts, value)
for call in calls:
call()
return True
def delete(self, path: str) -> bool:
parts = parse_path(path)
with self._lock:
parent = self._find(parts[:-1])
node = parent.children.get(parts[-1]) if parent else None
if node is None or node.children:
return False
del parent.children[parts[-1]]
return True
def children(self, path: str) -> list[str] | None:
with self._lock:
node = self._root if path == "/" else self._find(parse_path(path))
return None if node is None else sorted(node.children)
def watch(self, path: str, callback: Callable[[str, int], None]) -> None:
with self._lock:
node = self._root if path == "/" else self._find(parse_path(path))
if node is None:
raise KeyError(path)
node.watchers.append(callback)
6. Extensions, concurrency and what interviewers probe
- Directory sizes: a reported variant adds a total under a path; store a subtree sum on each node and update it along the path on writes, as in the size and validation problem.
- Recursive delete: a flag that detaches the subtree in O(depth); say that watchers beneath it are dropped with it.
- The check-then-act race: "parent exists and name free" followed by "attach" must be one critical section, or two threads can both create the same path. That is why the lock wraps the whole of
create. - Watch callbacks: collected under the lock and run after it, so a callback can call back into the store without blocking other writers, and a slow one does not hold the tree.
- Test first: walk the state table row by row; it is a ready-made test matrix. Add
/, an empty string, a trailing slash and uppercase letters as malformed inputs. - Common mistake: letting
createmake missing parents, which is the opposite of this problem's rule, and treating an empty string as the root.
02Where this question was reported
- FastPrep · Filesystem-like API that creates paths, stores and retrieves values; probed object model, state transitions, invalid paths, edge cases
- aceoffer.app · 'Total size of a file or directory, then validate the file system' (3 reports)
- techprep.app · 'Design In-Memory File System' listed as Google LLD prompt
- interviewing.io · 'Print folder structure' from file paths; 'File parsing' storage-structure design
- LLD Mastery · Commercial tag: File System (path resolution) and File Manager (chunking, metadata)