My Mental Model for Android Architecture in 2025

· 5 min read

My Mental Model for Android Architecture in 2025

Architecture discussions in Android tend to get religious fast. MVVM vs MVI. Clean Architecture vs pragmatic layers. Single-activity vs multi-activity. Everyone has opinions. Few of those opinions are grounded in the context that matters most: what works for your team, your product, and your scale.

Here's how I think about Android architecture in 2025, based on what I've seen work in production.

The Core Principle

Architecture exists to manage complexity. If your app has three screens and one data source, you don't need layers. You need working code. Architecture becomes necessary when the codebase grows beyond what one person can hold in their head.

The question isn't "what's the best architecture?" It's "what's the minimum structure that keeps this codebase maintainable at our current scale?"

The Layers That Matter

┌─────────────────────────────┐
│           UI Layer          │  Compose / Fragments
│    (State + Event Handling) │  ViewModels
├─────────────────────────────┤
│         Domain Layer        │  Use cases (optional)
│       (Business Logic)      │  Only if logic is shared
├─────────────────────────────┤
│          Data Layer         │  Repositories
│   (Data Access + Caching)   │  API + Database
└─────────────────────────────┘

UI Layer

The UI layer owns two things: displaying state and capturing user events. Nothing else.

@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
 
    when (val current = state) {
        is FeedUiState.Loading -> LoadingIndicator()
        is FeedUiState.Success -> FeedContent(
            posts = current.posts,
            onPostClick = viewModel::onPostClicked,
            onRefresh = viewModel::refresh
        )
        is FeedUiState.Error -> ErrorMessage(
            message = current.message,
            onRetry = viewModel::refresh
        )
    }
}

The UI doesn't know where data comes from. It doesn't make network calls. It doesn't contain business logic. If you can describe what the UI does without mentioning APIs, databases, or business rules, you've drawn the boundary correctly.

Domain Layer

This is the most over-engineered layer in most Android projects. The domain layer should contain shared business logic. If a piece of logic is only used by one screen, it belongs in that screen's ViewModel, not in a use case.

I create a use case when, and only when, the same logic is needed by multiple ViewModels:

// This use case exists because both FeedViewModel and SearchViewModel
// need the same filtering and sorting logic.
class GetFilteredPostsUseCase(
    private val postRepository: PostRepository
) {
    suspend operator fun invoke(
        filter: PostFilter
    ): List<Post> {
        return postRepository.getPosts()
            .filter { it.matchesFilter(filter) }
            .sortedByDescending { it.engagementScore }
    }
}

If you have a use case that just delegates to a repository with no additional logic, delete it. It's adding indirection without value.

Data Layer

The data layer is where I spend the most architectural effort. Getting data right solves most problems upstream.

class PostRepository(
    private val api: PostApiService,
    private val dao: PostDao,
    private val clock: Clock
) {
    fun getPosts(): Flow<List<Post>> {
        return dao.observePosts()
            .onStart { refreshIfStale() }
    }
 
    private suspend fun refreshIfStale() {
        val lastRefresh = dao.getLastRefreshTime() ?: 0
        if (clock.currentTimeMillis() - lastRefresh > STALE_THRESHOLD_MS) {
            try {
                val fresh = api.fetchPosts()
                dao.replaceAll(fresh.toEntities())
                dao.setLastRefreshTime(clock.currentTimeMillis())
            } catch (e: IOException) {
                // Stale data is better than no data
            }
        }
    }
}

The repository is the single source of truth. The database is the cache. The API is the refresh mechanism. ViewModels observe the database, not the API. This pattern handles offline, stale data, and concurrent access naturally.

State Management

I use StateFlow for UI state. Not LiveData (lifecycle-aware but limited), not Channel (one-shot but lossy), not shared mutable state (chaos).

class FeedViewModel(
    private val getFilteredPosts: GetFilteredPostsUseCase
) : ViewModel() {
 
    private val _uiState = MutableStateFlow<FeedUiState>(FeedUiState.Loading)
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
 
    init {
        refresh()
    }
 
    fun refresh() {
        viewModelScope.launch {
            _uiState.value = FeedUiState.Loading
            try {
                val posts = getFilteredPosts(PostFilter.Default)
                _uiState.value = FeedUiState.Success(posts)
            } catch (e: Exception) {
                _uiState.value = FeedUiState.Error(e.userFriendlyMessage())
            }
        }
    }
}

What I've Stopped Doing

Over-abstracting too early. I used to create interfaces for everything "in case we need to swap implementations." In practice, I've swapped an implementation maybe twice in five years. Now I create interfaces only when I have a concrete second implementation or need them for testing.

Following Clean Architecture religiously. Clean Architecture is a set of principles, not a file structure. The "data/domain/presentation" folder split is fine, but creating Mapper classes, Entity vs Model vs Dto distinctions, and UseCase wrappers for every operation is ceremony that slows teams down.

Using abstract base classes for ViewModels. Every BaseViewModel I've ever created became a dumping ground. Composition beats inheritance in ViewModels just like everywhere else.

The Test I Apply

Good architecture makes these things easy:

  1. New features are fast to build. If adding a new screen requires touching 15 files, the architecture is too heavy.
  2. Bugs are easy to find. If a data bug could be in the UI, the ViewModel, a use case, the repository, the API service, or the database, there are too many places to look.
  3. Onboarding is smooth. If a new teammate can't figure out where to put code, the structure isn't clear enough.
  4. Refactoring is safe. If changing one module breaks unrelated modules, the boundaries are wrong.

Architecture isn't about following patterns. It's about creating a codebase that a team can work in efficiently over time. The patterns are just tools for getting there.

Related Posts