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

  1. Pragmatism over purity — Bend rules when it makes sense
  2. Use cases for business logic — Not just wrappers
  3. Repository hides data sources — ViewModel doesn't care where data comes from
  4. Test at the right level — Use cases and repositories are most valuable
  5. Start simple — Add layers as complexity grows

Questions about Clean Architecture? Hit me up on Twitter!

Share this article