MVVM (Model-View-ViewModel) is the recommended architecture pattern for Android apps. Google’s official architecture guide is built on it, Jetpack libraries (ViewModel, LiveData, StateFlow) are designed for it, and every production Android app uses some variant of it. But “MVVM” is often explained vaguely — this guide shows you exactly what each layer does, how data flows through the layers, and a complete real-world implementation with precise identification of every component.


What is MVVM?

// MVVM separates your app into THREE layers:
//
// ┌────────────────────────────────────────────────────────────────┐
// │                                                                │
// │  VIEW (UI)                                                     │
// │  Activity / Fragment / Composable                              │
// │  Displays data, captures user events                           │
// │  Knows about: ViewModel (observes state, sends events)         │
// │  Does NOT know about: Repository, Database, API                │
// │                                                                │
// │          ↑ State (StateFlow)          │ Events (function calls)│
// │          │                            ↓                        │
// │                                                                │
// │  VIEWMODEL (Presentation Logic)                                │
// │  Holds UI state, handles business logic for the screen         │
// │  Transforms data from Repository into UI state                 │
// │  Knows about: Repository                                       │
// │  Does NOT know about: Activity, Fragment, Composable, Views    │
// │                                                                │
// │          ↑ Data (Flow/suspend)        │ Requests               │
// │          │                            ↓                        │
// │                                                                │
// │  MODEL (Data Layer)                                            │
// │  Repository + Data Sources (API, Database, Preferences)        │
// │  Single source of truth for data                               │
// │  Knows about: Retrofit, Room, DataStore                        │
// │  Does NOT know about: ViewModel, UI, Android framework         │
// │                                                                │
// └────────────────────────────────────────────────────────────────┘
//
// Data flows DOWN (Repository → ViewModel → UI)
// Events flow UP (UI → ViewModel → Repository)
// Each layer only talks to the one directly below/above it

Why MVVM?

// Without architecture (everything in Activity):
class ArticleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ❌ Activity does EVERYTHING:
        val api = Retrofit.Builder().build().create(Api::class.java)   // networking
        val db = Room.databaseBuilder(...).build()                      // database
        lifecycleScope.launch {
            val articles = api.getArticles()                            // fetch
            db.articleDao().insertAll(articles)                         // cache
            val cached = db.articleDao().getAll()                       // read
            adapter.submitList(cached)                                  // display
        }
    }
}
// Problems:
// - Untestable (can't test without Activity)
// - Data lost on rotation (no ViewModel)
// - Mixed concerns (networking + caching + UI in one class)
// - Can't reuse logic (another screen needs same data? Copy-paste!)

// With MVVM:
// Activity → just observes state and sends events
// ViewModel → holds state, calls repository
// Repository → manages data sources
// Each layer is testable, reusable, and has a single responsibility

The Model Layer — Repository + Data Sources

Data Source — talks to one external source

// A Data Source is a CLASS that wraps a single external data source
// Each data source handles ONE thing: API, database, or preferences

// Remote Data Source — wraps Retrofit API
class ArticleRemoteDataSource @Inject constructor(
    // @Inject is an ANNOTATION from javax.inject — marks constructor for Hilt injection
    private val api: ArticleApi
    // ArticleApi is an INTERFACE — Retrofit generates the implementation
) {
    suspend fun getArticles(): List<ArticleDto> {
        // suspend FUNCTION — calls the network on a background thread
        return api.getArticles()
    }

    suspend fun getArticle(id: String): ArticleDto {
        return api.getArticle(id)
    }
}

// Local Data Source — wraps Room DAO
class ArticleLocalDataSource @Inject constructor(
    private val dao: ArticleDao
    // ArticleDao is an INTERFACE annotated with @Dao — Room generates implementation
) {
    fun getArticlesFlow(): Flow<List<ArticleEntity>> {
        // Returns Flow — Room emits new list whenever database changes
        return dao.getArticles()
    }

    suspend fun insertArticles(articles: List<ArticleEntity>) {
        dao.insertAll(articles)
    }

    suspend fun getArticle(id: String): ArticleEntity? {
        return dao.getArticle(id)
    }
}

// API Interface (Retrofit)
interface ArticleApi {
    // ArticleApi is an INTERFACE — Retrofit creates the implementation at runtime

    @GET("articles")
    // @GET is an ANNOTATION from Retrofit — marks this as an HTTP GET request
    suspend fun getArticles(): List<ArticleDto>

    @GET("articles/{id}")
    suspend fun getArticle(@Path("id") id: String): ArticleDto
    // @Path is an ANNOTATION from Retrofit — replaces {id} in the URL
}

// Room DAO
@Dao
// @Dao is an ANNOTATION from Room — marks this as a Data Access Object
interface ArticleDao {

    @Query("SELECT * FROM articles ORDER BY published_at DESC")
    // @Query is an ANNOTATION from Room — raw SQL query
    fun getArticles(): Flow<List<ArticleEntity>>
    // Returns Flow — Room automatically emits when data changes

    @Query("SELECT * FROM articles WHERE id = :id")
    suspend fun getArticle(id: String): ArticleEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    // @Insert is an ANNOTATION from Room — generates insert SQL
    suspend fun insertAll(articles: List<ArticleEntity>)
}

Data Transfer Objects (DTOs) and Entities

// DTO — represents data from the API (network model)
data class ArticleDto(
    @SerializedName("id") val id: String,
    // @SerializedName is an ANNOTATION from Gson — maps JSON key to property
    @SerializedName("title") val title: String,
    @SerializedName("author") val author: String,
    @SerializedName("published_at") val publishedAt: Long,
    @SerializedName("image_url") val imageUrl: String?
)

// Entity — represents data in the database (Room model)
@Entity(tableName = "articles")
// @Entity is an ANNOTATION from Room — maps this class to a database table
data class ArticleEntity(
    @PrimaryKey val id: String,
    // @PrimaryKey is an ANNOTATION from Room — unique identifier
    val title: String,
    val author: String,
    val publishedAt: Long,
    val imageUrl: String?
)

// Domain Model — represents data in your app's business logic
// Clean, UI-ready, no framework annotations
data class Article(
    val id: String,
    val title: String,
    val author: String,
    val publishedAt: Long,
    val imageUrl: String?,
    val isBookmarked: Boolean = false
) {
    val formattedDate: String
        get() = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
            .format(Date(publishedAt))
}

// Mappers — convert between layers
fun ArticleDto.toEntity(): ArticleEntity = ArticleEntity(
    id = id, title = title, author = author,
    publishedAt = publishedAt, imageUrl = imageUrl
)
// toEntity() is an EXTENSION FUNCTION on ArticleDto

fun ArticleEntity.toDomain(): Article = Article(
    id = id, title = title, author = author,
    publishedAt = publishedAt, imageUrl = imageUrl
)
// toDomain() is an EXTENSION FUNCTION on ArticleEntity

Repository — the single source of truth

// The Repository COORDINATES data sources and provides a clean API to ViewModel
// It decides: fetch from network? Read from cache? Both?

class ArticleRepository @Inject constructor(
    private val remoteDataSource: ArticleRemoteDataSource,
    private val localDataSource: ArticleLocalDataSource
) {

    // Observe articles — Room Flow with automatic updates
    fun getArticlesFlow(): Flow<List<Article>> {
        return localDataSource.getArticlesFlow()
            .map { entities ->
                // map is an EXTENSION FUNCTION on Flow — transforms each emission
                entities.map { it.toDomain() }
            }
    }

    // Refresh from network and update cache
    suspend fun refreshArticles() {
        val remoteArticles = remoteDataSource.getArticles()
        val entities = remoteArticles.map { it.toEntity() }
        localDataSource.insertArticles(entities)
        // Room Flow automatically emits the updated list to all collectors
    }

    // Get single article
    suspend fun getArticle(id: String): Article? {
        // Try local first, fetch from network if not found
        val local = localDataSource.getArticle(id)
        if (local != null) return local.toDomain()

        return try {
            val remote = remoteDataSource.getArticle(id)
            localDataSource.insertArticles(listOf(remote.toEntity()))
            remote.toEntity().toDomain()
        } catch (e: Exception) {
            null
        }
    }
}

// Repository rules:
// ✅ Single source of truth — all data goes through Repository
// ✅ Hides data source details — ViewModel doesn't know about Retrofit or Room
// ✅ Handles caching strategy — network-first, cache-first, or offline-first
// ✅ Maps between layer models — DTO → Entity → Domain
// ❌ Does NOT hold UI state — that's ViewModel's job
// ❌ Does NOT know about Activity, Fragment, or Compose

The ViewModel Layer — Presentation Logic

@HiltViewModel
// @HiltViewModel is an ANNOTATION from dagger.hilt.android.lifecycle
class ArticleViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel() {
    // ViewModel is an ABSTRACT CLASS from androidx.lifecycle

    // ═══ UI STATE — what the screen should display ═══════════════════

    // Sealed interface for all possible screen states
    sealed interface UiState {
        data object Loading : UiState
        data class Success(
            val articles: List<Article>,
            val isRefreshing: Boolean = false
        ) : UiState
        data class Error(val message: String) : UiState
    }

    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    // MutableStateFlow constructor is a TOP-LEVEL FUNCTION
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    // asStateFlow() is an EXTENSION FUNCTION on MutableStateFlow → read-only

    // ═══ EVENTS — one-time actions (snackbar, navigation) ════════════

    private val _events = MutableSharedFlow<UiEvent>()
    // MutableSharedFlow constructor is a TOP-LEVEL FUNCTION
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()

    sealed interface UiEvent {
        data class ShowSnackbar(val message: String) : UiEvent
        data class NavigateToDetail(val articleId: String) : UiEvent
    }

    // ═══ DATA LOADING ════════════════════════════════════════════════

    init {
        loadArticles()
    }

    private fun loadArticles() {
        viewModelScope.launch {
            // viewModelScope is an EXTENSION PROPERTY on ViewModel
            // launch is an EXTENSION FUNCTION on CoroutineScope (builder)

            // Start observing database (reactive — updates automatically)
            repository.getArticlesFlow()
                .onStart {
                    // onStart is an EXTENSION FUNCTION on Flow
                    // Runs before the first emission
                    _uiState.value = UiState.Loading
                    // Also trigger a network refresh
                    try { repository.refreshArticles() }
                    catch (e: CancellationException) { throw e }
                    catch (e: Exception) { /* network error — show cached data */ }
                }
                .catch { e ->
                    // catch is an EXTENSION FUNCTION on Flow
                    _uiState.value = UiState.Error(e.message ?: "Unknown error")
                }
                .collect { articles ->
                    _uiState.value = UiState.Success(articles = articles)
                }
        }
    }

    // ═══ USER ACTIONS ════════════════════════════════════════════════

    fun refresh() {
        viewModelScope.launch {
            _uiState.update { currentState ->
                // update {} is an EXTENSION FUNCTION on MutableStateFlow (thread-safe)
                if (currentState is UiState.Success) {
                    currentState.copy(isRefreshing = true)
                } else currentState
            }

            try {
                repository.refreshArticles()
                // Room Flow will automatically emit updated articles
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _events.emit(UiEvent.ShowSnackbar("Failed to refresh: ${e.message}"))
                // emit() is a SUSPEND FUNCTION on MutableSharedFlow
            } finally {
                _uiState.update { currentState ->
                    if (currentState is UiState.Success) {
                        currentState.copy(isRefreshing = false)
                    } else currentState
                }
            }
        }
    }

    fun onArticleClicked(articleId: String) {
        viewModelScope.launch {
            _events.emit(UiEvent.NavigateToDetail(articleId))
        }
    }

    fun retry() {
        loadArticles()
    }
}

// ViewModel rules:
// ✅ Holds UI state (loading, success, error)
// ✅ Transforms Repository data into UI-ready state
// ✅ Handles user actions (click, refresh, retry)
// ✅ Exposes StateFlow for state, SharedFlow for events
// ❌ Does NOT reference Activity, Fragment, Views, or Compose
// ❌ Does NOT hold Context (use @ApplicationContext if needed)
// ❌ Does NOT make direct API/database calls (that's Repository's job)

The View Layer — UI

Compose implementation

@Composable
fun ArticleScreen(
    viewModel: ArticleViewModel = hiltViewModel(),
    // hiltViewModel() is a COMPOSABLE FUNCTION from hilt-navigation-compose
    onNavigateToDetail: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // collectAsStateWithLifecycle() is an EXTENSION FUNCTION on StateFlow

    val snackbarHostState = remember { SnackbarHostState() }

    // Handle one-time events
    LaunchedEffect(Unit) {
        // LaunchedEffect is a COMPOSABLE FUNCTION for side effects
        viewModel.events.collect { event ->
            when (event) {
                is ArticleViewModel.UiEvent.ShowSnackbar ->
                    snackbarHostState.showSnackbar(event.message)
                is ArticleViewModel.UiEvent.NavigateToDetail ->
                    onNavigateToDetail(event.articleId)
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        when (val state = uiState) {
            is ArticleViewModel.UiState.Loading -> {
                LoadingScreen(modifier = Modifier.padding(padding))
            }
            is ArticleViewModel.UiState.Success -> {
                ArticleListContent(
                    articles = state.articles,
                    isRefreshing = state.isRefreshing,
                    onRefresh = viewModel::refresh,
                    onArticleClick = viewModel::onArticleClicked,
                    modifier = Modifier.padding(padding)
                )
            }
            is ArticleViewModel.UiState.Error -> {
                ErrorScreen(
                    message = state.message,
                    onRetry = viewModel::retry,
                    modifier = Modifier.padding(padding)
                )
            }
        }
    }
}

@Composable
private fun ArticleListContent(
    articles: List<Article>,
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    onArticleClick: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    // Stateless composable — receives state, sends events UP
    SwipeRefresh(
        state = rememberSwipeRefreshState(isRefreshing),
        onRefresh = onRefresh,
        modifier = modifier
    ) {
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(articles, key = { it.id }) { article ->
                ArticleCard(
                    article = article,
                    onClick = { onArticleClick(article.id) }
                )
            }
        }
    }
}

// View rules:
// ✅ Observes ViewModel state (collectAsStateWithLifecycle)
// ✅ Handles events (snackbar, navigation)
// ✅ Delegates all user actions to ViewModel
// ✅ Is STATELESS — doesn't manage business data
// ❌ Does NOT call Repository or API directly
// ❌ Does NOT contain business logic (validation, filtering, sorting)

Complete Data Flow — Visual

// ═══ FULL DATA FLOW — User taps refresh ══════════════════════════════
//
//  USER                                              
//   │ taps refresh button                             
//   ↓                                                 
//  VIEW (Composable)                                  
//   │ calls viewModel.refresh()                       
//   ↓                                                 
//  VIEWMODEL                                          
//   │ updates uiState → isRefreshing = true           
//   │ calls repository.refreshArticles()              
//   ↓                                                 
//  REPOSITORY                                         
//   │ calls remoteDataSource.getArticles()            
//   ↓                                                 
//  REMOTE DATA SOURCE                                 
//   │ calls api.getArticles() (Retrofit HTTP GET)     
//   │ ← response: [ArticleDto, ArticleDto, ...]       
//   ↓                                                 
//  REPOSITORY                                         
//   │ maps DTOs to Entities                           
//   │ calls localDataSource.insertArticles(entities)  
//   ↓                                                 
//  LOCAL DATA SOURCE                                  
//   │ inserts into Room database                      
//   │ Room's Flow AUTOMATICALLY emits updated list    
//   ↓                                                 
//  REPOSITORY                                         
//   │ getArticlesFlow() emits new List<Article>       
//   ↓                                                 
//  VIEWMODEL                                          
//   │ updates uiState → Success(articles, isRefreshing=false)
//   ↓                                                 
//  VIEW (Composable)                                  
//   │ collectAsStateWithLifecycle receives new state   
//   │ Compose recomposes → shows updated list         
//   ↓                                                 
//  USER sees updated articles                         

MVVM vs MVC vs MVP

// ┌──────────────┬──────────────────┬──────────────────┬──────────────────┐
// │              │ MVC              │ MVP              │ MVVM             │
// ├──────────────┼──────────────────┼──────────────────┼──────────────────┤
// │ View knows   │ Model directly   │ Presenter        │ ViewModel        │
// │ about        │                  │ (interface)      │ (observes state) │
// │              │                  │                  │                  │
// │ Logic lives  │ Controller       │ Presenter        │ ViewModel        │
// │ in           │ (Activity)       │ (own class)      │ (Jetpack VM)     │
// │              │                  │                  │                  │
// │ View update  │ Direct (Activity │ Presenter calls  │ Reactive         │
// │ mechanism    │ updates Views)   │ view.showData()  │ (StateFlow/      │
// │              │                  │                  │  LiveData)       │
// │              │                  │                  │                  │
// │ Testability  │ ❌ Hard (need    │ ✅ Good (mock    │ ✅ Great (test   │
// │              │ Activity)        │ View interface)  │ StateFlow)       │
// │              │                  │                  │                  │
// │ Rotation     │ ❌ Data lost     │ ❌ Data lost     │ ✅ ViewModel     │
// │              │                  │ (or save/restore)│ survives         │
// │              │                  │                  │                  │
// │ Android      │ ❌ Outdated      │ ⚠️ Older apps    │ ✅ Recommended   │
// │ support      │                  │                  │ by Google        │
// └──────────────┴──────────────────┴──────────────────┴──────────────────┘

// MVVM is the clear winner for Android because:
// - ViewModel survives configuration changes (built into Jetpack)
// - Reactive updates via StateFlow/LiveData (no manual view.show() calls)
// - Google's official architecture guide uses MVVM
// - Works seamlessly with Compose AND XML Views

Dependency Injection Wiring (Hilt)

// Hilt wires all the layers together automatically

@Module
// @Module is an ANNOTATION from Dagger/Hilt — provides dependencies
@InstallIn(SingletonComponent::class)
// @InstallIn is an ANNOTATION — scope this module to the application lifetime
// SingletonComponent is a CLASS — Hilt's application-level component
object NetworkModule {

    @Provides
    // @Provides is an ANNOTATION — tells Hilt how to create this dependency
    @Singleton
    // @Singleton is an ANNOTATION — one instance for entire app
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideArticleApi(retrofit: Retrofit): ArticleApi {
        return retrofit.create(ArticleApi::class.java)
        // Hilt provides Retrofit (from above), we create ArticleApi from it
    }
}

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        // @ApplicationContext is an ANNOTATION from Hilt — provides app Context
        return Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
    }

    @Provides
    fun provideArticleDao(database: AppDatabase): ArticleDao {
        return database.articleDao()
    }
}

// Repository and DataSources use @Inject constructor — Hilt creates them automatically
// ViewModel uses @HiltViewModel + @Inject constructor
// No manual wiring needed — Hilt handles the entire dependency graph

Common Mistakes to Avoid

Mistake 1: ViewModel calling API directly (skipping Repository)

// ❌ ViewModel talks directly to Retrofit — no caching, no single source of truth
@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val api: ArticleApi   // ❌ direct API access
) : ViewModel() {
    fun load() {
        viewModelScope.launch { api.getArticles() }
    }
}

// ✅ ViewModel talks to Repository — Repository handles caching
@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val repository: ArticleRepository   // ✅ goes through Repository
) : ViewModel() {
    fun load() {
        viewModelScope.launch { repository.refreshArticles() }
    }
}

Mistake 2: Putting UI logic in Repository

// ❌ Repository formats data for UI — that's ViewModel's job
class ArticleRepository {
    suspend fun getFormattedArticles(): List<String> {
        return api.getArticles().map { "${it.title} by ${it.author}" }
        // Formatting is UI concern — Repository shouldn't know about display
    }
}

// ✅ Repository returns domain objects — ViewModel formats for UI
class ArticleRepository {
    suspend fun getArticles(): List<Article> = api.getArticles().map { it.toDomain() }
}

class ArticleViewModel {
    val displayItems = repository.getArticlesFlow().map { articles ->
        articles.map { ArticleDisplayItem(it.title, "${it.author} · ${it.formattedDate}") }
    }
}

Mistake 3: View making decisions about data

// ❌ Composable contains business logic
@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
    val articles by viewModel.articles.collectAsStateWithLifecycle()
    val filtered = articles.filter { it.publishedAt > lastWeekTimestamp }
    // ❌ Filtering logic in the View!
    ArticleList(filtered)
}

// ✅ ViewModel handles all logic — View just displays
class ArticleViewModel {
    val articles = repository.getArticlesFlow()
        .map { it.filter { article -> article.publishedAt > lastWeekTimestamp } }
        .stateIn(...)
}

@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
    val articles by viewModel.articles.collectAsStateWithLifecycle()
    ArticleList(articles)   // View just displays — no filtering logic
}

Mistake 4: Using multiple sources of truth

// ❌ ViewModel fetches from API AND reads from database separately
class ArticleViewModel {
    val networkArticles = api.getArticles()    // source 1
    val cachedArticles = dao.getArticles()     // source 2
    // Which one is correct? They can be different!
}

// ✅ Single source of truth — database is the source, network refreshes it
class ArticleRepository {
    fun getArticlesFlow(): Flow<List<Article>> = dao.getArticles()  // single source

    suspend fun refresh() {
        val remote = api.getArticles()
        dao.insertAll(remote)   // updates the single source → Flow emits automatically
    }
}

Mistake 5: Exposing MutableStateFlow to the View

// ❌ View can directly modify state — breaks unidirectional flow
class ArticleViewModel : ViewModel() {
    val uiState = MutableStateFlow(UiState.Loading)   // MUTABLE exposed!
}
// View: viewModel.uiState.value = UiState.Error("hacked!")  — bypasses ViewModel logic

// ✅ Expose read-only StateFlow — mutations only through ViewModel functions
class ArticleViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()   // read-only
    // View must call viewModel.refresh() — can't modify state directly
}

Summary

  • MVVM separates your app into three layers: View (UI), ViewModel (presentation logic), Model (data layer)
  • View: observes ViewModel state, sends user events, contains zero business logic
  • ViewModel: holds UI state (StateFlow), handles user actions, transforms data for the screen, survives rotation
  • Repository: coordinates data sources, provides single source of truth, hides API/database details
  • Data Sources: wrap individual external sources (Remote = Retrofit, Local = Room, Preferences = DataStore)
  • Data flows DOWN (Repository → ViewModel → View); events flow UP (View → ViewModel → Repository)
  • Use separate models per layer: DTO (network), Entity (database), Domain (business logic) with mapper extension functions
  • Expose read-only StateFlow from ViewModel — never expose MutableStateFlow
  • Use SharedFlow for one-time events (snackbar, navigation)
  • The database is the single source of truth — network refreshes update the database, Flow emits changes
  • Wire layers with Hilt@HiltViewModel, @Inject constructor, @Module, @Provides
  • MVVM beats MVC and MVP on Android because of ViewModel lifecycle and reactive state updates

MVVM isn’t just a pattern — it’s the foundation of every well-built Android app. The separation is simple: View displays, ViewModel decides, Repository provides data. Each layer has one job, each is independently testable, and data flows in one direction. Master this, and Clean Architecture, MVI, and multi-module patterns are just refinements on top of the same foundation.

Happy coding!