Patrones de diseño en JavaScript: Singleton, Observer y Factory

D
DanisCh
(Actualizado: ) • 14 min de lectura
Patrones de diseño en JavaScript: Singleton, Observer y Factory
Cursos de Programación Buenas Prácticas JavaScript

Los patrones de diseño son soluciones probadas a problemas que aparecen repetidamente al construir software. No son reglas estrictas ni código que copias y pegas: son formas de pensar sobre la estructura de tu código que, una vez las reconoces, te ayudan a comunicarte con otros desarrolladores y a evitar reinventar soluciones a problemas ya resueltos.

En esta guía vas a ver tres de los patrones más usados en JavaScript: Singleton, Observer y Factory. Para cada uno verás el problema que resuelve, cómo implementarlo correctamente, y ejemplos reales de dónde aparece en librerías y frameworks que probablemente ya usas.

Singleton: garantizar una única instancia

El patrón Singleton garantiza que una clase tenga exactamente una instancia en toda la aplicación, y proporciona un punto de acceso global a ella. Se usa cuando un recurso compartido (una conexión a base de datos, una configuración global, un gestor de estado) no debe duplicarse.

El problema que resuelve

// Sin Singleton: cada vez que se crea una instancia, se duplica el recurso
class ConexionBaseDatos {
    constructor(url) {
        this.url = url;
        console.log(`Conectando a ${url}...`);
        // Imagina que aquí se abre una conexión real y costosa
    }
}

const conexion1 = new ConexionBaseDatos("postgresql://servidor/db");
const conexion2 = new ConexionBaseDatos("postgresql://servidor/db");

console.log(conexion1 === conexion2);   // false ← son dos conexiones distintas
// Esto desperdicia recursos: cada conexión a una base de datos tiene un coste real

Implementación con una clase

class ConexionBaseDatos {
    static #instancia = null;   // campo privado estático

    constructor(url) {
        if (ConexionBaseDatos.#instancia) {
            throw new Error("Usa ConexionBaseDatos.obtenerInstancia() en lugar de new");
        }
        this.url = url;
        this.conectada = false;
        console.log(`Inicializando conexión a ${url}`);
    }

    static obtenerInstancia(url) {
        if (!ConexionBaseDatos.#instancia) {
            ConexionBaseDatos.#instancia = new ConexionBaseDatos(url);
        }
        return ConexionBaseDatos.#instancia;
    }

    conectar() {
        if (!this.conectada) {
            console.log(`Conectando a ${this.url}...`);
            this.conectada = true;
        }
        return this;
    }

    consultar(sql) {
        if (!this.conectada) throw new Error("No hay conexión activa");
        console.log(`Ejecutando: ${sql}`);
    }
}

const db1 = ConexionBaseDatos.obtenerInstancia("postgresql://servidor/db");
const db2 = ConexionBaseDatos.obtenerInstancia("postgresql://otro-servidor/db");

console.log(db1 === db2);   // true ← es la MISMA instancia
db1.conectar();
db2.consultar("SELECT * FROM usuarios");   // funciona: db1 y db2 son el mismo objeto

// Intentar crear directamente con 'new' después de la primera vez falla
// new ConexionBaseDatos("otra-url");   // Error: Usa obtenerInstancia()

Implementación con un módulo ES6 (la forma más idiomática en JavaScript moderno)

En JavaScript, los módulos ES6 ya son singletons por naturaleza: un módulo se ejecuta una sola vez y todas las importaciones reciben la misma instancia. Esta es la forma más limpia de implementar el patrón en proyectos modernos.

// configuracion.js
class Configuracion {
    constructor() {
        this.ajustes = {
            tema: "claro",
            idioma: "es",
            apiUrl: "https://api.ejemplo.com"
        };
    }

    obtener(clave) {
        return this.ajustes[clave];
    }

    establecer(clave, valor) {
        this.ajustes[clave] = valor;
    }
}

// Exportamos una instancia ya creada, no la clase
export default new Configuracion();
// archivo1.js
import configuracion from "./configuracion.js";
configuracion.establecer("tema", "oscuro");

// archivo2.js
import configuracion from "./configuracion.js";
console.log(configuracion.obtener("tema"));   // "oscuro"
// Es la MISMA instancia: los módulos ES6 se cachean tras la primera importación

Singleton en la práctica: gestores de estado

Redux, Zustand, Pinia y la mayoría de gestores de estado en aplicaciones frontend implementan el patrón Singleton internamente: el store es una única instancia compartida por toda la aplicación.

// Ejemplo simplificado de un store tipo Redux usando el patrón Singleton
class Store {
    static #instancia = null;

    #estado;
    #listeners = [];

    constructor(estadoInicial) {
        this.#estado = estadoInicial;
    }

    static crear(estadoInicial) {
        if (!Store.#instancia) {
            Store.#instancia = new Store(estadoInicial);
        }
        return Store.#instancia;
    }

    obtenerEstado() {
        return this.#estado;
    }

    despachar(accion) {
        this.#estado = { ...this.#estado, ...accion };
        this.#listeners.forEach(listener => listener(this.#estado));
    }

    suscribir(listener) {
        this.#listeners.push(listener);
        return () => {
            this.#listeners = this.#listeners.filter(l => l !== listener);
        };
    }
}

const store = Store.crear({ contador: 0, usuario: null });

const cancelar = store.suscribir((estado) => {
    console.log("Estado actualizado:", estado);
});

store.despachar({ contador: 1 });   // Estado actualizado: {contador: 1, usuario: null}
store.despachar({ usuario: "Ana" }); // Estado actualizado: {contador: 1, usuario: 'Ana'}

Cuándo usar (y cuándo evitar) Singleton

Úsalo para: conexiones a recursos compartidos (base de datos, caché), configuración global de la aplicación, gestores de estado, loggers compartidos.

Evítalo cuando: necesites testear el código de forma aislada (los singletons mantienen estado entre tests, lo que puede causar tests que se afectan entre sí), o cuando en el futuro podrías necesitar múltiples instancias (por ejemplo, conectar a varias bases de datos). El patrón Singleton introduce un acoplamiento global que dificulta los tests unitarios si no se gestiona con cuidado.

Observer: notificar cambios a múltiples suscriptores

El patrón Observer define una relación de uno a muchos entre objetos: cuando un objeto (el sujeto u observable) cambia de estado, todos sus observadores son notificados automáticamente. Es el patrón detrás de los eventos del DOM, los event emitters de Node.js y los sistemas reactivos.

El problema que resuelve

Sin Observer, si varios módulos necesitan reaccionar al mismo cambio, tendrías que llamarlos manualmente uno por uno desde el lugar donde ocurre el cambio, acoplando fuertemente ese código a todos los que necesitan saberlo.

Implementación desde cero

class Observable {
    #observadores = [];

    suscribir(observador) {
        this.#observadores.push(observador);

        // Devolver función de cancelación de suscripción (muy útil en la práctica)
        return () => {
            this.#observadores = this.#observadores.filter(obs => obs !== observador);
        };
    }

    notificar(datos) {
        this.#observadores.forEach(observador => observador(datos));
    }
}

// Caso de uso: un carrito de compras que notifica cambios
class CarritoCompra extends Observable {
    #items = [];

    agregar(producto) {
        this.#items.push(producto);
        this.notificar({ tipo: "item_agregado", item: producto, total: this.calcularTotal() });
    }

    eliminar(productoId) {
        this.#items = this.#items.filter(item => item.id !== productoId);
        this.notificar({ tipo: "item_eliminado", total: this.calcularTotal() });
    }

    calcularTotal() {
        return this.#items.reduce((sum, item) => sum + item.precio, 0);
    }
}

const carrito = new CarritoCompra();

// Múltiples observadores reaccionan de forma independiente
const cancelarUI = carrito.suscribir((evento) => {
    console.log(`[UI] Actualizando interfaz. Total: ${evento.total}€`);
});

const cancelarAnalytics = carrito.suscribir((evento) => {
    if (evento.tipo === "item_agregado") {
        console.log(`[Analytics] Producto añadido: ${evento.item.nombre}`);
    }
});

const cancelarNotificacion = carrito.suscribir((evento) => {
    console.log(`[Notificación] El carrito ha cambiado`);
});

carrito.agregar({ id: 1, nombre: "Laptop", precio: 999 });
// [UI] Actualizando interfaz. Total: 999€
// [Analytics] Producto añadido: Laptop
// [Notificación] El carrito ha cambiado

// Cancelar una suscripción específica sin afectar a las demás
cancelarAnalytics();
carrito.agregar({ id: 2, nombre: "Ratón", precio: 29 });
// [UI] Actualizando interfaz. Total: 1028€
// [Notificación] El carrito ha cambiado
// (analytics ya no se ejecuta)

Observer con eventos nombrados (como EventEmitter de Node.js)

class EventEmitter {
    #eventos = new Map();

    on(nombreEvento, callback) {
        if (!this.#eventos.has(nombreEvento)) {
            this.#eventos.set(nombreEvento, []);
        }
        this.#eventos.get(nombreEvento).push(callback);
        return this;   // permite encadenamiento
    }

    off(nombreEvento, callback) {
        const callbacks = this.#eventos.get(nombreEvento);
        if (callbacks) {
            this.#eventos.set(
                nombreEvento,
                callbacks.filter(cb => cb !== callback)
            );
        }
        return this;
    }

    emit(nombreEvento, ...args) {
        const callbacks = this.#eventos.get(nombreEvento) || [];
        callbacks.forEach(callback => callback(...args));
        return this;
    }

    once(nombreEvento, callback) {
        const wrapper = (...args) => {
            callback(...args);
            this.off(nombreEvento, wrapper);
        };
        this.on(nombreEvento, wrapper);
        return this;
    }
}

// Uso: un servidor que emite eventos
class Servidor extends EventEmitter {
    iniciar() {
        console.log("Servidor iniciando...");
        setTimeout(() => this.emit("listo", { puerto: 3000 }), 100);
    }

    recibirPeticion(ruta) {
        this.emit("peticion", { ruta, timestamp: Date.now() });
    }
}

const servidor = new Servidor();

servidor.on("listo", (info) => console.log(`Servidor listo en puerto ${info.puerto}`));
servidor.on("peticion", (info) => console.log(`Petición a ${info.ruta}`));
servidor.once("peticion", () => console.log("Esta es la primera petición registrada"));

servidor.iniciar();
servidor.recibirPeticion("/usuarios");
// Servidor iniciando...
// (100ms después) Servidor listo en puerto 3000
// Petición a /usuarios
// Esta es la primera petición registrada

servidor.recibirPeticion("/productos");
// Petición a /productos
// (el "once" no se vuelve a ejecutar)

Observer en el navegador: addEventListener

El sistema de eventos del DOM es una implementación directa del patrón Observer. Múltiples listeners pueden suscribirse al mismo evento sin conocerse entre sí.

const boton = document.getElementById("mi-boton");

function manejarClickAnalytics(evento) {
    console.log("Analytics: click registrado");
}

function manejarClickUI(evento) {
    boton.classList.add("clicked");
}

// Ambos observadores reaccionan al mismo evento de forma independiente
boton.addEventListener("click", manejarClickAnalytics);
boton.addEventListener("click", manejarClickUI);

// Se puede cancelar uno sin afectar al otro
boton.removeEventListener("click", manejarClickAnalytics);

Observer reactivo: la base de Vue, MobX y las señales

// Una implementación simplificada del patrón usado en sistemas reactivos
function crearEstadoReactivo(valorInicial) {
    let valor = valorInicial;
    const observadores = new Set();

    return {
        get() {
            return valor;
        },
        set(nuevoValor) {
            if (valor !== nuevoValor) {
                valor = nuevoValor;
                observadores.forEach(fn => fn(valor));
            }
        },
        observar(fn) {
            observadores.add(fn);
            return () => observadores.delete(fn);
        }
    };
}

const contador = crearEstadoReactivo(0);

contador.observar((valor) => console.log(`Contador actualizado: ${valor}`));
contador.observar((valor) => {
    if (valor > 5) console.log("¡Contador superó 5!");
});

contador.set(1);   // Contador actualizado: 1
contador.set(6);   // Contador actualizado: 6
                    // ¡Contador superó 5!

Factory: delegar la creación de objetos

El patrón Factory encapsula la lógica de creación de objetos en una función o método dedicado, en lugar de usar new ClaseEspecifica() directamente en el código que los necesita. Resulta especialmente útil cuando el tipo exacto de objeto a crear depende de condiciones que se evalúan en tiempo de ejecución.

El problema que resuelve

// ❌ Sin Factory: la lógica de decisión está mezclada con el código que usa los objetos
function procesarNotificacion(tipo, mensaje) {
    let notificacion;

    if (tipo === "email") {
        notificacion = { tipo: "email", mensaje, enviar: () => console.log(`Email: ${mensaje}`) };
    } else if (tipo === "sms") {
        notificacion = { tipo: "sms", mensaje, enviar: () => console.log(`SMS: ${mensaje}`) };
    } else if (tipo === "push") {
        notificacion = { tipo: "push", mensaje, enviar: () => console.log(`Push: ${mensaje}`) };
    } else {
        throw new Error("Tipo de notificación desconocido");
    }

    notificacion.enviar();
}
// Si este patrón if/else se repite en 10 lugares del código,
// añadir un nuevo tipo de notificación obliga a tocar 10 sitios

Función factory simple

class NotificacionEmail {
    constructor(mensaje, destinatario) {
        this.mensaje = mensaje;
        this.destinatario = destinatario;
    }

    enviar() {
        console.log(`Enviando email a ${this.destinatario}: ${this.mensaje}`);
    }
}

class NotificacionSMS {
    constructor(mensaje, telefono) {
        this.mensaje = mensaje;
        this.telefono = telefono;
    }

    enviar() {
        console.log(`Enviando SMS a ${this.telefono}: ${this.mensaje}`);
    }
}

class NotificacionPush {
    constructor(mensaje, dispositivoId) {
        this.mensaje = mensaje;
        this.dispositivoId = dispositivoId;
    }

    enviar() {
        console.log(`Enviando push a dispositivo ${this.dispositivoId}: ${this.mensaje}`);
    }
}

// La factory centraliza la lógica de creación
function crearNotificacion(tipo, mensaje, destino) {
    const tipos = {
        email: () => new NotificacionEmail(mensaje, destino),
        sms:   () => new NotificacionSMS(mensaje, destino),
        push:  () => new NotificacionPush(mensaje, destino)
    };

    const constructor = tipos[tipo];
    if (!constructor) {
        throw new Error(`Tipo de notificación desconocido: ${tipo}`);
    }

    return constructor();
}

// El código que usa las notificaciones no necesita saber las clases concretas
const notif1 = crearNotificacion("email", "Tu pedido ha sido enviado", "ana@mail.com");
const notif2 = crearNotificacion("sms",   "Código de verificación: 4821", "+34600111222");

notif1.enviar();   // Enviando email a ana@mail.com: ...
notif2.enviar();   // Enviando SMS a +34600111222: ...

// Añadir un nuevo tipo solo requiere modificar la factory, no cada lugar que la usa

Factory con clase (Factory Method)

// Cuando la lógica de creación es más compleja, conviene una clase Factory dedicada
class FabricaConexion {
    static crear(tipo, config) {
        switch (tipo) {
            case "postgresql":
                return new ConexionPostgreSQL(config);
            case "mysql":
                return new ConexionMySQL(config);
            case "mongodb":
                return new ConexionMongoDB(config);
            default:
                throw new Error(`Tipo de base de datos no soportado: ${tipo}`);
        }
    }
}

class ConexionPostgreSQL {
    constructor(config) {
        this.config = config;
        this.tipo = "postgresql";
    }
    conectar() {
        console.log(`Conectando a PostgreSQL en ${this.config.host}:5432`);
    }
}

class ConexionMySQL {
    constructor(config) {
        this.config = config;
        this.tipo = "mysql";
    }
    conectar() {
        console.log(`Conectando a MySQL en ${this.config.host}:3306`);
    }
}

class ConexionMongoDB {
    constructor(config) {
        this.config = config;
        this.tipo = "mongodb";
    }
    conectar() {
        console.log(`Conectando a MongoDB en ${this.config.host}:27017`);
    }
}

// La aplicación decide el tipo según configuración, variable de entorno, etc.
const tipoBD = process.env.DB_TYPE || "postgresql";
const conexion = FabricaConexion.crear(tipoBD, { host: "localhost" });

conexion.conectar();   // Conectando a PostgreSQL en localhost:5432

Factory en la práctica: document.createElement

Una de las factories más usadas en JavaScript está integrada en el propio navegador: document.createElement() crea diferentes tipos de elementos DOM según el string que le pasas, sin que tengas que conocer las clases internas de cada tipo de elemento.

const div    = document.createElement("div");      // crea un HTMLDivElement
const input  = document.createElement("input");    // crea un HTMLInputElement
const imagen = document.createElement("img");       // crea un HTMLImageElement

// Cada uno es una clase distinta con propiedades distintas
console.log(div instanceof HTMLDivElement);      // true
console.log(input instanceof HTMLInputElement);  // true

// Pero el código que las usa no necesita conocer las clases:
// simplemente pide el tipo y la factory hace el resto

Abstract Factory: familias de objetos relacionados

Una variante más avanzada es la Abstract Factory: una factory que crea familias completas de objetos relacionados entre sí, garantizando que sean compatibles.

// Fábrica abstracta para componentes de UI según el tema visual
class FabricaComponentesClaro {
    crearBoton() {
        return { estilo: "boton-claro", color: "#1a1a1a", fondo: "#ffffff" };
    }
    crearInput() {
        return { estilo: "input-claro", borde: "#cccccc", fondo: "#ffffff" };
    }
}

class FabricaComponentesOscuro {
    crearBoton() {
        return { estilo: "boton-oscuro", color: "#ffffff", fondo: "#1a1a1a" };
    }
    crearInput() {
        return { estilo: "input-oscuro", borde: "#444444", fondo: "#2a2a2a" };
    }
}

function obtenerFabrica(tema) {
    return tema === "oscuro" ? new FabricaComponentesOscuro() : new FabricaComponentesClaro();
}

// Toda la UI usa la misma fábrica, garantizando consistencia visual
function construirFormulario(tema) {
    const fabrica = obtenerFabrica(tema);
    return {
        boton: fabrica.crearBoton(),
        input: fabrica.crearInput()
    };
}

console.log(construirFormulario("oscuro"));
// { boton: {estilo: 'boton-oscuro', ...}, input: {estilo: 'input-oscuro', ...} }
// Todos los componentes son consistentes con el tema elegido

Combinando los tres patrones: un ejemplo real

Estos patrones rara vez se usan en aislamiento. Aquí un ejemplo que combina los tres en un sistema de gestión de notificaciones de una aplicación real.

// Singleton: un único gestor de notificaciones para toda la app
class GestorNotificaciones {
    static #instancia = null;
    #observadores = [];   // Observer: quien quiera saber de notificaciones se suscribe

    static obtenerInstancia() {
        if (!GestorNotificaciones.#instancia) {
            GestorNotificaciones.#instancia = new GestorNotificaciones();
        }
        return GestorNotificaciones.#instancia;
    }

    suscribir(callback) {
        this.#observadores.push(callback);
        return () => {
            this.#observadores = this.#observadores.filter(cb => cb !== callback);
        };
    }

    enviar(tipo, mensaje, destino) {
        // Factory: crear el tipo correcto de notificación según el parámetro
        const notificacion = crearNotificacion(tipo, mensaje, destino);
        notificacion.enviar();

        // Observer: notificar a todos los suscriptores que se envió algo
        this.#observadores.forEach(obs => obs({ tipo, mensaje, fecha: new Date() }));
    }
}

// Uso en la aplicación
const gestor = GestorNotificaciones.obtenerInstancia();

gestor.suscribir((info) => {
    console.log(`[Log] Notificación enviada: ${info.tipo} - ${info.mensaje}`);
});

gestor.suscribir((info) => {
    // Guardar histórico, actualizar contador en la UI, etc.
});

gestor.enviar("email", "Bienvenido a la plataforma", "nuevo@mail.com");
// Enviando email a nuevo@mail.com: Bienvenido a la plataforma
// [Log] Notificación enviada: email - Bienvenido a la plataforma

// En cualquier otra parte de la app, la misma instancia
const mismoGestor = GestorNotificaciones.obtenerInstancia();
console.log(gestor === mismoGestor);   // true

Resumen

  • Singleton garantiza una única instancia compartida. Útil para configuración global, conexiones compartidas y gestores de estado. En JavaScript moderno, un módulo ES6 que exporta una instancia ya es un Singleton de forma natural.
  • Observer permite que múltiples partes del código reaccionen a cambios sin acoplarse directamente entre sí. Es la base de los eventos del DOM, los event emitters y los sistemas reactivos de frameworks modernos.
  • Factory centraliza la lógica de creación de objetos, especialmente cuando el tipo exacto a crear depende de condiciones de tiempo de ejecución. Hace que añadir nuevos tipos no requiera modificar el código que los consume.
  • Los patrones rara vez aparecen aislados: en sistemas reales se combinan para resolver problemas más complejos, como muestra el ejemplo del gestor de notificaciones.
  • No fuerces patrones donde no hacen falta. Un patrón de diseño resuelve un problema concreto; si tu código no tiene ese problema, añadir el patrón solo introduce complejidad innecesaria.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a