1. Introduction
When you move from simple structures (numbers, strings, lists) to “real” objects, you start linking data together. This is natural: an expense has a category, a category has a list of expenses, a user has a manager, and that manager… also has a manager (sometimes it is the same person, but HR is better off not hearing about it).
From Kotlin’s point of view, classes and objects exist precisely to keep data and behavior together, and to create instances that can reference each other. This is the basic object-oriented model: “an object stores fields; fields can be references to other objects.”
The problem appears not when you create references, but when you want to serialize that state: turn it into text (JSON) so that you can restore it later.
2. Why JSON “is not about cycles”: tree vs. graph
Without academic pathos: JSON is, by its nature, a tree. In a tree, each node has “children” (nested objects/arrays), and if you always go “down”, you are guaranteed to eventually hit a leaf (string/number/boolean/null) and stop.
Cyclic references are already a graph. In a graph, you can follow arrows forever: leave A, arrive at B, then at C, and then return to A again — hello, infinity.
Let’s look at a picture:
flowchart TD
subgraph JSON_as_a_tree
A1["Root"] --> B1["Child 1"]
A1 --> C1["Child 2"]
B1 --> D1["leaf: 123"]
C1 --> E1["leaf: \"text\""]
end
subgraph Objects_as_a_graph_with_a_cycle
A2["A"] --> B2["B"]
B2 --> C2["C"]
C2 --> A2
end
JSON has no built-in mechanism for a “reference to a previously encountered object”. It cannot say: “this is not a new object, but the very same one as above.” That is why trying to serialize a graph as a tree usually turns into an attempt to “expand” the cycle into infinite nesting.
3. A mini example of a cycle in Kotlin: “Alice manages Bob, Bob manages Alice”
First, let’s build a very small model to make it clear that a cycle is not something rare and mystical — it is literally two references.
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 // cycle
println(alice.manager?.name) // Bob
println(bob.manager?.name) // Alice
}
In memory, this works perfectly: Kotlin objects can safely hold references to each other; the GC (JVM garbage collector) does not faint because of it. And this is where the classic thought appears: “Well, since it works, let’s add @Serializable and save it to JSON.”
4. What happens with naive serialization
When a serializer turns an object into JSON, it does roughly this: write the object fields → if a field is an object, serialize it → inside it, fields again → and so on. This is often implemented via recursion.
If there is a cycle, serialization starts “unrolling” it again and again. In practice, this usually ends with a stack overflow (often it looks like StackOverflowError) or another error depending on the specific implementation and configuration. The essence is the same: “infinite nesting does not fit into finite memory.”
Pseudo-example (note: do not do this; this is a demonstration of the problem):
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 is inconvenient to create a cycle here because data class is immutable,
// but the point of the problem is exactly the reference cycle.
println(Json.encodeToString(alice))
}
And here is the key practical takeaway: it is impossible to “magically” store a cyclic graph in pure JSON as nested objects. You need to change the data representation for storage.
5. Why this is not “a library screw-up”: format limitations, not Kotlin
It is very tempting to say: “Well, the library is bad; they should fix it.” But the problem is deeper: JSON as a format does not store “object identity” and “references.”
When you write Kotlin code, two fields can refer to the same object. But in JSON you write values — you literally print text. Text does not contain the concept of “this is the same object as 20 lines ago.”
If a serializer tried to solve this on its own, it would have to:
- introduce service object identifiers into JSON,
- introduce references like {"$ref": "id-123"},
- standardize the format of these references,
- be able to restore the graph when reading.
That is, effectively, it would invent a new format on top of JSON. Sometimes this is done in complex systems, but in a learning course we choose a more honest and practical approach: we design the storage model ourselves.
6. The “id instead of reference” strategy: the main trick against cycles
The most workable and common strategy sounds boring, but it saves your nerves: instead of storing a nested object, store its identifier.
That is, not manager: User, but managerId: Int?.
Not category: Category, but categoryId: Int.
And store the objects themselves (their “table”) separately: as a list or a Map by id. Essentially, we build a mini version of how relationships work in a database: data separately, references separately.
And this fits perfectly into our training console project (let’s call it BudgetBuddy): we store expenses and categories, print reports, and now we want to save it to JSON so that it can be read back without pain.
7. The cycle in “expenses ↔ categories”: why it appears
Imagine we want a “nice” in-memory model.
A category knows its expenses because it is convenient for reports like “show all expenses for this category.” An expense knows its category because it is convenient to print a line like “2026-01-14: coffee, category Food.”
This produces a two-way link:
flowchart LR
C["Category"] -->|expenses| E["Expense"]
E -->|category| C
Code (this demonstrates how the cycle arises):
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
}
Inside the app this is convenient, but for serialization it is a trap: if we try to write Category, the serializer will go into expenses, there is Expense, inside it there is category, and inside category there is expenses again… and off we go.
8. “Storage model” vs. “working model”
We do not need to dive into complex architecture right now. One simple rule is enough: what is convenient in memory does not have to be what is convenient to store in JSON.
Let’s make two models:
- Category and Expense — for working in the application (can include references).
- CategoryStored and ExpenseStored — for JSON: only primitives and id references.
Minimal “stored” models:
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,
)
Notice: there is no nested object structure here — only numbers and strings. JSON for this will be as calm as a cat after lunch.
9. A JSON container: an application state “dump”
If you store a “category table” and an “expense table”, it is convenient to put them into one root object that will be saved to a file.
import kotlinx.serialization.Serializable
@Serializable
data class BudgetDump(
val categories: List<CategoryStored>,
val expenses: List<ExpenseStored>,
)
Why this is nice:
- JSON has a single root (as is typically expected),
- we can clearly see which collections we store,
- adding new sections later is easier (for example, app settings) without breaking the format immediately.
10. Example of serialization without cycles
Now the code that produces a “dump” and serializes it.
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, no recursion
}
And this is the key point: we are not “fixing” the serializer — we make the data serializable.
11. How to restore links when reading: assembling the graph back
Saving “flat” is only half the job. The other half (yes, exactly like that) is: after decodeFromString, restore convenient in-memory references.
The assembly idea is simple:
- read BudgetDump,
- create categories and put them into Map<Int, Category> by id,
- create expenses; for each one, find its category by categoryId,
- set references and fill the category’s expense lists.
Code skeleton (not huge, but illustrative):
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()
}
Here we use getValue(...) because in a correct dump the category must exist. If it does not — the dump is corrupted or old, and that is a separate error-handling topic (we are not going deep today, but at least we understand where it “can blow up”).
12. Useful nuances
“stable id” is your mini data contract
For the “id instead of reference” strategy to work, identifiers must be stable and unique.
That is: if you once saved a Food category with id = 1, and then on the next launch you created a new Food category again with id = 1, everything is fine. But if you accidentally assign id = 1 to a different category, your expenses will “move” into someone else’s category, and you will get an accounting horror story.
In training apps, people often use a simple id generator: “max id + 1”. This is not perfect for large systems, but for a console project it is acceptable discipline.
An alternative to two-way references: a one-way model
Sometimes it is easier not to rebuild the “full graph”, but to keep the model one-way. That is, Expense stores categoryId, while Category does not store a list of expenses. When you need to show a category’s expenses, you filter the expense list.
It looks a bit less “object-oriented”, but serialization becomes almost trivial, and no post-read assembly is needed.
A small illustration on collections:
fun expensesForCategory(expenses: List<ExpenseStored>, categoryId: Int): List<ExpenseStored> {
return expenses.filter { it.categoryId == categoryId }
}
This approach often turns out to be even more useful than “references in all directions”, especially while the project is small: less mutability, less risk of getting out of sync.
Where @Transient fits: keeping “cache” out of JSON
Sometimes you want to store in an object “references, and ids, and an expense list, and also a flag ‘already calculated’”. But the JSON format should stay clean.
If you have a computed or cached field that must not leak into JSON, you already know the tool: @Transient (with a default). We will not go deeper (this was the previous lecture’s topic), but let’s lock in the important thought: JSON contains the contract, not your internal convenience.
Cheat-sheet diagram: “id instead of reference”
flowchart TD
subgraph InMemory["In memory (convenient to work with)"]
C1["Category(id=1)"] --> E1["Expense(id=10)"]
E1 --> C1
end
subgraph Stored["In JSON (convenient to store)"]
CS["CategoryStored(id=1)"]
ES["ExpenseStored(id=10, categoryId=1)"]
end
Stored -->|"buildState()"| InMemory
Saving goes from memory to Stored (usually via mapping), and loading goes back via assembly.
13. Typical mistakes when working with cyclic references
Mistake #1: trying to “just serialize an object with references” and expecting the library to figure it out.
This almost always ends with infinite recursion: the serializer honestly walks the fields, and the cycle prevents it from stopping. In such situations, you should not “break JSON”; you should change the data representation: store id instead of a nested object.
Mistake #2: keeping both manager: User and managerId: Int at the same time, but without a clear rule for “which one is the source of truth”.
It is easy to get out of sync: managerId = 2, but manager points, for some reason, to user 3. If you use both fields (for example, one for storage and one for convenience), there must be a clear rule: what is serialized, how things are assembled, and when updates happen.
Mistake #3: forgetting about identifier stability.
The “id instead of reference” strategy works only if id values are unique and are not accidentally “reused”. Otherwise, links will be restored incorrectly, and the program may not even crash — it will simply start lying to the user, which is the worst type of bugs.
Mistake #4: serializing internal caches and derived data.
For example, storing an expense list inside a category even though it can be restored from expenses by categoryId. In JSON this creates duplication and divergence risk. Usually it is better to store primary data and rebuild derived data at load time, while removing temporary fields from the format.
Mistake #5: not thinking through what to do when “there is no link”.
If a link can be absent (for example, a user has no manager), it must be explicitly expressed in the storage model: managerId: Int?. Trying to “stuff” missing links into 0 or -1 often leads to weird edge cases and extra checks.
Mistake #6: trying to treat cycles with a custom serializer without changing the model.
A custom serializer is great when the value format must be unusual (for example, Money as a string). But cycles are a data-structure problem: graph vs. tree. Usually this is solved by designing a storage model, not by clever tricks at the KSerializer<T> level.