Modularization and feature boundaries
Use module boundaries to reduce real coupling, protect ownership, and improve change speed.
Modularization is not a goal by itself. A Gradle module adds build configuration, dependency management, navigation complexity, and cognitive overhead. It earns that cost when it reduces a concrete pain: teams collide in one feature, changes recompile too much of the app, internal implementations leak into many callers, or you need a clear ownership boundary for scale.
Senior engineers justify modules with problems and metrics, not fashion.
Learning goals
- Find module boundaries from change frequency and ownership, not package names alone.
- Keep dependency arrows healthy (features depend on APIs, not on each other’s internals).
- Choose between monolith, package-by-feature, and multi-module with eyes open.
- Migrate incrementally with measurable checkpoints.
- Discuss modularization in system-design / architecture interviews without dogma.
Find a boundary from change and ownership
Start by mapping:
- What changes together?
- Who owns it?
- What must remain stable for others?
- What is expensive to recompile today?
Example: a checkout feature might own its UI, presentation state, and internal data coordination. The rest of the app should depend on a small entry point, not on checkout’s database classes or private composables.
API vs implementation modules
A common pattern:
:feature:checkout:api- public navigation entry, public models needed by others, interfaces.:feature:checkout:impl- screens, ViewModels, internal repositories - depended on only by:appor a feature aggregator.
// :feature:checkout:api
interface CheckoutEntryPoint {
fun intent(context: Context, cartId: String): Intent
// or NavGraph registration hooks / DI entry points
}
// other features depend only on the api module
This prevents FeedFragment from importing CheckoutDao.
Dependency direction matters more than module count
Healthy rules of thumb:
- Features do not depend on other features’ impl modules.
- Core libraries do not depend on features.
- App module wires graph (or a thin wiring module does).
- Prefer depending on interfaces for cross-feature collaboration.
When two features need shared behavior, extract a core or domain module with a stable API - do not let them reach into each other “just this once.”
What to put in core modules
| Module | Contents | Caution |
|---|---|---|
:core:model | Shared pure models | Avoid dumping everything |
:core:network | Retrofit, interceptors | Keep feature DTOs local when possible |
:core:database | Room setup | Feature-specific entities can live in feature modules |
:core:designsystem | Theme, components | Prevent feature-specific widgets from polluting it |
:core:common | Result types, coroutines helpers | “common” becomes a junk drawer quickly - police it |
Junk drawer modules recreate the monolith with worse navigation.
Build speed and configuration
Multi-module can improve:
- Parallel compilation
- Incremental compilation scope
- Clearer ABI / public API surfaces (with Kotlin explicit API mode)
It can also worsen:
- Configuration time (too many modules)
- Redundant dependency declaration
- Composite build complexity
Senior move: measure before/after with Gradle build scans on representative changes (“edit one file in checkout - what recompiles?”).
Navigation across modules
Options:
- Central NavHost in :app with feature-provided destinations registered at wiring time.
- Deep links as the cross-feature API.
- Navigator interfaces in api modules implemented by app/wiring.
interface AppNavigator {
fun openCheckout(cartId: String)
fun openArticle(articleId: String)
}
Avoid circular navigation dependencies between feature impls.
DI across modules
Hilt:
@InstallInmodules live next to the code they bind.- Feature impl modules contribute bindings; app aggregates.
- Use entry points carefully; prefer constructor injection.
Manual DI / kotlin-inject / Metro / Anvil - same principle: wiring at the edges, not static service locators inside features.
When not to modularize
- Solo developer, small app, no compile pain, no ownership boundaries.
- “We might hire more people someday” without current coordination cost.
- Splitting by technical layer only (
:ui,:domain,:data) when features still change as a ball of mud - layer modules can encode the wrong axis of change.
Package-by-feature inside a single module is often the right intermediate step.
Migrate in small, measurable steps
- Package by feature inside the monolith; ban cross-feature deep imports via convention / ArchUnit / Konsist.
- Extract core libraries that are already stable.
- Extract one leaf feature with low fan-in.
- Introduce api/impl when another feature needs a stable contract.
- Automate dependency rules (Gradle dependency-analysis, module graph checks).
Each step should answer: what became safer or faster?
Interview framing
A strong senior answer sounds like:
We modularize along ownership and change boundaries. Features expose a narrow API module; implementations stay private. Core holds shared infrastructure without becoming a dumping ground. We measure compile impact and migrate incrementally from a package-by-feature monolith rather than a big-bang rewrite.
Weak answers only say “Clean Architecture says five modules” or “Google’s Now in Android has many modules so we should too.”
Common pitfalls
- Circular dependencies “solved” with reflection or soft references.
:core:commonabsorbing feature logic.- Exposing Room entities as public API.
- 80 modules for a 3-person team - configuration tax.
- Modularizing UI without modularizing data ownership, so features still fight over the same tables without contracts.
Practice checklist
- Draw your current app’s module (or package) graph; mark illegal edges.
- Propose an api/impl split for one feature and list the public types only.
- Identify one shared junk type that should move or die.
- Write a dependency rule you could enforce in CI.
What you should be able to explain
- Boundary discovery from ownership and change.
- Dependency direction and api/impl.
- Build cost trade-offs with measurement.
- Incremental migration path.
- When a modular monolith is enough.
Next: Offline sync and mobile system design - designing features that live on unreliable networks with clear product truth.
Discussion
Comments are powered by GitHub Discussions. Sign in with GitHub to join in.