Cracking the LLD Interview: Designing a File System with Domain-Driven Design
The file owns the content, the directory owns the name — separate them, and move, rename, and delete all become simple.
Cracking the LLD Interview: Designing a File System with Domain-Driven Design
The file owns the content, the directory owns the name — separate them, and move, rename, and delete all become simple.

Introduction
The File System is one of the most revealing LLD interview problems because it tests two things simultaneously: can you model a recursive tree structure cleanly, and can you separate identity from location?
Most candidates jump to a File class with a path field and a children list. This works for five minutes — until the interviewer asks "how does move work?" and the candidate realizes that changing a file's path means updating every descendant's path too. The design crumbles because path was conflated with identity.
Domain-Driven Design prevents this. DDD forces you to ask: what owns what? In a file system, the answer is precise: the file owns its content, the directory owns the name. A file’s identity (its ID) is independent of where it sits in the tree. The directory holds entries — bindings of names to node IDs. Moving a file doesn’t change the file; it changes which directory’s entry list contains its name. This separation is the entire design.
In this article — we’ll design a file system in Go. By the end, you’ll have a design built on the Composite pattern, with clean aggregate boundaries, path resolution as a domain service, and cycle-safe directory moves.
Core Insight: The file system’s defining design challenge is the identity-vs-location split. A file’s identity (ID) survives moves and renames. Its location (path) is computed by walking the directory tree. The directory entry — the name-to-ID binding inside a directory — is the real unit of organization. Operations that seem complex (move, rename, hard link) become trivial once you model entries as value objects inside the directory aggregate.
Phase 1 — Domain Discovery
1.1 Clarifying Questions for the Interviewer

1.2 Requirements
Functional Requirements (FR)
- FR-1: Create files and directories at a specified path
- FR-2: Delete files and directories (directory deletion is recursive)
- FR-3: Move a file or directory to a different parent directory
- FR-4: Rename a file or directory
- FR-5: Read file content
- FR-6: Write/update file content
- FR-7: List the contents of a directory
- FR-8: Get the total size of a directory (recursive sum of all file sizes)
- FR-9: Resolve a string path (e.g.,
/home/user/docs/file.txt) to the corresponding node - FR-10: Search for files by name within a directory subtree
Non-Functional Requirements (NFR)
- NFR-1: Path resolution must be efficient (proportional to path depth, not tree size)
- NFR-2: Directory listing must not require loading all descendants (shallow listing)
- NFR-3: Concurrent reads on different files must not block each other
- NFR-4: File and directory names must be validated (no path separators, no empty names)
Out-of-Scope (OOS)
- OOS-1: Block-level storage management (disk allocation, fragmentation)
- OOS-2: File system journaling and crash recovery
- OOS-3: Access control lists and permission enforcement
- OOS-4: Symbolic links
- OOS-5: File versioning and change history
1.3 Invariants

1.4 User Stories
- As a user, I want to create files and directories at a specific path so I can organize my data hierarchically.
- As a user, I want to move a file from one directory to another so I can reorganize my file tree.
- As a user, I want to rename a file or directory without changing its location so I can fix naming mistakes.
- As a user, I want to see the total size of a directory (including all subdirectories) so I can understand disk usage.
- As a user, I want to search for a file by name within a directory tree so I can find files without knowing the full path.
- As a user, I want to list the immediate contents of a directory so I can browse the tree level by level.
1.5 Edge Cases

1.6 Ubiquitous Language Glossary

Phase 2 — Domain Modeling
2.1 Nouns and Verbs Extraction
Nouns (from requirements and user stories):

Verbs (behaviors):

2.2 Entity vs Value Object — Identity Reasoning
Why File is an Entity, not a VO: A file has a unique ID that persists across moves and renames. If you move /docs/report.txt to /archive/report.txt, it's the same file — same ID, same content. Only its location changed. Two files with identical content are still distinct (you can edit one without affecting the other). Identity is fundamental.
Why Directory is an Entity, not a VO: A directory has a unique ID. Moving or renaming a directory changes its entry in the parent but not its identity. The directory’s entries list mutates as children are added and removed. Two empty directories are distinct entities.
Why DirectoryEntry is a VO, not an Entity: An entry is a binding: {name: "report.txt", childID: "file-123", childType: FILE}. It has no identity of its own — it's defined entirely by its components. If you remove the entry and re-add it with the same values, it's indistinguishable from the original. Entries are replaced, not mutated: renaming creates a new entry with the new name and removes the old one.
Why Path is a VO, not an Entity: /home/user/docs is /home/user/docs — it has no identity beyond its string value. Paths are immutable, compared by value, and computed on demand. A path doesn't "exist" as a stored object — it's derived by walking the tree.
2.3 Value Object Immutability
// Path — immutable value object representing a location in the tree
type Path struct {
raw string
}
func NewPath(raw string) (Path, error) {
if raw == "" {
return Path{}, ErrEmptyPath
}
if raw[0] != '/' {
return Path{}, ErrPathMustBeAbsolute
}
return Path{raw: raw}, nil
}
func RootPath() Path {
return Path{raw: "/"}
}
// Value equality
func (p Path) Equal(other Path) bool {
return p.raw == other.raw
}
// Segments splits "/home/user/file.txt" into ["home", "user", "file.txt"]
func (p Path) Segments() []string {
if p.raw == "/" {
return []string{}
}
trimmed := strings.TrimPrefix(p.raw, "/")
return strings.Split(trimmed, "/")
}
// Parent returns the path of the parent directory: "/home/user/file.txt" → "/home/user"
func (p Path) Parent() Path {
if p.raw == "/" {
return p // root's parent is itself
}
lastSlash := strings.LastIndex(p.raw, "/")
if lastSlash == 0 {
return RootPath()
}
return Path{raw: p.raw[:lastSlash]}
}
// BaseName returns the last segment: "/home/user/file.txt" → "file.txt"
func (p Path) BaseName() string {
segments := p.Segments()
if len(segments) == 0 {
return ""
}
return segments[len(segments)-1]
}
// Join appends a child name to this path
func (p Path) Join(childName string) Path {
if p.raw == "/" {
return Path{raw: "/" + childName}
}
return Path{raw: p.raw + "/" + childName}
}
func (p Path) String() string { return p.raw }
func (p Path) IsRoot() bool { return p.raw == "/" }
// DirectoryEntry — immutable value object binding a name to a child node
type DirectoryEntry struct {
name string
childID string
nodeType NodeType
}
func NewDirectoryEntry(name, childID string, nodeType NodeType) (DirectoryEntry, error) {
// INV-7: name cannot contain "/" or be empty
if name == "" {
return DirectoryEntry{}, ErrEmptyName
}
if strings.Contains(name, "/") {
return DirectoryEntry{}, ErrInvalidName
}
return DirectoryEntry{
name: name,
childID: childID,
nodeType: nodeType,
}, nil
}
// Value equality
func (e DirectoryEntry) Equal(other DirectoryEntry) bool {
return e.name == other.name &&
e.childID == other.childID &&
e.nodeType == other.nodeType
}
func (e DirectoryEntry) Name() string { return e.name }
func (e DirectoryEntry) ChildID() string { return e.childID }
func (e DirectoryEntry) NodeType() NodeType { return e.nodeType }
func (e DirectoryEntry) IsDirectory() bool { return e.nodeType == NodeTypeDirectory }
// NodeMetadata — immutable timestamp snapshot
type NodeMetadata struct {
createdAt time.Time
modifiedAt time.Time
}
func NewNodeMetadata() NodeMetadata {
now := time.Now()
return NodeMetadata{createdAt: now, modifiedAt: now}
}
// Touch returns a new metadata with updated modifiedAt — immutable
func (m NodeMetadata) Touch() NodeMetadata {
return NodeMetadata{createdAt: m.createdAt, modifiedAt: time.Now()}
}
func (m NodeMetadata) Equal(other NodeMetadata) bool {
return m.createdAt.Equal(other.createdAt) && m.modifiedAt.Equal(other.modifiedAt)
}
func (m NodeMetadata) CreatedAt() time.Time { return m.createdAt }
func (m NodeMetadata) ModifiedAt() time.Time { return m.modifiedAt }
2.4 Anemic vs Rich Model
Anemic (what to avoid):
// BAD: Anemic directory — service manipulates fields directly
type Directory struct {
ID string
Name string
ParentID string
Entries []DirectoryEntry
}
func (s *FileService) AddFile(dir *Directory, name, fileID string) error {
for _, e := range dir.Entries {
if e.Name == name {
return errors.New("name exists")
}
}
dir.Entries = append(dir.Entries, DirectoryEntry{Name: name, ChildID: fileID})
return nil
}
Rich (what we want):
// GOOD: Rich directory — invariants enforced inside the aggregate
func (d *Directory) AddEntry(entry DirectoryEntry) error {
// INV-1: name must be unique within this directory
for _, existing := range d.entries {
if existing.Name() == entry.Name() {
return ErrNameAlreadyExists
}
}
// INV-8: child can only appear once (single parent)
for _, existing := range d.entries {
if existing.ChildID() == entry.ChildID() {
return ErrNodeAlreadyInDirectory
}
}
d.entries = append(d.entries, entry)
d.metadata = d.metadata.Touch()
d.version++
d.recordEvent(EntryAddedEvent{DirectoryID: d.id, EntryName: entry.Name()})
return nil
}
The rich model enforces INV-1 (name uniqueness) and INV-8 (single parent) inside the aggregate, where the data lives. The service layer doesn’t need to know these rules.
Phase 3 — Aggregate Design
3.1 Strategic Domain Classification

3.2 Consistency Boundary Analysis

This gives us two aggregate types:
- Directory Aggregate — Root:
Directory. Contains:[]DirectoryEntry(VOs). Enforces name uniqueness and single-parent constraint within its entries. - File Aggregate — Root:
File. Contains: content bytes, size, metadata. Independent of directory structure — referenced by ID from directory entries.
3.3 Four Aggregate Rules — Applied

3.4 Composite Interface
// NodeType — value object enum
type NodeType int
const (
NodeTypeFile NodeType = iota
NodeTypeDirectory
)
// FileSystemNode — the Composite pattern interface
// Both File and Directory implement this.
type FileSystemNode interface {
ID() string
Name() string
Type() NodeType
Size() int64
Metadata() NodeMetadata
IsDirectory() bool
ParentID() string
}
3.5 Aggregate Implementation — Directory
type Directory struct {
id string
name string
parentID string // empty for root (cross-aggregate ref)
entries []DirectoryEntry
metadata NodeMetadata
version int
events []DomainEvent
}
func NewDirectory(id, name, parentID string) (*Directory, error) {
if name == "" && parentID != "" {
return nil, ErrEmptyName // root is allowed empty name
}
return &Directory{
id: id,
name: name,
parentID: parentID,
entries: []DirectoryEntry{},
metadata: NewNodeMetadata(),
}, nil
}
func NewRootDirectory(id string) *Directory {
return &Directory{
id: id,
name: "",
parentID: "",
entries: []DirectoryEntry{},
metadata: NewNodeMetadata(),
}
}
// --- FileSystemNode interface ---
func (d *Directory) ID() string { return d.id }
func (d *Directory) Name() string { return d.name }
func (d *Directory) Type() NodeType { return NodeTypeDirectory }
func (d *Directory) IsDirectory() bool { return true }
func (d *Directory) ParentID() string { return d.parentID }
func (d *Directory) Metadata() NodeMetadata { return d.metadata }
func (d *Directory) Version() int { return d.version }
func (d *Directory) Entries() []DirectoryEntry {
c := make([]DirectoryEntry, len(d.entries))
copy(c, d.entries)
return c
}
// Size — directory's own size is 0; recursive size is computed by the app service
func (d *Directory) Size() int64 { return 0 }
// --- Entry Management (core domain behavior) ---
// INV-1 + INV-8: name unique, child ID unique within this directory
func (d *Directory) AddEntry(entry DirectoryEntry) error {
for _, existing := range d.entries {
if existing.Name() == entry.Name() {
return ErrNameAlreadyExists // INV-1
}
if existing.ChildID() == entry.ChildID() {
return ErrNodeAlreadyInDirectory // INV-8
}
}
d.entries = append(d.entries, entry)
d.metadata = d.metadata.Touch()
d.version++
d.recordEvent(EntryAddedEvent{
DirectoryID: d.id,
EntryName: entry.Name(),
ChildID: entry.ChildID(),
})
return nil
}
func (d *Directory) RemoveEntry(name string) (DirectoryEntry, error) {
for i, entry := range d.entries {
if entry.Name() == name {
d.entries = append(d.entries[:i], d.entries[i+1:]...)
d.metadata = d.metadata.Touch()
d.version++
d.recordEvent(EntryRemovedEvent{
DirectoryID: d.id,
EntryName: name,
ChildID: entry.ChildID(),
})
return entry, nil
}
}
return DirectoryEntry{}, ErrEntryNotFound
}
// Rename — INV-1: new name must not collide
func (d *Directory) RenameEntry(oldName, newName string) error {
if newName == "" {
return ErrEmptyName
}
if strings.Contains(newName, "/") {
return ErrInvalidName // INV-7
}
// Check new name doesn't already exist
for _, entry := range d.entries {
if entry.Name() == newName {
return ErrNameAlreadyExists // INV-1
}
}
// Find and replace the entry (immutable VO — create new, remove old)
for i, entry := range d.entries {
if entry.Name() == oldName {
newEntry, _ := NewDirectoryEntry(newName, entry.ChildID(), entry.NodeType())
d.entries[i] = newEntry
d.metadata = d.metadata.Touch()
d.version++
d.recordEvent(EntryRenamedEvent{
DirectoryID: d.id,
OldName: oldName,
NewName: newName,
})
return nil
}
}
return ErrEntryNotFound
}
// FindEntry — lookup by name
func (d *Directory) FindEntry(name string) (DirectoryEntry, error) {
for _, entry := range d.entries {
if entry.Name() == name {
return entry, nil
}
}
return DirectoryEntry{}, ErrEntryNotFound
}
// HasEntry — check if name exists
func (d *Directory) HasEntry(name string) bool {
for _, entry := range d.entries {
if entry.Name() == name {
return true
}
}
return false
}
func (d *Directory) EntryCount() int { return len(d.entries) }
func (d *Directory) SetParentID(parentID string) { d.parentID = parentID }
func (d *Directory) recordEvent(e DomainEvent) { d.events = append(d.events, e) }
func (d *Directory) DomainEvents() []DomainEvent { return d.events }
func (d *Directory) ClearEvents() { d.events = nil }
3.6 Aggregate Implementation — File
type File struct {
id string
name string
parentID string // cross-aggregate ref to parent directory
content []byte
size int64
metadata NodeMetadata
version int
events []DomainEvent
}
func NewFile(id, name, parentID string, content []byte) (*File, error) {
if name == "" {
return nil, ErrEmptyName
}
if strings.Contains(name, "/") {
return nil, ErrInvalidName // INV-7
}
return &File{
id: id,
name: name,
parentID: parentID,
content: content,
size: int64(len(content)),
metadata: NewNodeMetadata(),
}, nil
}
// --- FileSystemNode interface ---
func (f *File) ID() string { return f.id }
func (f *File) Name() string { return f.name }
func (f *File) Type() NodeType { return NodeTypeFile }
func (f *File) IsDirectory() bool { return false }
func (f *File) ParentID() string { return f.parentID }
func (f *File) Size() int64 { return f.size }
func (f *File) Metadata() NodeMetadata { return f.metadata }
func (f *File) Version() int { return f.version }
// INV-10: content size must match reported size
func (f *File) WriteContent(content []byte) {
f.content = make([]byte, len(content))
copy(f.content, content) // defensive copy
f.size = int64(len(content))
f.metadata = f.metadata.Touch()
f.version++
f.recordEvent(FileContentUpdatedEvent{FileID: f.id, NewSize: f.size})
}
func (f *File) ReadContent() []byte {
c := make([]byte, len(f.content))
copy(c, f.content)
return c // defensive copy — INV: content immutability for readers
}
func (f *File) SetParentID(parentID string) { f.parentID = parentID }
func (f *File) recordEvent(e DomainEvent) { f.events = append(f.events, e) }
func (f *File) DomainEvents() []DomainEvent { return f.events }
func (f *File) ClearEvents() { f.events = nil }
3.7 Domain Events
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
type EntryAddedEvent struct {
DirectoryID string
EntryName string
ChildID string
occurredAt time.Time
}
func (e EntryAddedEvent) EventName() string { return "EntryAdded" }
func (e EntryAddedEvent) OccurredAt() time.Time { return e.occurredAt }
type EntryRemovedEvent struct {
DirectoryID string
EntryName string
ChildID string
occurredAt time.Time
}
func (e EntryRemovedEvent) EventName() string { return "EntryRemoved" }
func (e EntryRemovedEvent) OccurredAt() time.Time { return e.occurredAt }
type EntryRenamedEvent struct {
DirectoryID string
OldName string
NewName string
occurredAt time.Time
}
func (e EntryRenamedEvent) EventName() string { return "EntryRenamed" }
func (e EntryRenamedEvent) OccurredAt() time.Time { return e.occurredAt }
type FileContentUpdatedEvent struct {
FileID string
NewSize int64
occurredAt time.Time
}
func (e FileContentUpdatedEvent) EventName() string { return "FileContentUpdated" }
func (e FileContentUpdatedEvent) OccurredAt() time.Time { return e.occurredAt }
type DirectoryDeletedEvent struct {
DirectoryID string
DeletedNodeIDs []string
occurredAt time.Time
}
func (e DirectoryDeletedEvent) EventName() string { return "DirectoryDeleted" }
func (e DirectoryDeletedEvent) OccurredAt() time.Time { return e.occurredAt }
Phase 4 — Bounded Contexts
4.1 Context Identification

Why one primary context? The file system tree structure and content management are tightly coupled in an LLD interview. The directory that holds the entry and the file it points to must be managed together for operations like create, move, and delete. Splitting them would add integration complexity for no interview benefit.
4.2 Context Map

4.3 Integration Patterns

Phase 5 — Application Service Design
5.1 Repository Interfaces (defined in domain layer)
// DirectoryRepository — one per aggregate root
type DirectoryRepository interface {
FindByID(ctx context.Context, id string) (*Directory, error)
Save(ctx context.Context, dir *Directory) error
Delete(ctx context.Context, id string) error
}
// FileRepository — one per aggregate root
type FileRepository interface {
FindByID(ctx context.Context, id string) (*File, error)
Save(ctx context.Context, file *File) error
Delete(ctx context.Context, id string) error
}
5.2 Domain Service — PathResolver
// PathResolver — domain service that converts a Path VO into a node ID
type PathResolver struct {
dirRepo DirectoryRepository
rootID string
}
func NewPathResolver(dirRepo DirectoryRepository, rootID string) *PathResolver {
return &PathResolver{dirRepo: dirRepo, rootID: rootID}
}
// Resolve walks the tree segment by segment — INV-4: deterministic resolution
func (r *PathResolver) Resolve(ctx context.Context, path Path) (string, NodeType, error) {
if path.IsRoot() {
return r.rootID, NodeTypeDirectory, nil
}
segments := path.Segments()
currentDirID := r.rootID
for i, segment := range segments {
dir, err := r.dirRepo.FindByID(ctx, currentDirID)
if err != nil {
return "", 0, ErrNodeNotFound
}
entry, err := dir.FindEntry(segment)
if err != nil {
return "", 0, fmt.Errorf("%w: segment '%s' not found in path '%s'",
ErrNodeNotFound, segment, path.String())
}
// If this is the last segment, return whatever we found
if i == len(segments)-1 {
return entry.ChildID(), entry.NodeType(), nil
}
// Not the last segment — must be a directory to continue traversal
if !entry.IsDirectory() {
return "", 0, fmt.Errorf("%w: '%s' is not a directory",
ErrNotADirectory, segment)
}
currentDirID = entry.ChildID()
}
return currentDirID, NodeTypeDirectory, nil
}
// ResolveParent resolves the parent directory of a path and returns (parentDirID, baseName)
func (r *PathResolver) ResolveParent(ctx context.Context, path Path) (string, string, error) {
parentPath := path.Parent()
baseName := path.BaseName()
if baseName == "" {
return "", "", ErrInvalidPath
}
parentID, parentType, err := r.Resolve(ctx, parentPath)
if err != nil {
return "", "", fmt.Errorf("parent path not found: %w", err)
}
if parentType != NodeTypeDirectory {
return "", "", ErrNotADirectory
}
return parentID, baseName, nil
}
// IsAncestor checks if ancestorID is an ancestor of nodeID — for cycle detection (INV-9)
func (r *PathResolver) IsAncestor(ctx context.Context, ancestorID, nodeID string) (bool, error) {
currentID := nodeID
visited := make(map[string]bool)
for currentID != "" {
if currentID == ancestorID {
return true, nil
}
if visited[currentID] {
return false, ErrCorruptedTree // should never happen in a valid tree
}
visited[currentID] = true
dir, err := r.dirRepo.FindByID(ctx, currentID)
if err != nil {
return false, nil // reached a file or non-existent node
}
currentID = dir.ParentID()
}
return false, nil
}
5.3 DTOs (separate from domain)
type CreateFileRequest struct {
Path string `json:"path"`
Content []byte `json:"content"`
}
type CreateDirectoryRequest struct {
Path string `json:"path"`
}
type MoveRequest struct {
SourcePath string `json:"source_path"`
TargetPath string `json:"target_path"` // target directory path
}
type RenameRequest struct {
Path string `json:"path"`
NewName string `json:"new_name"`
}
type NodeResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
Path string `json:"path"`
}
type DirectoryListingResponse struct {
Path string `json:"path"`
Entries []NodeResponse `json:"entries"`
}
5.4 Application Service — One Method per Use Case
type FileSystemAppService struct {
dirRepo DirectoryRepository
fileRepo FileRepository
pathResolver *PathResolver
idGen IDGenerator
}
// Use Case: Create a file at a given path
func (s *FileSystemAppService) CreateFile(ctx context.Context, req CreateFileRequest) (*NodeResponse, error) {
path, err := NewPath(req.Path)
if err != nil {
return nil, err
}
// Resolve parent directory
parentDirID, fileName, err := s.pathResolver.ResolveParent(ctx, path)
if err != nil {
return nil, err
}
parentDir, err := s.dirRepo.FindByID(ctx, parentDirID)
if err != nil {
return nil, err
}
// Create the file aggregate
file, err := NewFile(s.idGen.Generate(), fileName, parentDirID, req.Content)
if err != nil {
return nil, err
}
// Create entry VO and add to parent — INV-1 enforced inside Directory.AddEntry
entry, err := NewDirectoryEntry(fileName, file.ID(), NodeTypeFile)
if err != nil {
return nil, err
}
if err := parentDir.AddEntry(entry); err != nil {
return nil, err // ErrNameAlreadyExists if INV-1 violated
}
// Persist both aggregates
if err := s.fileRepo.Save(ctx, file); err != nil {
return nil, err
}
if err := s.dirRepo.Save(ctx, parentDir); err != nil {
return nil, err
}
return &NodeResponse{
ID: file.ID(),
Name: fileName,
Type: "FILE",
Size: file.Size(),
Path: req.Path,
}, nil
}
// Use Case: Create a directory at a given path
func (s *FileSystemAppService) CreateDirectory(ctx context.Context, req CreateDirectoryRequest) (*NodeResponse, error) {
path, err := NewPath(req.Path)
if err != nil {
return nil, err
}
parentDirID, dirName, err := s.pathResolver.ResolveParent(ctx, path)
if err != nil {
return nil, err
}
parentDir, err := s.dirRepo.FindByID(ctx, parentDirID)
if err != nil {
return nil, err
}
newDir, err := NewDirectory(s.idGen.Generate(), dirName, parentDirID)
if err != nil {
return nil, err
}
entry, err := NewDirectoryEntry(dirName, newDir.ID(), NodeTypeDirectory)
if err != nil {
return nil, err
}
if err := parentDir.AddEntry(entry); err != nil {
return nil, err
}
if err := s.dirRepo.Save(ctx, newDir); err != nil {
return nil, err
}
if err := s.dirRepo.Save(ctx, parentDir); err != nil {
return nil, err
}
return &NodeResponse{
ID: newDir.ID(),
Name: dirName,
Type: "DIRECTORY",
Path: req.Path,
}, nil
}
// Use Case: Delete a node (file or directory — recursive for directories)
func (s *FileSystemAppService) Delete(ctx context.Context, pathStr string) error {
path, err := NewPath(pathStr)
if err != nil {
return err
}
// INV-3: cannot delete root
if path.IsRoot() {
return ErrCannotDeleteRoot
}
parentDirID, name, err := s.pathResolver.ResolveParent(ctx, path)
if err != nil {
return err
}
parentDir, err := s.dirRepo.FindByID(ctx, parentDirID)
if err != nil {
return err
}
entry, err := parentDir.FindEntry(name)
if err != nil {
return ErrNodeNotFound
}
// If directory, recursively delete contents — INV-6
if entry.IsDirectory() {
if err := s.deleteDirectoryRecursive(ctx, entry.ChildID()); err != nil {
return err
}
} else {
if err := s.fileRepo.Delete(ctx, entry.ChildID()); err != nil {
return err
}
}
// Remove entry from parent
if _, err := parentDir.RemoveEntry(name); err != nil {
return err
}
return s.dirRepo.Save(ctx, parentDir)
}
func (s *FileSystemAppService) deleteDirectoryRecursive(ctx context.Context, dirID string) error {
dir, err := s.dirRepo.FindByID(ctx, dirID)
if err != nil {
return err
}
for _, entry := range dir.Entries() {
if entry.IsDirectory() {
if err := s.deleteDirectoryRecursive(ctx, entry.ChildID()); err != nil {
return err
}
} else {
if err := s.fileRepo.Delete(ctx, entry.ChildID()); err != nil {
return err
}
}
}
return s.dirRepo.Delete(ctx, dirID)
}
// Use Case: Move a node to a different directory
func (s *FileSystemAppService) Move(ctx context.Context, req MoveRequest) error {
sourcePath, err := NewPath(req.SourcePath)
if err != nil {
return err
}
targetPath, err := NewPath(req.TargetPath)
if err != nil {
return err
}
// INV-3: cannot move root
if sourcePath.IsRoot() {
return ErrCannotMoveRoot
}
// Resolve source: parent dir + entry name
sourceParentID, sourceName, err := s.pathResolver.ResolveParent(ctx, sourcePath)
if err != nil {
return err
}
// Resolve target directory
targetDirID, targetType, err := s.pathResolver.Resolve(ctx, targetPath)
if err != nil {
return err
}
if targetType != NodeTypeDirectory {
return ErrNotADirectory
}
sourceParent, err := s.dirRepo.FindByID(ctx, sourceParentID)
if err != nil {
return err
}
targetDir, err := s.dirRepo.FindByID(ctx, targetDirID)
if err != nil {
return err
}
// Find the entry being moved
entry, err := sourceParent.FindEntry(sourceName)
if err != nil {
return ErrNodeNotFound
}
// INV-9: if moving a directory, check it's not moving into its own descendant
if entry.IsDirectory() {
isAncestor, err := s.pathResolver.IsAncestor(ctx, entry.ChildID(), targetDirID)
if err != nil {
return err
}
if isAncestor {
return ErrCyclicMove // INV-9
}
}
// Same directory check
if sourceParentID == targetDirID {
return ErrAlreadyInDirectory
}
// Remove from source, add to target — cross-aggregate coordination
if _, err := sourceParent.RemoveEntry(sourceName); err != nil {
return err
}
if err := targetDir.AddEntry(entry); err != nil {
// Rollback: re-add to source (compensation)
_ = sourceParent.AddEntry(entry)
return err
}
// Update the node's parentID
if entry.IsDirectory() {
childDir, err := s.dirRepo.FindByID(ctx, entry.ChildID())
if err == nil {
childDir.SetParentID(targetDirID)
_ = s.dirRepo.Save(ctx, childDir)
}
} else {
childFile, err := s.fileRepo.FindByID(ctx, entry.ChildID())
if err == nil {
childFile.SetParentID(targetDirID)
_ = s.fileRepo.Save(ctx, childFile)
}
}
if err := s.dirRepo.Save(ctx, sourceParent); err != nil {
return err
}
return s.dirRepo.Save(ctx, targetDir)
}
// Use Case: Rename a node
func (s *FileSystemAppService) Rename(ctx context.Context, req RenameRequest) error {
path, err := NewPath(req.Path)
if err != nil {
return err
}
if path.IsRoot() {
return ErrCannotRenameRoot
}
parentDirID, oldName, err := s.pathResolver.ResolveParent(ctx, path)
if err != nil {
return err
}
parentDir, err := s.dirRepo.FindByID(ctx, parentDirID)
if err != nil {
return err
}
// INV-1 + INV-7 enforced inside Directory.RenameEntry
if err := parentDir.RenameEntry(oldName, req.NewName); err != nil {
return err
}
return s.dirRepo.Save(ctx, parentDir)
}
// Use Case: List directory contents
func (s *FileSystemAppService) ListDirectory(ctx context.Context, pathStr string) (*DirectoryListingResponse, error) {
path, err := NewPath(pathStr)
if err != nil {
return nil, err
}
dirID, nodeType, err := s.pathResolver.Resolve(ctx, path)
if err != nil {
return nil, err
}
if nodeType != NodeTypeDirectory {
return nil, ErrNotADirectory
}
dir, err := s.dirRepo.FindByID(ctx, dirID)
if err != nil {
return nil, err
}
entries := make([]NodeResponse, 0, dir.EntryCount())
for _, entry := range dir.Entries() {
resp := NodeResponse{
ID: entry.ChildID(),
Name: entry.Name(),
Type: nodeTypeString(entry.NodeType()),
Path: path.Join(entry.Name()).String(),
}
// Fetch size for files
if !entry.IsDirectory() {
file, err := s.fileRepo.FindByID(ctx, entry.ChildID())
if err == nil {
resp.Size = file.Size()
}
}
entries = append(entries, resp)
}
return &DirectoryListingResponse{
Path: pathStr,
Entries: entries,
}, nil
}
// Use Case: Get recursive directory size
func (s *FileSystemAppService) GetDirectorySize(ctx context.Context, pathStr string) (int64, error) {
path, err := NewPath(pathStr)
if err != nil {
return 0, err
}
dirID, nodeType, err := s.pathResolver.Resolve(ctx, path)
if err != nil {
return 0, err
}
if nodeType != NodeTypeDirectory {
return 0, ErrNotADirectory
}
return s.calculateSizeRecursive(ctx, dirID)
}
func (s *FileSystemAppService) calculateSizeRecursive(ctx context.Context, dirID string) (int64, error) {
dir, err := s.dirRepo.FindByID(ctx, dirID)
if err != nil {
return 0, err
}
var total int64
for _, entry := range dir.Entries() {
if entry.IsDirectory() {
childSize, err := s.calculateSizeRecursive(ctx, entry.ChildID())
if err != nil {
return 0, err
}
total += childSize
} else {
file, err := s.fileRepo.FindByID(ctx, entry.ChildID())
if err != nil {
return 0, err
}
total += file.Size()
}
}
return total, nil
}
5.5 API Endpoints — Named After Use Cases

Phase 6 — LLD Conversion
6.1 Class/Struct Diagram

6.2 Sequence Diagrams
Flow 1: Create file at /home/user/report.txt

Flow 2: Move /docs/report.txt to /archive/

Flow 3: Move directory /a/b to /a/b/c (cycle detection — INV-9)

6.3 Design Patterns Summary

6.4 Database Schema
-- Directory aggregate root
CREATE TABLE directories (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id VARCHAR(36) REFERENCES directories(id), -- NULL for root
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
modified_at TIMESTAMP NOT NULL DEFAULT NOW(),
version INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_dir_parent ON directories(parent_id);
-- DirectoryEntry — value objects stored as rows (within Directory aggregate boundary)
CREATE TABLE directory_entries (
directory_id VARCHAR(36) NOT NULL REFERENCES directories(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
child_id VARCHAR(36) NOT NULL,
node_type VARCHAR(10) NOT NULL CHECK (node_type IN ('FILE', 'DIRECTORY')),
PRIMARY KEY (directory_id, name), -- INV-1: name unique per directory
UNIQUE (child_id) -- INV-8: each child has at most one parent
);
CREATE INDEX idx_entries_child ON directory_entries(child_id);
-- File aggregate root
CREATE TABLE files (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id VARCHAR(36) NOT NULL REFERENCES directories(id),
content BYTEA,
size BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
modified_at TIMESTAMP NOT NULL DEFAULT NOW(),
version INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_files_parent ON files(parent_id);
-- Domain events outbox
CREATE TABLE domain_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(36) NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_events_unpublished ON domain_events(published, occurred_at)
WHERE published = FALSE;
Schema design notes:
The directory_entries table is separate from directories because entries are value objects within the directory aggregate — they're stored as child rows, not as a serialized blob. The PRIMARY KEY (directory_id, name) directly enforces INV-1 (name uniqueness per directory). The UNIQUE (child_id) constraint enforces INV-8 (single parent) at the database level.
The files table stores content as BYTEA for simplicity. In production, content would be stored in an object store (S3, GCS) with only a reference in the database.
6.5 Error Catalog

var (
ErrNameAlreadyExists = errors.New("name already exists in directory")
ErrCyclicMove = errors.New("cannot move directory into its own descendant")
ErrCannotDeleteRoot = errors.New("cannot delete root directory")
ErrCannotMoveRoot = errors.New("cannot move root directory")
ErrCannotRenameRoot = errors.New("cannot rename root directory")
ErrNodeNotFound = errors.New("node not found")
ErrNotADirectory = errors.New("path component is not a directory")
ErrEmptyName = errors.New("name cannot be empty")
ErrInvalidName = errors.New("name contains invalid characters")
ErrNodeAlreadyInDirectory = errors.New("node is already in this directory")
ErrEmptyPath = errors.New("path cannot be empty")
ErrPathMustBeAbsolute = errors.New("path must be absolute (start with /)")
ErrInvalidPath = errors.New("invalid path")
ErrAlreadyInDirectory = errors.New("node is already in the target directory")
ErrEntryNotFound = errors.New("entry not found in directory")
ErrCorruptedTree = errors.New("corrupted tree structure detected")
)
6.6 Folder Structure
filesystem/
├── domain/
│ ├── file.go # File aggregate root
│ ├── directory.go # Directory aggregate root
│ ├── entry.go # DirectoryEntry value object
│ ├── path.go # Path value object
│ ├── metadata.go # NodeMetadata value object
│ ├── node.go # FileSystemNode interface + NodeType enum
│ ├── path_resolver.go # PathResolver domain service
│ ├── events.go # Domain events
│ ├── errors.go # Sentinel errors mapped to invariants
│ └── ports.go # DirectoryRepository, FileRepository interfaces
│
├── application/
│ ├── filesystem_service.go # FileSystemAppService — all use cases
│ ├── dto.go # Request/Response DTOs
│ └── id_generator.go # IDGenerator interface
│
├── infrastructure/
│ ├── persistence/
│ │ ├── postgres_dir_repo.go
│ │ └── postgres_file_repo.go
│ ├── memory/
│ │ ├── inmemory_dir_repo.go # For testing
│ │ └── inmemory_file_repo.go
│ └── uuid_generator.go
│
└── presentation/
└── http/
├── router.go
└── filesystem_handler.go
Concurrency and Consistency
Does concurrency matter? Yes — multiple users can create files in the same directory, move files between directories, and read/write the same file concurrently.
Scenario 1: Two users create files with the same name in the same directory. Both resolve the parent directory, both check name uniqueness, both see no conflict, both add an entry. Result: duplicate names — INV-1 violated. Solution: optimistic concurrency on the directory’s version field. The second save fails on version mismatch and retries, at which point it sees the name conflict. Alternatively, the database's UNIQUE (directory_id, name) constraint catches this.
Scenario 2: User A moves file out of directory X while User B lists directory X. User B might see the file in the listing, then try to access it, and find it’s moved. This is acceptable — filesystem operations are not transactional across commands in real systems either. User B gets a 404 on access — standard behavior.
Scenario 3: User A deletes a directory while User B creates a file inside it. User B’s FindByID(parentDirID) succeeds, but by the time they Save(parentDir), the directory is deleted. The version mismatch catches this, or the foreign key constraint rejects the save.
Locking strategy: Optimistic concurrency (version fields) for directories. Database constraints (UNIQUE, REFERENCES) as the safety net. Pessimistic locking only if profiling shows excessive retry rates on hot directories — which is unlikely in most workloads.
Scalability Considerations
“What if the tree has millions of nodes?” Directory entries are shallow-listed — ListDirectory never loads the full subtree. Path resolution is O(d) where d is depth (typically 5–15 levels), not O(n) where n is total nodes.
“What if a single directory has 100,000 entries?” The directory_entries table has a composite primary key (directory_id, name), so entry lookup by name is O(log n) via index. Listing is a sequential scan of entries for that directory — paginate with LIMIT/OFFSET or cursor-based pagination for very large directories.
“What about recursive size calculation?” For large trees, recursive on-demand calculation is expensive. The production approach: maintain a cached total_size column on each directory, updated via domain events (FileContentUpdatedEvent, EntryAddedEvent). This trades write-time overhead for constant-time reads. Mention this as a scalability trade-off.
“How does search scale?” Linear tree traversal doesn’t scale. Add a search index — a flat table mapping (file_name, node_id, path) maintained via domain events. Or integrate with full-text search (Elasticsearch). Mention as a future enhancement.
Common Interview Follow-up Questions
Q: “How would you implement mkdir -p (create intermediate directories)?" Walk the path segment by segment. For each segment, check if the directory exists. If not, create it. This is a loop of CreateDirectory calls, not a single operation. The application service method would accept a flag createIntermediates bool and iterate through segments.
Q: “How would you support hard links?” Remove the UNIQUE (child_id) constraint on directory_entries. A file could then appear in multiple directories. The File.parentID field would need to become a list, or we'd track parentage purely through entries. Deletion becomes reference-counted: only delete the file when its last entry is removed.
Q: “How would you support symbolic links?” Add a SymLink entity implementing FileSystemNode that stores a target path (not a target ID). Resolution follows the symlink by re-resolving the target path. This introduces the possibility of broken links (target deleted) and must be cycle-checked (symlink chains).
Q: “What’s the time complexity of path resolution?” O(d × k) where d is the path depth and k is the average number of entries per directory (for the name lookup). With indexed entries (hash map or B-tree index), k lookup becomes O(1) or O(log k), making it effectively O(d).
Q: “How does move differ from copy?” Move relocates the entry — the same node ID appears in a different directory. Copy creates a new node with a new ID, duplicating the content. Move is O(1) in entry manipulation; copy is O(n) in content size.
Mistakes to Avoid
Storing the full path on each node. If /home/user/file.txt is stored as a field on the File, renaming /home to /workspacerequires updating every descendant's path. With our design, rename changes one entry in one directory — descendants are unaffected because they reference parents by ID, not by path.
Making the entire tree one aggregate. Loading the entire file system into memory for every operation violates the “keep aggregates small” rule. Each directory is its own aggregate with an entries list. Path resolution walks across aggregates, coordinated by the domain service.
Conflating File and Directory into one class. “Everything is a Node with a children list and a content field" leads to a god object where files can accidentally have children and directories can have content. The Composite interface gives you polymorphism; separate structs give you type safety.
Skipping cycle detection on directory moves. Moving /a into /a/b/c creates a cycle that makes the tree infinite. Candidates who don't address INV-9 get hit with this follow-up immediately. The IsAncestor check in the application service is the answer.
Ignoring the identity-vs-location distinction. If your file’s “identity” is its path, then moving a file changes its identity — which means all references break. Model identity as an opaque ID. Model location as the entry in the parent directory. Mention this explicitly to score points.
Recursive size calculation without caching. On-demand recursive GetDirectorySize is O(n) for the entire subtree. Mention the cached approach (maintain total_size on directories, update via events) as a scalability trade-off even if you implement the recursive version for correctness first.
Final Design Summary
The file system is built around two aggregate types — Directory and File — connected through directory entries(value objects) that bind names to node IDs:
The Directory aggregate owns the organizational structure — its entries list determines what names exist at this level of the tree. It enforces the critical invariants: name uniqueness within the directory (INV-1) and single-parent constraint (INV-8). Each entry is a value object containing a name, a child node ID, and a type flag. The directory doesn’t hold child objects — just references.
The File aggregate owns the content — bytes, size, and metadata. It’s completely independent of the directory structure. Moving a file doesn’t touch the File aggregate at all — it only changes which directory’s entry list references it. This is the design’s most important property.
The Path value object represents a location in the tree as a string. It’s immutable, compared by value, and provides segment parsing for traversal. The path is computed by walking the tree — it’s not stored on the node.
The PathResolver domain service bridges paths and aggregates. It walks the directory tree segment by segment, crossing aggregate boundaries at each level. It also provides ancestry checking for cycle detection during directory moves.
The Composite pattern gives us the FileSystemNode interface that both File and Directory implement. This enables uniform treatment in traversal operations (recursive size, search) while maintaining type-safe separation of concerns (files can't have children, directories can't have content).
Every design decision traces to an invariant. Every invariant traces to code. The identity-vs-location split is explicit and defensible. This is a design you can whiteboard, explain, and extend in 45 minutes.
Conclusion
The File System is a unique LLD problem because it forces you to model a recursive data structure with cross-aggregate relationships — something most LLD problems (flat entities, simple aggregates) never require. The tree is the structure, the Composite is the pattern, but the entry is the real design decision.
Five practical takeaways for your next interview:
- Separate identity from location. A file’s ID is permanent; its path is derived. If your file has a
pathfield that you update on every move and rename, you've chosen the wrong model. Store the parent reference as an ID. Compute the path by walking up. - The Composite pattern is the tree’s backbone. File and Directory implement the same interface. This isn’t academic OOP — it’s what enables recursive operations (size, delete, search) to work uniformly. Name the pattern explicitly when you draw the diagram.
- The directory entry is the most important value object. Most candidates model entries as an afterthought. In our design, the entry is what makes move work (relocate the entry), rename work (replace the entry), and deletion work (remove the entry). The file doesn’t change during any of these operations — only the entry does.
- Cycle detection is the hidden invariant. “Move
/ainto/a/b/c" is the follow-up that separates prepared candidates from unprepared ones. TheIsAncestorcheck on the path resolver is a simple ancestor walk — but you need to have the resolver in your design to implement it. - Keep aggregates small even for tree structures. The temptation is to load the entire tree into one aggregate. Resist it. Each directory is its own aggregate with just its entries. Path resolution walks across aggregates. The domain service coordinates; the aggregates stay small.
메타데이터
- post_id
- eea4d03ec04c
- slug
- cracking-the-lld-interview-designing-a-file-system-with-domain-driven-design-eea4d03ec04c
- url
- https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-a-file-system-with-domain-driven-design-eea4d03ec04c
- canonical_url
- https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-a-file-system-with-domain-driven-design-eea4d03ec04c
- author_url
- https://medium.com/@shubham.patel191295
- status
- ok
- fetched_at
- 2026-08-11 18:06:04