Introduction: Why "Clean Code" Is Not About Aesthetics
When you first start writing in Kotlin, it seems like the language itself already solves half the problems. No boilerplate like in Java. There are `data class`, lambdas, coroutines. But if you just throw code together "as it comes," in a month you won't be able to understand it yourself. And if a team is working on the project, your colleagues will hate you (or you'll hate them).
Clean code in Kotlin is not about tabs versus spaces. It's about reducing cognitive load, making logic transparent, and avoiding bugs that hide in "convenient" constructs. In this article, I'll show you specific techniques: from working with nullable to using scope functions and sealed classes. No fluff, just practice.
1. Null Safety: How Not to Proliferate "?" and "!!"
Kotlin prides itself on its type system, but many write as if it's Java with "syntactic sugar." The main antipattern is chains of `?.let` and panicked `!!`.
Bad (readability suffers):
user?.address?.city?.let { city -> println(city)} ?: run { println("No city")}Good (elegant and clean):
val city = user?.address?.cityprintln(city ?: "No city")Use `!!` only if you are 100% sure the value is not null (for example, in tests or after a check in `if`). In production, it's a grenade. If you need to convert a nullable to non-null and handle both cases, use when or the Elvis operator.
2. Scope Functions: `apply`, `run`, `with`, `let`, `also` — When to Use What?
These are powerful tools, but they are often overused. The golden rule: do not nest scope functions inside each other. If you have `list.apply { ... }.let { ... }`, you are writing spaghetti code.
Selection rules:
apply— for configuring an object (returns the object itself). Ideal for builders.let— for performing an action with a non-null value (often paired with `?.`).run— when you need to compute a value and return the result.with— for grouping calls on the same object without returning anything.also— for side effects (logging, validation).
Clean example:
data class User(var name: String, var age: Int)
fun createUser(): User = User("Unknown", 0).apply { name = "Alice" age = 30}Don't write user?.let { it.name } — it's redundant. Just use user?.name.
3. Extension Functions: Don't Proliferate Utility Classes
In Java, you would write StringUtils.capitalize(str). In Kotlin — str.capitalize(). Extension functions are a way to "add to" someone else's class without inheriting from it.
Example of a useful extension:
fun String.isValidEmail(): Boolean { return this.contains("@") && this.contains(".")}But don't overdo it. If an extension function is used in only one place, it should probably be private. And don't create extensions on `Any` — it breaks the type system.
4. Sealed Classes and When: Your Main Tool Instead of if-else
When you have a finite set of states (for example, the result of an API request), use sealed class. This guarantees that you have handled all the options.
Correct approach:
sealed class NetworkResult<out T> { data class Success<T>(val data: T) : NetworkResult<T>() data class Error(val message: String, val code: Int) : NetworkResult<Nothing>() object Loading : NetworkResult<Nothing>()}
fun handle(result: NetworkResult<String>) { when (result) { is NetworkResult.Success -> println("Data: ${result.data}") is NetworkResult.Error -> println("Error: ${resu