Harsh Mittal
Back to blog
2025-08-30·4 min read

Kotlin Best Practices Every Android Developer Must Master in 2025

AndroidKotlinAlso on Medium

Kotlin Best Practices Every Android Developer Must Master in 2025

The 2025 baseline for Kotlin on Android has moved past "avoid NullPointerExceptions" toward designing APIs that make the failure states explicit — a sealed class with Success/NotFound/Error variants communicates far more than a bare nullable return, and it means callers can't forget to handle a case the compiler already knows about. Coroutines follow the same principle: GlobalScope.launch is a liability because nothing ties its lifetime to anything, while viewModelScope/lifecycleScope plus withContext for the actual IO work keeps concurrency structured and cancellation automatic when the screen goes away.

The rest is mostly about reducing ambiguity at the call site — extension functions that read like part of the language instead of a static utility class, method names precise enough to explain themselves during a 3am incident, and Hilt as the default for dependency injection instead of hand-wired constructors. None of these are exotic; they're the difference between a codebase that stays maintainable under a growing team and one that quietly accumulates the kind of technical debt that shows up as slower release cycles.

Why Use Sealed Classes Over Nullable Returns?

A function returning User? tells the caller nothing about why it might be null — not found, network error, malformed response all collapse into the same signal, and the caller has to guess or dig into implementation details. A sealed class result type makes every failure mode explicit and exhaustive:

sealed class UserResult {
    data class Success(val user: User) : UserResult()
    object NotFound : UserResult()
    data class Error(val exception: Throwable) : UserResult()
}

suspend fun getUser(id: String): UserResult {
    return try {
        val user = api.fetchUser(id) ?: return UserResult.NotFound
        UserResult.Success(user)
    } catch (e: Exception) {
        UserResult.Error(e)
    }
}

// call site — the compiler forces handling every case
when (val result = getUser(id)) {
    is UserResult.Success -> showUser(result.user)
    is UserResult.NotFound -> showEmptyState()
    is UserResult.Error -> showError(result.exception)
}

If a new failure mode is added later, the when block above fails to compile until it's handled — the exact opposite of a nullable return, where a new failure mode silently falls into the same null bucket the caller already stopped thinking carefully about.

Structured Concurrency Over GlobalScope

GlobalScope.launch starts a coroutine with no lifecycle tied to anything — it keeps running even after the screen that started it is gone, which is both a memory leak and a source of "why did this callback fire after the user navigated away" bugs. Scoped coroutines fix both:

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    fun loadUser(id: String) {
        viewModelScope.launch {
            val result = withContext(Dispatchers.IO) {
                repository.getUser(id)
            }
            _userState.value = result
        }
    }
}

viewModelScope is automatically cancelled when the ViewModel is cleared; withContext(Dispatchers.IO) moves the actual network/disk work off the main thread without needing to manually manage a separate coroutine or worry about cancellation propagation — cancelling the parent scope cancels everything launched inside it.

Extension Functions That Read Like the Language

A static utility class (StringUtils.isValidEmail(str)) works, but an extension function reads at the call site the way a built-in method would:

fun String.isValidEmail(): Boolean =
    Patterns.EMAIL_ADDRESS.matcher(this).matches()

// call site
if (email.isValidEmail()) { ... }

The value compounds with domain-specific extensions too — Money.formatted(), Date.isToday() — each one removes a small amount of ceremony from call sites throughout the codebase, and collectively they make the code read closer to the actual business language of the app.

Precise Naming for the 3am Incident

process(), handle(), and doStuff() all compile fine and all communicate nothing to whoever's debugging a production incident at 3am with no memory of writing this code. retryFailedPaymentWithBackoff() is longer, but it's self-documenting in exactly the situation where documentation is least likely to have been read first — under time pressure, mid-incident, trying to understand what a function does from its name alone before diving into the implementation.

Hilt as the DI Baseline

Hand-wired dependency injection — passing a Repository through three constructor levels because a ViewModel two layers down needs it — becomes unmanageable as an app grows past a handful of screens. Hilt's compile-time-verified graph removes the manual wiring entirely:

@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository,
) : ViewModel()

The dependency just shows up, correctly scoped, without threading it through every intermediate layer manually — and because Hilt verifies the graph at compile time, a missing binding is a build error instead of a runtime crash discovered by a user.

What Actually Separates Maintainable From Not

None of these five practices are individually exotic or hard to learn — sealed classes, structured concurrency, extension functions, precise naming, and Hilt are all well-documented, mainstream Kotlin/Android idioms. What separates a codebase that stays maintainable as a team grows from one that quietly accumulates debt is whether these are applied consistently as defaults, or treated as "nice to have when there's time" — the latter is how the debt actually accumulates, one skipped sealed class and one GlobalScope.launch at a time.

Get new posts by email

No spam, no schedule — just an email when a new post goes up.