Clean Architecture for Small Android Teams: One Module, No Over-Engineering
Many developers' first reaction when they hear Clean Architecture is:
"How many Modules do I have to split it into?"
So, a small team of 2-3 people, for the sake of so-called "architectural cleanliness," forcibly splits the project into 5, 6, or even more Modules.
In the end, they find:
Modifying a feature requires crossing several Modules; Gradle configuration becomes increasingly complex; The debugging chain gets longer and longer; The time actually spent writing business logic becomes less and less.
What Clean Architecture truly solves is the problem of dependencies and responsibilities, not the problem of Module count.
So my viewpoint is very clear:
For an Android team of 2-3 people, not splitting Modules, but implementing Clean Architecture within a single Module through clear Package boundaries, is often the most cost-effective solution.
This article uses my practical project CleanArc as an example to discuss how this approach should actually be implemented.
1. Conclusion First: Clean Architecture ≠ Multi-Module
This is the most common misunderstanding.
The core of Clean Architecture is:
Controlling the direction of dependencies so that business rules are not held hostage by specific implementations.
It focuses on:
- Who depends on whom?
- Where is the business logic placed?
- Where is data access placed?
- Can the UI directly depend on data implementations?
- When the data source changes, does the business logic need to be modified accordingly?
And Module is just one engineering means of achieving isolation.
So:
Clean Architecture
≠
Multi Module
It is entirely possible to have:
Single Module
+
Package Boundary
+
Clean Architecture
This can equally achieve clear responsibilities and clear dependencies.
2. Why is it not recommended for a small team of 2-3 people to split Modules from the start?
Modules mainly solve:
- Compilation isolation
- Team collaboration boundaries
- Dependency isolation
- Large-scale project governance
But for an Android team of only 2-3 people, these problems are often not serious enough to require Modules to solve.
1. Fewer people, fewer collaboration conflicts naturally
If there are only two or three people:
A → Login
B → Homepage
C → Profile Center
Everyone is relatively familiar with the entire project.
At this point, splitting Modules for the sake of so-called "team boundaries" doesn't yield much benefit.
2. The project scale is not large, and the benefits of compilation isolation are limited
One of the most obvious benefits of Modules is:
Modifying one Module does not require recompiling the entire project.
But if the project itself is not large, this benefit might not be as significant as imagined.
Instead, it might increase:
Gradle configuration
Dependency relationships
Version management
Inter-Module navigation
Debugging costs
3. The business itself is relatively concentrated
Small team projects usually don't have business boundaries complex enough to warrant:
feature-a
feature-b
feature-c
feature-d
core-network
core-database
core-common
common-ui
Establishing such a complex structure from the very beginning.
You might even establish the engineering boundaries before you've figured out the business boundaries.
This easily leads to a problem:
Introducing complexity in advance for problems that might occur in the future.
3. The Truly Recommended Approach: Feature + Package
Since we're not splitting Modules, how do we ensure the code doesn't become a mess?
The answer is:
Use Features for business aggregation, and use Packages for architectural isolation.
For example, a practical project can be organized according to this approach:
app
└── src/main/java/com/sample/clean
│
├── core
│ ├── network
│ ├── db
│ └── common
│
└── feature
│
├── login
│ ├── presentation
│ │ ├── LoginViewModel.kt
│ │ └── LoginUiState.kt
│ │
│ ├── domain
│ │ ├── LoginUseCase.kt
│ │ ├── LoginRepository.kt
│ │ └── model/
│ │
│ └── data
│ ├── LoginRepositoryImpl.kt
│ ├── datasource/
│ └── model/
│
└── user
├── presentation
├── domain
└── data
The core idea can be summed up in one sentence:
Aggregate by Feature, and then apply Clean layering inside the Feature.
That is:
feature
↓
presentation
domain
data
Instead of splitting the entire project from the start into:
presentation module
domain module
data module
4. Why do I recommend Clean inside Features more?
Because it solves a very practical problem:
When modifying a business feature, the related code should be clustered together as much as possible.
For example, modifying the login:
feature/login
Inside, you can find simultaneously:
LoginScreen
LoginViewModel
LoginUiState
LoginUseCase
LoginRepository
User
LoginRepositoryImpl
LoginApi
You don't need to jump back and forth between:
presentation module
↓
domain module
↓
data module
↓
network module
This is very important for small teams.
Because what small teams really need to optimize is not:
"How beautiful the architecture diagram looks."
But rather:
"After one person takes over the code, can they find it quickly."
5. What's Truly Important in Clean Architecture is the Dependency Direction
The directory structure is just a form.
What's truly important is:
The dependency direction.
We want:
Presentation
↓
Domain
↑
Data
It can also be understood as:
Presentation → Domain ← Data
There is a very important point here:
Domain should not depend on Data.
Because Data is a concrete implementation.
For example:
Retrofit
Room
DataStore
OkHttp
These all belong to technical details.
Domain should only care about:
What data do I need
What business operation do I need to execute
As for:
Does the data come from Retrofit or Room?
Domain should not care.
6. Why Should Repository Be Placed in Domain?
This is a very critical point in Clean Architecture.
For example, we define:
interface UserRepository {
suspend fun getUsers(): Result<List<User>>
}
This interface should be placed in:
domain/repo/UserRepository.kt
Not in:
data/repo/UserRepository.kt
Why?
Because:
The Repository interface describes the Domain's requirement for data capabilities.
Domain is the "boss."
It says:
"I need the ability to get Users."
Thus it defines:
interface UserRepository {
suspend fun getUsers(): Result<List<User>>
}
Data is the "worker."
It is responsible for telling Domain:
"I can implement this ability through Retrofit."
Thus:
class UserRepositoryImpl(
private val apiService: ApiService
) : UserRepository {
override suspend fun getUsers(): Result<List<User>> {
return try {
val userList = apiService.getUsers()
Result.success(
userList.toDomainList()
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
}
The relationship becomes:
UserRepository
/ \
Domain Data
Interface Implementation
This is the classic:
Dependency Inversion Principle (DIP)
7. What Happens If the Repository Is Placed Incorrectly?
The wrong way:
data
└── UserRepository.kt
Then:
domain
↓
data.UserRepository
This means:
Domain → Data
Domain starts depending on the concrete data layer.
The entire dependency direction is reversed.
The correct way:
domain
└── UserRepository.kt
data
└── UserRepositoryImpl.kt
Forming:
Domain
↑
Data
Data depends on Domain's interface.
This is the true dependency inversion.
8. A Complete Look at the Login Flow
Suppose we have a login feature.
The final code structure:
feature/login
│
├── presentation
│ ├── LoginScreen.kt
│ ├── LoginViewModel.kt
│ └── LoginUiState.kt
│
├── domain
│ ├── LoginUseCase.kt
│ ├── LoginRepository.kt
│ └── model/
│
└── data
├── LoginRepositoryImpl.kt
├── LoginApi.kt
└── model/
The call chain:
LoginScreen
↓
LoginViewModel
↓
LoginUseCase
↓
LoginRepository
↓
LoginRepositoryImpl
↓
LoginApi
↓
Retrofit
Note the most critical point here:
LoginUseCase
↓
LoginRepository
UseCase only knows the interface.
It has absolutely no knowledge of:
Retrofit
OkHttp
Room
DataStore
The existence of these things.
9. Presentation: ViewModel Doesn't Need to Know About Data
For example:
class LoginViewModel(
private val loginUseCase: LoginUseCase
) : ViewModel() {
fun login(username: String, password: String) {
viewModelScope.launch {
loginUseCase(username, password)
}
}
}
ViewModel only depends on:
LoginUseCase
Not:
LoginApi
LoginRepositoryImpl
Retrofit
Room
This way, the UI layer won't be tightly coupled to specific technical implementations.
10. Domain: What Exactly Should a UseCase Do?
UseCase is the most easily abused.
Many projects end up becoming:
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke() =
repository.getUser()
}
Then every Repository method gets a matching UseCase:
GetUserUseCase
DeleteUserUseCase
UpdateUserUseCase
SaveUserUseCase
QueryUserUseCase
If the UseCase is just a simple passthrough:
ViewModel
↓
UseCase
↓
Repository
With no business value in between.
Then it's likely just adding an extra layer of code.
What is UseCase truly suitable for?
When there is a business process, its value becomes very obvious.
For example:
class LoginUseCase(
private val repository: LoginRepository
) {
suspend operator fun invoke(
username: String,
password: String
) {
require(username.isNotEmpty())
val user = repository.login(
username,
password
)
repository.saveUser(user)
}
}
This is not a simple passthrough.
It includes:
Parameter validation
↓
Login
↓
Save user
This is a business process.
So I recommend more:
UseCase carries business processes, not just exists to make the architecture look complete.
11. Don't Over-Design the Data Layer Either
Clean Architecture can easily go to another extreme:
RemoteDataSource
LocalDataSource
CacheDataSource
RemoteDataSourceImpl
LocalDataSourceImpl
CacheStrategy
Repository
RepositoryImpl
Factory
Provider
A simple API call ends up becoming:
ViewModel
↓
UseCase
↓
Repository
↓
DataSource
↓
RemoteDataSource
↓
Api
↓
Retrofit
This is no longer Clean Architecture.
This is:
Clean Architecture formalism.
If the business is truly complex:
Network + Local Cache + Multiple Data Sources + Offline Strategy
Of course, abstraction is warranted.
But if it's just:
Repository → Retrofit
Then just directly:
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository
That's enough.
Without complexity, don't abstract in advance.
12. Dependency Injection: Why Can Small Teams Choose Koin?
For a small team of 2-3 people, I tend to prefer:
Prioritize simple DI solutions.
For example, Koin.
The reason is not:
Koin is definitely better than Hilt.
But rather:
Small teams should focus more on business complexity, not the engineering complexity of the DI framework itself.
A simple module definition can complete it:
val appModule = module {
single<UserApi> {
RetrofitClient.create()
}
single<UserRepository> {
UserRepositoryImpl(get())
}
factory {
LoginUseCase(get())
}
}
The entire dependency relationship is very intuitive:
LoginUseCase
↓
UserRepository
↓
UserRepositoryImpl
↓
UserApi
For small projects, this is already sufficient.
13. Why Can This Structure Be Smoothly Split into Modules Later?
This is a very important advantage of this approach.
Now:
app
└── feature
└── login
├── presentation
├── domain
└── data
Later, if the project truly grows:
feature/login
Can gradually evolve into:
:feature:login
Even:
:feature:login:presentation
:feature:login:domain
:feature:login:data
That is to say:
Packages are candidate boundaries for future Modules.
You are not rejecting Modules now.
Rather:
You are not paying the cost of Modules in advance before you truly need them.
14. What Does This Architecture Truly Solve?
Ultimately we get:
┌──────────────┐
│ Presentation │
└──────┬───────┘
↓
┌──────────────┐
│ Domain │
│ │
│ UseCase │
│ Repository │
│ Model │
└──────┬───────┘
↑
┌──────┴───────┐
│ Data │
│ │
│ RepositoryImpl
│ API / DB │
└──────────────┘
What it truly establishes is:
Responsibility boundaries
+
Dependency boundaries
+
Business boundaries
Not:
More Modules = More Advanced
15. The Final Recommended Project Structure
If I were to design a directly implementable structure for a 2-3 person Android team, I would choose:
app
└── src/main/java/xxx
│
├── core
│ ├── network
│ ├── database
│ └── common
│
└── feature
│
├── login
│ ├── presentation
│ ├── domain
│ └── data
│
├── home
│ ├── presentation
│ ├── domain
│ └── data
│
└── profile
├── presentation
├── domain
└── data
Dependency relationship:
Presentation
↓
Domain
↑
Data
Where:
Repository Interface → Domain
Repository Impl → Data
UseCase → Domain
ViewModel → Presentation
Retrofit / Room → Data
This structure is already sufficient to support the vast majority of small to medium-sized Android projects.
Practical Code
Refer to my Clean Architecture project:
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Qiangqiang, one more thing — resources can also be divided by module. That makes migrating to multiple modules later much easier. You could add a Gradle task to enforce that resource names under a feature must start with the feature name. Something like this sourceSets { main { file('src/main/res-module') .listFiles() .each { res.srcDirs += it.path } }
Haha, awesome!