How you organize code inside a module matters as much as the module structure itself. The two dominant approaches — package by layer and package by feature — have very different implications for how a codebase scales.
Package by Layer
Groups all classes of the same type together, regardless of the feature they belong to:
com.example.myapp/
├── data/
│ ├── ArticleRepository.kt
│ ├── UserRepository.kt
│ └── SettingsRepository.kt
├── domain/
│ ├── GetArticlesUseCase.kt
│ ├── GetUserUseCase.kt
│ └── UpdateSettingsUseCase.kt
├── ui/
│ ├── ArticleListFragment.kt
│ ├── UserProfileFragment.kt
│ └── SettingsFragment.kt
└── network/
├── ArticleApi.kt
└── UserApi.kt
Pros: Easy to find all classes of a given type (all DAOs in one place). Good for small teams and small codebases.
Cons: Adding a new feature requires touching every layer folder. Related code is scattered. Hard to extract a feature into its own module later.
Package by Feature
Groups all code for a given feature together, regardless of what layer it's in:
com.example.myapp/
├── feed/
│ ├── FeedFragment.kt
│ ├── FeedViewModel.kt
│ ├── FeedRepository.kt
│ ├── FeedApi.kt
│ └── FeedAdapter.kt
├── profile/
│ ├── ProfileFragment.kt
│ ├── ProfileViewModel.kt
│ ├── ProfileRepository.kt
│ └── ProfileApi.kt
└── settings/
├── SettingsFragment.kt
├── SettingsViewModel.kt
└── SettingsRepository.kt
Pros: All code for a feature is co-located — easy to understand and modify. Natural boundary for ownership. Easy to extract into a module (just move the package).
Cons: Harder to see cross-cutting patterns (all DAOs are spread across packages). Can encourage coupling between features if not careful.
Recommendation: Feature-First, Layer-Second
The best approach combines both: packages organized by feature at the top level, with sub-packages by layer within each feature:
com.example.myapp/
├── feed/
│ ├── data/
│ │ ├── FeedRepository.kt
│ │ └── FeedApi.kt
│ ├── domain/
│ │ └── GetFeedUseCase.kt
│ └── ui/
│ ├── FeedFragment.kt
│ └── FeedViewModel.kt
├── profile/
│ ├── data/ ...
│ ├── domain/ ...
│ └── ui/ ...
└── core/
├── network/ ← shared infrastructure
├── database/
└── ui/ ← shared UI components
Kotlin Visibility and Package Structure
Use internal visibility to enforce feature boundaries:
// feed/data/FeedRepository.kt
internal class FeedRepository(...) // ← only feed package can use this
// feed/domain/GetFeedUseCase.kt
class GetFeedUseCase(private val repo: FeedRepository) { // ← public: other packages can use the use case
operator fun invoke() = repo.getArticles()
}
External packages use GetFeedUseCase but never access FeedRepository directly.
Transitioning from Layer to Feature
Practical migration path:
- Pick the least-coupled feature first
- Create a feature sub-directory
- Move all related files there
- Adjust
internalvisibilities - Repeat for each feature
At the end, what remains in the old layer folders becomes your core shared infrastructure.
Key Takeaways
| Approach | Best for |
|---|---|
| By layer | Small apps (< 5 features), single developer |
| By feature | Medium to large apps; teams with feature ownership |
| Feature-first + layer-second | Best of both: co-location + architectural clarity |
internal visibility | Natural enforcement of feature boundaries |