Engineering Atlas › Low-Level Design

Code an in-memory file system

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: persistence, permissions and owners, symbolic links, file handles with seek offsets, and quotas.

Entities and relationships

Relationships: a directory is composed of its children; removing a directory removes them. The file system composes the root. A path does not reference nodes at all; it is resolved on every call, which is what keeps it valid across renames.

Class diagram

classDiagram
    class FileSystem {
      -root: Directory
      -lock: RWLock
      +mkdir(path) void
      +write(path, data, append) void
      +read(path) string
      +ls(path) string[]
      +rm(path, recursive) void
      +mv(src, dst) void
      +du(path) int
    }
    class Node {
      <<abstract>>
      +isDir() bool
      +size() int
    }
    class File {
      -content: string
      +append(data) void
    }
    class Directory {
      -children: Map~string, Node~
      +child(name) Node
      +attach(name, node) void
      +detach(name) Node
    }
    class Path {
      +parts: string[]
      +parent() Path
      +name() string
      +isPrefixOf(other) bool
    }
    FileSystem *-- Directory : root
    Node <|-- File
    Node <|-- Directory
    Directory "1" *-- "many" Node : children
    FileSystem ..> Path : parses

Key flows

mv /docs/a /docs/archive/a resolves both parents, checks for a cycle and an existing target, then detaches and reattaches the node under one write lock. No content is copied.

sequenceDiagram
    participant C as Caller
    participant F as FileSystem
    participant P as Path
    participant S as srcParent
    participant D as dstParent
    C->>F: mv("/docs/a", "/docs/archive/a")
    F->>P: normalise both paths
    F->>F: dst under src? no
    F->>F: write lock
    F->>S: child("a")
    S-->>F: node
    F->>D: child("a")?
    D-->>F: none
    F->>S: detach("a")
    F->>D: attach("a", node)
    F->>F: unlock
    F-->>C: ok

Design patterns used and why

What not to put in the model: a parent pointer on every node unless you need upward walks, because it must be updated on every move and is easy to leave stale; and the node's own name, for the reasons above.

Concurrency and edge cases

Down the stack

Layer 1The contract

Every operation either succeeds completely or fails with one specific error and changes nothing. After mv(src, dst) returns, src does not resolve and dst resolves to the same node with the same content, and no reader ever sees both or neither. ls returns names in byte order. du equals the sum of content lengths of all files under the path at one instant. Resolution cost is proportional to path depth, not to the number of files.

Layer 2The mechanism

Linux splits the same ideas into objects you can name. A dentry (directory entry) maps a name in a parent to an inode; the inode holds size, timestamps, link count and the pointers to data. The dentry cache (dcache) is a hash table keyed by (parent dentry, name), so path walking is one hash lookup per component, mostly lock-free under RCU. rename(2) is atomic with respect to lookups: another process sees either the old name or the new one. Linux's own tmpfs is an in-memory file system built from exactly these pieces, with page-cache pages as file content. On disk, ext4 stores large directories as an HTree, a hashed B-tree of names, so lookup in a 100,000-entry directory stays a few block reads. Our design is the same shape in miniature: the directory map plays the dentry role, the node object plays the inode role.

Layer 3The machine

Each path component in the in-memory design costs one hash of a short string (a few ns) and one map probe, which is usually one or two cache misses at around 80 ns each from DRAM. A 6-level path resolves in well under a microsecond. Memory per entry is dominated by overhead: a Go map entry plus string header plus node struct is roughly 100 to 200 bytes before any content, so a million empty files cost on the order of 150 MB. An uncontended sync.RWMutex read lock costs tens of nanoseconds, but on many cores the shared reader counter becomes a contended cache line, which is why the kernel uses RCU for path walks instead of a reader lock.

Why it's built this way

  1. Users think in paths, but paths are just names; the stable thing is the node. So directories map names to nodes and nodes do not store their names.
  2. Because names live only in directories, rename is "remove one map entry, add another", O(1) and with no data copied, even for a directory holding gigabytes.
  3. Cheap rename invites misuse (moving a directory under itself), so the cycle check is mandatory at the one place that changes structure.
  4. Lookups vastly outnumber mutations, so reads share a lock (or, in the kernel, avoid locks with RCU), while structural changes serialise.
  5. Recursive operations dominate the rest of the API, so the node hierarchy is a Composite and each recursion is written once.

Extensibility

Leadership lens

Check yourself

  1. Why should a node not store its own name?

    Show answer

    Because the name belongs to the directory entry. Storing it in two places means rename must update both, and hard links (two names for one node) become impossible to represent correctly.

  2. How do you detect that mv /a /a/b would create a cycle, and why compare components rather than strings?

    Show answer

    Check whether the source's components are a prefix of the destination's. A string prefix check wrongly treats /ab as inside /a.

  3. Why is a whole-tree readers-writer lock acceptable here, and when would you abandon it?

    Show answer

    It is simple and correct, and in-process mutations are microseconds, so writers rarely wait long. Abandon it when many cores contend on the reader count or writers in unrelated subtrees block each other; then move to per-directory locks with a global order for renames, or to copy-on-write.

  4. du on a directory and size() on a file share a signature. Which pattern makes that natural, and what does it save?

    Show answer

    Composite. Recursive operations are written once against Node, and adding a new node type does not require editing every caller.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →