Empezamos el catálogo por el patrón que todo el mundo conoce, el que cabe en diez líneas... y el único que buena parte de la profesión considera hoy un antipatrón en la mayoría de sus usos. Singleton es la mejor lección doble del curso: primero aprenderás a implementarlo bien —que en Java tiene más miga de la que parece, por culpa de la concurrencia—, y después aprenderás por qué deberías pensártelo dos veces antes de usarlo. En PideYa lo veremos sobre un caso plausible: la configuración global de la plataforma.

Contenido

  1. Intención y problema en PideYa
  2. Estructura del patrón
  3. Implementación clásica lazy (y su bug)
  4. Soluciones al problema de concurrencia
  5. Variantes comparadas
  6. Por qué es el patrón más criticado
  7. La alternativa: inyección de dependencias
  8. Cuándo usarlo y cuándo no
  9. Errores comunes, ejercicios y conclusión

Intención y problema en PideYa

Intención (GoF): garantizar que una clase tenga una sola instancia y proporcionar un punto de acceso global a ella.

Fíjate en que la intención tiene dos mitades —unicidad y acceso global— y conviene juzgarlas por separado: la primera es a menudo legítima; la segunda es la fuente de casi todos los problemas.

El caso en PideYa: la plataforma tiene una configuración global —porcentaje de comisión a restaurantes, radio máximo de reparto, URL de la pasarela de pago, modo mantenimiento— que se carga desde un fichero al arrancar. En la lección anterior vimos el síntoma: cada servicio hacía new ConfiguracionPideYa() y cargaba su propia copia del fichero. Consecuencias reales:

  • Coste repetido: el fichero se parsea una vez por servicio (y sería peor si la fuente fuera una base de datos remota).
  • Incoherencia: el equipo de operaciones activó el modo mantenimiento y solo lo vieron las copias recargadas; dos servicios siguieron aceptando pedidos.

Queremos que exista una configuración, compartida por todos. La solución ingenua —una variable global— no existe como tal en Java, y aunque existiera no impediría que alguien siguiera haciendo new. Singleton resuelve ambas cosas haciendo que la propia clase controle su instanciación.

Estructura del patrón

classDiagram
    class Singleton {
        -static instancia : Singleton
        -Singleton()
        +static getInstancia() Singleton
        +operacion()
    }
    Singleton --> Singleton : crea y guarda\nsu única instancia

Tres ingredientes, los tres imprescindibles:

Elemento Papel
Constructor privado Nadie fuera de la clase puede hacer new: la puerta principal queda cerrada
Atributo estático privado con la instancia La clase se guarda a sí misma; es el "almacén" de la unicidad
Método estático público getInstancia() El único punto de acceso; crea la instancia la primera vez y la reutiliza después

Implementación clásica lazy (y su bug)

La versión de libro, con inicialización perezosa (lazy: la instancia no se crea hasta que alguien la pide por primera vez):

public class ConfiguracionPideYa {

    private static ConfiguracionPideYa instancia;   // (1) null hasta el primer uso

    private final Properties valores;

    private ConfiguracionPideYa() {                 // (2) constructor privado
        this.valores = cargarDesdeFichero("pideya.properties");
    }

    public static ConfiguracionPideYa getInstancia() {  // (3) punto de acceso
        if (instancia == null) {                    // (4) ¿primera vez?
            instancia = new ConfiguracionPideYa();  // (5) crear y recordar
        }
        return instancia;
    }

    public BigDecimal getComisionRestaurante() {
        return new BigDecimal(valores.getProperty("comision.restaurante"));
    }

    public int getRadioRepartoKm() {
        return Integer.parseInt(valores.getProperty("reparto.radio.km"));
    }

    private Properties cargarDesdeFichero(String ruta) { /* ... */ return new Properties(); }
}

Explicación paso a paso:

  • (1) El atributo estático empieza en null. Al ser estático pertenece a la clase, no a ningún objeto: hay uno solo por JVM (por class loader, para ser exactos).
  • (2) El constructor privado cierra la puerta al new externo. Es lo que convierte "una convención" en "una garantía".
  • (4)-(5) La primera llamada crea la instancia; las siguientes devuelven la ya creada. El coste de cargar el fichero se paga una vez y solo si alguien llega a necesitar la configuración.

El uso desde cualquier punto de PideYa:

BigDecimal comision = ConfiguracionPideYa.getInstancia().getComisionRestaurante();

El bug: esta versión es correcta en un programa de un solo hilo... y PideYa, como cualquier servidor web, atiende muchas peticiones concurrentes. Si dos hilos entran a la vez en getInstancia() cuando instancia aún es null, ambos pasan el if del paso (4) y cada uno crea su propia instancia. Resultado: dos configuraciones vivas, exactamente el problema que veníamos a resolver, pero ahora intermitente y casi imposible de reproducir. Este es el ejemplo canónico de race condition aplicado a la creación; el estudio general de la concurrencia queda para los patrones de concurrencia, aquí solo necesitamos resolver este caso concreto.

Soluciones al problema de concurrencia

Opción A: synchronized en el método

public static synchronized ConfiguracionPideYa getInstancia() {
    if (instancia == null) {
        instancia = new ConfiguracionPideYa();
    }
    return instancia;
}

synchronized garantiza que solo un hilo ejecuta el método a la vez: correcta y simple. Su pega es que el bloqueo se paga en todas las llamadas, para siempre, cuando solo hacía falta en la primera. En una clase consultada miles de veces por segundo, es un peaje innecesario (aunque en la práctica, con las JVM modernas, menor de lo que la literatura clásica sugiere).

Opción B: double-checked locking con volatile

El intento histórico de pagar el bloqueo solo la primera vez:

public class ConfiguracionPideYa {

    private static volatile ConfiguracionPideYa instancia;  // ¡volatile es OBLIGATORIO!

    private ConfiguracionPideYa() { /* ... */ }

    public static ConfiguracionPideYa getInstancia() {
        if (instancia == null) {                       // 1ª comprobación: sin bloqueo
            synchronized (ConfiguracionPideYa.class) {
                if (instancia == null) {               // 2ª comprobación: con bloqueo
                    instancia = new ConfiguracionPideYa();
                }
            }
        }
        return instancia;
    }
}
  • La primera comprobación evita el bloqueo en el 99,99% de llamadas (la instancia ya existe).
  • La segunda comprobación, ya dentro del bloque sincronizado, protege el caso en que dos hilos pasaron la primera a la vez: el segundo en entrar encuentra la instancia creada y no duplica.
  • volatile no es opcional: sin él, la JVM puede reordenar la construcción del objeto de forma que otro hilo vea la referencia asignada antes de que el constructor haya terminado, recibiendo un objeto a medio construir. Este error fue tan común que el double-checked locking estuvo roto en Java hasta que el modelo de memoria de Java 5 dio a volatile las garantías actuales.

Funciona, pero mira cuánta sutileza para inicializar un objeto. Por eso los dos idiomas siguientes son preferibles.

Opción C: holder idiom (la recomendada en Java clásico)

public class ConfiguracionPideYa {

    private ConfiguracionPideYa() { /* ... */ }

    private static class Holder {                    // clase interna: no se carga
        static final ConfiguracionPideYa INSTANCIA = // hasta que alguien la usa
                new ConfiguracionPideYa();
    }

    public static ConfiguracionPideYa getInstancia() {
        return Holder.INSTANCIA;                     // dispara la carga de Holder
    }
}

La jugada es elegante: la JVM garantiza que la inicialización de una clase es atómica y thread-safe (lo asegura el propio class loader), y que una clase interna estática no se carga hasta su primer uso. Combinando ambas cosas obtenemos lazy + seguridad ante hilos sin escribir una sola línea de sincronización: el trabajo sucio lo hace la JVM. Es el idiom recomendado cuando quieres lazy de verdad.

Opción D: enum singleton (la de Effective Java)

public enum RegistroEventos {

    INSTANCIA;

    private final List<String> eventos = new CopyOnWriteArrayList<>();

    public void registrar(String evento) {
        eventos.add(Instant.now() + " " + evento);
    }
}

// Uso desde cualquier punto de PideYa:
RegistroEventos.INSTANCIA.registrar("Pedido 4412 confirmado");

Aquí lo mostramos con el segundo caso clásico de PideYa: un registro de eventos/logging simple. Un enum de un solo valor es, técnicamente, el singleton más robusto posible en Java: la JVM garantiza la unicidad, es thread-safe, y además resiste dos ataques que rompen a todos los anteriores: la serialización (deserializar un singleton normal crea una segunda instancia si no implementas readResolve()) y la reflexión (con setAccessible(true) se puede invocar un constructor privado; con enums, la JVM lo impide). Joshua Bloch lo recomienda en Effective Java como "la mejor forma de implementar un singleton". Sus límites: no puede extender otra clase, no es lazy en sentido estricto (se crea al cargar el enum) y a muchos equipos les resulta chocante estilísticamente.

Variantes comparadas

Variante ¿Lazy? ¿Thread-safe? Complejidad Notas
Lazy clásica Sí No Mínima Solo válida en contextos monohilo; en un servidor, un bug latente
Eager (static final directo) No Sí Mínima Si la instancia es barata y siempre se usa, es perfectamente digna
synchronized Sí Sí Baja Peaje de bloqueo en cada llamada
Double-checked + volatile Sí Sí Alta Correcta solo desde Java 5 y solo con volatile; fácil de copiar mal
Holder idiom Sí Sí Baja La recomendada para lazy en Java
Enum Al cargar el enum Sí Mínima La más robusta (serialización y reflexión); no puede heredar

Por qué es el patrón más criticado

Si Singleton fuera solo "cómo garantizar una instancia", sería un idiom inocente. El problema es su segunda mitad: el punto de acceso global. ConfiguracionPideYa.getInstancia() puede invocarse desde cualquier línea de cualquier clase, y eso tiene consecuencias sistémicas:

  • Es estado global con otro nombre. Todo lo que la ingeniería aprendió sobre por qué las variables globales son dañinas (acoplamiento invisible, efectos a distancia, orden de inicialización frágil) aplica íntegro. Si el singleton además es mutable (piensa en un setModoMantenimiento(true)), cualquier parte del programa puede alterar el comportamiento de cualquier otra sin que ninguna firma lo delate.
  • Oculta las dependencias. Mira estas dos firmas:
// ¿De qué depende este servicio? Imposible saberlo sin leer el cuerpo:
public class ServicioReparto {
    public Repartidor asignar(Pedido p) {
        int radio = ConfiguracionPideYa.getInstancia().getRadioRepartoKm(); // ¡sorpresa!
        // ...
    }
}

// Aquí la dependencia está en la firma, a la vista:
public class ServicioReparto {
    private final ConfiguracionPideYa config;
    public ServicioReparto(ConfiguracionPideYa config) { this.config = config; }
}

Con el singleton, la dependencia es un secreto del cuerpo del método; con el constructor, es un contrato público. Es la violación práctica de DIP que vimos en la lección de principios: dependencia rígida de algo concreto, y encima escondida.

  • Arruina la testabilidad. El test de ServicioReparto con singleton usa a la fuerza la configuración real (¿y si el test necesita un radio de 1 km? ¿y si otro test lo cambió antes?). El estado compartido entre tests produce esa plaga conocida: tests que pasan solos y fallan en conjunto según el orden de ejecución. Con la dependencia inyectada, cada test construye su configuración de mentira y en paz.
  • La unicidad suele ser un requisito del despliegue, no de la clase. "Solo hay una configuración" es cierto hoy, en este proceso. El día que PideYa quiera tests en paralelo con configuraciones distintas, o multi-tenant (una configuración por país), la restricción cableada en la clase se convierte en la pared contra la que chocas.

La alternativa: inyección de dependencias

La corrección moderna separa las dos mitades de la intención: mantén la unicidad pero elimina el acceso global. Se crea una sola instancia en el arranque de la aplicación (la "raíz de composición", el único lugar que sabe construir el sistema) y se inyecta a quien la necesite:

public class Main {
    public static void main(String[] args) {
        // Raíz de composición: aquí se decide qué existe y cuántas veces
        ConfiguracionPideYa config = ConfiguracionPideYa.cargarDe("pideya.properties");

        ServicioReparto reparto = new ServicioReparto(config);
        ServicioCheckout checkout = new ServicioCheckout(config, reparto);
        // ...
    }
}

ConfiguracionPideYa deja de tener getInstancia(): es una clase normal, testeable, de la que casualmente solo se crea una instancia porque su creador así lo decide. La unicidad pasa de ser una propiedad cableada en la clase a una decisión de configuración del sistema, que es donde pertenece. Los contenedores como Spring industrializan exactamente esto: cuando declaras un bean con ámbito singleton (el ámbito por defecto), Spring garantiza una instancia única dentro del contenedor y la inyecta donde haga falta, sin constructores privados ni estáticos: es el "singleton sin patrón Singleton". Como vimos en la lección de historia, este es un caso de patrón absorbido por los frameworks.

Cuándo usarlo y cuándo no

Usos razonables (pocos y concretos):

  • Recursos técnicos sin estado de negocio, transversales y estables: un registro de logging como RegistroEventos, caches técnicas, un generador de identificadores. Cuanto más inmutable y más técnico (lejos del dominio), más inofensivo.
  • Aplicaciones pequeñas sin contenedor de inyección, donde montar infraestructura de DI sería sobreingeniería (lección 01-06).
  • Como detalle interno de implementación de otra pieza (por ejemplo, la instancia única de una fábrica, como veremos en la comparativa).

Evítalo cuando:

  • El objeto tiene estado de negocio mutable (un CarritoActual singleton es un bug multiusuario esperando a ocurrir).
  • La clase participa en lógica que quieres testear con dobles: inyecta.
  • Trabajas con un framework con contenedor (Spring, Jakarta EE): usa el ámbito singleton del contenedor, no el patrón.
  • La "unicidad" es en realidad "una por contexto" (por país, por tenant, por petición): la restricción de clase te estorbará pronto.

Relación con otros patrones (solo mención): las fábricas de Factory Method y Abstract Factory se implementan a menudo como singletons, pues no suelen tener estado; Facade y los pools de Flyweight también aparecen con frecuencia como instancia única.

Errores Comunes y Consejos

  • Copiar el double-checked locking sin volatile. Es el error clásico de este patrón: compila, funciona en las demos, y falla una vez al mes en producción. Si necesitas lazy thread-safe, usa el holder idiom y olvídate.
  • El "singleton de conveniencia": convertir en singleton cualquier clase para no tener que pasarla como parámetro. Ese "ahorro" de un parámetro se paga con dependencias ocultas y tests frágiles. Pasar dependencias por constructor no es burocracia: es documentación ejecutable.
  • Singleton mutable sin sincronizar. Si la instancia es compartida por todos los hilos del servidor, su estado interno también lo es: o es inmutable, o su mutabilidad debe protegerse (nota el CopyOnWriteArrayList de RegistroEventos).
  • Olvidar la serialización. Si tu singleton clásico implementa Serializable, cada deserialización fabrica una instancia nueva salvo que definas readResolve(). El enum singleton es inmune.
  • Consejo: antes de escribir getInstancia(), pregúntate "¿quién debería decidir que esto es único?". Si la respuesta es "quien monta la aplicación" (casi siempre lo es), inyecta en lugar de globalizar.

Ejercicios

Ejercicio 1: encontrar el fallo

Este singleton apareció en una revisión de código de PideYa. Señala todos los problemas:

public class CacheRestaurantes {
    private static CacheRestaurantes instancia;
    private Map<Long, Restaurante> cache = new HashMap<>();

    public CacheRestaurantes() { }

    public static CacheRestaurantes getInstancia() {
        if (instancia == null) {
            instancia = new CacheRestaurantes();
        }
        return instancia;
    }

    public void guardar(Restaurante r) { cache.put(r.getId(), r); }
    public Restaurante buscar(long id) { return cache.get(id); }
}

Ejercicio 2: reescribir con holder idiom

Reescribe CacheRestaurantes (corregida) usando el holder idiom y una estructura interna segura para hilos.

Ejercicio 3: del singleton a la inyección

El servicio de asignación de repartidores usa ConfiguracionPideYa.getInstancia() en tres métodos. Reescríbelo para recibir la configuración por constructor y escribe (en pseudocódigo o Java) un test unitario que fija el radio de reparto a 2 km sin tocar ningún fichero, comprobando que un repartidor a 5 km no resulta asignable.

Soluciones

Solución 1: (a) el constructor es público: cualquiera puede hacer new y romper la unicidad; (b) la inicialización lazy no es thread-safe: dos hilos concurrentes pueden crear dos cachés; (c) HashMap no es seguro para hilos y la instancia será compartida por todo el servidor: corrupción o bucles infinitos bajo carga (debería ser ConcurrentHashMap); (d) el atributo cache ni siquiera es final. De propina: una caché de entidades de negocio como singleton global merece la pregunta de la lección: ¿no debería ser una dependencia inyectada?

Solución 2:

public class CacheRestaurantes {

    private final Map<Long, Restaurante> cache = new ConcurrentHashMap<>();

    private CacheRestaurantes() { }

    private static class Holder {
        static final CacheRestaurantes INSTANCIA = new CacheRestaurantes();
    }

    public static CacheRestaurantes getInstancia() {
        return Holder.INSTANCIA;
    }

    public void guardar(Restaurante r) { cache.put(r.getId(), r); }
    public Restaurante buscar(long id) { return cache.get(id); }
}

Constructor privado, unicidad y lazy garantizadas por el class loader, y ConcurrentHashMap para el estado compartido.

Solución 3:

public class ServicioAsignacionRepartidores {

    private final ConfiguracionPideYa config;

    public ServicioAsignacionRepartidores(ConfiguracionPideYa config) {
        this.config = config;
    }

    public boolean esAsignable(Repartidor r, Pedido p) {
        return r.distanciaKmHasta(p.getDireccionEntrega()) <= config.getRadioRepartoKm();
    }
}

// Test: sin ficheros, sin estado global, sin orden de ejecución que importe
@Test
void repartidorFueraDeRadioNoEsAsignable() {
    ConfiguracionPideYa config = ConfiguracionPideYa.deValores(Map.of("reparto.radio.km", "2"));
    ServicioAsignacionRepartidores servicio = new ServicioAsignacionRepartidores(config);

    Repartidor lejano = repartidorADistanciaKm(5);

    assertFalse(servicio.esAsignable(lejano, pedidoDePrueba()));
}

La clave: al inyectar, el test construye su propia configuración (aquí con un método de fabricación deValores para tests) y no depende de ningún estado global. Con getInstancia() este test sería imposible de aislar.

Conclusión

Singleton te ha enseñado dos cosas de valor desigual. La técnica: en Java, la creación perezosa y segura ante hilos tiene sus idiomas (holder para lazy, enum para máxima robustez, y el double-checked locking como pieza de museo que hay que saber leer). Y la de diseño, que vale para toda tu carrera: la intención del patrón mezcla una necesidad legítima (unicidad) con un mecanismo tóxico (acceso global), y la práctica moderna las separa: instancia única decidida en la raíz de composición e inyectada como dependencia visible. Cuando dudes, inyecta.

El siguiente patrón ataca el problema central de la familia con el que abrimos el módulo: ese switch que decide qué notificador instanciar, repetido por tres servicios de PideYa, va a encontrar por fin su sitio. Nos vemos en Factory Method.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados