Architecture
January 10, 2024
12 min read

Clean Architecture in Android: A Practical Guide

Implement clean architecture patterns in your Android apps for better testability, maintainability, and scalability.

A
Shubham Kumar Bind
Android Developer

After working on several large Android projects, I've learned that architecture decisions made early can make or break a project. Clean Architecture has been my go-to approach for building maintainable Android apps.

What is Clean Architecture?

Clean Architecture, introduced by Uncle Bob, is about separating concerns and dependencies. In Android, this typically means organizing your code into layers:

  1. Presentation Layer (UI, ViewModels)
  2. Domain Layer (Use Cases, Entities)
  3. Data Layer (Repositories, Data Sources)

Why I Adopted Clean Architecture

The Problem I Faced

Early in my career, I built an e-commerce app that became a nightmare to maintain. Business logic was scattered across Activities, network calls were made directly from UI components, and testing was nearly impossible.

The Solution

Clean Architecture solved these problems by:

  • Separating concerns - Each layer has a single responsibility
  • Making testing easier - Dependencies can be easily mocked
  • Improving maintainability - Changes in one layer don't affect others
  • Enabling team collaboration - Different developers can work on different layers

My Android Clean Architecture Structure

Here's how I typically structure my Android projects:

app/
├── presentation/
│   ├── ui/
│   ├── viewmodel/
│   └── mapper/
├── domain/
│   ├── usecase/
│   ├── repository/
│   └── model/
└── data/
    ├── repository/
    ├── datasource/
    └── mapper/

Implementation Example

Let me walk you through a practical example - a user profile feature:

Domain Layer

// Entity
data class User(
    val id: String,
    val name: String,
    val email: String
)

// Repository Interface
interface UserRepository {
    suspend fun getUser(id: String): Result<User>
    suspend fun updateUser(user: User): Result<Unit>
}

// Use Case
class GetUserUseCase(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(userId: String): Result<User> {
        return userRepository.getUser(userId)
    }
}

Data Layer

// Repository Implementation
class UserRepositoryImpl(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource
) : UserRepository {
    
    override suspend fun getUser(id: String): Result<User> {
        return try {
            val user = remoteDataSource.getUser(id)
            localDataSource.saveUser(user)
            Result.success(user.toDomain())
        } catch (e: Exception) {
            localDataSource.getUser(id)?.let {
                Result.success(it.toDomain())
            } ?: Result.failure(e)
        }
    }
}

Presentation Layer

class UserProfileViewModel(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(UserProfileUiState())
    val uiState = _uiState.asStateFlow()
    
    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            
            getUserUseCase(userId)
                .onSuccess { user ->
                    _uiState.value = _uiState.value.copy(
                        user = user,
                        isLoading = false
                    )
                }
                .onFailure { error ->
                    _uiState.value = _uiState.value.copy(
                        error = error.message,
                        isLoading = false
                    )
                }
        }
    }
}

Lessons Learned

1. Don't Over-Engineer

Early on, I created too many layers and abstractions. Start simple and add complexity only when needed.

2. Use Dependency Injection

Hilt makes dependency injection in Android much easier. It's essential for Clean Architecture.

3. Testing Becomes a Breeze

With proper separation, unit testing becomes straightforward:

@Test
fun `when user exists, should return user`() = runTest {
    // Given
    val expectedUser = User("1", "John", "john@example.com")
    coEvery { userRepository.getUser("1") } returns Result.success(expectedUser)
    
    // When
    val result = getUserUseCase("1")
    
    // Then
    assertEquals(Result.success(expectedUser), result)
}

Common Mistakes to Avoid

  1. Mixing layers - Keep domain logic out of ViewModels
  2. Ignoring the dependency rule - Inner layers shouldn't know about outer layers
  3. Creating god classes - Keep use cases focused on single responsibilities
  4. Forgetting about mappers - Don't let data models leak into the domain

Tools That Help

  • Hilt for dependency injection
  • Room for local data persistence
  • Retrofit for network calls
  • Mockk for testing

Is It Worth It?

Absolutely. While there's initial overhead, the benefits compound over time:

  • Easier to add new features
  • Simpler to fix bugs
  • Better test coverage
  • Improved team productivity

Clean Architecture has made me a better Android developer, and I believe it can do the same for you.


What's your experience with Clean Architecture in Android? Have you found other patterns that work well? I'd love to discuss this further - reach out anytime!

Clean Architecture
MVVM
Best Practices
A

Shubham Kumar Bind

I'm a passionate Android developer building mobile apps that users love. When I'm not coding, you'll find me exploring new Android libraries, contributing to open source, or sharing what I've learned with the developer community.