Low-Level Design (LLD): Document Editor (Google Docs)
A comprehensive guide to low-level object-oriented design, transitioning a monolithic, tightly coupled Document Editor into a scalable, production-grade architecture that strictly adheres to SOLID principles and clean software design patterns.
1. Problem Statement & Feature Requirements
A document editor (such as Google Docs or Microsoft Word) allows users to create, edit, display, and persist structured documents containing heterogeneous content types.
Functional Requirements
- Heterogeneous Content Handling: Support inserting various content elements into a single document while strictly preserving user insertion order.
- Initial Scope: Plain Text and Images.
- Future Scope (Extensibility): Tables, Videos, Fonts, Newlines, Tab spaces, Bold/Italic formatting, and Code blocks.
- Rendering Engine: Transform the structured document elements into a formatted representation (console string output or rendered canvas).
- Persistence Engine: Export and persist the rendered document into external targets such as local disk files or remote databases.
Non-Functional Requirements
- Extensibility: Adding a new document element type or persistence target must not require modifying existing core application code.
- Maintainability: Low coupling and high cohesion across modules.
2. Architectural Paradigms: Top-Down vs. Bottom-Up Design
When approaching an LLD problem in a technical interview or production environment, software engineers employ one of two primary architectural methodologies:
Approach Breakdown
Pedagogical Preference: A Bottom-Up Approach is ideal for this problem. By first establishing the contract for individual document elements, we can naturally compose them into documents and persistence managers without locking ourselves into a rigid top-level structure.
3. Naive Architecture (Monolithic Anti-Pattern)
A common initial approach is bundling state management, element ordering, parsing logic, rendering rules, and file I/O into a single DocumentEditor class.
The Naive Monolithic Implementation (C++)
#include <iostream>
#include <vector>
#include <string>
#include <fstream>
class DocumentEditor {
private:
std::vector<std::string> documentElements;
std::string renderedDocument;
public:
void addText(const std::string& text) {
documentElements.push_back(text);
}
void addImage(const std::string& imagePath) {
documentElements.push_back(imagePath);
}
std::string renderDocument() {
if (!renderedDocument.empty()) {
return renderedDocument;
}
std::string result = "";
for (const auto& element : documentElements) {
// Fragile Heuristic: Check if element is an image by file extension
if (element.size() > 4 &&
(element.substr(element.size() - 4) == ".jpg" ||
element.substr(element.size() - 4) == ".png")) {
result += "[Image: " + element + "]\n";
} else {
result += element + "\n";
}
}
renderedDocument = result;
return renderedDocument;
}
void saveToFile() {
std::ofstream file("document.txt");
if (file.is_open()) {
file << renderDocument();
file.close();
std::cout << "Document successfully saved to file.\n";
} else {
std::cerr << "Unable to open file for writing.\n";
}
}
};
4. Deconstructing the Naive Design (SOLID Violations)
The monolithic design above exhibits multiple structural flaws:
-
Violation of Single Responsibility Principle (SRP):
- The
DocumentEditorclass has multiple reasons to change:- Modifications to content addition behavior.
- Changes in element identification or rendering formatting.
- Alterations to persistence mechanisms (e.g., writing to a database instead of disk).
- The
-
Violation of Open/Closed Principle (OCP):
- Introducing new content types (e.g., Tables, Videos) requires:
- Adding new methods like
addTable()oraddVideo(). - Modifying string-parsing logic in
renderDocument()with fragileif-elseconditionals.
- Adding new methods like
- Introducing new content types (e.g., Tables, Videos) requires:
-
Loss of Type Safety & Semantic Integrity:
- Relying on
std::stringextensions (.png,.jpg) to differentiate images from text is error-prone. A text string like"Read me.png"would be incorrectly rendered as an image element.
- Relying on
5. Step-by-Step Evolution: Abstraction & Decoupling
To fix these structural flaws, we break the system down into decoupled, polymorphic abstractions.
Key Decoupling Strategies
- Document Elements Abstraction: Introduce a unified
DocumentElementinterface with arender()contract. Derived types (TextElement,ImageElement,NewLineElement) encapsulate their own data and rendering behaviors. - Document Model Encapsulation: A dedicated
Documentclass maintains an ordered list (std::vector<DocumentElement*>) and delegates rendering to individual elements. - Persistence Layer Strategy: Abstract saving behaviors into a
Persistenceinterface (FileStorage,DbStorage), decoupling file I/O completely from document editing. - Delegating Orchestrator: The top-level
DocumentEditoracts as a facade/wrapper that delegates operations to underlying model and persistence abstractions.
6. Refactored Production-Grade Architecture
Unified Class Diagram (Vertical Conceptual Relationships)
Production-Grade Implementation (C++)
#include <iostream>
#include <vector>
#include <string>
#include <fstream>
#include <memory>
// ============================================================================
// 1. DOCUMENT ELEMENT HIERARCHY (Polymorphism & OCP)
// ============================================================================
class DocumentElement {
public:
virtual std::string render() const = 0;
virtual ~DocumentElement() = default;
};
class TextElement : public DocumentElement {
private:
std::string text;
public:
explicit TextElement(std::string textContent) : text(std::move(textContent)) {}
std::string render() const override {
return text;
}
};
class ImageElement : public DocumentElement {
private:
std::string imagePath;
public:
explicit ImageElement(std::string path) : imagePath(std::move(path)) {}
std::string render() const override {
return "[Image: " + imagePath + "]";
}
};
class NewLineElement : public DocumentElement {
public:
std::string render() const override {
return "\n";
}
};
class TabSpaceElement : public DocumentElement {
public:
std::string render() const override {
return "\t";
}
};
// ============================================================================
// 2. DOCUMENT MODEL (Collection & Aggregation Container)
// ============================================================================
class Document {
private:
std::vector<std::unique_ptr<DocumentElement>> elements;
public:
void addElement(std::unique_ptr<DocumentElement> element) {
elements.push_back(std::move(element));
}
std::string render() const {
std::string result = "";
for (const auto& element : elements) {
result += element->render();
}
return result;
}
};
// ============================================================================
// 3. PERSISTENCE LAYER HIERARCHY (DIP & SRP)
// ============================================================================
class Persistence {
public:
virtual void save(const std::string& content) = 0;
virtual ~Persistence() = default;
};
class FileStorage : public Persistence {
private:
std::string filename;
public:
explicit FileStorage(std::string name) : filename(std::move(name)) {}
void save(const std::string& content) override {
std::ofstream file(filename);
if (file.is_open()) {
file << content;
file.close();
std::cout << "[FileStorage] Document successfully saved to " << filename << "\n";
} else {
std::cerr << "[FileStorage] Error: Unable to open file " << filename << "\n";
}
}
};
class DbStorage : public Persistence {
private:
std::string dbConnectionString;
public:
explicit DbStorage(std::string connectionString) : dbConnectionString(std::move(connectionString)) {}
void save(const std::string& content) override {
std::cout << "[DbStorage] Connecting to " << dbConnectionString << "...\n";
std::cout << "[DbStorage] Inserting document content into DB table...\n";
std::cout << "[DbStorage] Save successful.\n";
}
};
// ============================================================================
// 4. DOCUMENT EDITOR (Facade Orchestrator)
// ============================================================================
class DocumentEditor {
private:
Document document;
std::unique_ptr<Persistence> persistenceEngine;
mutable std::string cachedRenderedDocument;
public:
explicit DocumentEditor(std::unique_ptr<Persistence> persistence)
: persistenceEngine(std::move(persistence)) {}
void addText(const std::string& text) {
document.addElement(std::make_unique<TextElement>(text));
cachedRenderedDocument.clear(); // Invalidate cache on state change
}
void addImage(const std::string& imagePath) {
document.addElement(std::make_unique<ImageElement>(imagePath));
cachedRenderedDocument.clear();
}
void addNewLine() {
document.addElement(std::make_unique<NewLineElement>());
cachedRenderedDocument.clear();
}
void addTabSpace() {
document.addElement(std::make_unique<TabSpaceElement>());
cachedRenderedDocument.clear();
}
std::string renderDocument() const {
if (cachedRenderedDocument.empty()) {
cachedRenderedDocument = document.render();
}
return cachedRenderedDocument;
}
void saveDocument() {
if (persistenceEngine) {
persistenceEngine->save(renderDocument());
} else {
std::cerr << "[DocumentEditor] Error: No persistence engine configured.\n";
}
}
};
// ============================================================================
// 5. CLIENT CODE / DRIVER EXECUTION
// ============================================================================
int main() {
// Inject FileStorage persistence policy
auto filePersistence = std::make_unique<FileStorage>("final_document.txt");
DocumentEditor editor(std::move(filePersistence));
// Build document content
editor.addText("Hello World!");
editor.addNewLine();
editor.addImage("/images/architecture.png");
editor.addNewLine();
editor.addTabSpace();
editor.addText("This is a production-grade decoupled document editor.");
// Render output
std::cout << "--- RENDERED OUTPUT ---\n";
std::cout << editor.renderDocument() << "\n";
std::cout << "-----------------------\n";
// Persist content
editor.saveDocument();
return 0;
}
7. Execution Flow Walkthrough
8. SOLID Principles Audit Matrix
9. Advanced Trade-Offs & Architectural Critique
During senior engineering interviews, interviewers may raise critical counter-questions to test your understanding of architectural trade-offs.
Trade-Off Discussion Matrix
Key Takeaway for Interviews: There is no single "perfect" architecture in low-level design. System design is an exercise in managing trade-offs. Demonstrating an awareness of these trade-offs—such as balancing SRP against the Law of Demeter—is what distinguishes senior engineering candidates.
10. Summary and Key Takeaways
- Avoid Monolithic Anti-Patterns: Never combine collection state management, type-parsing heuristics, formatting logic, and persistence code into a single editor class.
- Prefer Bottom-Up Modeling: Build granular domain entities (
DocumentElement) first, enforce contract abstractions, and then combine them into higher-level container models (Document). - Leverage Polymorphism over Parsing Logic: Replace fragile string-parsing checks (
.jpg,.png) with polymorphic objects that handle their own rendering behavior. - Ensure OCP Compliance: New document elements (Tables, Code Blocks) or storage methods (AWS S3, Databases) should be added by writing new derived classes without changing core system logic.
- Decouple Persistence via DIP: High-level editor classes should depend on generic persistence interfaces (
Persistence), allowing storage engines to be swapped cleanly at runtime via Dependency Injection. - Acknowledge Design Principles as Rubrics, Not Laws: Every design involves balancing trade-offs (e.g., Facade simplicity vs. Law of Demeter strictness). Clearly articulate these choices during architectural discussions.