Objetos y estado global: el precio del singleton
Qué cuesta de verdad una declaración object cuando guarda estado: retención permanente, acoplamiento invisible en las firmas, pruebas que se contaminan entre sí, y el criterio para separar el uso legítimo de la pereza disfrazada de simplicidad.
Escribir object es la forma más barata que ofrece Kotlin de crear una variable global, y la baratura es precisamente el problema. En un lenguaje sin la palabra clave, montar un singleton exige el suficiente trabajo como para que alguien se pregunte en voz alta si hace falta; aquí sale gratis y nadie pregunta nada. El resultado se ve en muchas bases de código maduras: una capa de objetos que empezaron siendo un contenedor inocente de funciones auxiliares y acabaron guardando cachés, clientes de red, configuración y banderas, todo ello invisible en las firmas y vivo durante toda la ejecución del proceso. Este nivel cierra con la parte que no es sintaxis, sino ingeniería: qué se paga por cada object con estado, por qué las pruebas son el primer sitio donde se nota, y cuál es el criterio para saber si estabas resolviendo un problema o evitando pensar.
- Cuantificar el coste real de un singleton: retención, acoplamiento oculto y carreras de datos.
- Explicar por qué el estado global rompe el aislamiento y el paralelismo de las pruebas.
- Aplicar el patrón interfaz más implementación
objectmás parámetro con valor por defecto. - Separar con un criterio explícito los usos legítimos del
objectde los que son pereza.
Lo que un objeto con estado se lleva por delante
El primer coste es de tiempo de vida. La instancia cuelga de un campo estático de una clase cargada por el cargador de la aplicación, que en la práctica no se descarga nunca, así que el objeto y todo lo que él referencia son raíces vivas del recolector hasta que el proceso termine. Una caché en un object sin política de expulsión no es una caché: es una fuga con buena reputación.
El segundo es de acoplamiento. Una función que consulta un object global tiene una dependencia que no aparece en su firma, y eso destruye la propiedad más útil de una firma, que es poder leer lo que una función necesita sin leer su cuerpo. Dos funciones con la misma firma pueden depender una de un reloj y otra de una base de datos, y nadie lo sabrá hasta abrirlas.
El tercero es de concurrencia. La máquina virtual garantiza que la creación del singleton es segura, no que su estado lo sea. Un var público en un object compartido es exactamente el mismo problema que cualquier campo mutable compartido, con el agravante de que el acceso está disponible desde cualquier punto del programa sin necesidad de pasar ninguna referencia.
object Contadores {
var peticiones = 0 // carrera de datos garantizada
val porRuta = mutableMapOf<String, Int>() // ademas, mapa no seguro
}
// La version defendible del mismo objeto
object ContadoresSeguros {
private val peticiones = java.util.concurrent.atomic.AtomicLong()
private val porRuta = java.util.concurrent.ConcurrentHashMap<String, Long>()
fun registra(ruta: String) {
peticiones.incrementAndGet()
porRuta.merge(ruta, 1L, Long::plus)
}
fun instantanea(): Map<String, Long> = porRuta.toMap()
}
flowchart LR O[Declaracion object con estado mutable] --> V[Vive mientras viva el cargador de clases] O --> A[Dependencia ausente de todas las firmas] O --> H[Acceso concurrente sin coordinacion por defecto] V --> P[Retencion de memoria y cachas sin expulsion] A --> T[Pruebas acopladas y orden significativo] H --> D[Carreras de datos dificiles de reproducir] style P fill:#f38ba8,color:#11111b style T fill:#f38ba8,color:#11111b style D fill:#f38ba8,color:#11111b
Hay un cuarto coste, menos citado y a menudo el más caro de diagnosticar: el orden de inicialización. Como cada object nace en su primer uso, el orden en que nacen depende del camino de ejecución concreto, no de tu diseño. En cuanto dos singletons se leen mutuamente durante su construcción, el resultado deja de ser determinista desde el punto de vista del programa: según quién se toque primero, uno de los dos verá al otro a medio construir. Ese fallo se manifiesta con una propiedad no nula que vale nulo, sobrevive a las pruebas locales y aparece en producción con un arranque distinto.
Se suele pensar en el estado global como en contadores y cachés, pero la forma más extendida es una bandera de configuración que se escribe una vez al arrancar y se lee en cien sitios. Como se escribe una sola vez, parece inofensiva; en realidad convierte el arranque en una secuencia obligatoria no escrita en ninguna parte, y cualquier código que se ejecute antes de esa escritura leerá un valor por defecto sin enterarse.
Las pruebas son el detector de humos
El estado global no se nota mientras el programa se ejecuta una sola vez de principio a fin. Se nota cuando algo lo ejecuta muchas veces dentro del mismo proceso, y eso es exactamente lo que hace una batería de pruebas. Ahí aparecen los tres síntomas clásicos a la vez: pruebas que pasan solas y fallan en grupo, pruebas cuyo resultado depende del orden alfabético de sus nombres, y la imposibilidad de ejecutarlas en paralelo porque comparten un mapa mutable.
A eso se suma que un object se compila como clase final con constructor privado: no hay forma de sustituirlo por una implementación falsa sin manipular el bytecode con herramientas que reescriben la clase en tiempo de ejecución. Que exista esa posibilidad no la convierte en una buena idea; convierte en una necesidad algo que un diseño distinto no habría requerido.
La solución no es prohibir el object, sino separar dos cosas que se habían mezclado: el contrato y la implantación por defecto.
interface Reloj {
fun ahora(): Instant
}
object RelojSistema : Reloj {
override fun ahora(): Instant = Instant.now()
}
class Suscripcion(
private val reloj: Reloj = RelojSistema, // valor por defecto, no dependencia rigida
) {
fun caducada(hasta: Instant): Boolean = reloj.ahora() > hasta
}
El truco está en el parámetro con valor por defecto. En producción nadie escribe nada y la comodidad del singleton se conserva íntegra; en una prueba se pasa un reloj fijo y el tiempo deja de ser un problema. La dependencia aparece en la firma del constructor, es decir, vuelve a ser información visible. Y el object sigue existiendo, pero ha bajado de categoría: de dependencia inevitable a cableado por defecto.
class RelojFijo(private val t: Instant) : Reloj {
override fun ahora(): Instant = t
}
@Test
fun caduca_en_el_instante_exacto() {
val limite = Instant.parse("2026-01-01T00:00:00Z")
val s = Suscripcion(RelojFijo(limite))
assertFalse(s.caducada(limite)) // determinista, sin esperas ni relojes reales
}
Fíjate en lo que ha desaparecido de la prueba: no hay preparación previa, no hay limpieza posterior, no hay nada que otra prueba pueda ensuciar y no hay ninguna razón para no ejecutarla en paralelo con el resto. Ese es el indicador que conviene vigilar en una batería de pruebas: si necesitas un bloque de limpieza que reinicia el estado de un singleton entre casos, ese bloque es la factura del acoplamiento, y llegará un día en que alguien olvide actualizarlo al añadir un campo nuevo.
Antes de montar toda la ceremonia de interfaz e inyección, comprueba si el object guarda estado que pueda variar. Un comparador, una estrategia pura o un conjunto de funciones deterministas no contaminan ninguna prueba, porque no hay nada que una prueba pueda dejar sucio para la siguiente. La inyección se justifica por el estado y por los efectos, no por el hecho de ser un singleton.
Cuándo es la herramienta correcta
Estrategias sin estado
Comparadores, serializadores, validadores y cualquier implementación de una interfaz cuyo comportamiento sea una función pura de sus argumentos. Una sola instancia es la representación fiel de que no hay nada que distinguir entre dos.
Centinelas y casos sin datos
Los casos de una jerarquía sellada que no llevan información, o valores como una lista vacía compartida. Aquí la identidad única no es un ahorro: es parte del significado, y permite comparar con ===.
Dependencias con efectos
Clientes de red, acceso a disco, relojes, generadores aleatorios y todo lo que tenga ciclo de vida. Un object los vuelve imposibles de sustituir y de cerrar de forma ordenada.
Cachés y registros mutables
Un mapa global que crece es el caso peor de los tres costes a la vez: retiene memoria, no aparece en ninguna firma y se corrompe bajo concurrencia. Casi siempre debería ser un campo de un componente con dueño.
El criterio que resume todo lo anterior cabe en una frase: un object es la herramienta correcta cuando su unicidad es una verdad sobre el dominio, y es pereza cuando es una comodidad sobre el cableado. Un comparador de cadenas es único porque no hay dos comparadores distinguibles, no porque te venga bien no pasarlo por parámetro. Un cliente HTTP, en cambio, es único solo mientras nadie necesite un segundo apuntando a otro servidor, y ese día llegará.
// Legitimo: la unicidad es parte del significado
sealed interface Resultado<out T> {
data class Ok<T>(val valor: T) : Resultado<T>
data object Pendiente : Resultado<Nothing> // no hay dos pendientes distintos
}
// Pereza: la unicidad es una suposicion sobre el despliegue
object ClienteApi {
private val http = HttpClient()
fun trae(ruta: String): String = http.get(ruta) // que servidor, cual timeout, como se cierra
}
La forma data object de la primera mitad merece una nota: aporta un toString legible y una igualdad estructural coherente con la identidad, y es la manera idiomática de escribir los casos sin datos de una jerarquía sellada. Ahí el singleton no es una optimización, es la representación exacta de un valor que no tiene variantes. En la segunda mitad ocurre lo contrario: cada línea comentada es una pregunta que el diseño impide formular, y ninguna de ellas se responde añadiendo más miembros al objeto.
El singleton se enseña como una respuesta a la pregunta de la cardinalidad: necesito exactamente una instancia, luego declaro un object. Esa formulación es la raíz de casi todo el daño, porque la cardinalidad es una observación sobre el presente y lo que estás decidiendo es una restricción sobre el futuro. Cuando escribes object, no estás afirmando que hoy haya una sola instancia: estás retirando a todos tus llamadores, para siempre y sin posibilidad de negociación, el derecho a decidir con cuál trabajan. Esa es la propiedad que de verdad se paga, y por eso el mismo mecanismo puede ser impecable en un sitio y catastrófico dos ficheros más allá. Es impecable cuando la elección no tiene sentido: no hay dos comparadores de orden natural distinguibles, no hay dos listas vacías con contenidos distintos, no hay dos casos de una jerarquía sellada sin datos que puedan diferir en nada observable. Ahí retirar la elección no le quita nada a nadie, porque no había nada que elegir, y la instancia única llega incluso a ganar significado propio: un centinela es útil justamente porque se puede comparar por identidad. Es catastrófico cuando la elección sí tenía sentido y alguien la enterró por comodidad. El cliente de red que un día debe apuntar a otro entorno, el reloj que una prueba necesita congelar, la caché que una segunda tenencia querría separar, el generador aleatorio que un experimento reproducible necesita sembrar. En todos esos casos la instancia única no era una verdad del dominio, era una suposición sobre el despliegue, y las suposiciones sobre el despliegue caducan. El coste de descubrirlo tarde es desproporcionado porque el object no deja rastro en ninguna firma: no hay un parámetro que cambiar ni un tipo que ensanchar, hay que encontrar todas las menciones repartidas por el programa. De ahí la disciplina que conviene adoptar, y que no depende de Kotlin en absoluto. Declara la interfaz primero, porque nombrar el contrato cuesta una línea y te obliga a decir qué papel cumple la cosa. Deja que el object sea una implementación de esa interfaz y no el concepto entero. Y ofrécelo como valor por defecto de un parámetro, que es la forma exacta de conservar la comodidad del global sin haber retirado a nadie el derecho a elegir. Habrás conseguido lo único que importa: que la instancia única siga siendo, en tu programa, una decisión revisable en lugar de un hecho consumado.
- Lista todas las declaraciones
objectde un proyecto tuyo y clasifícalas en dos columnas: las que guardan estado mutable y las que no. Trabaja solo sobre la primera columna. - Para cada una, escribe la frase “esto es único porque…” y termínala. Si el final es una verdad del dominio, déjala; si es una comodidad del cableado, márcala para refactorizar.
- Toma un
objectcon estado y provoca el fallo: escribe dos pruebas que pasen por separado y fallen al ejecutarse juntas. Después haz que fallen solo al invertir el orden. - Refactoriza ese caso al patrón de interfaz más implementación por defecto en un parámetro. Comprueba que ninguna llamada de producción necesita cambiar y que las pruebas ya no se contaminan.
- Somete un
objectcon un mapa mutable a acceso concurrente desde varios hilos y observa la corrupción o la excepción de modificación concurrente. Arréglalo primero con una estructura concurrente y después preguntándote si el mapa debía ser global.