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:
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase
) : ViewModel() {
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:
// MVI
sealed 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:
@AndroidEntryPoint
class 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.