Day 28: Design Patterns


Day 28: Design Patterns


1. Learning Objectives

By the end of this lesson, you will be able to:

  • Understand what design patterns are and why they are useful
  • Implement the Module pattern for encapsulation and organization
  • Use the Observer pattern for event-driven communication
  • Apply the Singleton pattern for shared state
  • Implement the Factory pattern for object creation
  • Use the Prototype pattern for object cloning
  • Apply the Decorator pattern for adding behavior dynamically
  • Recognize when to use each pattern in real-world applications

2. Theory

What are Design Patterns?

Design patterns are reusable solutions to common problems in software design. They are not code snippets but rather templates for how to solve problems. Patterns were popularized by the "Gang of Four" (GoF) book in 1994, but JavaScript's dynamic nature allows for simpler implementations.

Why Use Design Patterns?

  • Proven solutions — avoid reinventing the wheel
  • Common vocabulary — communicate design intent clearly
  • Code maintainability — structured, predictable code
  • Reusability — patterns transcend specific problems

Categories of Patterns

CategoryPurposeExamples
CreationalObject creation mechanismsSingleton, Factory, Prototype, Builder
StructuralObject compositionDecorator, Proxy, Adapter, Facade
BehavioralObject communicationObserver, Strategy, Command, Iterator

Module Pattern

The Module pattern provides encapsulation by creating private scope and exposing a public API. In modern JS, ES6 modules have largely replaced this, but the pattern is still useful for understanding scope.

// Module pattern using IIFE
const CounterModule = (function() {
    let count = 0; // Private variable

    function log(action) {
        console.log(`${action}: count is now ${count}`);
    }

    // Public API
    return {
        increment() {
            count++;
            log("increment");
            return count;
        },
        decrement() {
            count--;
            log("decrement");
            return count;
        },
        getCount() {
            return count;
        },
        reset() {
            count = 0;
            log("reset");
            return count;
        }
    };
})();

console.log(CounterModule.increment()); // 1
console.log(CounterModule.getCount());  // 1
console.log(CounterModule.count);       // undefined (private)

Revealing Module Pattern

A variation where all methods are defined privately and only references are exposed:

const UserModule = (function() {
    let users = [];

    function validate(name) {
        return name && name.trim().length > 0;
    }

    function add(name) {
        if (!validate(name)) return { success: false, error: "Invalid name" };
        const user = { id: Date.now(), name: name.trim() };
        users.push(user);
        return { success: true, user };
    }

    function remove(id) {
        const index = users.findIndex(u => u.id === id);
        if (index === -1) return { success: false, error: "User not found" };
        users.splice(index, 1);
        return { success: true };
    }

    function getAll() {
        return [...users]; // Return copy
    }

    // Reveal public references
    return { add, remove, getAll };
})();

Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified. This is the foundation of event-driven programming.

class Subject {
    constructor() {
        this.observers = new Set();
    }

    subscribe(observer) {
        this.observers.add(observer);
        return () => this.observers.delete(observer); // Return unsubscribe
    }

    unsubscribe(observer) {
        this.observers.delete(observer);
    }

    notify(data) {
        this.observers.forEach(observer => observer.update(data));
    }
}

class Observer {
    constructor(name) {
        this.name = name;
    }

    update(data) {
        console.log(`${this.name} received:`, data);
    }
}

// Usage
const subject = new Subject();
const observer1 = new Observer("Observer 1");
const observer2 = new Observer("Observer 2");

const unsubscribe = subject.subscribe(observer1);
subject.subscribe(observer2);

subject.notify("Hello!"); // Both receive
unsubscribe();            // Observer 1 unsubscribes
subject.notify("Again");  // Only Observer 2 receives

Real-World: Event Emitter

class EventEmitter {
    constructor() {
        this.events = new Map();
    }

    on(event, listener) {
        if (!this.events.has(event)) {
            this.events.set(event, []);
        }
        this.events.get(event).push(listener);
        return () => this.off(event, listener);
    }

    off(event, listener) {
        const listeners = this.events.get(event);
        if (listeners) {
            const index = listeners.indexOf(listener);
            if (index !== -1) listeners.splice(index, 1);
        }
    }

    emit(event, ...args) {
        const listeners = this.events.get(event);
        if (listeners) {
            listeners.forEach(listener => listener(...args));
        }
    }

    once(event, listener) {
        const wrapper = (...args) => {
            listener(...args);
            this.off(event, wrapper);
        };
        this.on(event, wrapper);
    }
}

// Usage
const emitter = new EventEmitter();
const unsub = emitter.on("data", (msg) => console.log("Data:", msg));
emitter.emit("data", "Hello"); // Logs
unsub();
emitter.emit("data", "World"); // No log

Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it.

class Database {
    constructor() {
        if (Database.instance) {
            return Database.instance;
        }
        this.connection = null;
        Database.instance = this;
    }

    connect(url) {
        console.log(`Connecting to ${url}...`);
        this.connection = url;
        return this;
    }

    query(sql) {
        console.log(`Executing query on ${this.connection}: ${sql}`);
        return [];
    }
}

// Freeze the instance to prevent modifications
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true

db1.connect("mongodb://localhost:27017");
db2.query("SELECT * FROM users"); // Uses db1's connection

Singleton with Module Pattern

const ConfigManager = (function() {
    let instance;

    function createInstance() {
        return {
            settings: {
                theme: "light",
                language: "en",
                debug: false
            },
            get(key) {
                return this.settings[key];
            },
            set(key, value) {
                this.settings[key] = value;
            },
            getAll() {
                return { ...this.settings };
            }
        };
    }

    return {
        getInstance() {
            if (!instance) {
                instance = createInstance();
            }
            return instance;
        }
    };
})();

const config1 = ConfigManager.getInstance();
const config2 = ConfigManager.getInstance();
console.log(config1 === config2); // true
config1.set("theme", "dark");
console.log(config2.get("theme")); // "dark"

Factory Pattern

The Factory pattern provides an interface for creating objects without specifying their concrete classes.

// Simple factory function
function createUser(type, data) {
    switch (type) {
        case "admin":
            return {
                ...data,
                role: "admin",
                permissions: ["read", "write", "delete"],
                isAdmin: true
            };
        case "moderator":
            return {
                ...data,
                role: "moderator",
                permissions: ["read", "write"],
                isAdmin: false
            };
        case "user":
            return {
                ...data,
                role: "user",
                permissions: ["read"],
                isAdmin: false
            };
        default:
            throw new Error(`Unknown user type: ${type}`);
    }
}

const admin = createUser("admin", { name: "Alice", email: "alice@example.com" });
const user = createUser("user", { name: "Bob", email: "bob@example.com" });
console.log(admin.permissions); // ["read", "write", "delete"]
console.log(user.permissions);  // ["read"]

Factory with Classes

class Button {
    constructor(text, type) {
        this.text = text;
        this.type = type;
    }

    render() {
        return `<button class="${this.type}">${this.text}</button>`;
    }
}

class PrimaryButton extends Button {
    constructor(text) {
        super(text, "primary");
    }
}

class SecondaryButton extends Button {
    constructor(text) {
        super(text, "secondary");
    }
}

class DangerButton extends Button {
    constructor(text) {
        super(text, "danger");
    }
}

class ButtonFactory {
    static createButton(type, text) {
        switch (type) {
            case "primary": return new PrimaryButton(text);
            case "secondary": return new SecondaryButton(text);
            case "danger": return new DangerButton(text);
            default: return new Button(text, "default");
        }
    }
}

const saveBtn = ButtonFactory.createButton("primary", "Save");
const deleteBtn = ButtonFactory.createButton("danger", "Delete");
console.log(saveBtn.render()); // <button class="primary">Save</button>

Prototype Pattern

The Prototype pattern creates new objects by cloning an existing object (the prototype). JavaScript's prototypal inheritance naturally supports this.

// Using Object.create for prototype-based cloning
const carPrototype = {
    init(make, model, year) {
        this.make = make;
        this.model = model;
        this.year = year;
        return this;
    },
    getInfo() {
        return `${this.year} ${this.make} ${this.model}`;
    },
    clone() {
        return Object.create(Object.getPrototypeOf(this),
            Object.getOwnPropertyDescriptors(this));
    }
};

const car1 = Object.create(carPrototype).init("Toyota", "Camry", 2020);
const car2 = car1.clone();
car2.year = 2024;
console.log(car1.getInfo()); // 2020 Toyota Camry
console.log(car2.getInfo()); // 2024 Toyota Camry

Using structuredClone (modern)

class ShoppingCart {
    constructor(items = []) {
        this.items = items;
    }

    addItem(item) {
        this.items.push(item);
    }

    clone() {
        return structuredClone(this);
    }
}

const cart1 = new ShoppingCart([{ id: 1, name: "Laptop" }]);
const cart2 = cart1.clone();
cart2.addItem({ id: 2, name: "Mouse" });

console.log(cart1.items.length); // 1
console.log(cart2.items.length); // 2 (deep copy)

Decorator Pattern

The Decorator pattern allows adding behavior to objects dynamically without affecting other objects of the same class.

// Simple function decorator
function withLogging(fn) {
    return function(...args) {
        console.log(`Calling function with args: ${args}`);
        const result = fn(...args);
        console.log(`Result: ${result}`);
        return result;
    };
}

function add(a, b) {
    return a + b;
}

const loggedAdd = withLogging(add);
loggedAdd(3, 5); // Logs: Calling function..., Result: 8

// Class decorator pattern (mixin-like)
class Coffee {
    cost() { return 5; }
    description() { return "Coffee"; }
}

class MilkDecorator {
    constructor(coffee) {
        this.coffee = coffee;
    }

    cost() { return this.coffee.cost() + 2; }
    description() { return this.coffee.description() + ", Milk"; }
}

class SugarDecorator {
    constructor(coffee) {
        this.coffee = coffee;
    }

    cost() { return this.coffee.cost() + 1; }
    description() { return this.coffee.description() + ", Sugar"; }
}

let myCoffee = new Coffee();
myCoffee = new MilkDecorator(myCoffee);
myCoffee = new SugarDecorator(myCoffee);

console.log(myCoffee.description()); // Coffee, Milk, Sugar
console.log(myCoffee.cost());        // 8 (5 + 2 + 1)

Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

// Strategy objects
const pricingStrategies = {
    regular(price) {
        return price;
    },
    student(price) {
        return price * 0.8; // 20% discount
    },
    premium(price) {
        return price * 0.5; // 50% discount
    },
    holiday(price) {
        return price * 0.85; // 15% seasonal discount
    }
};

class PriceCalculator {
    constructor(strategy = "regular") {
        this.setStrategy(strategy);
    }

    setStrategy(strategy) {
        if (typeof strategy === "string") {
            this.strategy = pricingStrategies[strategy];
        } else {
            this.strategy = strategy;
        }
    }

    calculate(price) {
        return this.strategy(price);
    }
}

const calculator = new PriceCalculator();
console.log(calculator.calculate(100)); // 100

calculator.setStrategy("student");
console.log(calculator.calculate(100)); // 80

calculator.setStrategy("premium");
console.log(calculator.calculate(100)); // 50

// Custom strategy
calculator.setStrategy((price) => price * 0.9);
console.log(calculator.calculate(100)); // 90

3. Code Examples

Example 1: Module Pattern — Todo Store

const TodoStore = (function() {
    let todos = [];
    let nextId = 1;
    const listeners = new Set();

    function notify() {
        listeners.forEach(fn => fn(todos));
    }

    return {
        add(title) {
            const todo = { id: nextId++, title, completed: false, createdAt: new Date() };
            todos = [...todos, todo]; // Immutable update
            notify();
            return todo;
        },

        toggle(id) {
            todos = todos.map(t =>
                t.id === id ? { ...t, completed: !t.completed } : t
            );
            notify();
        },

        remove(id) {
            todos = todos.filter(t => t.id !== id);
            notify();
        },

        getAll() {
            return [...todos];
        },

        getStats() {
            const total = todos.length;
            const completed = todos.filter(t => t.completed).length;
            return { total, completed, pending: total - completed };
        },

        subscribe(fn) {
            listeners.add(fn);
            return () => listeners.delete(fn);
        }
    };
})();

Example 2: Observer Pattern — Notification System

// Notification system using Observer
class NotificationSystem {
    constructor() {
        this.channels = new Map();
    }

    subscribe(channel, user) {
        if (!this.channels.has(channel)) {
            this.channels.set(channel, new Set());
        }
        this.channels.get(channel).add(user);
        return () => this.channels.get(channel)?.delete(user);
    }

    publish(channel, message) {
        const subscribers = this.channels.get(channel);
        if (subscribers) {
            subscribers.forEach(user => {
                user.receive(channel, message);
            });
        }
    }
}

class User {
    constructor(name) {
        this.name = name;
    }

    receive(channel, message) {
        console.log(`[${this.name}] ${channel}: ${message}`);
    }
}

// Usage
const notifications = new NotificationSystem();
const alice = new User("Alice");
const bob = new User("Bob");

const unsubAlice = notifications.subscribe("general", alice);
notifications.subscribe("general", bob);
notifications.subscribe("updates", alice);

notifications.publish("general", "Hello everyone!"); // Both receive
notifications.publish("updates", "New version released!"); // Only Alice

unsubAlice();
notifications.publish("general", "Is anyone here?"); // Only Bob

Example 3: Factory Pattern — Payment Processor

// Payment processing with Factory
class CreditCardPayment {
    constructor(details) {
        this.cardNumber = details.cardNumber;
        this.expiry = details.expiry;
        this.cvv = details.cvv;
    }

    process(amount) {
        console.log(`Processing $${amount} via Credit Card (${this.cardNumber.slice(-4)})`);
        return { success: true, transactionId: `CC-${Date.now()}` };
    }
}

class PayPalPayment {
    constructor(details) {
        this.email = details.email;
    }

    process(amount) {
        console.log(`Processing $${amount} via PayPal (${this.email})`);
        return { success: true, transactionId: `PP-${Date.now()}` };
    }
}

class CryptoPayment {
    constructor(details) {
        this.walletAddress = details.walletAddress;
        this.currency = details.currency || "BTC";
    }

    process(amount) {
        console.log(`Processing $${amount} via ${this.currency} (${this.walletAddress.slice(0, 6)}...)`);
        return { success: true, transactionId: `CR-${Date.now()}` };
    }
}

class PaymentFactory {
    static createProcessor(type, details) {
        switch (type) {
            case "credit": return new CreditCardPayment(details);
            case "paypal": return new PayPalPayment(details);
            case "crypto": return new CryptoPayment(details);
            default: throw new Error(`Unknown payment type: ${type}`);
        }
    }
}

// Usage
const payment = PaymentFactory.createProcessor("paypal", { email: "user@example.com" });
const result = payment.process(99.99);
console.log(result);

Example 4: Decorator Pattern — Middleware Pipeline

// Express-like middleware using Decorator pattern
class RequestHandler {
    constructor() {
        this.middlewares = [];
    }

    use(middleware) {
        this.middlewares.push(middleware);
        return this; // For chaining
    }

    async handle(request) {
        let index = 0;
        const next = async () => {
            if (index < this.middlewares.length) {
                const middleware = this.middlewares[index++];
                await middleware(request, next);
            }
        };
        await next();
        return request;
    }
}

// Usage
const app = new RequestHandler();

// Logger middleware
app.use(async (req, next) => {
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
    await next();
});

// Auth middleware
app.use(async (req, next) => {
    if (!req.headers?.authorization) {
        req.error = "Unauthorized";
        return;
    }
    req.user = { id: 1, name: "Alice" };
    await next();
});

// Route handler
app.use(async (req, next) => {
    if (req.error) {
        req.response = { status: 401, body: { error: req.error } };
    } else {
        req.response = { status: 200, body: { message: `Hello ${req.user.name}` } };
    }
});

// Test
app.handle({ method: "GET", url: "/api/users", headers: { authorization: "Bearer token" } })
    .then(req => console.log(req.response));

Example 5: Combining Patterns — E-Commerce System

// Combining Factory, Singleton, Observer, and Module patterns

// Logger (Singleton)
const Logger = (function() {
    let instance;
    function createInstance() {
        return {
            logs: [],
            log(level, message) {
                const entry = { level, message, timestamp: new Date() };
                this.logs.push(entry);
                console.log(`[${level.toUpperCase()}] ${message}`);
            },
            info(m) { this.log("info", m); },
            warn(m) { this.log("warn", m); },
            error(m) { this.log("error", m); },
            getLogs() { return [...this.logs]; }
        };
    }
    return { getInstance: () => instance || (instance = createInstance()) };
})();

// Product Factory
class ProductFactory {
    static create(type, data) {
        switch (type) {
            case "physical":
                return { ...data, type: "physical", requiresShipping: true };
            case "digital":
                return { ...data, type: "digital", requiresShipping: false };
            case "service":
                return { ...data, type: "service", requiresShipping: false };
            default:
                throw new Error(`Unknown product type: ${type}`);
        }
    }
}

// Order Subject (Observer)
class OrderSubject {
    constructor() {
        this.observers = new Set();
    }

    subscribe(observer) {
        this.observers.add(observer);
    }

    unsubscribe(observer) {
        this.observers.delete(observer);
    }

    notify(event, data) {
        this.observers.forEach(obs => obs.update(event, data));
    }
}

// Email Observer
class EmailService {
    update(event, data) {
        if (event === "order_placed") {
            Logger.getInstance().info(`Sending email confirmation for order #${data.orderId}`);
        }
    }
}

// Inventory Observer
class InventoryService {
    update(event, data) {
        if (event === "order_placed") {
            data.items.forEach(item => {
                Logger.getInstance().info(`Updating inventory for ${item.name}`);
            });
        }
    }
}

// Order Module
const OrderModule = (function() {
    const orders = [];
    const subject = new OrderSubject();
    const logger = Logger.getInstance();

    return {
        init() {
            subject.subscribe(new EmailService());
            subject.subscribe(new InventoryService());
            logger.info("Order module initialized");
        },

        placeOrder(items) {
            const order = {
                orderId: `ORD-${Date.now()}`,
                items,
                total: items.reduce((sum, item) => sum + item.price, 0),
                status: "confirmed",
                createdAt: new Date()
            };
            orders.push(order);
            logger.info(`Order #${order.orderId} placed for $${order.total}`);
            subject.notify("order_placed", order);
            return order;
        },

        getOrders() {
            return [...orders];
        }
    };
})();

// Usage
OrderModule.init();
const laptop = ProductFactory.create("physical", { name: "Laptop", price: 999.99, weight: "2kg" });
const ebook = ProductFactory.create("digital", { name: "E-Book", price: 19.99, fileSize: "5MB" });
OrderModule.placeOrder([laptop, ebook]);

4. Exercises

Beginner

  1. Implement a simple Module pattern for a MathUtils module with private helper square(n) and public sumSquares(a, b).
  2. Create a basic Observer pattern: a Button class that notifies listeners when clicked.
  3. Implement a Singleton Logger class that ensures only one instance logs messages.

Intermediate

  1. Build a ShapeFactory that creates Circle, Square, and Triangle objects based on a type string. Each shape should have an area() method.
  2. Implement a Decorator pattern for a TextProcessor base class with decorators Bold and Italic that wrap the render() method.
  3. Create a Strategy pattern for sorting: implement bubbleSort, quickSort, and mergeSort as strategies, and a Sorter class that can switch between them.

Advanced

  1. Combine Module, Observer, and Factory patterns to build a simple Chat Application: users can send messages (Factory creates message types), a ChatRoom (Module) manages state, and observers receive notifications.
  2. Build a Plugin System using the Decorator pattern: a base App class and plugins that add functionality (logging, analytics, authentication) via the use() method.

5. Mini Project: Notification Hub

Build a notification hub that uses multiple design patterns to manage and deliver notifications across different channels.

// === Notification Hub ===

// ---- Singleton: Config Manager ----
const Config = (function() {
    let instance;
    function createInstance() {
        return {
            channels: { email: true, sms: false, push: true },
            rateLimit: 5, // per minute
            set(key, value) { this[key] = value; },
            get(key) { return this[key]; }
        };
    }
    return { getInstance: () => instance || (instance = createInstance()) };
})();

// ---- Factory: Notification Creator ----
class NotificationFactory {
    static create(type, data) {
        switch (type) {
            case "email":
                return { type: "email", to: data.to, subject: data.subject, body: data.body, priority: data.priority || "normal" };
            case "sms":
                return { type: "sms", to: data.to, message: data.message, priority: data.priority || "normal" };
            case "push":
                return { type: "push", deviceId: data.deviceId, title: data.title, body: data.body, priority: data.priority || "normal" };
            default:
                throw new Error(`Unknown notification type: ${type}`);
        }
    }
}

// ---- Observer: Notification Channels ----
class EmailChannel {
    update(notification) {
        if (Config.getInstance().get("channels").email) {
            console.log(`📧 Email to ${notification.to}: ${notification.subject}`);
            console.log(`   Body: ${notification.body}`);
        }
    }
}

class SMSChannel {
    update(notification) {
        if (Config.getInstance().get("channels").sms) {
            console.log(`📱 SMS to ${notification.to}: ${notification.message}`);
        }
    }
}

class PushChannel {
    update(notification) {
        if (Config.getInstance().get("channels").push) {
            console.log(`🔔 Push to ${notification.deviceId}: ${notification.title}`);
            console.log(`   Body: ${notification.body}`);
        }
    }
}

// ---- Strategy: Priority Routing ----
const priorityRouting = {
    high(notification) {
        // Send via all available channels
        return ["email", "sms", "push"];
    },
    normal(notification) {
        // Send via default channels
        return ["email", "push"];
    },
    low(notification) {
        // Send via email only
        return ["email"];
    }
};

// ---- Subject: Notification Hub (Module) ----
const NotificationHub = (function() {
    const channels = {
        email: new EmailChannel(),
        sms: new SMSChannel(),
        push: new PushChannel()
    };
    const history = [];
    const MAX_HISTORY = 50;
    let rateCount = 0;
    let rateResetTimer = null;

    function startRateTimer() {
        if (!rateResetTimer) {
            rateResetTimer = setTimeout(() => {
                rateCount = 0;
                rateResetTimer = null;
            }, 60000);
        }
    }

    return {
        send(type, data) {
            const config = Config.getInstance();
            const notification = NotificationFactory.create(type, data);

            // Rate limiting
            if (rateCount >= config.get("rateLimit")) {
                console.warn("⚠️ Rate limit exceeded. Notification queued.");
                return { success: false, error: "Rate limit exceeded" };
            }

            // Determine channels
            const route = priorityRouting[notification.priority] || priorityRouting.normal;
            const targetChannels = route(notification);

            // Send via each channel
            targetChannels.forEach(channelName => {
                const channel = channels[channelName];
                if (channel) {
                    channel.update(notification);
                }
            });

            // Record history
            history.unshift({ ...notification, sentAt: new Date(), channels: targetChannels });
            if (history.length > MAX_HISTORY) history.pop();

            rateCount++;
            startRateTimer();

            console.log(`✅ Notification sent via ${targetChannels.join(", ")}`);
            return { success: true, notificationId: Date.now() };
        },

        getHistory() {
            return [...history];
        },

        updateConfig(key, value) {
            Config.getInstance().set(key, value);
            console.log(`⚙️ Config updated: ${key} = ${value}`);
        }
    };
})();

// ---- Test the Notification Hub ----
console.log("=== Notification Hub Demo ===\n");

// Send different types
NotificationHub.send("email", {
    to: "alice@example.com",
    subject: "Welcome!",
    body: "Thanks for joining.",
    priority: "high"
});

console.log("---");

NotificationHub.send("sms", {
    to: "+1234567890",
    message: "Your code is 123456",
    priority: "high"
});

console.log("---");

NotificationHub.send("push", {
    deviceId: "device-abc-123",
    title: "New Message",
    body: "You have a new message from Bob.",
    priority: "normal"
});

console.log("\n--- History ---");
NotificationHub.getHistory().forEach(n => {
    console.log(`[${n.type}] ${n.subject || n.message || n.title} via ${n.channels.join(", ")}`);
});

console.log("\n--- Disable SMS and send again ---");
NotificationHub.updateConfig("channels", { email: true, sms: false, push: true });
NotificationHub.send("email", { to: "bob@example.com", subject: "Hello", body: "Test" });

Try extending it: Add more channels (Slack, Telegram), add a retry mechanism for failed sends, implement a priority queue, or add scheduled/delayed notifications.


6. Common Mistakes

MistakeWhy it's wrongCorrect approach
Overusing SingletonCreates global state, makes testing difficultUse dependency injection or module patterns sparingly
Tight coupling in ObserverObservers know too much about the subjectPass minimal data; let observers decide what to do
Factory becoming too complexViolates Open/Closed principle when adding new typesUse registry pattern or dynamic dispatch
Decorator losing the original interfaceDecorated object behaves differently than expectedKeep the same interface; delegate to wrapped object
Strategy pattern with too many strategiesOver-engineering for simple conditional logicUse simple functions or objects for small variations
Module pattern with mutable exposed stateExternal code can modify internal stateReturn copies of arrays/objects; freeze if needed
Creating a new instance of Singletonnew Singleton() creates second instanceReturn the existing instance from constructor
Forgetting to unsubscribe observersMemory leaks from orphaned observersReturn unsubscribe function; clean up in lifecycle hooks

7. Best Practices

  • Use the Module pattern for organizing related functionality with private state.
  • Use the Observer pattern for event-driven communication between decoupled components.
  • Use the Singleton pattern sparingly — prefer dependency injection for testability.
  • Use the Factory pattern when object creation logic is complex or varies by type.
  • Use the Decorator pattern for adding behavior dynamically without modifying existing code.
  • Use the Strategy pattern when you need to swap algorithms at runtime.
  • Combine patterns when appropriate — they are not mutually exclusive.
  • Keep patterns simple — don't force a pattern where a simple function would suffice.
  • Document your patterns — make it clear which pattern you're using and why.
  • Prefer composition over inheritance — many patterns (Decorator, Strategy) use composition.

8. Challenge Assignment (Optional)

"Plugin Architecture with Multiple Patterns" Challenge

Build a plugin-based application framework that combines Module, Observer, Factory, Decorator, and Strategy patterns.

Requirements:

  1. Core Application (Module pattern):

    • Manages plugins, events, and configuration
    • Provides use(plugin), start(), stop() methods
  2. Plugin System (Decorator pattern):

    • Each plugin can wrap or extend app functionality
    • Plugins have lifecycle hooks: onInit(app), onStart(app), onStop(app)
  3. Event System (Observer pattern):

    • Built-in event emitter for app-wide events
    • Plugins can subscribe to events
  4. Service Factory (Factory pattern):

    • Creates services (Database, Cache, Logger) by type
    • Services are injected into plugins
  5. Task Runner (Strategy pattern):

    • Supports different execution strategies: immediate, debounced, throttled, queued

Example usage:

const app = new App();

// Register plugins
app.use(new LoggerPlugin());
app.use(new AnalyticsPlugin({ trackingId: "UA-123" }));
app.use(new CachePlugin());

// Start the app
app.start();

// Use services
const db = app.getService("database");
db.query("SELECT * FROM users");

// Emit events that plugins can react to
app.emit("user:login", { userId: 1 });

// Run tasks with different strategies
app.run("sync-data", () => fetchData(), { strategy: "debounced", delay: 500 });
app.run("send-email", () => sendEmail(), { strategy: "queued" });

9. Knowledge Check (Quiz)

  1. Which pattern ensures a class has only one instance?

    • a) Factory
    • b) Singleton
    • c) Observer
    • d) Decorator
  2. Which pattern defines a one-to-many dependency between objects?

    • a) Module
    • b) Strategy
    • c) Observer
    • d) Prototype
  3. Which pattern provides an interface for creating objects without specifying their concrete classes?

    • a) Factory
    • b) Singleton
    • c) Decorator
    • d) Module
  4. What is the main advantage of the Module pattern?

    • a) It creates only one instance
    • b) It encapsulates private state and exposes a public API
    • c) It allows swapping algorithms at runtime
    • d) It clones objects
  5. Which pattern adds behavior to an object dynamically without modifying its class?

    • a) Strategy
    • b) Factory
    • c) Decorator
    • d) Observer
  6. Which pattern allows you to swap algorithms at runtime?

    • a) Singleton
    • b) Strategy
    • c) Prototype
    • d) Module
  7. What is a common problem with the Observer pattern?

    • a) It creates too many objects
    • b) Memory leaks from forgotten unsubscriptions
    • c) It's hard to implement
    • d) It only works with classes
  8. When should you avoid the Singleton pattern?

    • a) When you need global state
    • b) When you need testable code with dependency injection
    • c) When you need only one instance
    • d) Never — Singleton is always good

Answers: 1-b, 2-c, 3-a, 4-b, 5-c, 6-b, 7-b, 8-b


10. Additional Resources


11. Summary

Today you learned:

  • Module pattern — encapsulation with private state and public API using IIFE or ES6 modules
  • Observer pattern — one-to-many dependency with subject and observers, event emitters
  • Singleton pattern — ensuring a single instance with shared state
  • Factory pattern — creating objects without specifying concrete classes
  • Prototype pattern — cloning objects using Object.create() and structuredClone()
  • Decorator pattern — adding behavior dynamically with wrapper functions and classes
  • Strategy pattern — swapping algorithms at runtime
  • Combining patterns — building a complete Notification Hub using Module, Singleton, Factory, Observer, and Strategy
  • Common pitfalls — overusing Singleton, tight coupling, memory leaks from unsubscribed observers, over-engineering with patterns

You now have a toolkit of reusable design patterns to solve common problems in JavaScript. These patterns will help you write more maintainable, scalable, and professional code. Tomorrow we'll explore testing with Jest — ensuring your code works correctly.


"Design patterns are not silver bullets — they are proven solutions to common problems. Use them wisely, and they'll make your code more robust and maintainable."

Comments

EARN AT THE COMFORT OF YOUR HOME

Sponsored content

Popular posts from this blog

Day 5 – Functions, Scope, and Docstrings

Day 10 – Exception Handling and Debugging

Day 1 – Python Foundations for AEC Professionals