Every Android team eventually hits the wall: the app works, but adding one more feature feels like surgery. The architecture that got you to MVP now slows every change. This guide is for busy developers who want a practical, no-fluff checklist to keep their codebase maintainable without over-engineering. We'll focus on decisions that matter—not every pattern ever invented.
Why Architecture Actually Matters for Your Timeline
Architecture isn't about following a trend; it's about controlling the cost of change. When a new developer joins your team and needs to add a search bar, how long before they touch production code? If the answer is 'a couple of hours,' you're in good shape. If it's 'a couple of days' because they have to untangle dependencies, you have an architecture problem.
The real stakes show up in maintenance. A typical Android project lives for years, and the team that built it often isn't the team that maintains it. Without clear boundaries between UI, business logic, and data, simple bug fixes become multi-day investigations. We've seen teams spend weeks refactoring a single screen because the ViewModel was doing network calls, caching, and formatting—all at once.
Architecture also directly impacts your ability to test. If your code is tightly coupled, writing unit tests feels like a chore, and you skip them. That's when regressions creep in. A good architecture makes testing the default, not an afterthought.
But here's the nuance: you don't need the most elaborate architecture from day one. You need a skeleton that can grow. The checklist we provide helps you decide what to put in place early and what to defer. It's about being intentional, not dogmatic.
The Cost of No Architecture
Without any deliberate structure, code tends to accumulate in the easiest place: Activities and Fragments. These become god objects that handle clicks, network responses, database writes, and navigation. The result is a monolith that's hard to test and impossible to reuse. We've seen apps where a single Activity has over 2000 lines of code—and every new feature makes it worse.
When Architecture Becomes Overhead
On the flip side, teams sometimes adopt every new pattern from talks and blogs without considering their actual needs. We've consulted on projects where the team spent more time configuring dependency injection modules than writing business logic. The key is to match the complexity of your architecture to the complexity of your app. A simple note-taking app doesn't need Clean Architecture with five layers. A banking app does.
The Core Ideas: Separation of Concerns and Unidirectional Data Flow
Two principles underpin most modern Android architectures: separation of concerns and unidirectional data flow (UDF). Separation of concerns means each class has a single, well-defined responsibility. UDF means data flows in one direction—from the data source to the UI, and user actions flow back through a single pipeline. This makes the flow of data predictable and debuggable.
In practice, UDF often looks like this: the UI observes a state object from the ViewModel. When a user taps a button, the UI calls a function on the ViewModel, which updates the state. The UI automatically reflects the new state. No two-way binding, no random state mutations from different places. This pattern is the foundation of Android's recommended architecture, and it works well for most apps.
But UDF isn't the only way, and it's not always the best. For small screens with simple logic, a more direct approach might be faster. The guideline is: use UDF when the screen has multiple sources of truth or when the business logic is complex enough to warrant testing in isolation.
Why Separation of Concerns Matters for Testing
When you separate concerns, you can test each piece independently. The ViewModel can be unit-tested without the UI. The repository can be tested with fake data sources. This isolation is what makes tests fast and reliable. Without it, tests become integration tests that are slow and brittle.
The Role of the Repository Pattern
A repository is a single entry point for data, whether it comes from a local database, a remote API, or an in-memory cache. It abstracts the data source from the rest of the app. This means you can change the data source (e.g., switch from SQLite to Room) without changing the code that uses the data. It also simplifies testing: you can replace the real repository with a mock that returns predefined data.
How the Pieces Work Together Under the Hood
Let's trace a typical user action through a well-architected Android app. The user taps a 'Refresh' button. The Activity or Fragment calls a function on the ViewModel. The ViewModel, in turn, calls a function on the repository. The repository decides whether to fetch fresh data from the network or return cached data from the database. Once the repository returns the data, the ViewModel updates its state object, which is observed by the UI. The UI re-renders with the new data.
This chain works because each layer has a clear contract. The UI only knows about the ViewModel's state and actions. The ViewModel only knows about the repository. The repository only knows about data sources. This separation means you can replace any layer without affecting the others.
But the devil is in the details. For example, the ViewModel should not hold a reference to the Activity or any View. If it does, it will leak memory when the Activity is destroyed. Similarly, the repository should not expose LiveData or Flow directly to the ViewModel if the data needs to be transformed. Instead, the ViewModel should map the data into a UI state that the View understands.
State Management: StateFlow vs. LiveData
Both StateFlow and LiveData are observable data holders. LiveData is lifecycle-aware and works well with the Android framework, but it's tied to the main thread and can be less explicit about its behavior. StateFlow is a Kotlin coroutine-based alternative that is more flexible and testable. We recommend StateFlow for new projects, but LiveData is still fine for simpler cases or when you need lifecycle awareness out of the box.
Handling Configuration Changes
One of the biggest challenges in Android is retaining state across configuration changes (like screen rotations). ViewModel survives these changes automatically, so you should keep your UI state in the ViewModel. But what about transient UI state like scroll position? You can either save it in the ViewModel or use savedStateHandle. The rule of thumb: persist any state that the user would expect to survive a process death, and keep in the ViewModel only what's needed during the current session.
Worked Example: Building a Search Screen with UDF
Let's walk through a concrete example: a search screen that queries a remote API and displays results. We'll use a ViewModel, a repository, and a UI that observes state.
First, define the UI state. It should represent everything the screen needs to render: the current query, the list of results, loading status, and any error messages. A data class works well here:
data class SearchUiState(
val query: String = "",
val results: List<Result> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)Next, the ViewModel exposes a StateFlow of this state. It also exposes a function for the UI to call when the query changes:
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
fun onQueryChanged(query: String) {
// debounce, then call repository
}
}The UI collects the StateFlow and renders accordingly. When the user types, the ViewModel debounces the input, calls the repository, and updates the state. The UI automatically reacts.
This pattern makes the code easy to test: you can write unit tests for the ViewModel by feeding it fake repository responses and verifying the state. You can also test the UI with Compose's testing APIs by providing a fake ViewModel.
What About Error Handling?
Errors should be part of the UI state, not thrown as exceptions. In the ViewModel, catch exceptions from the repository and set the error field. The UI can then display a Snackbar or a retry button. This keeps the UI in control of the user experience.
Navigation and State
When navigating between screens, each screen should have its own ViewModel. Shared state between screens can be handled by a shared ViewModel scoped to the Activity or by using a navigation library that passes arguments. Avoid putting navigation logic in the ViewModel; keep it in the UI layer.
Edge Cases and Exceptions
No architecture works for every scenario. Here are common edge cases and how to handle them.
Screen with multiple data sources. For example, a dashboard that shows weather, calendar events, and notifications. Each data source may have different loading times and error states. The solution is to have separate state fields for each source or to use a more complex state structure that models the loading state of each component independently.
Real-time updates via WebSocket or Firebase. These introduce a push-based data flow that doesn't fit neatly into the request-response model. You can still use UDF by treating the incoming data as external events that update the repository. The ViewModel observes the repository's flow and updates the UI state accordingly.
Complex user input forms. Forms with validation, interdependent fields, and async checks (e.g., checking if a username is taken) can be challenging. Consider using a dedicated form ViewModel or a state machine that tracks the form's progress. Keep the validation logic in the ViewModel, not in the UI.
When UDF Adds Complexity
For very simple screens, like a static 'About' page, UDF is overkill. You can just display data directly. The checklist should be applied pragmatically: if a screen has no user interactions and no state changes, don't force a ViewModel.
Testing Edge Cases
When testing, make sure to cover configuration changes, process death, and network errors. For configuration changes, verify that the ViewModel retains its state. For process death, use savedStateHandle to persist critical data. For network errors, ensure the UI shows a meaningful error state and allows retry.
Limits of the Approach
The architecture described here is not a silver bullet. It assumes a certain level of discipline from the team, and it can feel heavy for small projects. The main trade-off is between structure and speed. In the early stages of a startup, moving fast might be more important than clean architecture. You can always refactor later—but the cost of refactoring grows as the codebase grows.
Another limitation is that this architecture works best for apps with a clear separation between UI and business logic. If your app is heavily game-like or uses a lot of custom animations, the UDF pattern might feel unnatural. In those cases, consider a more event-driven approach.
Also, this architecture doesn't solve all testing problems. You still need to write tests. The architecture makes testing easier, but it doesn't do the work for you. Teams that skip tests will still have regressions, no matter how clean their architecture is.
Finally, the architecture is only as good as the team's understanding. If developers on your team don't understand the patterns, they will misuse them. Invest in pair programming, code reviews, and documentation to ensure everyone is on the same page.
When to Consider Alternatives
If your team is small and the app is simple, consider skipping the repository layer and having the ViewModel call the data source directly. You can always add a repository later when you need to cache data or switch sources. Similarly, if you're using Compose, you might find that a simple state holder in the composable works fine for local UI state, without a ViewModel.
Frequently Asked Questions
Should I use LiveData or StateFlow? For new projects, we recommend StateFlow because it integrates better with coroutines and is more flexible. LiveData is still supported and works well for simpler cases, especially if you're not using coroutines extensively. The choice is less important than consistency: pick one and use it throughout the project.
How do I handle shared state between screens? Use a shared ViewModel scoped to the Activity or a navigation graph. Alternatively, use a singleton repository that holds the shared data. Avoid putting shared state in the Application class, as it makes testing harder.
What about dependency injection? We recommend using Hilt or Koin for dependency injection. It makes the architecture more testable and easier to manage. Start with a simple setup and add modules as needed.
How do I test the ViewModel? Use a test framework like JUnit and Mockito or MockK. Create a fake repository that returns predefined data, then call the ViewModel's functions and verify the state. Use Turbine to test Flows.
Is Clean Architecture necessary? No. Clean Architecture is a set of principles, not a prescription. You can apply the core ideas—separation of concerns, dependency inversion—without having five layers. The key is to keep the business logic independent of the framework.
Practical Takeaways
Here's your short checklist to apply starting tomorrow:
- Start every screen with a single UI state data class. Include fields for loading, error, and data.
- Use a ViewModel for any screen that has user interaction or data loading. Keep the ViewModel free of Android framework references.
- Introduce a repository for each data domain (e.g., UserRepository, ProductRepository). This abstracts the data source and makes testing easy.
- Prefer StateFlow over LiveData for new code. Use it with coroutines and collect in the UI lifecycle-aware manner.
- Write unit tests for your ViewModels. They are the most valuable tests for catching business logic bugs.
- Agree on error handling patterns with your team. Use a sealed class for success/error/loading states, or include error in the UI state.
- Keep your architecture as simple as possible for your current needs. Refactor when the pain of not refactoring exceeds the pain of refactoring.
Architecture is a tool, not a goal. The best architecture is the one that helps your team ship features confidently and fix bugs quickly. Use this checklist as a starting point, and adapt it to your project's real needs.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!