Three years of maintaining existing Android apps is long enough to lose something specific: the ability to start a new one.
Not platform familiarity — that stays. Not architecture instincts — those stay too. What erodes is the leading edge: which tools have compatibility problems right now, which APIs are actually stable on a blank project versus which ones live only in muscle memory from 2022. Maintenance mode doesn’t test any of that. You don’t notice the decay until you need the capability you’ve lost.
I build Android for clients. At some point in 2025 I had three production apps in maintenance mode and no new Android project on the horizon. The gap between what I could maintain and what I could greenfield was unknowable from inside maintenance mode. That’s the trap.
TackleBox is a fishing rod inventory app — my project, deliberately narrow scope, no deadline, no client. A controlled environment to map exactly where three years had left me. The answer turned out to be: gaps scattered across the tooling layer, nothing in the architecture layer. The latter held because structure-enforced boundaries don’t erode when you stop writing to them.
Day zero: the tooling already told me something
First decision on a blank project: annotation processor. KSP — Kotlin Symbol Processing, Kotlin-native, no reflection overhead — was the obvious choice in 2026. Hilt’s KSP integration had compatibility issues. I ended up with kapt.
One constraint before a single line of product code. Not blocking — kapt works — but the friction was informative. Returning after three years isn’t the same as reading the changelog. The changelog doesn’t tell you which integrations are actually stable versus which ones are still resolving. You find that out by trying.
The domain doesn’t know how data is stored — or displayed
The core architectural decision in TackleBox: three separate model types, one per layer.
// Data Layer: RodEntity knows about Room, knows nothing else
@Entity(tableName = "rods")
data class RodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val series: String,
val model: String,
val brand: String,
@ColumnInfo(name = "length_mm") val lengthMm: Int,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
// Domain Layer: Rod is pure Kotlin — no android.*, no Room annotations
data class Rod(
val id: Long,
val series: String,
val model: String,
val brand: String,
val lengthInMm: Int
)
// Presentation Layer: RodUiModel is display-ready
data class RodUiModel(
val id: Long,
val displayName: String, // "Megabass Ballistick 100ML"
val lengthDisplay: String // "3.06m"
)
The domain interface reflects this separation: RodRepository returns Flow<List<Rod>> — not entities, not UI models. The repository contract lives in the domain layer; the implementation lives in the data layer. The ViewModel maps domain models to UI models before pushing to state. Each layer owns its own representation, and no layer reaches past its boundary.
Where this gets non-obvious is mappers. The instinct is to put all mappers together. The correct placement follows the dependency rule: RodEntity.toDomain() lives in the data layer (which depends on the domain layer and can see both types), and Rod.toUiModel() lives in the presentation layer (which depends on the domain layer and can see both types). The domain layer hosts neither mapper, because the domain cannot depend on the layers above or below it. Put another way: a mapper requires knowing both its input type and its output type. The domain layer knows only its own types.
Why Flow, not suspend
RodRepository returns Flow<List<Rod>>, not suspend fun getAll(): List<Rod>. The distinction is reactivity, not coroutine preference.
Room’s DAO emits a new list every time the underlying table changes. If the repository surface returns a snapshot, the ViewModel must manually re-fetch after every write. If it returns a Flow, writes automatically propagate to every observer. In an app where the user continuously adds and deletes rods, the difference is an entire class of stale-data bugs versus none of them.
StateFlow and the dependency argument
Every migration guide recommends moving from LiveData to StateFlow. The argument usually stops at “StateFlow is more Kotlin-idiomatic.” The structural argument is about what each primitive pulls into the dependency graph.
LiveData requires a LifecycleOwner to observe — meaning android.lifecycle imports into any code that observes state, including the ViewModel. StateFlow is pure Kotlin. A ViewModel written against StateFlow has no android.* imports. This matters for the same reason the three-model separation matters: the compiler tells you when a boundary has been crossed.
class RodViewModel(
private val repository: RodRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<RodUiState>(RodUiState.Loading)
val uiState: StateFlow<RodUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
try {
repository.getAllRods()
.map { rods -> rods.map { it.toUiModel() } }
.collect { uiModels -> _uiState.value = RodUiState.Success(uiModels) }
} catch (e: Exception) {
_uiState.value = RodUiState.Error(e.message)
}
}
}
}
UI state is a sealed class — Loading, Success(rods), Error(message) — which forces the Compose layer to handle every case. One-time events (delete confirmation toast, back navigation) go through a Channel, not StateFlow. StateFlow replays its last value on every new subscription, which means a new subscriber joining after a delete confirmation would see it again. Channel delivers each event exactly once.
The ViewModel-must-not-hold-an-Activity-reference rule has existed since ViewModels were introduced. With Compose, the blast radius grew: the framework keeps the ViewModel alive across configuration changes while recreating the Activity. Every rotation, every split-screen entry leaks if you’re holding that reference. The rule is older than Compose; the consequences are not.
What survived, and what didn’t
Across three years of maintenance mode, the following transferred intact: dependency inversion, the value of a framework-free domain layer, the ViewModel-holds-no-Android-references discipline. These don’t erode because they’re structural — the compiler enforces them regardless of whether any new code was written.
What didn’t transfer: coroutine scope semantics in nested contexts. Not the syntax — launch, async, collect are readable on day one. The mental model of what cancellation means when a user action triggers a coroutine that spawns a child coroutine inside a different scope: that intuition requires several real features to rebuild, not one afternoon of documentation. Hilt’s component hierarchy specifics — the practical difference between @Singleton, @ViewModelScoped, and unscoped in a real injection graph — reads cleanly in the docs and requires one real graph to internalize. Navigation Compose 2.8’s type-safe route API was a full relearn; the migration guide assumes familiarity with string-based routes I’d stopped using before Compose existed.
None of it was blocking. The architecture held the structure while the tooling knowledge rebuilt itself. Three years of maintenance mode cost weeks of relearning, not months — because the parts that make architecture architecture never needed to be rebuilt at all.
The value of structure-enforced architecture isn’t that it makes platform gaps smaller. It’s that it makes them findable.