I recently joined a new company to continue my career in Android development and leadership. Part of that is seeing how people code, what the team has found valuable in the past for best practices, and sprinkling some of my own ideas in as time goes on.
I thought I’d share some of my thoughts here so that others can see these and consider them as well.
Kotlin Best Practices
Only use destructuring on positional classes
Destructuring can be a really clean way to unpack data, but it should be reserved for data types where the order of fields makes intuitive sense—namely, positional classes
like Pair
, Triple
, or when splitting a string, e.g.
val input = "userId=42"
val (key, value) = input.split("=")
When you destructure something like val (a, b, c) = someData
, it’s no longer obvious what a
, b
, or c
actually represent without jumping back to the class definition.
This hurts readability and can lead to subtle bugs if fields are ever reordered or misunderstood.
Instead, prefer using named accessors (someData.foo
) unless destructuring truly enhances clarity and is immediately obvious from context.
Structure state in a way that models the data available
In modern Android architecture (especially with MVI), it’s critical to design your UI state in a way that matches what the user can actually see or experience at a given moment. This usually means using a sealed class to represent the different “modes” of the screen:
sealed class UiState {
object Loading : UiState()
data class Error(val reason: String) : UiState()
data class Success(val data: MyData) : UiState()
}
This approach helps prevent invalid combinations of state (like showing both a spinner and an error) and makes it clear in the UI layer how to render each state. Each subclass should fully represent a single, valid UI configuration.
Use object
/data object
, not class
…
You’ll often see people reach for class
when they really mean object
, especially for singleton values. There’s a widely shared article that argues for using object
on the basis of memory efficiency — but that’s largely a red herring.
In practice, the memory savings are negligible. The real value in using object
or data object
(as of Kotlin 1.9) is clarity of intent: if something doesn’t carry instance-specific data and doesn’t need to be instantiated multiple times, it shouldn’t be a class.
This makes your code easier to reason about. Using object
reduces boilerplate and makes exhaustive when
statements cleaner and more reliable, since there’s no ambiguity around instantiation or equality.
It’s not about saving memory — it’s about making your code harder to misuse and easier to understand.
Keep extension functions focused and contextual
Extension functions are great for adding functionality to existing types, but they can become a dumping ground if not scoped appropriately. Prefer keeping them close to where they’re used (e.g. in the same file or module) and naming them in a way that reflects their context.
Good:
fun List<Event>.upcomingOnly(): List<Event> = filter { it.date > now }
Bad:
fun List<Any>.filterValid(): List<Any> {
// Who knows what "valid" means here?
return filterNotNull()
}
In this bad example, the extension is:
- Too generic (
List<Any>
) - Vaguely named (
filterValid()
) - Unclear in behavior — the reader has to inspect the implementation to understand what “valid” actually means.
Avoid vague, overly generic names like filterValid()
or toCleaned()
, especially if it’s not obvious what “valid” or “clean” means in that scope.
Avoid nullable types unless truly necessary
Kotlin gives you powerful tools to model nullability explicitly — use them thoughtfully. If a value should always exist, don’t make it nullable “just in case.”
Instead of this:
var name: String? = null
…consider this:
lateinit var name: String
Or better yet, design your class so that name can be injected or passed in directly and be non-null from the start.
Make nullability meaningful—not a default.
Avoid else
in exhaustive when
statements
When using when
with enums, sealed classes, or sealed interfaces, avoid adding a catch-all else
branch if you can enumerate all possible cases. Relying on else
makes it much harder to find places in your code that need to be updated when new values are added to an enum, sealed class, or interface. Without else
, the compiler will fail on unhandled cases, making your code safer and easier to maintain.
For example:
when (state) {
is UiState.Loading -> showLoading()
is UiState.Error -> showError(state.reason)
is UiState.Success -> showData(state.data)
// don't use `else` here so we can easily find cases we need to handle if we add a new UiState
is UiState.Unauthenticated,
is UiState.NetworkFailure -> showRetry()
}
If you add a new subclass to UiState
, the compiler will alert you to update all relevant when
statements. This helps prevent subtle bugs and ensures your code stays robust as your models evolve.
Choosing between let
, apply
, run
, also
, and with
Kotlin provides several scope functions—let
, apply
, run
, also
, and with
—each with its own use case. Choosing the right one improves code clarity and intent:
let
: Use when you want to execute a block with the object asit
and return the block’s result. Common for null checks and chaining operations.val result = value?.let { doSomethingWith(it) }
apply
: Use when you want to configure an object and return the object itself. The receiver isthis
.val paint = Paint().apply { color = Color.RED; strokeWidth = 2f }
run
: Use when you want to execute a block with the object asthis
and return the block’s result. Good for combining object configuration and computation.val length = myString.run { trim().length }
also
: Use for performing additional actions (like logging or debugging) with the object asit
, returning the object itself.val list = mutableListOf("a").also { println("List created: $it") }
with
: Use when you want to operate on a non-null object without returning a result, usingthis
as the receiver.with(myView) { alpha = 0.5f visibility = View.VISIBLE }
For more details and examples, see the official Kotlin documentation: Scope Functions