Back to Blog
Clean Architecture in Android: A Practical Guide
How to implement Clean Architecture in Android apps that actually scales. Real examples from production apps.
February 17, 20264 min read
#android#architecture#kotlin#clean-architecture
Clean Architecture in Android: A Practical Guide
After implementing Clean Architecture in apps serving millions of users, here's my practical take — what works, what doesn't, and when to bend the rules.
The Problem with Most Guides
Most Clean Architecture tutorials show perfect, idealized examples. Reality is messier:
- Deadlines exist
- Teams have varying skill levels
- Requirements change constantly
This guide is about pragmatic Clean Architecture.
The Layers
┌─────────────────────────────────────┐
│ Presentation │
│ (Activities, Fragments, VMs) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Domain │
│ (Use Cases, Entities, Repos) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Data │
│ (Repo Impl, APIs, Database) │
└─────────────────────────────────────┘
Module Structure
app/
├── :app (Android application)
├── :core
│ ├── :core:common (shared utilities)
│ ├── :core:ui (design system)
│ ├── :core:network (API client)
│ └── :core:database (Room setup)
├── :domain
│ ├── :domain:model (entities)
│ └── :domain:repository (interfaces)
└── :feature
├── :feature:home
├── :feature:profile
└── :feature:orders
Use Cases: The Heart of Business Logic
class GetOrdersUseCase @Inject constructor(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(
filter: OrderFilter
): Result<List<Order>> {
// Business logic lives HERE
val user = userRepository.getCurrentUser()
?: return Result.failure(NotAuthenticatedException())
return orderRepository.getOrders(
userId = user.id,
status = filter.status,
limit = filter.limit
)
}
}
When to Create a Use Case?
✅ Create one when:
- Logic involves multiple repositories
- Business rules need to be enforced
- Logic is reused across ViewModels
❌ Skip it when:
- It's just a pass-through to repository
- Only used in one place
// DON'T do this - unnecessary wrapper
class GetUserUseCase(private val repo: UserRepository) {
suspend operator fun invoke() = repo.getUser()
}
// Just inject the repository directly in ViewModel
Repository Pattern
Interface (Domain Layer)
interface OrderRepository {
suspend fun getOrders(
userId: String,
status: OrderStatus?,
limit: Int
): Result<List<Order>>
suspend fun getOrderById(id: String): Result<Order>
fun observeOrders(userId: String): Flow<List<Order>>
}
Implementation (Data Layer)
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao,
private val networkMonitor: NetworkMonitor
) : OrderRepository {
override suspend fun getOrders(
userId: String,
status: OrderStatus?,
limit: Int
): Result<List<Order>> = runCatching {
if (networkMonitor.isOnline) {
// Fetch from API
val response = api.getOrders(userId, status, limit)
// Cache in DB
dao.insertOrders(response.toEntities())
response.toDomain()
} else {
// Return cached data
dao.getOrders(userId, status, limit).toDomain()
}
}
override fun observeOrders(userId: String): Flow<List<Order>> {
return dao.observeOrders(userId)
.map { it.toDomain() }
}
}
ViewModels: Keep Them Thin
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val getOrdersUseCase: GetOrdersUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(OrdersUiState())
val uiState: StateFlow<OrdersUiState> = _uiState.asStateFlow()
fun loadOrders(filter: OrderFilter) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getOrdersUseCase(filter)
.onSuccess { orders ->
_uiState.update {
it.copy(isLoading = false, orders = orders)
}
}
.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message)
}
}
}
}
}
Dependency Injection with Hilt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindOrderRepository(
impl: OrderRepositoryImpl
): OrderRepository
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOrderApi(retrofit: Retrofit): OrderApi {
return retrofit.create(OrderApi::class.java)
}
}
Common Mistakes
1. Anemic Use Cases
// ❌ Bad - just a wrapper
class GetOrderUseCase(private val repo: OrderRepository) {
suspend operator fun invoke(id: String) = repo.getOrder(id)
}
// ✅ Good - contains business logic
class GetOrderWithValidationUseCase(...) {
suspend operator fun invoke(id: String): Result<Order> {
if (!validator.isValidOrderId(id)) {
return Result.failure(InvalidOrderIdException())
}
return repo.getOrder(id)
}
}
2. Leaking Framework Types
// ❌ Bad - LiveData in domain
interface OrderRepository {
fun getOrders(): LiveData<List<Order>>
}
// ✅ Good - Kotlin Flow is framework-agnostic
interface OrderRepository {
fun getOrders(): Flow<List<Order>>
}
3. Too Many Layers
Sometimes, simple is better:
// For a simple settings screen, this is fine:
class SettingsViewModel(
private val settingsRepository: SettingsRepository
) : ViewModel() {
// No use case needed for simple CRUD
}
Testing
Clean Architecture makes testing easy:
@Test
fun `getOrders returns cached data when offline`() = runTest {
// Given
val cachedOrders = listOf(Order("1"), Order("2"))
whenever(networkMonitor.isOnline).thenReturn(false)
whenever(dao.getOrders(any(), any(), any())).thenReturn(cachedOrders)
// When
val result = repository.getOrders("user1", null, 10)
// Then
assertTrue(result.isSuccess)
assertEquals(cachedOrders, result.getOrNull())
verify(api, never()).getOrders(any(), any(), any())
}
Key Takeaways
- Pragmatism over purity — Bend rules when it makes sense
- Use cases for business logic — Not just wrappers
- Repository hides data sources — ViewModel doesn't care where data comes from
- Test at the right level — Use cases and repositories are most valuable
- Start simple — Add layers as complexity grows
Questions about Clean Architecture? Hit me up on Twitter!