I remember when I first started Android development - my Activities were 2000+ lines of spaghetti code. Business logic mixed with UI code, network calls happening directly in onClick handlers, and good luck trying to write a single unit test. It was a mess.
MVVM changed that for me. Not because it's trendy or because Google recommends it, but because it actually solved the problems I was facing every day. This post covers how I structure MVVM in Jetpack Compose, with a real authentication flow as the example.
Why MVVM? (The Honest Version)
I've tried MVC, MVP, and even some "clean architecture" implementations that required 47 files just to show a list. Here's my take after years of production apps:
MVC - Fine for small apps, but your Activities become god classes fast. I've maintained 3000-line Activities. Never again.
MVP - Better separation, but the Presenter-View interface boilerplate drove me crazy. Every new screen meant creating an interface, implementing it, and wiring everything up.
MVVM - The sweet spot for most Android apps. ViewModels survive rotation (finally!), Compose makes state observation trivial, and you can actually test your logic without spinning up an emulator.
Clean Architecture - Great in theory, but I've seen teams spend weeks debating which layer a class belongs in. For most apps, it's overkill. Save it for when you actually need it.
The 4-Layer Structure I Use
After trying different approaches, I've settled on this structure:
┌─────────────────────────────────────┐
│ UI Layer (Composables) │ ← What users see
├─────────────────────────────────────┤
│ ViewModel Layer (State Logic) │ ← Manages UI state
├─────────────────────────────────────┤
│ Domain Layer (Use Cases) │ ← Business rules
├─────────────────────────────────────┤
│ Data Layer (Repository + API/DB) │ ← Where data comes from
└─────────────────────────────────────┘
The rule is simple: each layer only talks to the layer directly below it. UI calls ViewModel, ViewModel calls UseCase, UseCase calls Repository. Never skip layers.
I know some people skip the Domain layer for simple apps, and honestly, that's fine. But I've been burned too many times by "simple" apps that grew. Adding UseCases later is painful.
StateFlow vs SharedFlow vs LiveData - Which One?
This confused me for months. Here's how I think about it now:
StateFlow - For state that the UI needs to observe. Loading spinners, error messages, list data. Use this 90% of the time.
SharedFlow - For one-time events. Navigation, showing a toast, triggering a dialog. Things that should happen once and not repeat when the screen rotates.
LiveData - I don't use it with Compose anymore. It was great with XML views, but StateFlow works better with Compose's reactive model.
mutableStateOf - For local UI state inside a composable. Text field values, checkbox states, stuff that doesn't need to survive rotation.
The mistake I see constantly: using StateFlow for navigation events. User logs in, you emit "NavigateToHome" to a StateFlow, and every time the screen recomposes, it tries to navigate again. Use SharedFlow for events.
Building a Login Screen (The Real Way)
Let me walk through how I'd actually build a login feature. Not a toy example - one that handles network errors, loading states, and validation.
The State and Events
First, I define what state the UI needs and what events can happen:
data class LoginState(
val isLoading: Boolean = false,
val error: String? = null
)
sealed class LoginEvent {
object NavigateToHome : LoginEvent()
data class ShowError(val message: String) : LoginEvent()
}
Why separate State and Events? Because I learned this lesson the hard way. State is something the UI observes continuously - "are we loading?", "is there an error?". Events are fire-and-forget - "navigate to home screen" should happen once, not every time the composable recomposes.
The ViewModel
This is where most of the interesting stuff happens:
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase,
private val connectivityManager: ConnectivityManager
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
fun onLoginClick(email: String, password: String) {
// I always check network first - users hate seeing "unknown error"
// when they just forgot to turn off airplane mode
if (!isNetworkAvailable()) {
viewModelScope.launch {
_events.emit(LoginEvent.ShowError("No internet connection"))
}
return
}
if (email.isBlank() || password.isBlank()) {
viewModelScope.launch {
_events.emit(LoginEvent.ShowError("Please fill all fields"))
}
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val result = loginUseCase(email, password)
if (result.isSuccess) {
_state.value = _state.value.copy(isLoading = false)
_events.emit(LoginEvent.NavigateToHome)
} else {
_state.value = _state.value.copy(
isLoading = false,
error = result.message ?: "Login failed"
)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
isLoading = false,
error = e.message ?: "An error occurred"
)
}
}
}
private fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
A few things I want to point out:
The _state / state pattern - private mutable, public immutable. I've seen codebases where UI code directly modifies ViewModel state. Chaos ensues.
The network check happens before anything else. I put this in after getting too many bug reports that turned out to be "user was on a plane."
Notice I'm using viewModelScope.launch. This scope is tied to the ViewModel's lifecycle - when the ViewModel is cleared, any running coroutines get cancelled automatically. No memory leaks.
The UseCase
Some people skip this layer. I used to, until I had the same email validation logic copy-pasted in three different ViewModels. Now I always extract business logic:
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(email: String, password: String): AuthResult {
if (!isValidEmail(email)) {
return AuthResult(
isSuccess = false,
message = "Invalid email format"
)
}
if (password.length < 6) {
return AuthResult(
isSuccess = false,
message = "Password must be at least 6 characters"
)
}
return authRepository.login(email, password)
}
private fun isValidEmail(email: String): Boolean {
return android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
}
}
data class AuthResult(
val isSuccess: Boolean,
val message: String? = null,
val user: User? = null
)
The operator fun invoke trick lets you call the UseCase like a function: loginUseCase(email, password) instead of loginUseCase.execute(email, password). Small thing, but it reads nicer.
The Repository
Repository handles the actual data fetching. API calls, database queries, caching - all that lives here:
class AuthRepository @Inject constructor(
private val apiService: ApiService,
private val userPreferences: UserPreferences
) {
suspend fun login(email: String, password: String): AuthResult {
return try {
val response = apiService.login(LoginRequest(email, password))
if (response.isSuccessful && response.body() != null) {
val user = response.body()!!.user
// Save locally so user stays logged in
userPreferences.saveUser(user)
userPreferences.saveToken(response.body()!!.token)
AuthResult(isSuccess = true, user = user)
} else {
AuthResult(
isSuccess = false,
message = response.message() ?: "Login failed"
)
}
} catch (e: Exception) {
AuthResult(
isSuccess = false,
message = "Network error: ${e.localizedMessage}"
)
}
}
}
interface ApiService {
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
}
data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val token: String, val user: User)
The UI
Finally, the Compose UI. This should be the dumbest layer - it just displays state and forwards user actions to the ViewModel:
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit
) {
val state by viewModel.state.collectAsState()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val context = LocalContext.current
// Handle one-time events
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateToHome()
is LoginEvent.ShowError -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Login",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(bottom = 32.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { viewModel.onLoginClick(email, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Login")
}
}
}
}
That LaunchedEffect(Unit) block is how you handle SharedFlow events in Compose. It starts collecting when the composable enters composition and stops when it leaves.
The enabled = !state.isLoading on the text fields and button prevents users from spam-clicking while a request is in flight. Learned that one from production crash logs.
Hilt Setup (The Boring But Necessary Part)
I use Hilt for dependency injection. It's more setup than Koin, but the compile-time safety is worth it - you find out about missing dependencies before your app crashes on a user's phone.
// Application class
@HiltAndroidApp
class MyApplication : Application()
// Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
LoginScreen()
}
}
}
}
// Modules for providing dependencies
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
@Provides
@Singleton
fun provideConnectivityManager(
@ApplicationContext context: Context
): ConnectivityManager {
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
}
The Data Flow (How It All Connects)
Here's what happens when a user taps the login button:
User taps Login
↓
LoginScreen calls viewModel.onLoginClick()
↓
ViewModel checks network, validates input
↓
ViewModel calls loginUseCase(email, password)
↓
UseCase validates business rules (email format, password length)
↓
UseCase calls authRepository.login()
↓
Repository makes API call, saves token on success
↓
Result bubbles back up: Repository → UseCase → ViewModel
↓
ViewModel updates state (isLoading = false)
↓
ViewModel emits NavigateToHome event
↓
LoginScreen observes state change, UI updates
↓
LaunchedEffect catches event, triggers navigation
Mistakes I Made So You Don't Have To
Passing Context to ViewModel - Don't do this. ViewModels outlive Activities. If your ViewModel holds a reference to a Context, you'll leak the entire Activity. If you need system services, inject them through Hilt like I did with ConnectivityManager.
Collecting Flows wrong in Compose - I once wrote viewModel.state.collect { } directly in a composable. The app recomposed infinitely. Use collectAsState() - it's designed for Compose.
Business logic in composables - It's tempting to add "just a small validation" in your onClick handler. Don't. That logic can't be unit tested, and it'll spread like a virus.
Using StateFlow for one-time events - Navigation, showing toasts, any action that should happen once - use SharedFlow. StateFlow replays the last value to new collectors, which means your navigation event fires again after screen rotation.
Forgetting to disable UI during loading - Users will spam that button if you let them. Always disable inputs while an operation is in progress.
Testing (Why This Architecture Pays Off)
The whole point of separating layers is testability. Here's how easy it becomes:
class LoginViewModelTest {
@Test
fun `login with valid credentials navigates to home`() = runTest {
val loginUseCase = mockk<LoginUseCase>()
coEvery { loginUseCase(any(), any()) } returns AuthResult(isSuccess = true)
val viewModel = LoginViewModel(loginUseCase, mockk(relaxed = true))
viewModel.onLoginClick("[email protected]", "password123")
// Verify navigation event was emitted
assert(viewModel.state.value.isLoading == false)
}
}
class LoginUseCaseTest {
@Test
fun `invalid email returns error`() = runTest {
val repository = mockk<AuthRepository>()
val useCase = LoginUseCase(repository)
val result = useCase("not-an-email", "password")
assert(!result.isSuccess)
assert(result.message == "Invalid email format")
// Repository was never called
coVerify(exactly = 0) { repository.login(any(), any()) }
}
}
No emulators, no UI testing frameworks, just fast unit tests. The UseCase test doesn't even need a mock repository for the invalid email case because the validation fails before reaching it.
Wrapping Up
MVVM isn't magic, and it won't fix bad code by itself. But it gives you a structure that makes the right thing easy and the wrong thing hard. State lives in ViewModels, business logic lives in UseCases, data fetching lives in Repositories, and your UI just displays what it's told.
The authentication example covers most patterns you'll need: state management, events, loading states, error handling, and dependency injection. Once you've built one feature this way, the rest follow the same pattern.
In Part 2, I cover the more advanced stuff - search with debounce, pagination, and how to handle communication between different feature modules.
Continue to: Part 2: Advanced MVVM Patterns - Search, Pagination & Cross-Module Communication