1. Introduccion
Cuando pasas de estructuras simples (numeros, cadenas, listas) a objetos «de verdad», empiezas a vincular los datos entre si. Es natural: un gasto tiene una categoria, la categoria tiene una lista de gastos, un usuario tiene un manager, y el manager... tambien tiene manager (a veces es la misma persona, pero de eso es mejor no hablarle a RR. HH.).
Desde el punto de vista de Kotlin, las clases y los objetos sirven precisamente para mantener cerca datos y comportamiento, y para crear instancias que pueden referenciarse entre si. Este es el modelo basico de la programacion orientada a objetos: «un objeto almacena campos, y los campos pueden ser referencias a otros objetos».
El problema no aparece cuando creas las referencias, sino cuando quieres serializar ese estado: convertirlo a texto (JSON) para poder restaurarlo despues.
2. Por que JSON «no va de ciclos»: arbol vs grafo
Dicho sin pompa academica: JSON, por su naturaleza, es un arbol. En un arbol, cada nodo tiene «hijos» (objetos/arrays anidados), y si siempre vas «hacia abajo», estas garantizado a encontrarte tarde o temprano con una hoja (cadena/numero/booleano/null) y terminar.
Las referencias ciclicas ya son un grafo. En un grafo puedes caminar por las flechas infinitamente: sales de A, llegas a B, luego a C, y despues vuelves a A, y hola, infinito.
Veamos el esquema:
flowchart TD
subgraph JSON_as_tree
A1["Root"] --> B1["Child 1"]
A1 --> C1["Child 2"]
B1 --> D1["leaf: 123"]
C1 --> E1["leaf: \"text\""]
end
subgraph Objects_as_graph_with_cycle
A2["A"] --> B2["B"]
B2 --> C2["C"]
C2 --> A2
end
JSON no tiene un mecanismo integrado de «referencia a un objeto visto anteriormente». No sabe decir: «y aqui no hay un objeto nuevo, sino el mismo que estaba arriba». Por eso, intentar serializar un grafo como un arbol suele convertirse en un intento de «desplegar» el ciclo en una anidacion infinita.
3. Mini-ejemplo de un ciclo en Kotlin: «Alice dirige a Bob, y Bob dirige a Alice»
Primero haremos un modelo muy pequeno para que quede claro que un ciclo no es algo raro y mistico, sino literalmente dos referencias.
class User(val id: Int, val name: String) {
var manager: User? = null
}
fun main() {
val alice = User(1, "Alice")
val bob = User(2, "Bob")
alice.manager = bob
bob.manager = alice // ciclo
println(alice.manager?.name) // Bob
println(bob.manager?.name) // Alice
}
En memoria esto funciona perfectamente: los objetos de Kotlin mantienen referencias entre si sin problema, y el GC (recolector de basura de la JVM) no se desmaya por ello. Y aqui aparece el pensamiento clasico: «Si funciona, anadamos @Serializable y guardemoslo en JSON».
4. Que pasa con una serializacion ingenua
Cuando un serializador convierte un objeto a JSON, suele hacer una logica aproximada como esta: escribe los campos del objeto → si un campo es un objeto, lo serializa → dentro de el, vuelve a los campos → y asi sucesivamente. Esto a menudo se implementa con recursion.
Si dentro hay un ciclo, la serializacion empieza a «desplegarlo» una y otra vez. En la practica, normalmente termina en desbordamiento de pila (a menudo se ve como StackOverflowError) u otro error, segun la implementacion concreta y la configuracion. La idea es la misma: «una anidacion infinita no cabe en memoria finita».
Pseudo-ejemplo (nota: no hay que hacerlo asi; es una demostracion del problema):
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class UserDto(
val id: Int,
val name: String,
val manager: UserDto? = null,
)
fun main() {
val alice = UserDto(1, "Alice")
// In reality it's inconvenient to create a cycle here because data class immutable,
// but the point of the problem is exactly the cycle of references.
println(Json.encodeToString(alice))
}
Y aqui hay una conclusion practica importante: es imposible guardar «magicamente» un grafo con ciclos en JSON puro como anidacion. Necesitas cambiar la representacion de los datos para su almacenamiento.
5. Por que no es un «fallo de la biblioteca»: limitaciones del formato, no de Kotlin
Da muchas ganas de decir: «Pues vaya, la biblioteca es mala; que lo arreglen». Pero el problema es mas profundo: JSON como formato no almacena la «identidad del objeto» ni «referencias».
Cuando escribes codigo Kotlin, dos campos pueden apuntar al mismo objeto. Pero en JSON escribes valores, es decir, literalmente imprimes texto. El texto no contiene la idea de «es el mismo objeto que estaba 20 lineas mas arriba».
Si el serializador intentara resolverlo por su cuenta, tendria que:
- introducir en JSON identificadores de objeto de servicio,
- introducir referencias del tipo {"$ref": "id-123"},
- acordar el formato de esas referencias,
- ser capaz de reconstruir el grafo al leer.
Es decir, en la practica, inventar un formato nuevo encima de JSON. A veces se hace en sistemas complejos, pero en un curso formativo elegimos una via mas honesta y practica: disenamos nosotros mismos el modelo de almacenamiento.
6. Estrategia «id en lugar de referencia»: el truco principal contra los ciclos
La estrategia mas util y extendida suena aburrida, pero salva los nervios: en lugar de almacenar el objeto anidado, almacenamos su identificador.
Es decir, no manager: User, sino managerId: Int?.
No category: Category, sino categoryId: Int.
Y los propios objetos (su «tabla») los almacenamos aparte: como lista o como Map por id. En esencia hacemos una mini-version de como se organizan las relaciones en una base de datos: datos por un lado, referencias por otro.
Y esto encaja perfectamente en nuestro proyecto educativo de consola (lo llamaremos, por ejemplo, BudgetBuddy): almacenamos gastos y categorias, imprimimos informes, y ahora queremos guardar todo eso en JSON para poder leerlo de vuelta sin sufrimiento.
7. El ciclo en «gastos ↔ categorias»: por que aparece
Imaginemos que queremos un modelo «bonito» en memoria.
La categoria conoce sus gastos, porque es comodo para construir informes tipo «muestrame todos los gastos por categoria». El gasto conoce su categoria, porque es comodo para imprimir la linea «2026-01-14: cafe, categoria Food».
Acaba quedando una relacion bidireccional:
flowchart LR
C["Category"] -->|expenses| E["Expense"]
E -->|category| C
Codigo (demostracion de como aparece el ciclo):
class Category(val id: Int, val title: String) {
val expenses: MutableList<Expense> = mutableListOf()
}
class Expense(val id: Int, val title: String, val amountCents: Long) {
lateinit var category: Category
}
Dentro de la aplicacion es comodo, pero para serializar es una trampa: si intentamos escribir Category, el serializador se metera en expenses, ahi hay un Expense, dentro esta category, y dentro de category vuelve a haber expenses... y asi sucesivamente.
8. «Modelo para almacenar» y «modelo para trabajar»
Ahora no necesitamos meternos en arquitectura compleja. Basta una regla sencilla: lo que es comodo en memoria no tiene por que ser lo que es comodo guardar en JSON.
Hagamos dos modelos:
- Category y Expense: para trabajar en la aplicacion (pueden tener referencias).
- CategoryStored y ExpenseStored: para JSON, solo primitivas y referencias por id.
Modelos «stored» minimos:
import kotlinx.serialization.Serializable
@Serializable
data class CategoryStored(
val id: Int,
val title: String,
)
@Serializable
data class ExpenseStored(
val id: Int,
val title: String,
val amountCents: Long,
val categoryId: Int,
)
Fijate: aqui no hay ninguna anidacion de objetos, solo numeros y cadenas. Con esto JSON estara tan tranquilo como un gato despues de comer.
9. Contenedor para JSON: «volcado» del estado de la aplicacion
Si almacenas una «tabla de categorias» y una «tabla de gastos», es comodo meterlas en un unico objeto raiz, que sera el que se guarde en el archivo.
import kotlinx.serialization.Serializable
@Serializable
data class BudgetDump(
val categories: List<CategoryStored>,
val expenses: List<ExpenseStored>,
)
Por que esto es agradable:
- JSON tiene una unica raiz (como suele esperarse),
- queda claro que colecciones almacenamos,
- es mas facil anadir secciones nuevas mas adelante (por ejemplo, ajustes de la aplicacion) sin romper el formato de golpe.
10. Ejemplo de serializacion sin ciclos
Ahora el codigo que hace el «volcado» y lo serializa.
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun main() {
val dump = BudgetDump(
categories = listOf(CategoryStored(1, "Food")),
expenses = listOf(ExpenseStored(10, "Coffee", 350, categoryId = 1)),
)
val json = Json { prettyPrint = true }
println(json.encodeToString(dump))
// Prints JSON with categories and expenses, without recursion
}
Y este es el punto clave: no «curamos» el serializador; hacemos que los datos sean serializables.
11. Como restaurar relaciones al leer: reconstruimos el grafo
Guardar «en plano» es solo la mitad. La otra mitad (si, exactamente asi) es, despues de decodeFromString, devolver referencias comodas en memoria.
La idea de reconstruccion es simple:
- leemos BudgetDump,
- creamos categorias y las ponemos en un Map<Int, Category> por id,
- creamos gastos; para cada uno buscamos su categoria por categoryId,
- asignamos referencias y rellenamos las listas de gastos en la categoria.
Esqueleto de codigo (no enorme, pero ilustrativo):
fun buildState(dump: BudgetDump): List<Category> {
val categoriesById = dump.categories.associate { it.id to Category(it.id, it.title) }
for (e in dump.expenses) {
val category = categoriesById.getValue(e.categoryId)
val expense = Expense(e.id, e.title, e.amountCents).also { it.category = category }
category.expenses.add(expense)
}
return categoriesById.values.toList()
}
Aqui usamos getValue(...) porque, en un volcado correcto, la categoria debe existir. Si no existe, significa que el volcado esta danado o es antiguo, y eso ya es otro tema de gestion de errores (hoy no profundizamos, pero al menos entendemos donde «puede explotar»).
12. Matices utiles
«id estable»: tu mini-contrato de datos
Para que la estrategia «id en lugar de referencia» funcione, los identificadores deben ser estables y unicos.
Es decir: si una vez guardaste la categoria Food con id = 1, y luego en el siguiente arranque creaste una categoria nueva Food otra vez con id = 1, todo bien. Pero si por accidente asignas id = 1 a otra categoria, los gastos «se mudaran» a una categoria ajena y tendras un horror contable.
En aplicaciones educativas suele hacerse un generador simple de id: «id maximo + 1». No es ideal para sistemas grandes, pero para un proyecto de consola es una disciplina razonable.
Alternativa a referencias bidireccionales: modelo unidireccional
A veces es mas facil no reconstruir el «grafo completo», sino mantener el modelo unidireccional. Es decir, Expense guarda categoryId, y Category no guarda la lista de gastos. Cuando necesitas mostrar los gastos de una categoria, filtras la lista de gastos.
Se ve un poco menos «orientado a objetos», pero la serializacion se simplifica casi a cero, y no hace falta reconstruccion tras la lectura.
Una pequena ilustracion con colecciones:
fun expensesForCategory(expenses: List<ExpenseStored>, categoryId: Int): List<ExpenseStored> {
return expenses.filter { it.categoryId == categoryId }
}
Este enfoque a menudo resulta incluso mas util que «referencias en todas direcciones», sobre todo mientras el proyecto es pequeno: menos mutabilidad, menos riesgo de desincronizacion.
Donde entra @Transient: quitamos «cache» del JSON
A veces apetece guardar en el objeto «y referencias, y id, y lista de gastos, y ademas un flag “ya calculado”». Pero el formato JSON debe seguir siendo limpio.
Si tienes un campo calculado o cacheado que no debe filtrarse a JSON, ya conoces la herramienta: @Transient (con valor por defecto). No entraremos en detalle (es tema de la leccion anterior), pero fijemos la idea importante: a JSON va el contrato, no tu comodidad interna.
Diagrama recordatorio: «id en lugar de referencia»
flowchart TD
subgraph InMemory["In memory (conveniente para trabajar)"]
C1["Category(id=1)"] --> E1["Expense(id=10)"]
E1 --> C1
end
subgraph Stored["In JSON (conveniente para almacenar)"]
CS["CategoryStored(id=1)"]
ES["ExpenseStored(id=10, categoryId=1)"]
end
Stored -->|"buildState()"| InMemory
El guardado va de memoria a Stored (normalmente mediante mapping), y la carga va en sentido contrario mediante reconstruccion.
13. Errores tipicos al trabajar con referencias ciclicas
Error n.º 1: intentar «simplemente serializar un objeto con referencias» y esperar que la biblioteca lo adivine.
Casi siempre esto termina en recursion infinita: el serializador recorre honestamente los campos, pero el ciclo no le deja detenerse. En estas situaciones no hay que «romper JSON», sino cambiar la representacion de los datos: guardar id en lugar del objeto anidado.
Error n.º 2: mantener a la vez manager: User y managerId: Int, pero sin una regla clara de «quien manda».
Asi es facil obtener desincronizacion: managerId = 2, pero manager por alguna razon apunta al usuario 3. Si usas ambos campos (por ejemplo, uno para almacenamiento y otro por comodidad), debe haber una regla clara: que se serializa, como se reconstruye, cuando se actualiza.
Error n.º 3: olvidarse de la estabilidad de los identificadores.
La estrategia «id en lugar de referencia» solo funciona si id son unicos y no se «reutilizan» por accidente. Si no, las relaciones se restauraran mal, pero el programa puede ni siquiera fallar: simplemente empezara a «mentir» al usuario, y ese es el peor tipo de bugs.
Error n.º 4: serializar caches internos y datos derivados.
Por ejemplo, guardar en la categoria la lista de gastos, aunque se puede reconstruir a partir de los gastos por categoryId. En JSON esto produce duplicacion y riesgo de divergencia. Normalmente es mejor guardar los datos primarios y reconstruir los derivados al cargar, y eliminar los campos temporales del formato.
Error n.º 5: no pensar que hacer cuando «no hay relacion».
Si la relacion puede faltar (por ejemplo, un usuario no tiene manager), esto debe expresarse de forma explicita en el modelo de almacenamiento: managerId: Int?. Intentar «meter» la ausencia de relacion en 0 o -1 suele llevar a casos borde extranos y a comprobaciones innecesarias.
Error n.º 6: intentar curar ciclos con un serializador personalizado sin cambiar el modelo.
Un serializador personalizado es bueno cuando el formato del valor debe ser inusual (por ejemplo, Money como cadena). Pero los ciclos son un problema de estructura de datos: grafo contra arbol. Normalmente esto se resuelve disennando el modelo de almacenamiento, no con trucos a nivel de KSerializer<T>.
GO TO FULL VERSION