Every Android project starts clean. Then comes the second feature request, the third developer, and the inevitable ViewModel that grows past 500 lines. We have seen teams blame the architecture — but the real culprit is usually a lack of clear, enforced conventions. This guide is for developers who already know the basics of MVVM, MVI, or Clean Architecture and want a repeatable checklist to keep code from degrading. We will walk through seven steps, from data-layer decisions to testing strategies, with honest notes on where each pattern helps and where it hurts.
1. The Real Cost of Disorganized State Management
State management is the single biggest source of architectural debt in modern Android apps. When every screen holds its own mutable state in a ViewModel without a clear contract, bugs become hard to reproduce and even harder to fix. The core problem is not choosing between StateFlow and LiveData — it is deciding who owns the state and how it flows through the app.
In a typical project, we see teams start with a single ViewModel per screen. That works until a shared state — like a user profile or a shopping cart — needs to be updated from multiple screens. Developers then either duplicate the state or create a global singleton. Both approaches lead to stale data and inconsistent UI. A better pattern is to lift shared state into a dedicated repository or a use case that emits a single source of truth, and let each ViewModel observe only the slice it needs.
Unidirectional Data Flow (UDF) as a Baseline
UDF means that state flows down from a source (repository or use case) to the UI, and events flow up from the UI to the source. This sounds simple, but we often see violations: a ViewModel directly modifying a repository's internal list, or a fragment pushing state back to the ViewModel via a mutable LiveData. Enforce UDF by making all state updates go through a single function in the ViewModel, and expose state as an immutable StateFlow. This makes the data flow traceable in a single direction, which simplifies debugging and testing.
One team we worked with had a chat app where message read status was updated from three different places: the ViewModel, a background sync job, and a push notification handler. The result was a race condition that took weeks to untangle. Moving all status updates into a single MessageRepository with a StateFlow eliminated the race. The lesson: centralize state mutation, even if it feels like adding indirection.
2. Dependency Injection: Beyond the Buzzword
Dependency injection (DI) is not optional for a maintainable Android app, but many teams misuse it. The most common mistake is injecting every dependency into a ViewModel or a fragment, turning the constructor into a long list of parameters. This makes the class hard to test and harder to refactor. The goal of DI is to decouple creation from usage, not to centralize all dependencies in one place.
A better approach is to group related dependencies into thin modules. For example, instead of injecting a Retrofit instance, a Room database, and a shared preferences object directly into a ViewModel, create a UserRepository that encapsulates all data access for user-related features. The ViewModel then depends only on the repository, not on the underlying data sources. This reduces the number of parameters and makes it easy to swap implementations for testing.
Scoping Dependencies Correctly
Dagger and Hilt offer scopes like @Singleton, @ActivityScoped, and @ViewModelScoped. We often see teams over-scope: a network client that should live for the whole app gets scoped to a single activity, causing unnecessary re-creation. Conversely, a user session token that should be per-login is made a singleton, leaking across logout. The rule of thumb: scope a dependency to the longest-lived component that needs it, but no longer. For most apps, @Singleton is appropriate for network clients, database instances, and repositories. @ViewModelScoped is for use cases or states that are tied to a single screen.
One composite scenario: a team building a banking app scoped the authentication token to the application singleton. When the user logged out and back in, the old token was still cached, causing API calls to fail with 401 errors. The fix was to scope the token to a session component that is recreated on login. This is a classic example of why DI scoping matters beyond just performance — it affects correctness.
3. Repository Pattern: The Good, the Bad, and the Over-Engineered
The repository pattern is the most widely adopted abstraction in Android architecture. It promises a clean separation between data sources (network, database, cache) and the rest of the app. In practice, we see two extremes: repositories that are thin wrappers around a single API call, and repositories that try to handle every possible data operation in one class. Both extremes cause problems.
A thin repository adds no value — you might as well call the API directly from the ViewModel. A fat repository, on the other hand, becomes a god class that knows about every data source and every business rule. The sweet spot is a repository that coordinates between sources but delegates actual data operations to dedicated data source classes. For example, a UserRepository might decide whether to fetch from network or cache, but the actual network call goes to a UserRemoteDataSource, and the cache logic goes to a UserLocalDataSource.
When to Skip the Repository
Not every data access needs a repository. If you have a simple read-only list that comes from a single API endpoint and never needs offline support, a repository adds unnecessary abstraction. In that case, a use case or a direct API call in the ViewModel is fine. The repository pattern shines when you have multiple data sources or complex caching strategies. We recommend adding a repository only when you have at least two sources or a clear offline-first requirement. Premature abstraction is just as harmful as no abstraction.
One team we observed built a repository for every entity in their app — including a ButtonClickRepository for tracking analytics. That repository had one method and one source (an analytics SDK). The abstraction added no value and made the codebase harder to navigate. They later removed it and called the SDK directly from the use case, reducing boilerplate by 30% in that module.
4. Testing Architecture: Unit Tests vs. Integration Tests
Architecture decisions directly affect testability. A well-architected app makes it easy to write unit tests for business logic and integration tests for data flows. A poorly architected app forces developers to write large, slow integration tests that cover everything at once. We see teams either test nothing or test everything through the UI, both of which are unsustainable.
The key is to isolate business logic in use cases or domain layer classes that have no Android dependencies. These classes can be unit-tested with plain JUnit tests, without Robolectric or an emulator. For example, a CalculateLoanInterestUseCase takes input values and returns a result — no context, no database, no network. Testing it is straightforward and fast. Data layer classes, like repositories, are better tested with integration tests that use a real or in-memory database and a mock network. The ViewModel should be tested with unit tests that mock the repository, verifying that state updates correctly in response to events.
Common Testing Anti-Patterns
The most common mistake is testing the ViewModel with real repository instances, which turns a unit test into a slow integration test. Another is writing tests that assert on internal state (like a private MutableStateFlow) instead of the public state the UI observes. Tests should mirror how the code is used: send an event, observe the state, and verify the expected output. If you find yourself testing private methods or internal state, the architecture likely has a separation problem — move that logic to a dedicated class that can be tested independently.
One composite scenario: a team wrote 200 UI tests for a single screen, each launching the activity and waiting for network responses. The test suite took 45 minutes to run. After refactoring the ViewModel to use a repository interface and mocking the repository, they replaced the UI tests with 50 unit tests that ran in 2 seconds. The UI tests were kept only for critical user journeys. This is the kind of trade-off that a good architecture enables.
5. Handling Configuration Changes and Process Death
Android's lifecycle is unique, and architecture must account for it. Configuration changes (screen rotation, locale switch) and process death (when the system kills your app to reclaim memory) are not edge cases — they happen regularly. A common mistake is relying on SavedStateHandle for all state persistence, which is designed for small amounts of data (like a text field value) and not for complex domain objects.
The correct approach is to separate UI state from business state. UI state — like scroll position, selected tab, or input field text — can be saved via SavedStateHandle or a similar mechanism. Business state — like a list of items fetched from the network — should be stored in a repository that survives configuration changes and can be re-fetched after process death. The repository can use Room or DataStore to persist data across process restarts.
A Practical Checklist for State Persistence
- For UI-only state: use
SavedStateHandlein theViewModel. Limit data to primitives or small serializable objects. - For business state that must survive process death: persist in Room or DataStore. The repository should expose a
Flowthat theViewModelcollects. - For network responses that are expensive to re-fetch: cache them in a local database with a TTL (time-to-live). The repository decides whether to serve cached data or fetch fresh data.
- For one-shot events (like navigation or snackbar messages): use a sealed class or a shared flow with
replay = 0so that the event is not replayed on configuration change.
One team we worked with stored the entire user profile in SavedStateHandle. When the profile grew to include a large avatar bitmap, the app crashed on rotation because the serialized size exceeded the 1 MB limit. They moved the profile to Room and used SavedStateHandle only for the user ID. The crash disappeared, and the profile loaded faster from the local database than from the network.
6. When Not to Use This Architecture
Not every app needs a full MVVM + Clean Architecture stack. If you are building a prototype, a simple app with one screen, or an internal tool that will never be maintained long-term, the overhead of repositories, use cases, and dependency injection is not worth it. For those cases, a straightforward ViewModel with direct API calls may be sufficient.
Another scenario where strict architecture hinders productivity is when the team is small and the codebase is less than 10,000 lines. In that case, the abstraction layers add more friction than value. We have seen teams spend two days setting up Hilt modules and repository interfaces for an app that could have been built in one day with a simpler approach. The architecture should grow with the app, not be imposed from day one.
Signs You Are Over-Architecting
- You have a use case that simply delegates to a repository method without any additional logic.
- You have a repository that wraps a single API call with no caching or offline support.
- You inject a dependency that is used only in one place and could be created locally.
- You spend more time maintaining the architecture than adding features.
If any of these apply, consider simplifying. You can always add layers later when the complexity justifies them. The goal is not to follow a pattern for its own sake, but to make the code easier to change and test.
7. Open Questions and FAQ
We regularly get questions about specific choices in Android architecture. Here are the most common ones, with our honest answers.
Should we use MVI instead of MVVM?
MVI (Model-View-Intent) is a stricter form of MVVM that emphasizes a single, immutable state object and a reducer pattern. It works well for complex screens with many states, like a form with validation or a multi-step wizard. For simple screens, the overhead of defining an Intent class, a reducer, and a sealed state can feel heavy. We recommend starting with MVVM and moving to MVI only when you encounter state management issues that MVVM cannot solve cleanly.
Is it okay to use LiveData in 2025?
LiveData is still valid, but StateFlow offers better integration with Kotlin coroutines and is easier to test. LiveData has the advantage of being lifecycle-aware out of the box, but StateFlow with repeatOnLifecycle in the view layer achieves the same effect. For new projects, we lean toward StateFlow in the data and domain layers, and use LiveData only if the team is more familiar with it or if the project has existing LiveData usage.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!