Best Practices
Across 63 lessons we've built up TrailLog piece by piece — from a plain Kotlin Trail
data class to a fully layered app with coroutines, Room, Retrofit, Hilt, Firebase, tests, and a
signed release build ready for the Play Store. This final lesson doesn't introduce anything new.
Instead, it pulls together the habits worth carrying into every Kotlin Android project you write
from here on — ten checklist-style practices, each with a short reminder of the "why" and a small
TrailLog example.
1. Prefer val Over var Everywhere Genuinely Possible
An immutable reference cannot be reassigned by some other piece of code halfway through a function,
which means one entire category of bugs — "who changed this and when" — simply cannot happen. Default
to val; reach for var only when a value genuinely needs to change over time,
like a running total or a mutable UI state field.
// Prefer this
val trail = Trail(
id = 1,
name = "Ridge Loop",
notes = "Clear skies, steady climb",
photoUri = null,
latitude = 37.7749,
longitude = -122.4194,
distanceKm = 8.2,
date = "2026-08-17"
)
// Instead of a reassignable var holding the same thing
- Default every local variable and class property to
val. - Reach for
varonly when reassignment is genuinely part of the logic. - Model changing state with a new immutable copy (
trail.copy(...)), not a mutated field.
2. Let Null Safety Work For You — Don't Fight It With !!
The !! operator tells the compiler "trust me, this isn't null" — and when you're wrong,
it crashes with a NullPointerException at exactly the moment you were trying to avoid one.
Kotlin's null safety is only useful if you actually use its tools: ?., ?:, and
let.
// Avoid: crashes if photoUri is null
val uri = trail.photoUri!!
// Prefer: handle the absence explicitly
trail.photoUri?.let { uri ->
holder.thumbnail.load(uri)
} ?: run {
holder.thumbnail.setImageResource(R.drawable.trail_placeholder)
}
- Treat every
!!in a code review as a question: "what happens when this is actually null?" - Use
?.and?:to fall back to a sensible default instead of crashing. - Use
letto scope logic to only run when a nullable value is genuinely present.
3. Use data class and sealed class Deliberately
TrailLog's Trail data class gives us equals(), hashCode(),
toString(), and copy() for free — writing all of that by hand is exactly the
kind of boilerplate that quietly grows stale bugs. A sealed class does the same for
results and states: it makes illegal states unrepresentable, because the compiler forces every
when branch to be handled.
sealed class TrailResult {
data class Success(val trails: List<Trail>) : TrailResult()
data class Error(val message: String) : TrailResult()
object Loading : TrailResult()
}
- Reach for
data classfor anything that's fundamentally a bag of values, likeTrail. - Reach for
sealed classwhenever a value can only be one of a fixed, known set of shapes. - Let the compiler's exhaustive
whenchecks catch a forgotten case at compile time, not at runtime.
4. Keep the UI Layer "Dumb" — Push Logic Into ViewModel/Repository/Use Cases
An Activity or Fragment that reaches straight into a database or a network call is nearly impossible
to unit test, because testing it means dragging along the whole Android framework. Keep the UI layer
responsible only for displaying state and forwarding user actions — everything else belongs in a
ViewModel, a Repository, or a use case that has no dependency on Activity or
Fragment at all.
// UI layer: just observes and displays, no logic of its own
viewModel.trails.observe(viewLifecycleOwner) { trails ->
adapter.submitList(trails)
}
- If a class needs an
Activityor aViewto be tested, the logic is in the wrong layer. - Let the ViewModel expose state; let the UI only render it.
- Push decisions ("is this trail valid to save?") into a Repository or use case, not a click listener.
5. Use Coroutines and Flow Idiomatically
Coroutines are easy to misuse in ways that don't show up until production: launching work on the wrong
dispatcher, ignoring structured concurrency, or fighting cancellation instead of cooperating with it.
Idiomatic coroutine code picks the right Dispatcher for the work, scopes every coroutine
to a lifecycle that actually owns it, and lets cancellation propagate instead of swallowing it.
fun loadTrails() {
viewModelScope.launch {
val trails = withContext(Dispatchers.IO) {
repository.getTrails() // runs off the main thread
}
_trails.value = trails // back on Main automatically
}
}
- Use
Dispatchers.IOfor disk/network work,Dispatchers.Defaultfor CPU-heavy work, and stay onMainfor UI updates. - Launch coroutines from a scope tied to a lifecycle (
viewModelScope,lifecycleScope), never a scope that outlives its owner. - Don't catch
CancellationExceptionand swallow it — let cancellation do its job.
6. Never Store Secrets in Source Code, and Lock Down Network Traffic
An API key committed into a Kotlin file or a Gradle script is an API key that's now in git history forever, visible to anyone with repository access — and easily extracted from a compiled APK besides. TrailLog's own networking layer only talks over HTTPS, and its Network Security Config explicitly rejects any accidental plaintext connection.
// build.gradle.kts — read from local.properties or CI secrets, never hardcode
val apiKey: String = project.findProperty("TRAILLOG_API_KEY") as? String ?: ""
- Keep API keys and credentials out of version control — use
local.propertiesor a secrets manager. - Enforce HTTPS everywhere; use Network Security Config to block cleartext traffic entirely.
- Treat a leaked key as compromised immediately and rotate it, don't just delete the commit.
7. Write Unit Tests for ViewModels, Repositories, and Use Cases
The parts of TrailLog most worth testing are exactly the parts kept "dumb-UI-friendly" in practice
#4 — a ViewModel, a Repository, a use case — because they're plain Kotlin classes with no Android
framework dependency. MockK is built for Kotlin's language features (like mocking a
suspend function directly), which makes it a better fit here than adapting a Java-era
mocking library by hand.
@Test
fun `loadTrails updates state with repository result`() = runTest {
val repository = mockk<TrailRepository>()
coEvery { repository.getTrails() } returns listOf(sampleTrail)
val viewModel = TrailViewModel(repository)
viewModel.loadTrails()
assertEquals(listOf(sampleTrail), viewModel.trails.value)
}
- Test ViewModels, Repositories, and use cases first — they carry the actual decisions.
- Prefer MockK's
coEvery/coVerifyover hand-rolled fakes for suspend functions. - A test that never fails is a test that isn't checking anything — make sure it fails when the logic breaks.
8. Reach for Hilt, Clean Architecture, and Modularization as the App Genuinely Grows
None of these are wrong to add — but all three come with real overhead, and a five-screen app doesn't need the same structure as a fifty-screen one. Introduce Hilt when constructor-passing dependencies by hand gets tedious across TrailLog's growing screen count; introduce Clean Architecture's layers when a single Repository stops being enough to keep logic organized; split into modules when build times or team ownership boundaries start to hurt.
- Don't reach for Hilt, layered architecture, or multi-module Gradle setup by default on day one.
- Add structure when it solves a problem you actually have, not one you might have someday.
- A small app with plain, well-organized classes can be just as maintainable as a heavily architected one.
9. Protect the Release Keystore, and Test a Signed Build Before Publishing
The keystore that signs TrailLog's release build is not recoverable if lost — Google Play ties an app's identity to that specific signing key, and losing it can mean losing the ability to ever update the app again under the same listing. Back it up somewhere durable and private, keep its passwords out of source control, and always install and run the actual signed release build before uploading it.
- Store the keystore file and its passwords outside the git repository, in a password manager or secure vault.
- Keep at least one verified backup — a lost keystore is effectively permanent.
- Install the signed release APK/App Bundle on a real device and smoke-test it before every Play Store upload.
10. Profile Before You Optimize
It's tempting to "optimize" the code that looks slow — but as the previous lesson's
onBindViewHolder() example showed, the actual bottleneck is often somewhere unexpected.
Open Android Studio's Profiler first, measure where TrailLog's CPU, memory, or network
time is genuinely going, and only then make a targeted fix.
- Measure with the CPU, Memory, and Network Profilers before changing a single line for "performance."
- Fix the bottleneck the data actually points to, not the one that seems obvious.
- Re-measure after the fix to confirm it actually helped.
That's the full 64-lesson Android (Kotlin) track — the most comprehensive one on this entire site — and if you've followed it from start to finish, congratulations: you've built a real, working mental model of modern Android development, not just a scattered list of APIs. Through one running example, TrailLog, you've gone from Kotlin language fundamentals through coroutines and Flow, the full Jetpack architecture story (ViewModel, Room, Navigation, WorkManager, DataStore), networking with Retrofit, device features like CameraX, Fused Location, and Google Maps, Firebase's auth, database, and messaging services, unit and UI testing, and finally the whole path to a signed, published Play Store release. That's a genuinely complete picture of how a real Android app gets built today. From here, the Next button below leads back to the Android hub, where the Android (Java) track and the Jetpack Compose track are both waiting if you'd like to see the same TrailLog ideas expressed a different way. Well done — genuinely.