JavaRush /Courses /Kotlin SELF /Common OOP start-up mistakes: mutability, references, res...

Common OOP start-up mistakes: mutability, references, responsibility, names

Kotlin SELF
Level 30, Lesson 4
Available

1. Introduction

When you first start writing classes, your brain often pulls a tricky move: “Oh, now I have my own types—so I can stuff into them everything I see and mutate it however I want.” It sounds logical… right up until the first bug, when the total suddenly becomes negative, an expense title “changes by itself,” and the list() method starts printing to the console in the most unexpected places. Today we’ll break down the typical OOP start-up pitfalls—so you step on them a bit less often, and at least consciously.

Let’s agree on the context: we continue our console mini-project for tracking expenses (let’s call it BudgetBuddy). We have an expense entity Expense and a list MutableList<Expense> where we add elements and build reports.

2. Mutability: when var turns code into jelly

Usually the very first “bad habit” in OOP looks like this: you see that properties can be declared as var, and your hand automatically writes var everywhere. Because “what if I need to change it later.” The result is a class that can be accidentally changed from anywhere, like modeling clay: press a bit—and the shape is different. The problem is not that var is evil; the problem is that uncontrolled mutability makes program behavior unpredictable, especially when there are many objects and they live in collections.

Too many var: “just in case” is the most expensive argument

Let’s start with an illustration. Here is a “suspicious” version of the expense model:

class Expense(var title: String, var amount: Int, var category: String)

fun main() {
    val e = Expense("Coffee", 300, "food")
    e.amount = -10
    println(e.amount) // -10
}

The code compiles; everything is honest. But from a domain perspective, “expense = -10” sounds like an “anti-expense,” a “credit from the universe,” or simply a bug. If a class allows writing an invalid value, then state correctness is held by programmer discipline (and programmer discipline usually ends after the second coffee).

A healthier default for an initial model is to make properties immutable (val) until you know for sure what you need to change and why:

class Expense(val title: String, val amount: Int, val category: String)

fun main() {
    val e = Expense("Coffee", 300, "food")
    println("${e.title}: ${e.amount}") // Coffee: 300
}

This does not make the program “perfect,” but it greatly reduces the number of places where chaos can appear: after creation, the object is stable, which makes it easier to read.

val protects the reference, not the internals

There is another trap. Many people think: “Okay, then I’ll store everything in val, and everything will become immutable.” But val for an object forbids reassigning the reference, not changing the object (if the object has var properties). The same applies to collections: val protects the variable from reassignment, but it does not “freeze” the collection. You can see this directly with the standard library: a val list can be modified if it is a MutableList.

fun main() {
    val expenses = mutableListOf<String>()
    expenses.add("Coffee")
    expenses.add("Taxi")

    println(expenses) // [Coffee, Taxi]

    // expenses = mutableListOf() // not allowed; that's the whole point of val
}

That’s why it’s useful to keep a simple table in mind (it saves nerves):

What you write What it means What remains “mutable”
val x = ...
you cannot reassign x the object inside can still change if it has var
var x = ...
you can reassign x plus the object inside can also change
val list = mutableListOf(...)
you cannot replace the list with another one but you can add/remove because the list is mutable

Rule: val by default, var when necessary

When you write a model, try to say this phrase in your head: “In real life, does this thing change?”

An expense title usually does not change (if the user made a mistake, that’s more often a different operation: “fix input,” but that’s a separate action). The amount is usually fixed as well. The category sometimes changes, but that is already a deliberate “reclassify” scenario. So you can start with val, and for the rare change scenario, make a separate method that performs validation and changes exactly what needs to change.

Here is an example where exactly one field is mutable, and the change happens via a method:

class Expense(val title: String, val amount: Int, var category: String) {

    fun recategorize(newCategory: String) {
        val prepared = newCategory.trim()
        if (prepared.isBlank()) return
        category = prepared.lowercase()
    }
}

fun main() {
    val e = Expense("Taxi", 1200, "transport")
    e.recategorize("  Travel  ")
    println(e.category) // travel
}

Note: we are not discussing “advanced property protection” (accessors, private set, etc.) right now—that’s a separate topic. The important idea here is simple: if an object changes, let it change predictably, via methods that enforce minimal correctness rules.

3. Shared references: one object, two names, and three problems

The second big beginner mistake is forgetting that a variable of an object type stores a reference. You have already seen this, but now the problem becomes practical: the object sits in a list, different variables refer to it, you change a field somewhere—and suddenly something “breaks” somewhere else entirely. This is not mysticism; it’s the normal reality of reference types.

Mental model: an “object table” and reference arrows

It helps to picture it like this: somewhere there is an Expense(...) object, and variables are arrows pointing to it.

flowchart LR
    a[val a] --> O["Expense(title='Coffee', amount=300)"]
    b[val b] --> O

If the object is mutable, a change through any arrow changes the same single object.

Example: why it changed “not there”

class Box(var value: Int)

fun main() {
    val a = Box(10)
    val b = a

    b.value = 99

    println(a.value) // 99
}

From the JVM’s point of view, there is one Box object and two variables that point to it. The mistake happens when you expected b = a to “create a copy.” No—only explicitly creating a new Box(...) object creates a copy.

How this breaks collections of objects

Now let’s transfer the same idea to an expense list. Imagine you found an expense by index, stored it into a variable, and later changed it somewhere. If the object is mutable, you changed the list element (because the list stores a reference to the same object).

class Expense(var title: String, var amount: Int)

fun main() {
    val expenses = mutableListOf(
        Expense("Coffee", 300),
        Expense("Taxi", 1200)
    )

    val first = expenses[0]
    first.amount = 999

    println(expenses[0].amount) // 999
}

Sometimes this is exactly what you want. But more often at the beginning you run into “side effects”: you changed something in one place—it reflected somewhere else, and debugging is hard.

val does not remove shared references

val does not remove shared references. val only says: “this variable cannot be pointed to a different object.” But if the object has var inside, its state can still change. So if you want fewer surprises, two simple approaches usually work best at an early stage.

  • The first approach is to make the model more immutable (more val, fewer var), so that even with shared references there is nothing to “accidentally change.”
  • The second approach is: if you need an “independent” object, create a new object explicitly, without relying on “copying by assignment.”
class Expense(val title: String, val amount: Int)

fun main() {
    val original = Expense("Coffee", 300)
    val independent = Expense(original.title, original.amount)

    println(original === independent) // false
}

We are not using any special “model copying” mechanisms yet—keeping the idea simple: a new object appears only via ClassName(...).

4. Responsibility: a model without readln() and println()

Another typical OOP start-up mistake is to make a class a “god object”: it stores data, reads input, draws menus, prints reports. At first it seems convenient: “well, it’s all about expenses.” Then the class grows, and any change turns into open-heart surgery without anesthesia.

Problem sign: “the class knows about the console”

Here is a suspicious example (it works, but it pulls the project in the wrong direction):

class Expense(val title: String, val amount: Int) {
    fun printLine() {
        println("$title: $amount")
    }
}

fun main() {
    val e = Expense("Coffee", 300)
    e.printLine() // Coffee: 300
}

Why is this a problem? Because Expense now “knows how to print.” If tomorrow you want output not to the console but to a string (for a report), or to a file, or to a GUI, the model becomes awkward. It starts to depend on the presentation method.

It’s much more flexible when a model describes what it is, and output is separate logic (even if for now it’s in main or a utility function).

class Expense(val title: String, val amount: Int)

fun formatExpense(e: Expense): String {
    return "${e.title}: ${e.amount}"
}

fun main() {
    val e = Expense("Coffee", 300)
    println(formatExpense(e)) // Coffee: 300
}

This keeps Expense “clean”: it knows nothing about the console, input, or how you want to render the data.

The model should not “ask the user”

Sometimes beginners do this: “let Expense ask the user for the title and amount itself.” It looks like saving code, but in practice it mixes layers: the model (data) and the interface (I/O) become the same thing, and changes become painful.

Better: main (or your scenario functions) read input, validate it, and create an object. The object does not run around the room with a microphone asking, “so how much did you spend?”

5. Names: why Item, Data, Info are fog

At the start of OOP, there is a desire to name everything as “generic” as possible: Item, Entity, Data, Record. It feels “more professional.” In reality, it’s like labeling moving boxes with “stuff” and “more stuff,” and then trying to find your laptop charger. In programming, names are part of the interface, not decoration.

Bad names make code longer

Let’s compare two variants. The first is “generic”:

class Item(val a: String, val b: Int)

fun main() {
    val x = Item("Coffee", 300)
    println(x.a) // Coffee
    println(x.b) // 300
}

The second is domain-specific:

class Expense(val title: String, val amount: Int)

fun main() {
    val e = Expense("Coffee", 300)
    println(e.title)  // Coffee
    println(e.amount) // 300
}

In the second version, your brain does less extra work. You read the code and understand the meaning without an additional “translation”: amount is an amount, not “some b.”

Short and clear beats short and unclear

Clarity does not have to be long. title, amount, category—short and clear. But t, a, c are a riddle. In a small educational project it’s tolerable, but as soon as you have 5–10 functions and a list of objects, riddles start eating your time.

6. Mini-refactoring BudgetBuddy without “architecture for the sake of architecture”

Now we’ll bring all the ideas together into one small, down-to-earth piece of code: an expense model + a few operations on the list. We are not building an “ideal architecture,” not introducing new language constructs, and not turning the project into enterprise. We are simply removing common start-up mistakes.

Model: minimum mutability, maximum clarity

class Expense(val title: String, val amount: Int, val category: String)

fun createExpense(titleInput: String, amount: Int, categoryInput: String): Expense? {
    val title = titleInput.trim()
    val category = categoryInput.trim().lowercase()

    if (title.isBlank()) return null
    if (amount <= 0) return null
    if (category.isBlank()) return null

    return Expense(title = title, amount = amount, category = category)
}

We made a simple factory function that returns Expense?: either a valid object, or null if the data is bad. This is the familiar “read → prepare → validate” pattern from the early days of the course, just now the result is an object.

List: val on the collection, operations via functions

It’s important to remember: val on a mutable collection protects the reference from reassignment, which is useful for predictability.

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses.add(e)
}

fun totalAmount(expenses: List<Expense>): Int {
    return expenses.sumOf { it.amount }
}

fun main() {
    val expenses = mutableListOf<Expense>()

    addExpense(expenses, Expense("Coffee", 300, "food"))
    addExpense(expenses, Expense("Taxi", 1200, "transport"))

    println(totalAmount(expenses)) // 1500
}

Here we have no “combine-everything class.” The model is separate, operations on the collection are separate, and main is the place where the scenario is wired together.

Protection from shared references through design

Right now Expense is fully immutable. That means: even if you have a thousand references to the same expense object (which is suspicious by itself, but let’s assume), there is nothing to “accidentally change.” You either create a new expense, or work with the existing one as a fact.

If you know for sure that an expense must be mutable (for example, the category changes by a user command), make the change via a method, and let the method decide what is allowed.

7. Common OOP start-up mistakes

Mistake #1: “make everything var, because it might be useful later.”
You buy yourself freedom “later,” but you pay for it with chaos “now”: any piece of code can mutate the object, and it becomes hard to prove where exactly it happened. Early on, it’s safer to use val for properties by default and add var only for a specific change scenario, preferably via a separate method with validation.

Mistake #2: forgetting that assignment copies a reference, not creates a new object.
The moment you do b = a does not create a clone. It is just a second name for the same object. If the object is mutable, a change via a will be visible via b, and vice versa. The fix is not “magic,” but discipline: either make objects more immutable, or explicitly create a new object when you need an independent instance.

Mistake #3: storing an entity as a set of parallel variables and lists.
When you have titles, amounts, categories, sooner or later you’ll get out of sync: you removed an element from one list, forgot the other, and now “taxi costs 300 and coffee is 1200.” A single list of objects (MutableList<Expense>) solves this at the data structure level.

Mistake #4: making the model responsible for input and output.
The Expense class should not read readln() and print println()—not because “you can’t,” but because you lose flexibility: the model starts depending on a specific interface (the console). Better to keep I/O in the scenarios (main and the functions around it), and the model as a description of data and simple change rules.

Mistake #5: unclear names (Data, Item, Info, a/b/c).
Name “generality” often means only one thing: you don’t want to think about meaning right now. But you will have to invent that meaning anyway—just later, during debugging. Good names save code-reading time, and in real development, reading time is usually more expensive than writing time.

Mistake #6: trying to “fix” the shared-reference problem with extra var and variable reshuffling.
When you notice unexpected changes, it’s very tempting to “move the object into another variable” and hope it gets better. But the problem is not the variable; it’s that you have one object and many references to it. If you need an independent instance—create a new object. If you don’t—make changes centralized and predictable.

1
Task
Kotlin SELF, level 30, lesson 4
Locked
Clear Expense
Clear Expense
1
Task
Kotlin SELF, level 30, lesson 4
Locked
Discount via copy
Discount via copy
1
Task
Kotlin SELF, level 30, lesson 4
Locked
Shared references
Shared references
1
Task
Kotlin SELF, level 30, lesson 4
Locked
Report without console
Report without console
1
Survey/quiz
Classes and objects, 30 level, 4 lesson
Unavailable
Classes and objects
Classes and objects
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION