Android development has changed significantly over the last few years. A modern Android application is no longer simply an Activity, a few XML layouts, and some API calls. Today's applications need to handle asynchronous data, configuration changes, offline behavior, testing, dependency management, background work, scalability, and increasingly complex user interfaces. For production applications, the goal should not simply be to make the application work. The goal should be to build software that remains maintainable, testable, scalable, and understandable as the product grows. A typical modern Android architecture can be represented like this:
User
│
▼
Jetpack Compose UI
│
User Events
│
▼
ViewModel
│
StateFlow / Flow
│
▼
Use Case
│
▼
Repository
/ \
/ \
Remote API Local Data
Retrofit/Ktor Room/DataStore
\ /
\ /
Data Layer
Let's look at what each part is responsible for and why this separation matters.
1. Kotlin as the Foundation
For modern native Android development, Kotlin should generally be the default language. It provides features such as null safety, coroutines, extension functions, sealed types, data classes, and excellent interoperability with existing Java code. For example, application state can be modeled cleanly using a sealed interface:
sealed interface UiState {
data object Loading : UiState
data class Success(
val data: List<Device>
) : UiState
data class Error(
val message: String
) : UiState
}
This is significantly safer than representing application state using multiple unrelated Boolean variables such as:
isLoading
hasError
hasData
Those variables can accidentally create impossible combinations. A sealed hierarchy makes the valid states explicit.
2. Jetpack Compose for Declarative UI
Jetpack Compose changes the way Android interfaces are designed. Traditional UI development is largely imperative:
Find View
↓
Change View
↓
Update another View
↓
Hide something
↓
Show something else
Compose encourages a declarative approach. Instead of manually telling the UI how to change, we describe what the UI should look like for the current state.
@Composable
fun DeviceScreen(
state: UiState
) {
when (state) {
UiState.Loading -> LoadingScreen()
is UiState.Success ->
DeviceList(state.data)
is UiState.Error ->
ErrorScreen(state.message)
}
}
The fundamental idea becomes:
State
↓
UI
When the state changes, Compose updates the necessary parts of the interface through recomposition. This leads to one of the most important principles in modern Android development: The UI should be a representation of application state.
3. Keep Business Logic Out of Composables
Composable functions should primarily be responsible for displaying state and reporting user interactions. They should not become containers for networking, database operations, and business logic. Avoid designs like:
Composable
├── HTTP request
├── database query
├── business rules
├── state management
└── UI rendering
A healthier structure is:
Composable
│
│ event
▼
ViewModel
│
▼
Use Case / Repository
For example:
@Composable
fun DeviceScreen(
state: DeviceUiState,
onRefresh: () -> Unit
) {
// Render state
}
This also makes Composables easier to preview, reuse, and test.
4. ViewModel as the UI State Holder
The ViewModel acts as the bridge between the UI and the rest of the application. A simplified example:
class DeviceViewModel(
private val repository: DeviceRepository
) : ViewModel() {
private val _uiState =
MutableStateFlow<DeviceUiState>(
DeviceUiState.Loading
)
val uiState: StateFlow<DeviceUiState> =
_uiState.asStateFlow()
fun loadDevices() {
viewModelScope.launch {
// Load data
}
}
}
Notice an important detail:
private val _uiState
is mutable internally. But:
val uiState
is exposed as immutable state. The UI can observe the state but cannot arbitrarily modify it. This creates a predictable direction of data flow:
State
ViewModel ────────► UI
ViewModel ◄──────── UI
Events
This pattern is often called Unidirectional Data Flow.
5. StateFlow and Flow
Modern applications are naturally asynchronous. A device may change state. A database may emit new records. An API request may complete. A connection may disappear. A user may change settings. Representing these changes as streams makes the architecture much easier to reason about. Kotlin Flow provides an asynchronous stream abstraction. StateFlow is particularly useful for UI state because it always contains a current value. The typical chain becomes:
Repository
│
▼
Flow
│
▼
ViewModel
│
▼
StateFlow
│
▼
Compose
│
▼
Recomposition
In Compose, state can be collected lifecycle-aware:
val state by viewModel.uiState
.collectAsStateWithLifecycle()
Now the interface reacts naturally to state changes.
6. Coroutines and Structured Concurrency
Android applications perform many asynchronous operations:
- network requests
- database operations
- file access
- device communication
- background synchronization
- CPU-intensive processing
These operations should not block the main thread. Kotlin Coroutines provide the foundation for asynchronous work. For example:
viewModelScope.launch {
val devices = repository.getDevices()
}
One important misconception is:
suspend = background thread
This is incorrect. A suspend function can suspend execution without blocking the thread, but its execution context still matters. Common dispatchers include:
Dispatchers.Main
UI work
Dispatchers.IO
Blocking I/O
Dispatchers.Default
CPU-intensive work
Structured concurrency is equally important. Using:
viewModelScope
ties coroutine lifetime to the ViewModel rather than allowing uncontrolled background tasks to survive indefinitely.
7. The Repository Pattern
The UI should not care whether data comes from:
- REST
- Bluetooth
- a database
- cache
- a file
- another device
That is one of the responsibilities of the repository. Consider:
interface DeviceRepository {
fun observeDevices(): Flow<List<Device>>
suspend fun refreshDevices()
}
The ViewModel only understands the abstraction. The actual implementation could communicate with a REST API today and use a local database tomorrow.
ViewModel
│
▼
DeviceRepository
/ \
▼ ▼
Remote API Room
This reduces coupling between layers.
8. Dependency Injection
Consider this:
class DeviceViewModel : ViewModel() {
private val repository =
DeviceRepositoryImpl()
}
The ViewModel is now responsible for creating its own dependency. That creates unnecessary coupling and makes testing harder. Instead:
class DeviceViewModel(
private val repository: DeviceRepository
) : ViewModel()
The dependency is supplied externally. Libraries such as Hilt can manage this dependency graph in larger Android applications. Dependency Injection gives us several advantages:
Loose coupling
+
Testability
+
Replaceable implementations
+
Centralized dependency management
9. Clean Architecture — Without Overengineering
A larger Android application may benefit from three major layers:
Presentation
│
▼
Domain
│
▼
Data
Presentation
Contains things such as:
Compose
ViewModel
UiState
Domain
Contains application/business rules:
Use Cases
Domain Models
Repository Interfaces
Data
Contains infrastructure:
Repository Implementations
Retrofit/Ktor
Room
DataStore
DTOs
Data Sources
For example:
DeviceScreen
↓
DeviceViewModel
↓
ObserveDevicesUseCase
↓
DeviceRepository
↓
DeviceRepositoryImpl
↙ ↘
Room REST API
However, Clean Architecture should not become a religion. A small application does not necessarily need:
Screen
↓
ViewModel
↓
UseCase
↓
Interactor
↓
Repository
↓
DataSource
↓
Another abstraction
just to read one value. Architecture exists to manage complexity. It should not create complexity simply to look sophisticated.
10. Modularization
As an application grows, separating functionality into Gradle modules can improve maintainability. A possible structure might look like:
app/
core/
├── common/
├── designsystem/
├── network/
├── database/
└── model/
feature/
├── home/
├── devices/
├── settings/
└── profile/
Feature modules can then have clear responsibilities. This can improve:
- ownership
- build organization
- dependency boundaries
- reuse
- testability
But, just like Clean Architecture, modularization should solve an actual problem. Creating 40 modules for a tiny application is not automatically good architecture.
11. Networking
A typical network stack could look like:
Compose
↓
ViewModel
↓
Repository
↓
Retrofit / Ktor
↓
HTTP
↓
Backend
Network failures should also be treated as normal application states rather than unexpected situations. Applications must consider:
No connection
Timeout
Authentication failure
Server error
Malformed response
Rate limiting
Temporary failure
A production application needs a deliberate strategy for each relevant failure mode.
12. Local Persistence
Not every piece of data belongs in the same storage system.
Room
Useful for structured relational data and offline caching.
DataStore
Useful for smaller preferences such as:
Theme
Units
User settings
Feature preferences
Files
Useful for larger binary resources such as media. The repository layer can coordinate local and remote sources:
Repository
/ \
/ \
Local Remote
Room API
This is also the basis for offline-first architectures.
13. Error Handling Should Be Part of the Architecture
A production application should not assume the happy path. Consider a connected-device application. What happens when: The device disconnects? Bluetooth is disabled? Permission is denied? The network disappears? The application goes into the background? The process is recreated? The server returns invalid data? The user presses Connect ten times? These are not unusual situations. They are normal application states. For example:
sealed interface ConnectionState {
data object Disconnected : ConnectionState
data object Connecting : ConnectionState
data object Connected : ConnectionState
data class Error(
val cause: Throwable
) : ConnectionState
}
Thinking explicitly about states makes edge cases much easier to manage.
14. Testing Should Exist at Multiple Levels
A maintainable project should not rely exclusively on manual testing. A practical testing strategy contains multiple layers.
UI / E2E Tests
▲
/ \
/ \
Integration Tests
▲
/ \
/ \
Unit Tests
Unit tests are fast and should cover important logic. Integration tests verify that components work correctly together. UI tests validate important user journeys. The important point is not maximizing the number of tests. It is protecting important behavior with tests at the cheapest appropriate level.
15. Design for Testability
Testing should influence architecture. Suppose the ViewModel creates everything itself:
class DeviceViewModel : ViewModel() {
private val api = RealApi()
private val database = RealDatabase()
}
Testing becomes difficult. With dependency inversion:
class DeviceViewModel(
private val repository: DeviceRepository
)
a fake implementation can be provided: class FakeDeviceRepository :
DeviceRepository {
// deterministic test behavior
}
Good architecture and good testing practices reinforce each other.
16. Performance: Measure Before Optimizing
Performance optimization should begin with evidence. Not:
“I think this code might be slow.”
Instead:
Measure
↓
Find bottleneck
↓
Form hypothesis
↓
Optimize
↓
Measure again
Android developers should be comfortable investigating:
- CPU usage
- memory allocation
- memory leaks
- excessive recompositions
- slow rendering
- network behavior
- main-thread blocking
- startup performance
The principle is simple: Profile first. Optimize second.
17. Native C++ When It Actually Makes Sense
Most Android applications do not need C++. But there are legitimate use cases:
Audio/DSP
Computer vision
Existing C/C++ libraries
Performance-sensitive algorithms
Cross-platform native engines
Android's NDK allows native C/C++ code to be integrated with the Android application. Conceptually:
Kotlin
│
▼
JNI
│
▼
C++
However, JNI introduces additional complexity and boundary-crossing costs. Therefore: Use native code because the problem requires it, not because native code looks impressive.
18. CI/CD Is Part of the Project
A modern project does not end when the code compiles locally. The repository should ideally automate important checks:
Push / Pull Request
│
▼
Build
│
▼
Linting
│
▼
Unit Tests
│
▼
Integration Tests
│
▼
Artifact
Tools such as GitHub Actions can automate these workflows. The objective is simple: Make incorrect changes difficult to merge and correct changes easy to release.
19. A Practical Modern Android Stack
There is no universal stack, but a production project in 2026 could reasonably look like:
Language
└── Kotlin
UI
└── Jetpack Compose
└── Material 3
Concurrency
├── Coroutines
└── Flow / StateFlow
Architecture
├── MVVM
├── Unidirectional Data Flow
├── Repository Pattern
└── Clean Architecture where justified
Dependency Injection
└── Hilt
Networking
├── Retrofit
└── Ktor
Persistence
├── Room
└── DataStore
Background Work
└── WorkManager
Testing
├── JUnit
├── Integration Tests
└── Compose UI Tests
Build / CI
├── Gradle
└── GitHub Actions
Monitoring
├── Crash Reporting
└── Analytics
Native
└── C++ / NDK / JNI when required
The technologies themselves, however, are not the architecture. That distinction matters.
20. The Bigger Picture
If there is one diagram worth remembering, it is this:
USER
│
▼
┌─────────────────┐
│ Jetpack Compose │
└────────┬────────┘
│ Events
▼
┌─────────────────┐
│ ViewModel │
│ StateFlow │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Use Cases │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Repository │
└───────┬─────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
REST API Room Device
│ │ │
└────────────┼────────────┘
│
▼
Flow
│
▼
ViewModel
│
▼
StateFlow
│
▼
Compose
│
▼
Recomposition
This creates a predictable cycle: User action → ViewModel → business/data layer → new state → UI update. Once that flow is clear, the project becomes much easier to understand, debug, test, and extend.
Conclusion
A modern Android project is not defined by how many libraries it uses. Using Compose, Hilt, Room, Coroutines, and Clean Architecture does not automatically produce good software. Good Android architecture is about establishing clear answers to a few fundamental questions: Who owns the state? Where does business logic live? How does data move through the application? How are dependencies managed? What happens when something fails? Can important behavior be tested? Can another developer understand the system without reading the entire codebase? Modern Android development therefore comes down to a few principles: Keep state predictable. Keep dependencies explicit. Separate responsibilities. Design for failure and lifecycle changes. Test important behavior. Measure before optimizing. And introduce complexity only when it solves a real problem. That last principle may be the most important one. The best architecture is not the architecture with the most layers. It is the simplest architecture that can handle the complexity of the product without becoming the next source of complexity.