1. Introduction
Refactoring is when you change the structure of the code while trying not to change its meaning (behavior). Sounds simple, but in practice your brain really loves “since I’m touching it anyway, let’s also impro…”. And now you’re moving files, renaming entities, changing message formats, and rewriting an algorithm all at once. It’s like deciding during cleanup “and while we’re at it, let’s knock down a wall” — and suddenly you live on a construction site.
The “big bang” problem is that you lose control: when something breaks, it’s unclear which one of the 37 changes is to blame. So in this lecture we’ll learn to make changes in a way that, at any moment, you can honestly say: “the code compiles, the program runs, and I understand what I just changed.”
To make it concrete, let’s imagine our typical “starting point” — a piece of CLI code that already works but looks like hot spaghetti:
fun main() {
val items = mutableListOf<Pair<String, Int>>() // "title" to amount
while (true) {
print("> ")
val line = readln().trim()
if (line == "exit") break
if (line.startsWith("add ")) {
val rest = line.removePrefix("add ")
val parts = rest.split(" ")
val title = parts.dropLast(1).joinToString(" ").trim()
val amount = parts.last().toIntOrNull() ?: 0
if (title.isNotEmpty() && amount > 0) {
items.add(title to amount)
println("Added: $title ($amount)") // Added: Coffee (200)
} else {
println("Bad input")
}
} else if (line == "list") {
for ((t, a) in items) println("$t: $a")
} else {
println("Unknown command")
}
}
}
It “sort of works,” but changing it is scary: any edit can break parsing, output formatting, or the add logic. Next, we’ll turn this into a clearer structure — in small steps, without drama.
2. The discipline of small steps
When people say “take small steps,” it sounds like advice at the level of “drink water” — sure it’s right, but what exactly do you do? Let’s turn it into a concrete rule you can apply literally. After each change, you must be able to: (1) build the project without errors, (2) run the program, (3) quickly check that basic scenarios don’t look broken.
Compilation invariant
First — the compilation invariant: you don’t accumulate “half-working” states. The code either compiles, or you don’t finish the step.
Meaning invariant
Second — the meaning invariant: during a refactoring step you try not to change behavior. If you do change behavior, do it intentionally and separately. Otherwise you get the classic: “I only renamed a variable, but for some reason "add" now adds negative amounts…”.
One more practical thing: use built-in precondition checks (require, check) where it helps make the contract explicit. In Kotlin, require() is convenient for validating input arguments (throws IllegalArgumentException), and check() is for validating state (throws IllegalStateException). This isn’t “magic,” it’s a way to make a failure loud and understandable.
Minimal safety net without tests
Right now we’re deliberately not going into test frameworks and unit tests (that’s a separate big topic), but without any safety net, refactoring turns into a “guess where it broke” game. So in a console project it’s handy to have a couple of stable “manual” verification scenarios — not as “homework,” but as your personal coordinate system.
The idea is: you keep in your head (or in a note) 3–4 commands that can be run quickly after each step, for example: add an expense, print the list, try invalid input, exit. It takes 15–30 seconds, but it gives confidence: “I didn’t break everything at once.” In real development, this is replaced by automated tests, but before we get there, it’s important to learn the discipline of small checks.
In refactoring, the habit of “don’t touch everything at once” helps a lot. For example, if you want to extract command parsing into a function, don’t change the command format at the same time. First extract — confirm it works — then improve the format.
How to plan the sequence of steps
Refactoring is not only “do it in small pieces,” but also “do it in the right order.” If you first move everything into packages, then start changing signatures, then realize you forgot about parsing — you’ll end up with lots of small problems that are unpleasant to untangle.
A good strategy: first stabilize action boundaries (extract functions, introduce explicit types like ParsedCommand), then stabilize responsibility areas (router/handler), and only then change dependencies and contracts (repository, exporter, etc.). Then each change “lands” more easily.
A small flowchart (in the spirit of “where to move so it doesn’t hurt”):
flowchart TD
A["Big main: everything mixed together"] --> B["Extract small functions: normalize/parse"]
B --> C["Introduce explicit types: ParsedCommand"]
C --> D["Extract command routing: CommandRouter/Handler"]
D --> E["Split into packages: app.cli / domain / storage"]
E --> F["Replace dependencies: MutableList -> Repository"]
F --> G["Stabilize the command result format (next lecture)"]
Notice that at each step you can stop: the project still compiles and runs. That’s very calming — especially if you’ve already once gone through “rewrote everything, nothing works.”
3. Small step #1: extract repeated code into functions
When code starts to sprawl, readability is the first thing to suffer: you open main and you don’t see “the program’s workflow scenario,” you see mush made of string normalization, checks, parsing, branching, printing, and list updates. The softest (and usually safest) step is to extract small functions that don’t change meaning, but simply let names “stick” to actions.
Let’s start with the most harmless thing: command normalization. We’ve already done this in previous topics, but right now the important part is the process: extract a piece, run it, make sure nothing changed.
private fun normalizeCommand(raw: String): String =
raw.trim().lowercase()
fun main() {
val line = readln()
val cmd = normalizeCommand(line)
println(cmd) // if you enter " LiSt ", you'll see "list"
}
Why is this a good refactoring move? Because it almost can’t break program logic: you extracted the exact same expression, you just gave it a name. If something goes wrong, you know where to look.
Next you can extract the “exit check”:
private fun isExitCommand(cmd: String): Boolean =
cmd == "exit" || cmd == "quit"
fun main() {
val cmd = normalizeCommand(readln())
if (isExitCommand(cmd)) return
println("Continue...") // Continue...
}
And here the readability magic begins: main gradually turns into “read → normalize → choose a branch.” That is, it becomes the workflow scenario that’s easy to read top-down.
4. Small step #2: introduce ParsedCommand instead of “ubiquitous strings”
One reason why the “big main” is hard to refactor is that there are strings everywhere. A string is the command, the arguments, the raw input, and the normalized version. It’s like trying to run a warehouse where every box is labeled “stuff.” Convenient? No. Realistic? Yes. People often live like that.
A small step is to introduce a small data model for the parsing result. It’s not “OOP for OOP’s sake,” it’s simply a way to make the contract clearer.
data class ParsedCommand(
val name: String,
val arg: String?
)
Now you can write a parsing function that returns a structure, not “try to guess”:
private fun parseCommandLine(line: String): ParsedCommand {
val s = line.trim()
val space = s.indexOf(' ')
return if (space == -1) {
ParsedCommand(name = s.lowercase(), arg = null)
} else {
val name = s.substring(0, space).lowercase()
val arg = s.substring(space + 1).trim().takeIf { it.isNotEmpty() }
ParsedCommand(name = name, arg = arg)
}
}
Pay attention to takeIf: it helps return null if the argument is empty. That’s convenient because later you can distinguish “no argument” from “has an argument.” (And yes, null is appropriate here: it is precisely “absence of a value,” not an error.)
And now main starts reading more calmly:
fun main() {
print("> ")
val cmd = parseCommandLine(readln())
println("name=${cmd.name}, arg=${cmd.arg}") // e.g.: name=add, arg=Coffee 200
}
This is an important refactoring technique: you don’t rewrite the entire program at once. You simply change the data representation into something more convenient, and then build the next step on top of that representation.
5. Small step #3: extract command routing into CommandRouter
Once parsing returns a ParsedCommand, the next spaghetti piece is usually branching via if/else if or a huge when right inside main. And again, our goal is not “make it pretty like in a book,” but to make it so that each piece of code has a single clear responsibility.
Let’s create a class that does only one thing: takes an already-parsed command and decides what to do next. For now, let it return a string (a message for the CLI). This matters: we don’t drag the console in here and don’t print inside. We return a string — the CLI will decide how to display it.
class CommandRouter {
fun route(cmd: ParsedCommand): String =
when (cmd.name) {
"help" -> "Commands: help, add, list, exit"
else -> "Unknown command: ${cmd.name}"
}
}
when in Kotlin can be an expression and return a value, which helps write such functions compactly and readably.
And main turns into a simple scenario:
fun main() {
val router = CommandRouter()
print("> ")
val cmd = parseCommandLine(readln())
val message = router.route(cmd)
println(message) // e.g.: Commands: help, add, list, exit
}
Then you “connect” real actions ("add"/"list") to the router, but you do it in small steps too: first support "list" with a stub, then connect a service, then add validation.
6. Small step #4: moving files and packages without changing logic
Moving things into packages sounds like “it’s just folders,” but in practice it’s one of the common places where beginners break a project. The good news: the IDE can do it almost safely (Move/Refactor), and the bad news: if you do it manually and “all at once,” it’s easy to end up on a quest of “why can’t it see the class.”
The right small step looks like this: you move one file, fix imports, make sure everything compiles, and only then take the next one. It’s boring, but it works.
For example, move the command model into the CLI package.
// FILE: app/cli/ParsedCommand.kt
package app.cli
data class ParsedCommand(
val name: String,
val arg: String?
)
And in Main.kt:
// FILE: app/cli/Main.kt
package app.cli
fun main() {
val cmd = ParsedCommand("help", null)
println(cmd) // ParsedCommand(name=help, arg=null)
}
Then move the parser there as well:
// FILE: app/cli/CommandParsing.kt
package app.cli
fun parseCommandLine(line: String): ParsedCommand {
val s = line.trim()
val space = s.indexOf(' ')
return if (space == -1) ParsedCommand(s.lowercase(), null)
else ParsedCommand(s.substring(0, space).lowercase(), s.substring(space + 1).trim())
}
At this step it’s very important not to “improve the parser,” but just to move it. Otherwise you’ll mix two changes: (1) organizational, (2) logical.
7. Small step #5: change dependencies via an “intermediary layer”
The most painful part of refactoring is replacing direct dependencies with interfaces. For example, earlier your ExpenseService worked directly with a list, but now by architecture it should work through ExpenseRepository. If you do it “in one go,” you’ll touch half the project: signatures, object creation, imports, storage logic.
The small step here is to build an “adapter” around what you already have and gradually move code onto the new contract.
Let’s assume we already have a domain model:
// FILE: domain/model/Expense.kt
package domain.model
data class Expense(
val title: String,
val amount: Int
)
Previously we could store MutableList<Expense> right in main. Now we create an in-memory repository. Important: you can do this without changing command logic at all — only by replacing “where the data lives.”
// FILE: domain/service/ExpenseRepository.kt
package domain.service
import domain.model.Expense
interface ExpenseRepository {
fun add(expense: Expense)
fun all(): List<Expense>
}
Implementation:
// FILE: storage/InMemoryExpenseRepository.kt
package storage
import domain.model.Expense
import domain.service.ExpenseRepository
class InMemoryExpenseRepository : ExpenseRepository {
private val items = mutableListOf<Expense>()
override fun add(expense: Expense) {
items.add(expense)
}
override fun all(): List<Expense> = items
}
Notice: all() returns List, not MutableList. This is a small detail, but it protects you from “the CLI accidentally changed the storage behind the rules.”
Now the service. And here there’s a temptation to “rewrite everything nicely.” Don’t. Do the minimum: just replace the list with a repository and keep the old logic.
// FILE: domain/service/ExpenseService.kt
package domain.service
import domain.model.Expense
class ExpenseService(private val repo: ExpenseRepository) {
fun add(title: String, amount: Int) {
val normalizedTitle = title.trim()
require(normalizedTitle.isNotEmpty()) { "title must not be empty" }
require(amount > 0) { "amount must be > 0" } // require throws IllegalArgumentException
repo.add(Expense(normalizedTitle, amount))
}
fun all(): List<Expense> = repo.all()
}
And only now do we change the wiring in main (exactly in one place, without touching everything else):
// FILE: app/cli/Main.kt
package app.cli
import domain.service.ExpenseService
import storage.InMemoryExpenseRepository
fun main() {
val service = ExpenseService(InMemoryExpenseRepository())
service.add("Coffee", 200)
println(service.all()) // [Expense(title=Coffee, amount=200)]
}
That’s refactoring in small steps: you added a new layer (Repository), but you didn’t rewrite the entire world. The old world simply started using the new contract.
8. How to plan the sequence of steps
Refactoring is not only “do it in small pieces,” but also “do it in the right order.” If you first move everything into packages, then start changing signatures, then realize you forgot about parsing — you’ll end up with lots of small problems that are unpleasant to untangle.
A good strategy: first stabilize action boundaries (extract functions, introduce explicit types like ParsedCommand), then stabilize responsibility areas (router/handler), and only then change dependencies and contracts (repository, exporter, etc.). Then each change “lands” more easily.
A small flowchart (in the spirit of “where to move so it doesn’t hurt”):
flowchart TD
A["Big main: everything mixed together"] --> B["Extract small functions: normalize/parse"]
B --> C["Introduce explicit types: ParsedCommand"]
C --> D["Extract command routing: CommandRouter/Handler"]
D --> E["Split into packages: app.cli / domain / storage"]
E --> F["Replace dependencies: MutableList -> Repository"]
F --> G["Stabilize the command result format (next lecture)"]
Notice that at each step you can stop: the project still compiles and runs. That’s very calming — especially if you’ve already once gone through “rewrote everything, nothing works.”
9. Typical mistakes when refactoring in small steps
Mistake #1: “I refactored just a little” and accidentally changed behavior.
This is the most common story: you extracted a function, and at the same time “improved” a condition, and then it turns out the "add" command started accepting empty strings or, conversely, began rejecting what used to pass. The cure is boring but effective: during a refactoring step, don’t improve logic. Improvements are a separate step, when the structure is already clear.
Mistake #2: moving files across packages and simultaneously renaming everything.
When you move a file and rename classes, the IDE/compiler will complain “from all sides,” and you won’t understand what exactly went wrong: package? import? name? It’s better to do two small changes: first the move (get compilation), then the rename (get compilation). Yes, that’s two commits, but without detective work.
Mistake #3: the function is “extracted,” but the dependency stays hidden (it pulls an external var or global state).
Beginners often extract a fun addExpense() and then read/write an outer list that lives in main. Formally the code looks “cleaner across files,” but in reality the dependency remains implicit. A good sign of a high-quality step: the function receives the needed data as parameters and returns a result, rather than “crawling” through the outside world.
Mistake #4: trying to introduce interfaces everywhere at once, without a transitional layer.
Interfaces (Repository, Exporter) are useful, but if you abruptly replace “everything at once,” you’ll have to change constructors, signatures, object creation sites, and import ordering simultaneously. That’s the “big bang.” It’s much calmer to adapt through an intermediary layer: first add the repository and hook it up only in the service, then migrate the CLI, then remove the old direct dependencies.
Mistake #5: premature “perfect architecture.”
After the first successes, it’s very tempting to immediately do it “like in a big project”: 20 packages, 15 interfaces, factories, and one more abstraction layer “because that’s what grown-ups do.” The problem is that grown-ups do that when it’s actually needed. In a learning project and in an early v1, something else matters more: that the code is easy to read and extend, not that it looks like a diagram from a book.
Mistake #6: forgetting a small run check after each step.
When you’re engrossed in moving code around, it’s easy to make 10 changes in a row and only then hit Run. If it doesn’t start — you mentally roll back half an hour and start “remembering what you did.” It’s much cheaper (both time- and nerve-wise) to run more often: after each noticeable step.