Refactoring Legacy Code in Kotlin: Strategy and Practical Examples
Working with legacy code is an inevitable part of any developer's life. Often, such code is written in Java but migrated to Kotlin without deep rethinking, or it represents "spaghetti" of utility functions and nullable fields. Refactoring such code not only improves readability but also enhances application performance and safety. In this article, we will break down a step-by-step strategy for refactoring legacy code in Kotlin with real examples.
1. Preparation and Testing: The Safety Foundation
Before changing code, it is necessary to ensure its correct operation after the changes. Without tests, refactoring turns into guesswork.
- Write Characterization Tests: Run the existing code with different input data and record the output values. These tests will become your "safety net."
- Use the @Deprecated Annotation: If you cannot remove the old public API immediately, mark it as
@Deprecatedwith an indication of the alternative. - Work in Small Steps: Never refactor more than one logical block at a time. Each commit must pass all tests.
Example of a characterization test for an old function:
// Legacy codefun calculateTotal(items: List<Int>?): Int { if (items == null) return 0 var sum = 0 for (item in items) { sum += item } return sum}
// Test@Testfun `calculateTotal returns sum or zero`() { assertEquals(0, calculateTotal(null)) assertEquals(6, calculateTotal(listOf(1, 2, 3))) assertEquals(0, calculateTotal(emptyList()))}2. Eliminating Null Safety and Using Scope Functions
Legacy code often suffers from excessive null checks. Kotlin provides elegant tools for working with nullable types.
What we do: Replace nested if (x != null) with let, run, or also.
// Before refactoringfun processUser(user: User?) { if (user != null) { if (user.name != null) { println("User: ${user.name}") } }}
// After refactoringfun processUser(user: User?) { user?.let { u -> u.name?.let { name -> println("User: $name") } }}
// Or even shorter using runfun processUserShort(user: User?) { user?.run { name?.let { println("User: $it") } }}This approach makes the code more declarative and reduces the chance of missing a check.
3. Replacing Utility Classes with Extension Functions
In legacy Java projects, static utility classes (StringUtils, DateUtils) are often found. In Kotlin, it is better to replace them with extension functions.
Why this is important: Extension functions make the code more readable and allow the IDE to suggest them as methods of the class itself.
// Legacy Java-styleobject StringUtils { fun isPalindrome(input: String): Boolean { val cleaned = input.filter { it.isLetterOrDigit() }.lowercase() return cleaned == cleaned.reversed() }}
// Usageval result = StringUtils.isPalindrome("A man, a plan, a canal: Panama")
// Refactoring to Kotlinfun String.isPalindrome(): Boolean { val cleaned = this.filter { it.isLetterOrDigit() }.lowercase() return cleaned == cleaned.reversed()}
// Usageval result = "A man, a plan, a canal: Panama".isPalindrome()It is also worth paying attention to replacing java.util.Date with java.time (LocalDate, Instant) — this will improve the safety of working with time.