Introduction: Why Refactoring Legacy Code in Kotlin is Not Just Rewriting
Working with legacy code in Kotlin is not just a mechanical replacement of Java constructs with the language's syntax. It is a strategic process aimed at improving code readability, safety, and performance without changing its external behavior. In this article, we will look at advanced refactoring techniques that will help you turn a chaotic "zoo" of classes into a well-structured, maintainable system. We will not touch on basic things like var vs val — we will focus on what truly changes the architecture.
1. Migration from Java: Replacing Getters, Setters, and Utility Classes
The most common problem with legacy code is the abundance of Java classes with explicit getters and setters, as well as static utility classes. Kotlin offers elegant solutions.
1.1. Property-based Access Instead of Java Beans
Instead of writing user.getName() and user.setName("Alice"), use Kotlin properties. This is not just syntactic sugar: you get a unified access interface that can be overridden.
// Java legacypublic class User { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; }}
// Kotlin refactoreddata class User(val name: String) // Or with var if mutability is neededIf the getter/setter logic is more complex (e.g., validation), use custom accessors:
class User { var name: String = "" set(value) { field = value.trim() // Automatic trimming of whitespace }}1.2. Extension Functions Instead of Static Utilities
Legacy code often contains classes like StringUtils or FileUtils. In Kotlin, they are replaced by extension functions. This makes the code more readable and closer to the "object method" style.
// Java: StringUtils.isNullOrEmpty(str)// Kotlin: str.isNullOrEmpty() (already in stdlib)
// Example of a custom extensionfun String.isValidEmail(): Boolean { return this.contains("@") && this.contains(".")}
// Usageval email = "user@example.com"if (email.isValidEmail()) { ... }2. Working with Null Safety: Replacing Optional and Null Checks
Legacy Java code often suffers from NullPointerException. Kotlin solves this problem at the type level, but during refactoring, you need to carefully replace Optional and chains of null checks.
2.1. Replacing Optional with Nullable Types
Instead of Optional<String>, use String?. This reduces the overhead of creating an object and makes the code more concise.
// Javapublic Optional<String> findName() { ... }
// Kotlinfun findName(): String? { ... }For call chains, use the ?. operator and the Elvis operator ?::
// Instead of if (user != null && user.address != null) ...val city = user?.address?.city ?: "Unknown"2.2. Using require() and check() for Invariants
Instead of manual checks at the beginning of functions, use built-in functions:
fun processUser(user: User?) { requireNotNull(user) { "User must not be null" } // or require(user != null) { ... } // Further work with user (smart cast)}3. Refactoring Conditional Logic: Sealed Classes and when
Huge blocks of if-else or switch are a sign of legacy code. In Kotlin, they can be replaced with sealed class and exhaustive when.
3.1. Replacing Enum with Fields with Sealed Class
If you have an enum that stores different data types for each value