Singleton en JavaScript: patrón de diseño, clase ES6 y módulo

El patrón Singleton garantiza una instancia única. Tres implementaciones de JavaScript ejecutadas (clase con campo estático privado, constructor que devuelve la instancia, módulo ES), la versión TypeScript, el equivalente en PHP y los casos en los que conviene prescindir de él.

Patrón de diseño Singleton en JavaScript con clases ES6
Respuesta rápida

El Singleton garantiza que una clase tenga una sola instancia y ofrece un punto de acceso global a ella. En JavaScript moderno, la forma más sencilla es un campo estático privado y un método getInstance(): static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Un módulo ES que exporta un objeto ya es un singleton: a menudo es la mejor respuesta.

El Singleton es probablemente el patrón de diseño más conocido y más discutido. Su promesa es sencilla: una clase de la que solo puede existir una instancia, accesible desde cualquier sitio. Una conexión a una base de datos, un logger o la configuración de la aplicación son los ejemplos clásicos. En JavaScript se escribe en unas pocas líneas, y el lenguaje ofrece incluso una alternativa gratis: el módulo. Aquí tienes las implementaciones que funcionan hoy, con sus trampas.

El Singleton en una frase

Un Singleton impide la creación de varias instancias de una clase y ofrece un punto de acceso único a la instancia existente. La primera llamada crea el objeto; las siguientes devuelven el mismo. Se usa cuando un recurso tiene que compartirse de verdad: abrir diez conexiones a la misma base de datos porque diez módulos han hecho new Database() sería un derroche, y un registro repartido en diez archivos sería inservible.

Se desaconseja en cuanto solo sirve para evitar pasar un parámetro: se convierte entonces en una variable global disfrazada, difícil de testear y de sustituir. Volvemos sobre ello al final del artículo.

Implementación 1: clase con campo estático privado

Es la escritura moderna recomendada. El campo #instance es privado y estático: pertenece a la clase, no a los objetos, y nadie puede leerlo ni sobrescribirlo desde fuera. getInstance() crea la instancia en la primera llamada gracias al operador ??= («asigna si es nulo»).

database.js
class Database {
  static #instance;

  constructor(dsn) {
    this.dsn = dsn;
    this.connectedAt = new Date();
  }

  static getInstance(dsn = 'mongodb://localhost') {
    Database.#instance ??= new Database(dsn);
    return Database.#instance;
  }

  query(sql) {
    return `${this.dsn} > ${sql}`;
  }
}

const a = Database.getInstance('mongodb://prod');
const b = Database.getInstance('mysql://autre');

console.log(a === b);   // true  — el mismo objeto
console.log(b.dsn);     // 'mongodb://prod' — se ha ignorado el segundo dsn

La última línea ilustra una trampa del patrón: los parámetros de la segunda llamada se pierden en silencio. Si tu singleton acepta parámetros, o bien vienen de una fuente única (la configuración), o bien getInstance() lanza un error cuando se le pasan distintos.

Todavía nada impide escribir new Database() directamente. JavaScript no tiene constructor privado; se simula con un token que solo conoce la clase:

javascript
const cle = Symbol('Database');

class Database {
  static #instance;

  constructor(jeton) {
    if (jeton !== cle) {
      throw new Error('Utilisez Database.getInstance()');
    }
  }

  static getInstance() {
    return (Database.#instance ??= new Database(cle));
  }
}

new Database();   // Error: Utilisez Database.getInstance()

Implementación 2: el constructor devuelve la instancia existente

Es la versión histórica de este artículo y sigue funcionando: si un constructor devuelve explícitamente un objeto, new devuelve ese objeto en lugar del nuevo. La instancia se guarda en una propiedad estática.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new devuelve el objeto existente
    }
    this._data = data;
    Database.instance = this;
  }

  getData() {
    return this._data;
  }

  setData(data) {
    this._data = data;
  }
}

const mongo = new Database('mongo');
console.log(mongo.getData()); // 'mongo'

const mysql = new Database('mysql');
console.log(mysql.getData()); // 'mongo' — es el mismo objeto que mongo
console.log(mongo === mysql); // true

Tiene la ventaja de dejar intacta la sintaxis new Database() para el código que la llama. Y tiene el inconveniente de sorprender: un new que no crea nada va contra la intuición de quien lee el código, y Database.instance es público, o sea modificable. La versión con campo privado es más explícita.

Implementación 3: un módulo ES ya es un singleton

Un módulo de JavaScript solo se evalúa una vez por aplicación, sea cual sea el número de archivos que lo importan. Exportar una instancia basta, por tanto, para conseguir un singleton, sin clase ni getInstance().

config.js
const config = Object.freeze({
  env: process.env.NODE_ENV ?? 'development',
  apiUrl: 'https://api.example.com',
});

export default config;
logger.js
class Logger {
  #lines = [];
  log(message) {
    this.#lines.push(`${new Date().toISOString()} ${message}`);
  }
  get count() {
    return this.#lines.length;
  }
}

export const logger = new Logger();   // una sola instancia para toda la aplicación
app.js
import { logger } from './logger.js';
import config from './config.js';

logger.log(`Démarrage en ${config.env}`);

Cualquier archivo que importe logger recibe el mismo objeto. Es la solución preferible en la mayoría de los proyectos: se lee bien, no tiene magia y el sistema de módulos garantiza la unicidad. Dos límites: la instancia se crea al cargar el módulo, aunque nadie la use, y el singleton solo es único por ruta de archivo (dos copias de un paquete en node_modules dan dos instancias).

Versión TypeScript

TypeScript añade lo que le falta a JavaScript: un constructor realmente private, que prohíbe new en tiempo de compilación.

database.ts
class Database {
  private static instance: Database | undefined;

  private constructor(private readonly dsn: string) {}

  static getInstance(dsn = 'mongodb://localhost'): Database {
    Database.instance ??= new Database(dsn);
    return Database.instance;
  }

  query(sql: string): string {
    return `${this.dsn} > ${sql}`;
  }
}

const db = Database.getInstance();
// new Database('x');  // Error TS2673: el constructor es privado

El equivalente en PHP

La estructura es idéntica: propiedad estática, constructor privado y método getInstance(). PHP añade dos cerrojos útiles, un __clone privado y un __wakeup que rechaza la deserialización, para que no pueda aparecer ninguna copia.

Database.php
final class Database
{
    private static ?Database $instance = null;

    private function __construct(private readonly string $dsn)
    {
    }

    public static function getInstance(string $dsn = 'mysql:host=localhost'): Database
    {
        return self::$instance ??= new self($dsn);
    }

    public function query(string $sql): string
    {
        return "{$this->dsn} > {$sql}";
    }

    private function __clone()
    {
    }

    public function __wakeup(): void
    {
        throw new LogicException('Un singleton ne se désérialise pas.');
    }
}

$a = Database::getInstance('mysql:host=prod');
$b = Database::getInstance();

var_dump($a === $b);   // bool(true)
echo $a->query('SELECT 1'); // mysql:host=prod > SELECT 1

Cuándo no usar un Singleton

El patrón se critica por buenas razones, y conviene conocerlas antes de adoptarlo:

  • Esconde las dependencias. Una función que llama a Database.getInstance() en mitad de su código depende de la base de datos sin decirlo en su firma. Pasar la conexión como parámetro (inyección de dependencias) hace que la dependencia sea visible y sustituible.
  • Complica los tests. El estado sobrevive de un test a otro. Si te quedas con un singleton, añade un método static reset() reservado a los tests, o testea a través de la inyección.
  • Es un estado global. Todo lo que vale para las variables globales vale para el singleton: acoplamiento, efectos a distancia y orden de inicialización.

La regla práctica: un módulo que exporta una instancia (implementación 3) para la configuración y las utilidades sin estado; la inyección de dependencias para todo lo que toca un recurso externo; y la clase con getInstance() cuando una librería impone ese punto de acceso único. El patrón guard clauses y las clases en JavaScript prolongan este tema por el lado de la estructura del código.

Errores frecuentes

Una instancia imposible de reiniciar en los tests Un singleton conserva su estado de un test a otro. Prevé un método static reset() reservado a los tests, o inyecta la dependencia en lugar de llamar a getInstance() en el código de negocio.
Parámetros ignorados en la segunda llamada new Database('mysql') devuelve la instancia creada con «mongo»: los argumentos de la segunda llamada se pierden sin avisar. Documéntalo o lanza un error si los parámetros son distintos.
Un singleton por cada copia del módulo Dos versiones de un mismo paquete instaladas en node_modules dan dos módulos y, por tanto, dos «singletons». El módulo ES solo es único para una ruta de archivo dada.
Código ejecutado al importar Una instancia creada al cargar el módulo se construye aunque nadie la use. Es mejor la instanciación perezosa dentro de getInstance().

JavaScript

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.