跪拜 Guibai
← All articles
Android

Clean Architecture for Small Android Teams: One Module, No Over-Engineering

By 潜龙勿用之化骨龙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Multi-module setups add Gradle complexity, slow down debugging, and scatter related code across modules for teams that rarely hit the collaboration or compile-time problems modules solve. A single-module Clean Architecture with feature packages delivers the same dependency discipline without paying the module tax before it's needed.

Summary

A single-module Clean Architecture approach keeps feature code together inside packages, avoiding the cross-module navigation and build-config overhead that hurts small teams. The structure organizes code by feature, with each feature containing its own presentation, domain, and data layers. Dependency inversion is enforced through package boundaries rather than module boundaries, keeping domain logic independent of frameworks like Retrofit or Room. The approach also avoids premature abstraction: UseCases only exist when there is real business logic to orchestrate, and the data layer stays thin unless multiple data sources genuinely require it. Koin is recommended over heavier DI frameworks to keep tooling complexity low. The package structure is designed to evolve into separate Gradle modules later if the project grows, making it a low-cost starting point that doesn't block future scaling.

Takeaways
Clean Architecture controls dependency direction, not module count; a single module with package boundaries achieves the same goal.
For teams of 2-3 developers, multi-module projects introduce more overhead in Gradle config, debugging, and cross-module navigation than they save.
Organize code by feature, with each feature containing its own presentation, domain, and data packages.
Domain must not depend on Data; the Repository interface lives in domain, and its implementation lives in data, inverting the dependency.
UseCases should carry real business processes like validation and multi-step orchestration, not just pass calls through to a repository.
Don't over-design the data layer with DataSource abstractions unless multiple data sources or caching strategies actually exist.
Koin provides sufficient DI for small projects without the annotation-processing and build complexity of Hilt or Dagger.
Feature packages are natural candidates to become separate Gradle modules later, so nothing is lost by starting simple.
Conclusions

Many Android teams adopt multi-module architecture because it's presented as a best practice, but the original motivation is large-team governance, not code quality. Applying it to a 2-person project solves problems the team doesn't have.

The advice to put Repository interfaces in the domain layer and implementations in data is a concrete, testable rule that prevents the most common Clean Architecture mistake: domain depending on frameworks.

Recommending Koin over Hilt for small teams is a pragmatic call that prioritizes build speed and simplicity over compile-time safety, which matters less when the codebase is small and the team knows it well.

The argument that UseCases should only exist when they orchestrate business logic pushes back against the cargo-cult pattern of creating one UseCase per repository method, which bloats code without adding value.

Concepts & terms
Dependency Inversion Principle (DIP)
High-level modules (domain) should not depend on low-level modules (data). Both should depend on abstractions. In Clean Architecture, this means the domain layer defines repository interfaces, and the data layer implements them, so data depends on domain, not the reverse.
Feature-based packaging
Organizing code by business feature rather than by technical layer. A login feature package contains its own UI, business logic, and data access code together, making it easier to find and modify all code related to a single feature.
UseCase
A class in the domain layer that encapsulates a single business operation. It orchestrates calls to repositories and applies business rules, rather than simply forwarding calls. If it only passes a call through, it adds no value.
From the discussion
Featured comments
绿色水杯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!

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗