Clean architecture on Android in Modern Android & Kotlin
After 60 days of building features, you've probably noticed that your Android codebase either scales smoothly or becomes a tangled mess—and the difference usually comes down to structure. Clean architecture on Android in Modern Android & Kotlin gives you a proven way to organize code into independent, testable layers without turning every hobby project into an over-engineered nightmare. We'll walk through when to use these layers, how to implement them, and, just as importantly, when to skip them entirely.
The three-layer foundation: UI, domain, and data
Clean architecture typically splits an Android app into three layers, each with a specific responsibility. The UI layer handles presentation—Activities, Fragments, and Compose. The domain layer contains pure business logic—use cases that don't know about Android or the database. The data layer manages all sources of truth—local databases, APIs, caches, and files.
Think of a banking app showing account transactions. The UI layer renders a list and handles user clicks. The domain layer orchestrates the business rule "fetch transactions only if the user is authenticated." The data layer queries a local database first, then syncs from an API if needed. Each layer depends inward only; the domain layer never imports Android classes, and the data layer doesn't know about Compose.
In practice, this looks like:
// UI Layer (Presentation)
@Composable
fun TransactionScreen(viewModel: TransactionViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsState()
when (val current = state) {
is TransactionUiState.Loading -> LoadingIndicator()
is TransactionUiState.Success -> TransactionList(current.transactions)
is TransactionUiState.Error -> ErrorMessage(current.exception)
}
}
// Domain Layer (Business Logic)
class FetchTransactionsUseCase(private val repository: TransactionRepository) {
suspend operator fun invoke(userId: String): Result<List<Transaction>> {
return if (userId.isEmpty()) {
Result.failure(IllegalArgumentException("User ID required"))
} else {
repository.getTransactions(userId)
}
}
}
// Data Layer (Sources of Truth)
class TransactionRepository(
private val localDb: TransactionDao,
private val remoteApi: TransactionService
) {
suspend fun getTransactions(userId: String): Result<List<Transaction>> = runCatching {
val cached = localDb.fetchByUserId(userId)
if (cached.isNotEmpty()) {
return@runCatching cached
}
val remote = remoteApi.getTransactions(userId)
localDb.insertAll(remote)
remote
}
}The payoff: each layer is testable in isolation. You can mock the repository in domain tests without touching Android APIs. You can test the UI with a fake view model without hitting the network.
Use cases for shared business logic
A use case is a single, focused piece of domain logic wrapped in a callable class or function. It's not required for every feature, but it shines when you have logic that appears in multiple places or needs careful orchestration.
Consider a ride-hailing app. The "validate and request a ride" logic might be needed from a map screen, a quick-book screen, and a voice assistant integration. Without a use case, you'd duplicate this validation three times. With a use case, it lives in one place:
class RequestRideUseCase(
private val rideRepository: RideRepository,
private val userRepository: UserRepository,
private val validationService: ValidationService
) {
suspend operator fun invoke(request: RideRequest): Result<Ride> = runCatching {
val user = userRepository.getCurrentUser()
validationService.validatePaymentMethod(user)
validationService.validateServiceArea(request.pickupLocation)
rideRepository.createRide(request, user.id)
}
}The validation logic is centralized, versioned, and tested once. When a new payment rule ships, you update the use case, not three scattered screens. Use cases are also natural seams for analytics, logging, and feature flags—you can wrap the invoke function without changing any caller.
Repositories hide data sources
A repository is a facade over your data sources. It decides where to read from and write to—local cache, API, file system, preferences—without the caller knowing. This is where you implement caching strategies, conflict resolution, and fallback logic.
Let's say a travel app needs to show hotel listings. The data might come from a remote API, but you want to cache recent searches locally and prefer cached results if the network is slow:
class HotelRepository(
private val hotelApi: HotelApi,
private val hotelCache: HotelCacheDao,
private val networkMonitor: NetworkMonitor
) {
suspend fun searchHotels(query: HotelSearchQuery): Result<List<Hotel>> = runCatching {
val cacheKey = query.hashCode().toString()
val cached = hotelCache.get(cacheKey)
if (cached != null && !networkMonitor.isConnected()) {
return@runCatching cached
}
val remote = hotelApi.search(query)
hotelCache.save(cacheKey, remote)
remote
}
}The domain layer and UI layer never know whether data came from cache or the network. If you later add a third source—a local SQLite table for offline support—you update the repository, and everything above it continues working. This isolation is the core value of the data layer.
Skip the domain layer in simple apps
Clean architecture sounds great in theory, but not every app needs all three layers. A simple feature with one screen, one API call, and no complex business rules doesn't need a separate domain layer. The overhead outweighs the benefit.
Consider a settings screen that fetches and saves user preferences. You might have:
// Simpler: UI calls data directly
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsState()
// ...
}
class SettingsViewModel(private val prefsRepository: PrefsRepository) : ViewModel() {
private val _uiState = MutableStateFlow<SettingsUiState>(Loading)
val uiState = _uiState.asStateFlow()
fun updateTheme(theme: Theme) {
viewModelScope.launch {
prefsRepository.setTheme(theme)
_uiState.value = Success
}
}
}This is fine. The business logic is thin—just "save to the database." Adding a use case would be boilerplate. As your app grows and rules get complex ("only allow dark mode if battery saver is off", "sync theme across devices"), then pull out a use case. Architecture should serve your code, not the reverse.
A good heuristic: if your business logic fits in a viewmodel method without becoming hard to test, you don't need a use case yet.
Common mistakes
Layers that talk outward: A common trap is letting the data layer depend on the UI layer, or the domain layer depend on Android classes. This creates circular dependencies and kills testability. The rule is strict: inner layers never import outer layers.
Repositories that are too thin: Some teams treat repositories as simple database accessors, which defeats the purpose. A good repository encapsulates the strategy—when to cache, when to refresh, how to handle errors. If your repository just forwards every call to a DAO, merge them.
Use cases as glorified passthrough: Creating a use case that does nothing but call a repository method adds noise. Use cases should contain logic—validation, transformation, orchestration across multiple repositories. Single-line use cases are a code smell.
Overcomplicating the domain layer too early: Start with simple use cases. Don't pre-emptively add mappers, value objects, and immutable data classes if your domain is just "fetch and cache." Let complexity emerge, then refactor.
Key takeaways
- Structure apps into UI, domain, and data layers when complexity justifies it. Each layer has a single responsibility and depends inward only.
- Use cases centralize business logic and become natural seams for testing, analytics, and feature flags. Add them when logic is reused or orchestration is complex.
- Repositories hide the implementation details of data access—caching strategy, fallback sources, conflict resolution—so callers don't need to know or change.
- Skip the domain layer in simple features. Not every settings page needs a use case. Let architecture grow with your complexity.
- Avoid layers talking outward. Strict dependency direction keeps code testable and modular.






Comments
Loading comments…