跪拜 Guibai
← Back to the summary

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:

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:

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:

ThirdPrince/CleanArc

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

绿色水杯007

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!