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
- Intención y problema en PideYa
- Estructura del patrón
- Implementación clásica lazy (y su bug)
- Soluciones al problema de concurrencia
- Variantes comparadas
- Por qué es el patrón más criticado
- La alternativa: inyección de dependencias
- Cuándo usarlo y cuándo no
- 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
newexterno. 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:
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.
volatileno 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 avolatilelas 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
ServicioRepartocon 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
CarritoActualsingleton 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
CopyOnWriteArrayListdeRegistroEventos). - Olvidar la serialización. Si tu singleton clásico implementa
Serializable, cada deserialización fabrica una instancia nueva salvo que definasreadResolve(). 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
- ¿Qué son los Patrones de Diseño?
- Historia y Origen de los Patrones de Diseño
- Principios de Diseño: SOLID y Otros Fundamentos
- UML Esencial para Entender Patrones
- Clasificación de los Patrones de Diseño
- Ventajas y Desventajas de Usar Patrones de Diseño
Módulo 2: Patrones Creacionales
- Introducción a los Patrones Creacionales
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparativa y Elección de Patrones Creacionales
Módulo 3: Patrones Estructurales
- Introducción a los Patrones Estructurales
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparativa y Elección de Patrones Estructurales
Módulo 4: Patrones de Comportamiento
- Introducción a los Patrones de Comportamiento
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparativa y Elección de Patrones de Comportamiento
Módulo 5: Aplicación de Patrones de Diseño
- Cómo Seleccionar el Patrón Adecuado
- Ejemplos Prácticos de Uso de Patrones
- Patrones de Diseño en Proyectos Reales
- Refactorización Usando Patrones de Diseño
- Antipatrones: Cuándo los Patrones se Vuelven un Problema
Módulo 6: Patrones de Diseño Avanzados
- Patrones de Diseño en Arquitecturas Modernas
- Patrones de Diseño en Microservicios
- Patrones de Diseño en Sistemas Distribuidos
- Patrones de Concurrencia
- Patrones de Diseño en Desarrollo Ágil
