Photo by Glenn Carstens-Peters on Unsplash

My Top 10 Clean Code Tips for Kotlin Mobile in 2024

--

Which you can apply immediately to your code.

In recent years, ‘clean code’ has become both a mark of good programming practice and a popular buzzword at the same time. While there are numerous guidelines and well-named principles out there, they sometimes contradict each other. For instance, following the SOLID principles can lead to highly testable code, but overly strict use of them might result in your code being fragmented into too many small classes, making it difficult to read, extend, and maintain.

I have a long story of application development, journeying from Sinclair Basic through Pascal, Delphi and Erlang to Objective-C and Java for mobile applications, and now Kotlin and Swift. Throughout my career, I’ve adopted several techniques in my daily work which you can start using today to enhance your code quality.

Above all these principles and tips, there’s one crucial thing to remember:

The only truly existent app is the one that gets released.

This means if you’ve developed a perfectly clean code but haven’t released it, then all your effort is for nought.

Also, efficiency of the code is more important than its cleanness in some cases.

Bearing this in mind, keep developing and releasing your apps. Continuously review, refactor, and improve your code. The following 10 tips are designed to help you not only release your app but to do so without getting trapped in endless polishing.

1. Organize project files

There are several widely discussed code architectures that impact the file structure of a project. One of the most commonly recognized is Clean Architecture. However, this doesn’t mean that other architectures like Hexagonal or Onion, or your own are bad. Existing architectures solve common problems that most developers encounter, but the choice of architecture depends on what suits your development needs and what your team can effectively support.

What do you need to pay attention to is consistency in naming a do not be afraid of long file names.

Adopting a consistent naming pattern is essential. Names should offer immediate insight into the content and purpose of a file. For instance:

  • NetworkManager, NetworkHelper for network-related operations.
  • MainScreen, OtherScreen for Composable screens (or MainActivity, MainFragment for old-style).
  • MainViewModel, MenuViewModel for ViewModel classes.

Such naming conventions make it easy to trace the relationship between different components, such as how MainActivity, MainScreen, and MainViewModel are interconnected. Even if they are placed in different packages.

Effective Package Structure

Once again, you may choose between existing architectures but always remember that none of them is law.

In my projects I used structure which just easier for me to navigate, the UI package is organized hierarchically is this way:

w

This structure, where related components are grouped together, facilitates quick navigation and a better understanding of the codebase.

2. One, Two… Refactor!

In other words, the rule of three. This rule will help you understand when it is time to refactor the code.

  1. When you code something for the first time, just do it.
  2. Second Time you repeat it, but stay alert.
  3. Third Time? Stop and refactor.

This rule helps strike a balance between too early abstraction, which can lead to overly complex architecture, and excessive code duplication, making the project difficult to maintain. This approach helps develop maintainable code while avoiding the pitfalls of over-engineering or under-engineering your solutions.

3. Avoid Deep Nesting: Confronting the Arrowhead Anti-Pattern

Deep nesting, where multiple layers of control structures like loops and conditionals are embedded within each other, can significantly reduce the readability and clarity of code. It makes understanding and maintaining the code more challenging.

The example demonstrates the complexity caused by deep nesting:

fun getUserRole(userInput: UserInput): String? {
if (userInput.login.isNotEmpty()) {
if (userInput.password.isNotEmpty()) {
if (isUserExist(userInput.login)) {
if (isPasswordValid(userInput.password)) {
return userRole(userInput.login)
} else {
return UserRole.Unknown
}
}
return UserRole.Unknown
}
return UserRole.Unknown
}
return UserRole.Unknown
}

This code structure requires careful navigation through each nested layer, making it cumbersome to understand and modify. It also increases the chance of bugs related to exit logic and returning values.

Refactoring the code with early exits simplifies its structure and enhances readability:

fun getUserRole(userInput: UserInput): String? {
if (userInput.login.isEmpty()) return UserRole.Unknown
if (userInput.password.isEmpty()) return UserRole.Unknown
if (!isUserExist(userInput.login)) return UserRole.NotExists
if (!isPasswordValid(userInput.password)) return UserRole.Unauthorized
return userRole(userInput.login)
}

This refactored version eliminates unnecessary complexity, making the code more straightforward and easier to follow (not the most beauty code which needs optimisation but now it is clearly visible).

Alternatively, we can use Kotlin’s when statement:

fun getUserRole(userInput: UserInput): String? {
return when {
userInput.login.isEmpty() -> UserRole.Unknown
userInput.password.isEmpty() -> UserRole.Unknown
!isUserExist(userInput.login) -> UserRole.NotExists
!isPasswordValid(userInput.password) -> UserRole.Unauthorized
else -> userRole(userInput.login)
}
}

By avoiding deep nesting and employing strategies like early exits and the when statement, the code becomes more accessible, maintainable, and easier to work with. This approach provides a streamlined structure, enhancing the overall readability of the code. It can also help detect which code needs refactoring.

4. Document your code

It’s a controversial point. Many people say “Code is like humor. When you have to explain it, it’s bad”, and they start thinking that commenting, in general, is a bad thing.

However, if you want to provide maintainable code through the years, you should document it. However, not each line deserves it :)

When to Write Comments

  • Documenting methods and properties if their name is not self-explanatory. Explaining complex algorithms or intricate logic.
  • Integration points with 3rd party libraries. It does not mean that you need to document Retrofit; however, if you write interceptor, it’s good to give it an explanatory name and add a brief note of what it does and when it needs to be added.
    Sometimes, code may include non-standard solutions or workarounds for specific issues. Adding a link to the task in the issue tracker or bug report could be a good idea.
    - A brief comment explaining the purpose or intention behind a code block can be good, especially in larger or more complex functions.

You may say, “Wait, we are talking about clean code. It’s not intentional that functions could be complex or hard to understand”.

Clean code is essential, but it’s not an absolute solution to code clarity, especially in elaborate systems like data encryption or custom hardware integration. In these cases, even well-written code can be dense and not entirely self-explanatory. Striking a balance is key — use comments to complement your code, providing insights where the code alone might not suffice. It’s about ensuring that the code is as readable as possible but also acknowledging when comments are necessary to illuminate complex sections.

For instance:

data class HardwareConnection(
val ip: String,
val port: Int
) {
/**
* Send command a close connection. If the connection not closed,
* it will block the device for 60 seconds after the last
* command is sent. This block happens on the device side.
*/
fun sendCommand(command: String) {
val connection = HardwareSDK.connect(ip, port)
connection.sendCommand(command)
connection.close()
}
}

However, over-commenting, especially with obvious statements, can clutter the code and detract from its readability. The goal is to avoid stating the obvious — for example, there’s no need to comment on standard library calls or very basic operations. Comments should add value and not just repeat what is already clear from well-named functions and variables. It’s about finding that sweet spot where each comment serves a clear purpose without overwhelming the code.

Do not do this

// Define a variable to store the user's age
var userAge: Int = 25

or this

class UserManager(private val database: Database) {
fun createUser(user: User) {
// Save the user to the database
database.save(user)
}

fun deleteUser(userId: String) {
// Delete the user from the database
database.delete(userId)
}
}

5. Limit Global State and Singletons

When developing apps, we often need to manage values or instances that exist throughout the app’s lifecycle.

Using a singleton to manage this data may seem simple. However, clear code isn’t just about being easy for humans to understand. It should also be straightforward for the build system and for testing.

The global state can cause several problems:

Unpredictable Behavior: For instance, if you store a context object or runtime permissions, their values can change over time. The global state doesn’t ensure it stays current.

Complicated Testing: Mocking complex states in global variables is difficult, and tests might interfere with each other. This can lead to unreliable or incorrect test results.

Difficult Traceability: It’s hard to track when and how the global state changes, which can complicate debugging.

Concurrent Access Issues: This can cause crashes that are tough to debug and fix.

Recreation/Reinitialisation Issues: If Android decides to stop and restart the app, there’s no guarantee that all variables in the global state will be properly initialized. This can lead to Null Pointer Exceptions, even with non-nullable fields in Kotlin, making debugging quite challenging.

Rather than using singletons or global objects, consider passing class instances between components of your app or using helper/service classes that don’t store data themselves but retrieve it from specific sources. Dependency injection frameworks like Koin or Hilt can simplify object management and creation.

Sometimes, avoiding singletons entirely isn’t feasible. Every time you have a need to create such a thing analyze your data, and ensure that you really need a global state or singleton. For example:

  • If the data is only needed within specific activities, fragments, screens, or view models lifecycle, it should be tied to that lifecycle.
  • If you need to keep the app state during restarts, consider using a combination of Helper/Service and shared preferences or a database.
  • If you have a background process fetching new data from the backend periodically and it needs to be passed into UI, this data can be stored in a database and connected to a cold flow or hot flow, emitting updates as they arrive. The same trick works for application-wide events.

6. Avoid complex oneliners

They do not save space, just make code really hard to understand. For instance

val result = someList.filter { it.hasChildren }.map { it.children }.takeIf { it.size() > 2 } ?: listOf(0)

Breaking down the operations into separate steps makes the code more readable.

val result = someColdFlowOrList
.filter { it.hasChildren }
.map { it.children }
.takeIf { it.size() > 2 }

7. Use names for values instead of `it`

In many cases you can skip names, however, when you have several layers one inside the other, it’s better to give names to closure parameters.

data class User(
val name: String,
val address: Address? = null,
val age: Int? = null
)

data class Address(val city: String)

val user = User("Alex", Address("Stockholm"))

user.let {
it.address?.let {
println("City: ${it.city}")
}
}

It will be easier to read with names

user.let { user ->
user.address?.let { address ->
println("City: ${address.city}")
}
}

Yes, Android Studio helps you identify such simple cases. However, it will not say a word in case of this use.

user.let {
println("Name: ${it.name}")
it.address?.let {
println("City: ${it.city}")
}
it.age?.let {
println("Age: ${it}")
}
}

Compare it with

user.let { user ->
println("Name: ${user.name}")
user.address?.let { userAddress ->
println("City: ${userAddress.city}")
}
user.age?.let { userAge ->
println("Age: $userAge")
}
}

8. Avoid hack-like or genius code

For example, I wrote this in one of my projects. It’s pretty straightforward for me but my colleagues find it hard to understand. What happens with filters here? Is there any chance that it will be concurrently modified?

filters
.indexOfFirst { it.id == id }
.takeIf { it != -1 }
?.let { index ->
filters[index] = filters[index].copy(
isActive = !filters[index].isActive
)
}

It would be easier to read with less chaining

val index = filters.indexOfFirst { it.id == id }
if (index != -1) {
filters[index] = filters[index].copy(isActive = !filters[index].isActive)
}

You see there is no question if it could be modified concurrently or not.

9. Avoid IfNeeded and Maybe functions

These types of functions encapsulate conditional logic within them to cause some side-effects.

Usually, they show up in complex scenarios where data consistency depends on many asynchronous data streams, and you have to encapsulate some logic to avoid code duplication or simplify logic branches. On the other hand, they make code less predictive and can also introduce unneeded dependencies.

For instance:

class User(
val name: String,
val email: String,
val age: Int,
val emailMarketingEnabled: Boolean
)

class UserManager {
private var user: User = User.empty()
private val parentalControlManager = ParentalControlManager()
private val marketingManager = MarketingManager()
private val database = Database()

fun updateUserProfileIfNeeded() {
if (user.age < 18) {
parentalControlManager.checkPermissions(user) {
updateUser(user.copy(emailMarketingEnabled = true))
}
} else {
marketingManager.sendPromotionalEmail(user)
updateUser(user.copy(emailMarketingEnabled = true))
}
}

fun updateUser(user: User) {
database.update(user)
}
}

class MainViewModel(private val userManager: UserManager) : ViewModel() {
// ...
fun onActivatePromotionEmails() {
userManager.updateUserProfileIfNeeded()
}
}

In this example, a classical logic trap. The app needs to update the user but only in specific cases. Here, this case is about underage. If a user is under 18 then the app needs to check their permissions. We may assume also that there is a successfull callback when we can update the user.

I saw this many times. Something between composition and mediator patterns. Everything is placed in one spot and it looks convenient. However, such code introduces many issues:

  • The updateUserProfileIfNeeded method is responsible for too many things: checking the user's age, handling parental control checks, updating user profiles, and interacting with the marketing manager.
  • The method performs different actions based on the user’s age, which is not immediately apparent from its name or signature. This hidden logic makes the method less predictable and more difficult to understand.
  • The user object is being modified as a side effect of the age check. Side effects like this can lead to bugs and make the code harder to maintain.
  • The updateUserProfileIfNeeded method depends on the user object's current state. It’s an example of a global or shared state, which can lead to unpredictable behavior, especially in a multi-threaded environment, making the system more error-prone.
  • The method is tightly coupled with ParentalControlManager and MarketingManager. This tight coupling makes the UserManager class harder to test and maintain.
  • The updateUserProfileIfNeeded method's name doesn't clearly communicate its actual functionality.

It’s not possible to fix with small actions like breaking down the function to smaller functions or passing managers as parameters. This class need to be refactored completely to contain only update functionality. Permissions check and marketing actions should be moved outside of UserManager.

class ParentalControlManager {
fun canUpdateProfile(user: User): Boolean {
return user.age < 18 && hasPermissions(user)
}
}

class UserManager {
private var user: User = User.empty()
private val database = Database()

fun updateUser(user: User) {
database.update(user)
}

fun activatePromotionEmails(user: User) {
updateUser(
user.copy(emailMarketingEnabled = true)
)
}
}

class MainViewModel(
private val parentalControlManager : ParentalControlManager,
private val marketingManager : MarketingManager,
private val userManager : UserManager
) : ViewModel() {

// ...
fun onActivatePromotionEmails() {
if (parentalControlManager.canUpdateProfile(user)) {
userManager.activePromotionEmails(user)
marketingManager.sendPromotionalEmail(user)
}
}
}

So, what happened here:

  • The logic related to parental control checks is now encapsulated within the ParentalControlManager.
  • UserManager is now focused solely on user-related operations like updating the user and activating promotion emails, making it more consistent.
  • By moving the parental control and marketing logic out of UserManager, the code has reduced coupling. UserManager no longer directly depends on the behavior of ParentalControlManager and MarketingManager.
  • The method names like canUpdateProfile and activatePromotionEmails are more descriptive and accurately reflect their functionality. This enhances readability and maintainability.
  • The MainViewModel now explicitly checks if the profile can be updated based on the result of parentalControlManager.canUpdateProfile(user). This makes the flow of logic clearer and more predictable.
  • The new structure avoids modifying the user’s state as a side effect within the UserManager. Instead, changes to the user are made explicitly and transparently.
  • ParentalControlManager, MarketingManager, and UserManager do not depend on each other anymore and all of them are passed to MainViewModel. This is good practice to improve testing and allows to use of dependency injection in a clear way instead of distributed instances initialisation or use of global state.

10. Sleep well

The primary tip for better and clearer code is to have a rest and know yourself. If you do not rest enough, you may easily lose track of complex code solutions or diminish your desire to improve your work. Watch your work-life balance, aim for 7–8 hours of sleep a day, and engage in activities that give your mind a break, like reading a good book (not from a screen). Remember, your mental health is as important as your technical skills. A well-rested and healthy developer is more likely to write efficient, error-free, and maintainable code.

--

--

First I have fika, then I write apps.

I'm Alex, a mobile apps developer from Sweden, Stockholm. This blog is about my side projects and the cool things I learned.