Architecture 12 min read

Clean Architecture on Android: 5 Years of Lessons

Five years ago I read Uncle Bob's Clean Architecture book and immediately went and applied everything to a todo app. The result was 12 layers, 40 files, and a working delete-todo button that required touching 7 of them.

Since then I've shipped Clean Architecture in production across 10+ apps — social networks, news apps, streaming platforms, celebrity engagement apps. Here's the honest assessment: where it genuinely earns its complexity, and where I'd push back if someone proposed it today.

What Clean Architecture Actually Gives You

Before discussing trade-offs, let's be clear about what you get:

The main benefit of Clean Architecture isn't cleanliness — it's the explicit dependency rule. Data flows inward, dependencies point inward. Once you internalize this, architectural decisions become obvious.

The Standard Layer Setup I Use

In every project I follow this structure, with one module per layer for large apps:

// Domain layer — zero Android dependencies
data class User(val id: String, val name: String, val avatarUrl: String)

interface UserRepository {
    suspend fun getUser(id: String): Result<User>
    fun observeCurrentUser(): Flow<User?>
}

class GetUserUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(userId: String): Result<User> =
        userRepository.getUser(userId)
}
// Data layer — implements the domain contract
class UserRepositoryImpl @Inject constructor(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource,
    private val mapper: UserMapper
) : UserRepository {
    override suspend fun getUser(id: String): Result<User> =
        localDataSource.getUser(id)?.let { Result.success(mapper.map(it)) }
            ?: remoteDataSource.getUser(id).map { mapper.map(it) }
                .also { if (it.isSuccess) localDataSource.saveUser(mapper.mapToLocal(it.getOrThrow())) }
}

Where I've Seen It Go Wrong

1. One use case per UI action, regardless of complexity

I've seen codebases with GetUserNameUseCase, GetUserAvatarUseCase, and GetUserEmailUseCase — each wrapping a single repository call that returns one field of the same User object. This is cargo culting, not architecture.

Use cases should encapsulate meaningful business rules — not be thin wrappers around repository calls. If your use case body is return repository.getSomething(id), ask if it's earning its existence.

A use case that only delegates without adding logic is noise. Merge it into the ViewModel or combine it with a related use case that does have real business logic.

2. Mapper explosion

Three data classes for one entity — UserDto, UserEntity, User — plus two mappers. For simple apps, this is pure overhead. For a User that appears on every screen and maps identically everywhere, consider using the same class across layers and only introducing a separate model when the shapes diverge meaningfully.

3. Modules for everything

Multi-module is valuable at scale, but I've seen solo developers split a 3-screen app into 8 modules because Clean Architecture "requires it." It doesn't. Start with packages, not modules. Migrate to modules when:

How I Evolve It Over Time

I start every project with Clean Architecture at the package level, not the module level:

com.bytevikas.app
├── data/
│   ├── remote/
│   ├── local/
│   └── repository/
├── domain/
│   ├── model/
│   ├── repository/   ← interfaces only
│   └── usecase/
└── presentation/
    ├── feature_home/
    └── feature_profile/

When a feature starts showing complexity — multiple data sources, non-trivial business rules, a team of >1 working on it — that's when I extract it into its own module. The architecture is the same, it just moves to a separate Gradle module.

The Rule I Never Break

Domain layer must have zero Android imports. If I see import android.* anywhere in the domain package, it's a red flag. The domain model is pure Kotlin. It knows nothing about Contexts, Intents, Bundles, or ViewModels. This single constraint enforces everything else.


Bottom Line

Clean Architecture is the right default for any app that:

It's overkill for a hackathon prototype, a personal utility app, or an MVP you're planning to throw away. The cost is real — more files, more boilerplate, steeper onboarding curve. Make sure the benefits justify the overhead for your specific project before committing.

After 5 years, I still choose it for almost everything. But I apply it with pragmatism, not dogma.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment