The most popular Android architecture advice is also the easiest to misuse: add more layers, more abstractions, and more interfaces until the code resembles a reference diagram. That approach can produce tidy boxes while making a mid-sized product slower to change, harder to debug, and intimidating for every engineer who joins the team.
Good architecture isn't measured by how many folders it contains. It earns its keep by isolating change, making behaviour predictable, and keeping production failures understandable. The practical question behind Android app architecture best practices is therefore less “Which pattern is correct?” and more “Which boundaries will still help us after the app, team, and operating environment become complicated?”
Table of Contents
- The Simplicity Trap in Android Architecture
- Essential Architecture Layers and Boundaries
- Implementing Unidirectional Data Flow Correctly
- Dependency Injection for Maintainable Code
- State Management and Offline-First Strategies
- Testing Patterns That Match Your Architecture
- Production Readiness and Operational Excellence
The Simplicity Trap in Android Architecture
More layers don't automatically create a better Android app. A feature can have a repository, use case, mapper, domain model, ViewModel, state holder, and several interfaces, yet still place its real business decisions in UI code or scatter one workflow across too many files.
Google's recommendations focus on clear boundaries and reduced coupling, not architectural ceremony. The current guidance recommends at least a UI layer and a data layer, with a domain layer when it adds value. It also recommends unidirectional data flow, state holders, repositories, lifecycle-aware collection, coroutines and flows, and dependency injection. It doesn't define a universal point at which another module or abstraction becomes harmful to delivery and maintenance. That gap is why the right question for a mid-sized app is when Clean Architecture or MVVM stops reducing risk and starts creating cognitive load. Google's architecture recommendations are principle-based, while empirical Android architecture research emphasises decoupling and dependency management rather than one mandatory pattern.

A layer needs a job
Keep an abstraction when it does at least one useful thing:
- Contains a changing dependency: A repository can hide whether a feature reads from Room, a network service, or both.
- Protects a boundary: A domain service can express a business rule without importing Android classes.
- Improves independent testing: A pure function or small use case may be easier to verify than logic embedded in a ViewModel.
- Supports multiple consumers: Shared business behaviour deserves separation when several screens or workflows use it.
Remove a layer when it merely forwards every argument to another class, duplicates a model without transforming it, or exists because a template included it. A GetProfileUseCase that only calls profileRepository.getProfile() may be harmless, but it isn't automatically valuable. The cost is the extra navigation, naming, test setup, and mental model that every future change carries.
Practical rule: Design boundaries around sources of change, not around the number of boxes in an architecture diagram.
Production durability also depends on product constraints. A regulated workflow, a long-lived enterprise app, and a small consumer utility may all use MVVM, but they shouldn't all receive the same degree of modularisation. Teams making these decisions can also use how to align tech with business goals as a useful reminder that architecture should serve delivery, risk, and product outcomes together.
A sensible starting point is a UI layer, a data layer, and explicit interfaces where change or testing demands them. Add a domain layer when business rules become substantial, reused, or difficult to test in presentation code. Add feature modules when ownership, build boundaries, or dependency control justify them. Textbook completeness is optional. Production resilience isn't.
Essential Architecture Layers and Boundaries
Android's official architecture guidance recommends at least two layers, UI and data, and treats the domain layer as optional. It also recommends unidirectional data flow, lifecycle-aware state collection, coroutines and flows, dependency injection, clear module boundaries, and reduced dependence on Android classes. The purpose is not to make every project look identical. The purpose is to keep application data and state out of app components and prevent business rules from becoming trapped in screens. Android's official architecture guidance sets out these boundaries directly.

Presentation should coordinate, not decide
The presentation layer contains Compose UI or Views, ViewModels, and state holders. It translates user actions into intents or method calls, observes state, and renders loading, content, empty, and error conditions. It shouldn't decide how a discount is calculated, how persistence works, or which HTTP endpoint supplies a value.
A ViewModel can coordinate a screen workflow, but it shouldn't become a private application service. If a single ViewModel validates input, calls Retrofit, writes to Room, maps transport responses, and decides business policy, the screen has become a dependency hub. That design may feel fast for a small feature, then becomes expensive when requirements change.
Data owns access and persistence
The data layer contains repositories, remote data sources, local data sources, database interfaces, and mapping logic. A repository should offer a stable contract to the rest of the app while deciding whether data comes from a server, cache, or persisted store.
A useful rule is that the UI consumes a domain-facing or presentation-ready model, not a Retrofit response object or a Room entity. Mapping at the boundary prevents API naming, database schema, and UI needs from becoming one coupled model. It also gives the team a place to handle stale records, missing fields, and migration concerns without leaking them into composables.
Domain is earned, not assumed
The domain layer is valuable when business rules need to stand apart from Android and data technologies. It can hold use cases, policy objects, validators, and domain models that express decisions such as eligibility, permissions, or workflow transitions.
It isn't valuable merely because a Clean Architecture checklist names it. If a feature reads a record, displays it, and has no meaningful business rule, a repository and ViewModel may be enough. If several flows apply the same rules, or the rules need isolated tests, a domain layer starts paying for itself.
| Boundary | Owns | Should avoid |
|---|---|---|
| Presentation | Rendering and screen state | Database and network policy |
| Domain | Business decisions | Android framework details |
| Data | Remote and local access | UI event handling |
For a broader comparison of boundary-driven design, Technioz's practical architecture guide provides useful context, while software architecture design patterns can help teams compare patterns without treating any one pattern as mandatory.
Implementing Unidirectional Data Flow Correctly
Unidirectional Data Flow, or UDF, works when the team treats state as an observable result rather than a bag of mutable fields. A user action enters through the UI, the state holder delegates work, the data or domain layer performs the operation, and a single state source emits the result back to the UI.
The sequence is simple:
- The user submits an action.
- The ViewModel validates or delegates it.
- A repository or use case performs the work.
- The state holder updates one screen state.
- The UI renders that state.
StateFlow is often a natural fit for this model, although LiveData can also support lifecycle-aware observation. The important decision isn't the library. It's that the UI doesn't reach around the ViewModel to update a repository directly, and the repository doesn't reach into a composable to trigger rendering.
One state source beats several partial truths
A screen should have an explicit state model that represents what the UI needs to render. That might include content, progress, an error state, and a one-off event channel, but each element needs a clear ownership rule.
A common failure is keeping isLoading in one observable, a list in another, and an error in a third, then updating them in separate coroutine paths. The UI can briefly render combinations that never represented a valid application state. A single immutable state object reduces that ambiguity:
data class OrdersUiState(
val orders: List<Order> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)The implementation details can vary, but the principle is stable. The persistence module should act as the single source of truth for persisted data, and the UI should observe a stream derived from that source. The empirical Android architecture study describes independent, Android-independent components, responsibility boundaries, local caching, avoidance of nested callbacks, and interface-based communication as recurring practitioner guidance. Those choices reduce callback complexity and make behaviour more resilient when network or device conditions change. The empirical study on Android architecture guidelines supports that relationship.
Side effects need a home
Navigation, snackbars, permission requests, and analytics aren't durable screen state. Treating them as ordinary state can cause repetition after configuration changes or process recreation. Use an explicit event strategy, and make event handling safe when the UI collects again.
Don't launch work from a composable body, don't mutate shared state from several unrelated scopes, and don't use a repository as an event bus. These shortcuts hide ownership. A ViewModel should expose state and accept actions, while application services perform work through injected contracts.
UDF also doesn't mean every method must pass through five layers. For a simple local interaction, a ViewModel can update state directly. The architecture remains directional if data moves through one controlled path and the UI remains a renderer rather than a second business layer.
Dependency Injection for Maintainable Code
Dependency injection is useful because it makes construction explicit and replacement possible. It isn't useful because every class must be registered in a graph. Hilt and Koin can both support Android applications, but neither library fixes unclear ownership or poor module boundaries.
Start with the dependencies that define a feature's external behaviour: a repository interface, a clock, a dispatcher provider where needed, a database access object, or a remote client. Inject those into the class that uses them. Avoid injecting a large service locator and asking the consumer to discover its own dependencies, because that preserves hidden coupling behind a different API.
Scope follows lifetime
A database client or configured network client may live at application scope. A screen-specific coordinator should usually live with the screen or ViewModel. A short-lived operation may need no special scope at all.
Over-scoping creates long-lived object graphs that retain references unnecessarily. Under-scoping can recreate expensive resources or produce inconsistent state. The correct scope follows the dependency's lifetime and ownership, not a blanket preference for singletons.
Use Hilt modules to expose construction at stable boundaries. Use Koin definitions when its runtime configuration and lighter setup suit the team. In both cases, keep modules organised by responsibility or feature rather than building one giant dependency file that knows every implementation in the application.
Keep the graph boring
A maintainable graph has few surprises:
- Interfaces at changeable edges: Inject abstractions where the implementation may vary, especially for network, storage, time, and external services.
- Concrete types for stable internals: Don't create an interface for a class that has one implementation, no meaningful test substitution, and no likely boundary.
- Constructor injection first: It makes required dependencies visible and keeps objects valid after creation.
- Test replacements at the same boundary: A fake repository should replace the repository contract, not require a special production pathway.
Circular dependencies usually reveal a design problem. A ViewModel shouldn't require a service that requires the ViewModel, and a repository shouldn't depend on UI state. Break the cycle by moving policy into a domain component or narrowing the contract.
Dependency injection supports an MVP when it stays small. Later, it can support feature modularisation and test isolation. It becomes a maintenance burden when the team spends more time understanding scopes and generated wiring than changing product behaviour. Inject what you own, isolate what changes, and leave trivial objects uncomplicated.
State Management and Offline-First Strategies
A production Android app shouldn't treat the network as the only place truth exists. Connectivity changes, requests fail, and users expect the interface to remain useful while the device is offline. Local caching gives the application a stable state to render, while synchronisation updates that state when remote data becomes available.
A practical offline-first flow begins with a user action entering a ViewModel. The ViewModel delegates to a repository, the repository writes or reads through a local database such as Room, and the UI observes the persisted stream. Network work can refresh the cache or enqueue a pending mutation, but it shouldn't block every screen render.

Design the repository around truth
The repository should answer a clear question: which source drives the UI, and how do remote changes enter that source? For read-heavy features, that often means observing Room and refreshing it from the network. For write-heavy features, it may mean recording a local pending operation, displaying its status, and synchronising it through WorkManager or another controlled mechanism.
Conflict resolution must be explicit. Last-write-wins might be acceptable for a low-risk preference, but not for inventory, financial data, or collaborative editing. Store enough metadata to distinguish pending, synchronised, failed, and conflicted records, then expose those states deliberately rather than hiding them behind a generic error.
The 2019 empirical study cited earlier identifies local caching, Android-independent components, libraries instead of reinvention, and a dedicated persisted source of truth as foundational practices. Those recommendations connect directly to resilience and maintainability because each layer can evolve without forcing the UI to understand transport or device conditions.
Treat lifecycle and performance as one concern
Use lifecycle-aware collection so inactive screens don't continue consuming work unnecessarily. Keep database and network operations off the main thread, expose flows rather than manually coordinating nested callbacks, and avoid retaining Activities or Views in long-lived objects.
Offline-first architecture does add policy. It needs cache invalidation, retry behaviour, conflict handling, and migration decisions. But putting those rules in a repository is usually simpler than making every screen understand connectivity. The architecture should make failure states ordinary, observable, and testable.
The University of Illinois System describes application development through an SDLC-based service supporting web, batch, mobile, and application integrations. That enterprise context reinforces a useful lesson: reliable application work benefits from an organised lifecycle rather than ad hoc structure. The University of Illinois application-development service provides that formal process context, while the architecture practices above translate it into Android boundaries.
Testing Patterns That Match Your Architecture
Testing should influence architecture before the first feature becomes difficult to change. If a ViewModel can only be tested by launching an Activity, creating a database, configuring a network client, and waiting for real timing, the boundary is doing too much. If a use case requires a framework object for a pure calculation, the domain boundary is leaking.
Unit tests belong where behaviour can be evaluated quickly and deterministically. Test validators, mappers, reducers, and business rules with plain Kotlin. Test ViewModels with fake repositories and controlled coroutine execution, checking state transitions rather than internal implementation calls.
Instrumented tests have a different purpose. They verify Android integration, navigation, database behaviour, permissions, and interactions that a local unit test can't represent. UI tests should focus on meaningful user flows, not every private composable detail, because brittle selectors and incidental layout assertions create noise.
Compare the test to the failure risk
| Test type | Best fit | Common mistake |
|---|---|---|
| Pure unit test | Business rules, mapping, state reduction | Testing framework behaviour |
| ViewModel test | Loading, success, empty, and failure transitions | Depending on real network timing |
| Repository test | Cache, refresh, and persistence policy | Mocking every internal line |
| Instrumented test | Android integration and navigation | Repeating all unit coverage |
| UI test | Critical interaction paths | Asserting implementation details |
Dependency injection should make test doubles ordinary. A fake repository with deterministic flows often reveals more than a heavily mocked chain because it tests the contract the ViewModel needs. For asynchronous work, control dispatchers and time so a test doesn't pass or fail based on scheduling luck.
Architecture that requires extensive test-only plumbing is sending a signal. Sometimes the answer is a new boundary. Sometimes it's deleting an unnecessary abstraction. Use mobile app testing strategies for additional comparison points, but keep the central decision local to the feature's risk and behaviour.
The most valuable tests protect decisions that would hurt users if they changed accidentally. They don't prove that the project has a fashionable architecture. They prove that the architecture makes important behaviour easy to observe.
Production Readiness and Operational Excellence
Production readiness is the final test of architecture. A design that looks clean in a pull request but produces unclear crashes, slow startup, memory retention, or risky hotfixes hasn't solved the core problem. Durable Android architecture lets teams locate failures, isolate changes, and release corrections without understanding the entire application first.
The operational checklist should start with evidence:
- Crash visibility: Group failures by meaningful cause and preserve enough context to identify the feature boundary.
- Performance profiling: Inspect startup, rendering, database work, and network scheduling with Android Studio profiling tools.
- Memory discipline: Watch lifecycle ownership, image loading, cached collections, and long-lived scopes for retained Activities or Views.
- Release safety: Keep feature boundaries clear enough that a correction doesn't require unrelated screens to change.
- State observability: Make loading, stale, offline, pending, and failed states distinguishable in logs and support diagnostics.

Optimise for diagnosis, not just execution
Clear module boundaries reduce the search area during an incident. If a screen depends on a repository contract and the repository owns cache and network policy, an engineer can inspect those edges instead of tracing arbitrary calls through UI callbacks. If the app uses a feature flag, record the evaluated state in diagnostics where appropriate so a release issue isn't mistaken for a code defect. Teams considering rollout controls can review feature flag management as part of that operational design.
Architecture also affects maintenance outside runtime. A formal lifecycle, clear ownership, and testable contracts make code review and release planning more predictable. The University of Illinois service's SDLC framing is a useful reminder that application quality depends on process as well as structure, particularly when mobile software integrates with broader enterprise systems.
The best architecture is the simplest one that preserves these operational properties. Start with the minimum layers that isolate meaningful change. Add a domain layer, module, abstraction, or synchronisation policy when production evidence shows the boundary is needed. Remove ceremony when it only makes navigation and testing harder.
Production standard: If the team can't explain where state comes from, where business rules live, and how a failed request is diagnosed, the architecture isn't finished.
Ryware helps teams design and build mobile applications with clear boundaries, testable patterns, and operational reliability across Android and connected systems. Visit Ryware to discuss an Android architecture that fits your product's actual complexity and supports dependable delivery after launch.