I started writing Android in 2011 — Gingerbread era, ListView, AsyncTask, HttpClient. Walked through the whole arc from there: RecyclerView, Dagger 2, Material Design, MVVM, then Kotlin toward the end. From around 2022, most of my Android work shifted to maintenance: keeping existing apps running, fixing bugs, not chasing new releases. Compose was stable, Kotlin 2.0 was coming, but none of that was urgent when the apps just needed to stay alive.
TackleBox is the first greenfield Android project I’ve started since then. A small fishing tackle inventory app, but really an excuse to properly learn Jetpack Compose and revisit Clean Architecture with a modern Kotlin stack. These aren’t notes from someone new to Android — the architecture instincts carried over — but from someone who let three years of new tooling accumulate without paying attention.
Clean Architecture: Familiar Concept, Different Execution
The three-layer split (domain / data / presentation) isn’t new. I’d applied variants of it since the MVP era, and MVVM pushed everyone toward something similar around 2018. What I didn’t expect is how much more enforceable the boundary is in Kotlin.
In the Java days, nothing actually stopped you from importing Context into a “domain” class. You’d rely on code review and discipline. With Kotlin’s stricter module boundaries and the convention that domain is pure Kotlin with no Android imports, the compiler becomes part of the enforcement. It’s a small thing but it adds up.
The Domain Layer Has One Rule
No Android imports. Not Context, not LiveData, nothing from android.*. This constraint is what makes the domain layer actually portable and testable without a device or emulator.
// domain/model/TackleItem.kt
data class TackleItem(
val id: Long,
val name: String,
val type: TackleType,
val quantity: Int,
val notes: String?
)
// domain/repository/TackleRepository.kt
interface TackleRepository {
suspend fun getAll(): List<TackleItem>
suspend fun save(item: TackleItem)
suspend fun delete(id: Long)
}
The suspend functions are Kotlin coroutines — this is where the gap from 2022 shows up. In Java-era Android, you’d handle async with callbacks or AsyncTask. The Kotlin coroutine model is cleaner, but I had to rebuild the mental model for how scope and cancellation work.
Data Layer: Room, Mappers, and What Changed
Room has been around since 2017, so the concept wasn’t foreign. What’s different is how the Kotlin version handles the mapping between Room entities and domain models.
@Entity(tableName = "tackle_items")
data class TackleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val type: String,
val quantity: Int,
val notes: String?
)
class TackleRepositoryImpl @Inject constructor(
private val dao: TackleDao
) : TackleRepository {
override suspend fun getAll(): List<TackleItem> =
dao.getAll().map { it.toDomain() }
override suspend fun save(item: TackleItem) =
dao.upsert(item.toEntity())
}
In Java this would be a lot more boilerplate. Kotlin extension functions (toDomain(), toEntity()) make the mapper pattern actually readable. Nothing revolutionary, but the friction is low enough that you actually follow it instead of cutting corners.
Presentation Layer: LiveData is Gone
This is the biggest mental shift. In the Java era (and into early Kotlin), LiveData was the standard for reactive UI updates. It’s lifecycle-aware and it worked fine.
StateFlow replaces it in modern Compose apps, and the difference matters more than it looks:
// Planned — not yet wired up in TackleBox
@HiltViewModel
class TackleListViewModel @Inject constructor(
private val repository: TackleRepository
) : ViewModel() {
private val _items = MutableStateFlow<List<TackleItem>>(emptyList())
val items: StateFlow<List<TackleItem>> = _items.asStateFlow()
init {
viewModelScope.launch {
_items.value = repository.getAll()
}
}
}
StateFlow is pure Kotlin — no Android dependency in the ViewModel itself. LiveData required a LifecycleOwner to observe, which pulled Android into places that didn’t need it. StateFlow gives you the same reactive behavior without the coupling.
One rule I hadn’t internalized from before: the ViewModel must never hold an Activity reference. Compose handles lifecycle differently than XML views, and an Activity reference in a ViewModel will leak on config changes (rotation, split-screen). The ViewModel outlives the Activity during those transitions — the framework recreates the Activity but keeps the ViewModel.
KSP vs KAPT
kapt was the annotation processor we used for Dagger 2, Room, and everything else in the Java era. KSP is the Kotlin-native replacement — faster builds, designed for Kotlin from the ground up.
I planned to use KSP for Room and Hilt, but ran into compatibility issues and had to stay on kapt:
// build.gradle.kts
// ksp(libs.room.compiler) // on hold — Hilt compatibility issue
kapt(libs.room.compiler)
This isn’t a blocker, just friction I didn’t expect. The plan is to migrate once Hilt’s KSP support stabilizes.
One thing that actually improved: Gradle Version Catalogs (libs.versions.toml). In the Java era, version management across build.gradle files was genuinely painful. Centralizing everything in one TOML file is the kind of tooling improvement that makes a real difference in day-to-day work.
What the Three Years Away Cost
Mostly it’s the new mental models rather than the syntax. The Kotlin syntax I mostly had — it was the coroutine scope semantics, Hilt’s component hierarchy (@Singleton vs @ViewModelScoped vs unscoped), and Compose’s recomposition model that took time to rebuild from scratch.
Navigation Compose with type-safe routes was another one. The new API (Compose Navigation 2.8) is genuinely better than string-based routes — no more typos in route strings, the compiler catches mismatches. But the migration guide assumes familiarity with the string-based approach, so I ended up reading both sets of docs.
The architecture instincts transferred fine. The DI patterns, the separation of concerns, the value of testable domain code — none of that went away. What expired was the specific APIs and the mental models for the new async and UI layers. Those took a few weeks of building something real to rebuild.