Code smells, bad code patterns, and poorly thought-out code all refer to the same thing — a piece of code that works now but may generate problems for you in the future. These future issues can arise because the code is difficult to understand, modify, or test.
As a Kotlin developer, it’s important to identify and address these issues to ensure your code is maintainable. In this article, we’ll dive into the five most common code smells in Kotlin and provide practical solutions for fixing them.
Jump ahead:
Code smells exist in all programming languages, and while there’s no definitive definition, code smells do share some general characteristics:
Code smells are not good for code bases and should be avoided whenever possible. I know this sounds obvious – like, who would intentionally write code that’s difficult to maintain?
Dealing with code smells is something even the most experienced developers do — the difference is that they’re good at identifying these code smells and fixing them!
The first step to identifying code smells is to understand them. For example, it’s very difficult to remove a “primitive obsession” code smell if you don’t know anything about that.
Be sure to identify all code smells, even if you don’t have time to fix them immediately. All projects have code smells, but most developers are unaware of many of them. Identifying a code smell as soon as you spot it, either by leaving a to-do comment or by adding it to a list, is a great start for fixing it later.
To save precious development time, there are several automated tools that are handy for identifying code smells. For example, Detekt is a popular, open source and highly customizable library that identifies Kotlin code smells.
N.B., it’s important to remember that automated tools can’t always understand context, so in the end it’s up to you to decide if something really is a code smell or if it can be ignored
The best way to address or eliminate a code smell varies according to the type of code smell you’ve uncovered. However, there are a few things you should not do — regardless of the type of code smell you’re dealing with. Let’s take a look.
A common mistake people make is trying to add functionality and refactor at the same time, or trying to refactor and change something else simultaneously.
If you’re implementing a new feature and see the possibility of refactoring a piece of code, finish implementing the functionality first and then refactor. This ensures that you’re focused on just one task at a time and reduces complexity. If you’re working on a complex problem and try to refactor some code at the same time, that’s only going to make things harder.
We refactor code to make it easier to read, maintain, and so on. However, that’s not very useful if every time we refactor some piece of code, something breaks. That’s why it’s highly recommended that you first make sure there are tests covering the piece of code you wish to change.
If there are no tests covering something you want to refactor, first write tests for that piece of code, and then refactor. Not only have you improved the code by refactoring it, but you also added some tests; that’s very useful.
And finally, all projects have code smells. Prioritize the code smells that are most important, and deal with those.
Now, let’s take a look at five common code smells (duplicate code, long method/class, tight coupling, primitive obsession, and magic numbers) and review solutions to address them.
I’d like to start by stating that not all code duplication is a code smell. You don’t need to go around refactoring your application every time you copy an existing code block.
The main problem with duplicate code is that it increases maintenance costs. If you need to change some piece of code that has a bug and it is duplicated all over the project then that’s going to be more difficult.
Fortunately, automated tools like Detekt are very good at spotting duplicate code, so most of the time you won’t need to handle this manually.
The way to fix this code smell is by refactoring the common code into a single function or module that can be reused in multiple places.
Let’s imagine you need to calculate the sale price for some items and by default you have a 10% margin:
val bookSalePrice = book.price * (1 + 0.1) val penSalePrice = pen.price * (1 + 0.1)
The problem with the code above is that you need to repeat (1 + 0.1)
multiple times — that’s code duplication. To solve that, extract the common piece of code into a new function:
fun calculateSalePrice(itemPrice: Double, margin: Double): Double { return itemPrice * (1 + margin) }
Now, call your new function instead:
val margin = 0.1 // 10% margin calculateSalePrice(book.price, margin) calculateSalePrice(pen.price, margin)
As a rule of thumb, one duplication is okay, two is suspicious, and three means there’s a common abstraction that you need to extract.
Unlike duplicate code, which is sometimes valid, a long method or class is always a code smell.
Long methods and classes do too much, thereby increasing the complexity of a project and reducing its code quality. They either indicate that your code is poorly organized or that it has too many responsibilities.
If you’re using automated tools, you can usually define a max line count for functions and classes. That way, you’ll get an alert if your functions or classes get too long. If that’s the case, you can fix it by extracting the existing code into smaller functions or by creating smaller classes that have fewer responsibilities.
Using the same example as above, imagine the margin changes based on how many items you have in inventory:
fun calculateSalePrice(itemPrice: Double, quantityInStock: Int, margin: Double): Double { return if (quantityInStock < 10) { if (itemPrice <= 10.0) { val newMargin = margin * 2 // 2x the margin for cheap items calculateSalePrice(itemPrice, newMargin) } else if (itemPrice > 500.0) { val newMargin = margin * 1.5 // 1.5x the margin for expensive calculateSalePrice(itemPrice, newMargin) } else { val newMargin = margin * 1.7 // 1.7x the margin in between items calculateSalePrice(itemPrice, newMargin) } } else if (quantityInStock < 500) { val newMargin = margin * 1.3 // 1.3x the margin calculateSalePrice(itemPrice, newMargin) } else { calculateSalePrice(itemPrice, margin) } }
This function has 18 lines; we can make it easier to read by breaking it down into smaller functions, like so:
fun calculateSalePrice(itemPrice: Double, quantityInStock: Int, margin: Double): Double { return if (quantityInStock < 10) { calculateSalePriceForLowStockItems(itemPrice, margin) } else if (quantityInStock < 500) { calculateSalePriceForMediumStockItems(margin, itemPrice) } else { calculateSalePriceForHighStockItems(itemPrice, margin) } }
By refactoring the code into smaller functions we were able to reduce the code from 18 lines to just eight.
There’s no way to write software without coupling — that would be like writing code that doesn’t depend on other things! Some level of coupling is needed, but if we have too much it becomes “tight coupling” and consequently a code smell.
Tight coupling is when two or more classes have a direct, hard-coded dependency on one another. This can make it difficult to change or modify one class without affecting the other(s).
Automated tools are unlikely to help with this code smell, because as I mentioned earlier, code always has some coupling. And, unfortunately, it’s not always easy to define how much coupling is “too much”.
If you’re hard coding your classes, one simple thing you can do to reduce tight coupling is to use dependency injection. Instead of initiating another class manually, get it injected through the constructor.
Let’s look at an example of tight coupling.
Imagine you have a Car
class that has an Engine
. You’re manually instantiating the Engine
class:
class Engine { ... } class Car { private val engine = Engine() }
Now, suppose your Engine
class requires a new parameter in the constructor:
class Engine constructor(private val coolingSystem: CoolingSystem) { ... } class Car { private val engine = Engine(CoolingSystem.AIR) }
You changed the Engine
class, but because the Car
class is tightly coupled to it, you have to change that as well. One way to fix that is by using dependency injection:
class Car constructor(private val engine: Engine) { ... }
Now if you change the Engine
constructor, you don’t need to change your Car
class because they’re not tightly coupled anymore.
Primitive obsession is the excessive use of primitive data types, such as integers, strings, or Booleans, to represent domain-specific concepts.
It’s important to point out that this code smell is not called “using primitives”, instead it’s called “primitive obsession.” Most of the time, using primitives is fine; this becomes a code smell when everything in your code is primitive.
Primitive obsession results in decreased code readability. When primitives are used to represent domain-specific concepts, it can be difficult to understand the intent of the code.
Automated tools are not likely to be of much use in identifying this code smell. There’s simply no way to define rules for identifying this code smell because it varies from project to project.
To resolve primitive obsession, it is best to create custom data types, also known as value objects, that represent domain-specific concepts. These value objects can have specific behavior and validation rules, making it easier to understand and manipulate the data.
Imagine there’s an app that alerts the user every day at a specific time. The code might look like this:
val hourMinute = "11:30" // This would probably come from user input val hour = hourMinute.split(":")[0].toInt() val minute = hourMinute.split(":")[1].toInt() val nextAlertTime = Date().time + (hour * 1000 * 60 * 60) + (minute * 1000 * 60)
This code works, but the following might be a better way to do this:
data class HourMinute(val hours: Int, val minutes: Int) { init { require(hours >= 0) { "hour can't be negative" } require(minutes >= 0) { "minute can't be negative" } } val millis = (hours * 60 * 60 * 1000) + (minutes * 60 * 1000) } fun Date.plus(hourMinute: HourMinute) = time + hourMinute.millis val hourMinute = HourMinute(hours = 11, minutes = 30) val nextAlertTime = Date().plus(hourMinute)
First of all, this code removes the possibility of having invalid dates; if you used a string it’s possible that you’d get values like “”, “:”, “aa:aa”, and so on.
It’s also easier to understand. In the first example, is “11:30”, 11 minutes and 30 seconds? Is it some kind of coordinate, like x=11, y=30? It’s not clear because it’s just a string and that can represent many things.
Magic numbers refers to the use of hard-coded, literal values in code that have no descriptive meaning. These values are called “magic” because they have no clear explanation or context, making it difficult to understand the purpose of the code that uses them.
Magic numbers can also make it more difficult to change the code. Imagine you have two places where the number “15” is used. How do you know if you need to replace both of them?
Automated tools are really good at detecting magic numbers. You can easily rely on them for this purpose.
To resolve the issue of magic numbers, it’s best to replace them with named constants or enumerated values that have descriptive names that explain their purpose. This makes the code easier to understand and maintain, as the values have a clear meaning and context.
Let’s use the same example from the last code smell. If you take close look, you can see that we have some magic numbers like 1000
and 60
:
private const val ONE_SECOND_MILLIS = 1000 private const val ONE_MINUTE_MILLIS = 60 * ONE_SECOND_MILLIS private const val ONE_HOUR_MILLIS = 60 * ONE_MINUTE_MILLIS class HourMinute(val hours: Int, val minutes: Int) { ... val millis = hours * ONE_HOUR_MILLIS + minutes * ONE_MINUTE_MILLIS }
We can address this code smell by replacing the numbers with constants that define what they are. This makes it much easier for developers to understand what’s happening.
To further improve your knowledge of code smells, I recommend the following books:
There are many other books on this topic, but these two are a good start.
Maintainable code isn’t the norm, it requires effort to write. But being able to produce maintainable code is an essential skill that all developers should master.
In this article, we discussed how to spot code smells using both manual and automated tools. We also investigated how to remove five of the most common code smells. I hope these tips will help you become a better Kotlin developer.
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.