1. El problema del singleton mutable en Spring
Singleton en Spring es algo absolutamente normal e incluso acogedor: por defecto casi todos tus @Service y @Component serán singleton-bean. Pero el singleton tiene un «superpoder» que a la vez es su punto débil: el mismo objeto atiende todas las llamadas. Y eso significa que todo lo que pongas en los campos de este objeto se volverá común — para todos los escenarios, todas las llamadas y toda la vida del contenedor. Y aquí empiezan las historias del género «por qué mi pedido ORD-1 se fue de repente por SMS, aunque yo estaba haciendo EMAIL».
Vamos a separar tranquilamente dos conceptos que los novicios suelen confundir:
- scope responde a la pregunta: cuántas instancias y durante cuánto tiempo las gestiona el contenedor.
- «estado» (state) responde a la pregunta: qué almacena el propio objeto entre llamadas.
Y en esta lección nos interesa precisamente lo segundo.
Mini-esquema: dónde vive el problema
Cuando tu servicio es singleton, existe una única instancia para todo el contenedor. Cualquier llamada, desde cualquier lugar de la aplicación, llega al mismo objeto.
flowchart LR
A[ScenarioRunner: create order] --> S[OrderCancellationService singleton]
B[ScenarioRunner: cancel order] --> S
C[Reporting] --> S
S -->|campos del objeto| State[(shared mutable state)]
Si State (los campos) se modifica durante la llamada, la siguiente llamada puede ver «rastros» de la anterior. A veces se manifiesta de inmediato. A veces solo después de una semana, cuando añades otro escenario y de repente un servicio se usa con más frecuencia.
2. Stateful bean vs servicio stateless
Cuando se dice «hagan servicios stateless», el novicio suele sentir que le piden lo imposible: «¿cómo que no almacenar nada?». Claro que almacenarás: dependencias, configuraciones, quizás un caché de catálogo. Lo importante es otra cosa: no almacenar en campos lo que pertenece a una operación concreta (un pedido, un informe, una ejecución de escenario). A eso se le llama «estado temporal de la operación».
Para no confundirse, es útil tener en mente una tabla simple. No es teoría rigurosa, sino un «chuleta» de ingeniería que realmente es cómodo aplicar.
| Qué hay en el campo del singleton-bean | ¿Es state? | ¿Normalmente seguro? | Por qué |
|---|---|---|---|
| private final OrderStore orderStore; | sí | sí | es una dependencia, es la misma para todas las llamadas |
| private final Clock clock; | sí | sí | dependencia, comportamiento estable |
| private Map<String, String> templates; (se llena una vez y luego no cambia) | sí | normalmente sí | estado «efectivamente inmutable» después de la inicialización |
| private String currentOrderId; | sí | no | es el estado de una operación; «fluirá» entre llamadas |
| private List<String> buffer = new ArrayList<>(); como buffer temporal | sí | casi siempre no | buffer mutable compartido para todas las llamadas |
| private int processedCount; | sí | depende | un contador puede tener sentido, pero requiere disciplina y comprensión de las consecuencias |
La idea clave: el singleton en sí mismo no es el villano. El villano son los «datos temporales en campos» que convierten al singleton en un «mantenedor de estado» compartido.
3. Cómo nace el shared mutable state
Lo más traicionero del shared mutable state es que aparece de una forma muy humana. No porque seas un mal desarrollador, sino porque estás cansado, con prisas y decidiste «¿por qué voy a andar arrastrando orderId por diez métodos? Lo pongo en el campo y los métodos privados lo verán por sí mismos». Se ve ordenado… hasta que el servicio permanece solitario y vive solo en tu cabeza. Pero Spring lo convierte despiadadamente en algo común.
Veamos un ejemplo muy típico al estilo ContextFlow. Supongamos que tenemos un servicio de cancelación de pedidos. Quieres dividir la lógica en pequeños métodos privados. Y te da pereza pasar orderId.
Opción mala: «currentOrderId» como parámetro oculto
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// ❌ Estado compartido oculto: todas las llamadas de este singleton-bean lo ven
private String currentOrderId;
public void cancel(String orderId) {
// ❌ «Preparamos» el estado para la operación — y lo dejamos en el campo
this.currentOrderId = orderId;
// Estos métodos ahora dependen implícitamente de que el campo ya esté establecido
writeAudit();
notifyCustomer();
}
private void writeAudit() {
// Aquí no usamos un parámetro, sino el estado que quedó en el objeto
System.out.println(currentOrderId + ": CANCELLED"); // ORD-1: CANCELLED
}
private void notifyCustomer() {
System.out.println(currentOrderId + ": notify customer"); // ORD-1: notify customer
}
}
A primera vista funciona. Incluso se ve bonito: métodos privados sin parámetros, todo «limpio».
Ahora imagina que alguien (o tú dentro de un mes) decide reutilizar writeAudit de otra forma. Por ejemplo, llamarlo desde otro lugar, añadir una rama de lógica, hacer una cancelación repetida, o simplemente olvidar que cancel debía haber sido llamado primero.
El problema no es que los métodos privados sean malos. El problema es que el contrato del servicio se ha vuelto implícito: el servicio depende de un estado interno que debe «prepararse correctamente».
Opción buena: parámetros explícitos, servicio stateless
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
public void cancel(String orderId) {
// ✅ Datos de la operación se pasan explícitamente: no hay «pedido actual» dentro del servicio
writeAudit(orderId);
notifyCustomer(orderId);
}
private void writeAudit(String orderId) {
// ✅ La fuente de datos es visible de inmediato — es un parámetro, no un campo oculto
System.out.println(orderId + ": CANCELLED"); // ORD-1: CANCELLED
}
private void notifyCustomer(String orderId) {
System.out.println(orderId + ": notify customer"); // ORD-1: notify customer
}
}
Sí, se ha vuelto un par de caracteres más largo. Pero a cambio el servicio ahora:
- no almacena el pedido «actual» en el campo;
- se lee fácilmente (todas las dependencias de datos son visibles en las firmas de los métodos);
- es mucho más fácil de probar con un unit-test (incluso sin Spring).
Y lo más importante: ha dejado de ser un «pequeño estado global».
4. Peligro en aplicaciones no web y sin hilos
En este momento a menudo surge el pensamiento: «Bueno, en web será aterrador, hay muchas peticiones. Pero nuestra aplicación es de consola. No lo ejecutamos en paralelo». Y aquí queremos decir suavemente, pero con firmeza: eres demasiado optimista.
Incluso en una aplicación no web, el problema se manifiesta por dos razones.
La primera razón es muy simple: puedes ejecutar varios escenarios en secuencia y el estado se «trasladará» de una ejecución a otra. Esto no ocurre porque tengas paralelismo, sino porque el singleton vive mucho tiempo y tus operaciones son cortas.
La segunda razón es un poco más «del futuro», pero real: tan pronto como la aplicación empieza a hacer algo «además de un solo comando», aparecen razones para ejecutar lógica en diferentes hilos (incluso si ahora no lo planeas). No necesitas conocer concurrencia para entender el riesgo: si el estado es compartido, puede ser corrompido por otra llamada.
Ilustración sin hilos: olvidar la preparación
Tomemos un ejemplo muy cotidiano. Supón que creas un servicio de notificaciones que almacena el «canal actual» en un campo porque «es más cómodo».
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
// ❌ El canal actual es el estado de una operación concreta, no una «propiedad del servicio»
private String currentChannel;
public void useChannel(String channel) {
// ❌ Ahora la corrección de send() depende de si se llamó a useChannel() antes
this.currentChannel = channel;
}
public void send(String message) {
// Si currentChannel no está establecido — obtendrás null en la salida (o NPE en código real)
System.out.println(currentChannel + ": " + message);
}
}
Ahora el siguiente código compila y a veces incluso «funciona»:
NotificationDispatchService s = ctx.getBean(NotificationDispatchService.class);
s.useChannel("EMAIL");
s.send("Order created"); // EMAIL: Order created
s.send("Order cancelled"); // EMAIL: Order cancelled (y podrías esperar SMS)
El problema es que send ahora tiene una dependencia oculta: antes de él debe haberse llamado a useChannel. Es casi como una variable global, solo que cuidadosamente empaquetada en un @Service.
Ilustración de cómo se «mezclan» las llamadas
Incluso si ahora no ejecutas hilos, es útil ver el principio:
sequenceDiagram
participant A as Operación A
participant B as Operación B
participant S as Servicio Singleton
A->>S: setCurrentOrder ORD-1
B->>S: setCurrentOrder ORD-2
A->>S: writeAudit esperaba ORD-1
Note right of S: Imprimimos ORD-2 oops
Si tu servicio almacena el «pedido actual» en un campo, cualquier otra llamada puede «cambiar» ese campo. Y no importa por qué ocurrió la otra llamada: porque ejecutaste un segundo escenario, porque añadiste otro runner, porque «reutilizaste» el servicio en otro lugar. El resultado es el mismo: el comportamiento pasó a depender del orden de las llamadas, y eso casi siempre es una mala noticia.
5. final y servicio stateless
Hay otra trampa clásica de novicio: «Yo lo hice todo final, así que tengo inmutabilidad, por lo tanto stateless». Por desgracia, final fija la referencia, no el contenido interno del objeto.
Esto sale a menudo con colecciones. Por ejemplo, quisiste optimizar la generación de un informe y decidiste mantener un buffer ArrayList en el campo: «para no crear una nueva lista cada vez».
Ejemplo: referencia final a una colección mutable
package com.example.contextflow.application.reporting;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// ❌ final «clava» la referencia, pero la lista en sí sigue siendo mutable
private final List<String> buffer = new ArrayList<>();
public List<String> buildLines(List<String> input) {
// ❌ Mutamos el buffer compartido — es común para todas las llamadas de este singleton
buffer.clear();
buffer.addAll(input);
// ❌ Devolvemos al exterior una referencia «viva» a las entrañas del servicio
return buffer;
}
}
Desde el punto de vista del compilador Java — todo normal. Desde el punto de vista de la vida real — el servicio se ha convertido en un contenedor común de datos.
El momento más desagradable: puedes obtener un bug incluso sin hilos. Simplemente porque devolviste al exterior una referencia a la lista interna y alguien la modificó:
List<String> lines = reportingService.buildLines(List.of("A", "B"));
lines.add("HACK"); // ahora has modificado el buffer interno del servicio 😬
System.out.println(reportingService.buildLines(List.of("X")));
// puede devolver [X], o [X, HACK] dependiendo de tu lógica
Hay dos soluciones correctas, y ambas son simples.
Si el buffer no es necesario en absoluto, crea la lista dentro del método y devuelve una copia al exterior. Para un proyecto de aprendizaje es totalmente normal.
public List<String> buildLines(List<String> input) {
// ✅ Variable local vive solo dentro de la llamada
List<String> lines = new ArrayList<>(input);
// ✅ Devolvemos al exterior una representación/copia inmutable
return List.copyOf(lines);
}
Si mantienes conscientemente un caché o buffer (caso raro en nuestro nivel), entonces debe estar aislado y no se puede devolver al exterior su referencia «viva». Incluso entonces tendrás que pensar en quién y cuándo puede llamar al método, pero eso ya es nivel de «vida adulta». Por ahora nos aferramos al principio: en los servicios — mínimo estado.
6. «Estado útil»: cuándo un singleton puede almacenar datos
Para no caer en el extremo contrario («ningún campo en absoluto, solo vacío e iluminación»), es importante decir honestamente: a veces almacenar estado en un singleton es normal. Por ejemplo, si cargaste un catálogo, plantillas, configuración o valores precalculados una vez al inicio, y luego solo lees.
En el día anterior (sobre lifecycle) ya aparecieron beans para los que es importante realizar una preparación durante la inicialización. Un ejemplo típico es un catálogo de plantillas de notificaciones: puede cargar las plantillas en memoria al inicio para no leer el archivo en cada llamada.
Es importante que ese estado sea:
- o inmutable (Map.of(...), List.of(...)),
- o «efectivamente inmutable» (se construye una vez y luego no se modifica),
- o bien controlado y que no dependa de la «operación actual».
Mini-ejemplo de estado «seguro»: caché de catálogo
package com.example.contextflow.infrastructure.resources;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class NotificationTemplateCatalog {
// ✅ El catálogo se crea una vez y no cambia — es normal para un singleton
private final Map<String, String> templates = Map.of(
"order-created", "Order {0} created",
"order-cancelled", "Order {0} cancelled"
);
public String template(String key) {
// En código real se podría añadir manejo de clave ausente
return templates.get(key);
}
}
Aquí hay estado: templates. Pero no es «actual», ni «último», ni «para el tiempo de la operación». Es simplemente un catálogo que es igual para todas las llamadas. Este singleton es generalmente seguro por su propósito (no discutimos ahora sutilezas como recarga en caliente de plantillas — esa es otra historia).
Una buena regla para no equivocarse: si el campo responde a la pregunta «qué hice en la llamada anterior?» o «qué estoy haciendo en la llamada actual?», entonces es casi seguro un mal campo para un servicio singleton. Si el campo responde a la pregunta «qué información común e inmutable tiene la aplicación?», entonces puede ser normal.
7. Refactorización práctica
Lo más agradable del tema de servicios stateless es que casi siempre mejora el código de inmediato. No «algún día después», sino hoy mismo: menos dependencias ocultas, más fácil de probar, más fácil de leer, menos sorpresas.
Técnicamente, la refactorización suele resumirse en uno de dos escenarios.
Primer escenario: eliminas el «campo actual» y empiezas a pasar los datos explícitamente como parámetros de los métodos. Esto es exactamente lo que hicimos con OrderCancellationService: currentOrderId desapareció, aparecieron parámetros.
Segundo escenario: te das cuenta de que tienes datos temporales de una operación y necesitan un «contenedor» separado. En casos simples son solo variables locales. En casos más complejos — un objeto separado que vive una operación (y eso es justo el puente hacia la siguiente lección, donde aparecerá OrderProcessingSession).
Ejemplo: «división en métodos privados» sin campo oculto
Así es como a menudo se ve el «estado en el campo» en código real: no aparece por avaricia, sino por el deseo de hacer el código más bonito.
Estilo malo (pero tentador):
@Service
public class OrderPlacementService {
// ❌ «Pedido actual» se guarda en el objeto y fluye entre llamadas
private String currentOrderId;
public void placeOrder(String orderId) {
// ❌ Preparación del estado en lugar de paso explícito de parámetros
this.currentOrderId = orderId;
// Estos métodos leerán implícitamente currentOrderId
validate();
save();
System.out.println("placed " + currentOrderId); // placed ORD-1
}
private void validate() { /* uses currentOrderId */ }
private void save() { /* uses currentOrderId */ }
}
Estilo bueno — el mismo código «bonito», pero con parámetros explícitos:
package com.example.contextflow.application.service;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
public void placeOrder(String orderId) {
// ✅ Datos de la operación son visibles directamente en las firmas de los métodos
validate(orderId);
save(orderId);
System.out.println("placed " + orderId); // placed ORD-1
}
private void validate(String orderId) { /* use orderId */ }
private void save(String orderId) { /* use orderId */ }
}
Sí, pasaste orderId dos veces. Pero compraste con esos dos parámetros algo importante: previsibilidad. Ahora cualquier método es autosuficiente: para entender de dónde vienen los datos, basta mirar los parámetros, no leer toda la clase como una novela.
Ejemplo «buffer temporal» → «variable local»
Si en algún lugar intentaste «reutilizar una colección», casi siempre se puede hacer más simple.
Antes (buffer mutable compartido):
import java.util.ArrayList;
import java.util.List;
public List<String> format(List<String> input) {
// ❌ buffer es común para todas las llamadas; lo mutamos durante la operación
buffer.clear();
buffer.addAll(input);
// ✅ aunque aquí devolvemos una copia, la propia existencia del buffer común es fuente de riesgos
return List.copyOf(buffer);
}
Después (buffer local):
import java.util.ArrayList;
import java.util.List;
public List<String> format(List<String> input) {
// ✅ La colección local «muere» junto con la llamada al método
List<String> lines = new ArrayList<>(input);
// ✅ Devolvemos al exterior un resultado inmutable
return List.copyOf(lines);
}
En nuestro proyecto de aprendizaje, la ganancia es más importante que la «optimización»: hacemos código que es fácil de entender.
Y aquí hay una buena «prueba de honestidad»: si puedes reescribir el método para que funcione solo con parámetros y variables locales, eso es casi siempre una señal de buen diseño de la capa de servicio.
8. Errores típicos al trabajar con singleton
Error nº1: almacenar el identificador, canal, pedido o comando «actual» en un campo de un servicio singleton.
Ese código se ve ordenado porque evita «parámetros innecesarios». Pero en realidad conviertes el servicio en una variable global oculta: la corrección del funcionamiento empieza a depender del orden de las llamadas y de quién más usa ese mismo singleton. Es mejor pasar los datos como parámetros o mantener valores temporales en variables locales.
Error nº2: pensar que final automáticamente significa «sin estado».
final protege la referencia de ser reasignada, pero no hace inmutable al objeto. private final List<String> buffer sigue siendo una lista mutable, solo que con la referencia «clavada con clavos». Si mutas la colección, igual estás creando shared mutable state, especialmente si devuelves esa colección al exterior.
Error nº3: usar colecciones en campos de un singleton como buffers de trabajo «durante la llamada».
Es una micro-optimización frecuente que en proyectos de aprendizaje (y en la mayoría de los proyectos normales) no es necesaria. A cambio obtienes bugs extraños: «¿por qué en el informe aparecieron líneas de la ejecución anterior?», «¿por qué alguien añadió un elemento a mi lista?». Es mucho más seguro y claro crear colecciones temporales dentro del método.
Error nº4: hacer el contrato del método implícito mediante llamadas «preparatorias».
Servicios del tipo useChannel(...) → send(...), begin(orderId) → write(action) se sostienen en un estado oculto: si el desarrollador olvida llamar a la «preparación», el servicio empieza a comportarse de forma extraña. Ese código a menudo parece «orientado a objetos», pero en la práctica es solo una interfaz para un estado global. En la capa de servicio, normalmente es mejor hacer los métodos autosuficientes.
Error nº5: mezclar dos tipos de «estado» — catálogos de la aplicación y datos de una operación concreta.
Un catálogo de plantillas o un directorio precargado puede vivir en un singleton y ser normal. Pero el «pedido actual» no. Si te sorprendes a ti mismo pensando que un campo cambia en cada llamada de una operación de negocio, es casi siempre una señal: has escondido datos temporales donde no deben vivir.
GO TO FULL VERSION