This post is based on a talk I gave at Google I/O Extended Bangladesh 2022, organised by GDG Sonargaon.
Good Android architecture is invisible when it works and painful when it doesn't. The goal is code you can change without fear — where adding a feature doesn't break three others, and where new developers can find what they're looking for.
Here's the mental model I use and shared at the event.
The Core Principle: Separation of Concerns
Every architectural decision in Android comes back to one rule — separate what changes from what stays stable.
UI changes frequently (new screens, redesigns, A/B tests)
Business logic changes with product decisions
Data sources change with infrastructure decisions
Mix these together and you get a codebase where touching one thing breaks another. Keep them separate and changes stay local.
The Layered Architecture
Google's recommended architecture has three layers:
UI Layer → What the user sees (Activities, Fragments, Composables)
Domain Layer → Business logic (Use Cases, pure Kotlin)
Data Layer → Data access (Repositories, APIs, databases)
Dependencies only flow inward — UI depends on Domain, Domain has no dependencies, Data depends on nothing above it.
UI ──────→ Domain ←────── Data
UI Layer
The UI layer has two parts: UI elements (what renders on screen) and state holders (what manages the state they display).
Use ViewModel as the state holder. It survives configuration changes and provides a clean separation between the Composable/Fragment and the data it renders:
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
fun loadTasks() {
viewModelScope.launch {
getTasksUseCase().collect { tasks ->
_uiState.update { it.copy(tasks = tasks) }
}
}
}
}
UI state should be a single sealed class or data class. Never scatter multiple LiveData/StateFlow properties across the ViewModel — it creates race conditions in the UI.
Domain Layer
The domain layer contains your business logic as Use Cases (also called Interactors). A use case does one thing:
class GetTasksUseCase @Inject constructor( private val taskRepository: TaskRepository) { operator fun invoke(): Flow<List<Task>> = taskRepository.getActiveTasks() .map { tasks -> tasks.sortedByDescending { it.priority } }}
Domain layer is pure Kotlin — no Android imports, no framework dependencies. This makes it trivially testable:
class GetTasksUseCaseTest { @Test fun `tasks sorted by priority descending`() = runTest { val repo = FakeTaskRepository(listOf( Task(priority = 1), Task(priority = 3), Task(priority = 2) )) val useCase = GetTasksUseCase(repo) val result = useCase().first() assertEquals(3, result[0].priority) }}
Data Layer
The data layer exposes data through Repositories. The repository pattern hides the data source — the domain layer doesn't care whether data comes from an API, a database, or a cache.
class TaskRepositoryImpl @Inject constructor( private val taskDao: TaskDao, private val taskApi: TaskApi, private val networkMonitor: NetworkMonitor) : TaskRepository { override fun getActiveTasks(): Flow<List<Task>> = taskDao.watchActiveTasks() .map { entities -> entities.map { it.toDomain() } } override suspend fun syncTasks() { if (!networkMonitor.isOnline()) return val remote = taskApi.getTasks() taskDao.upsertAll(remote.map { it.toEntity() }) }}
The UI always reads from the local database. syncTasks() keeps it fresh. This is the offline-first pattern — the UI never waits on network.
MVVM vs MVI
MVVM (Model-View-ViewModel) is Google's recommended pattern. The ViewModel exposes state, the UI observes and renders it.
MVI (Model-View-Intent) adds structure to how events flow. The UI sends intents to the ViewModel, which processes them and emits new state:
// MVIsealed class TaskIntent { data class LoadTasks(val filter: Filter) : TaskIntent() data class CompleteTask(val id: String) : TaskIntent()}fun processIntent(intent: TaskIntent) { when (intent) { is TaskIntent.LoadTasks -> loadTasks(intent.filter) is TaskIntent.CompleteTask -> completeTask(intent.id) }}
MVI shines in complex screens with many user interactions. MVVM is simpler for most screens. I default to MVVM and reach for MVI when a screen has more than 4–5 distinct user actions.
Dependency Injection with Hilt
Hilt removes the boilerplate of manual DI. Annotate your ViewModel, inject your dependencies, done:
@AndroidEntryPointclass TaskListFragment : Fragment() { private val viewModel: TaskListViewModel by viewModels()}
Never construct dependencies inside classes. Anything that creates its own dependencies is untestable and tightly coupled.
Key Rules I Follow
No business logic in Composables or Fragments — they render state, that's all
ViewModel never imports from android.view — if it does, it's too coupled to the UI
Domain layer has zero Android imports — pure Kotlin only
Repositories return domain models, not API/database models — mapping happens at the boundary
One ViewModel per screen — not one per feature area, not one for the whole app
Consistent architecture doesn't make you go faster on day one. It makes you go faster on day 300.