A well-designed module dependency graph is the backbone of a maintainable multi-module project. It determines build performance, team independence, and how changes propagate through the codebase.
The DAG Requirement
Module dependencies must form a Directed Acyclic Graph (DAG). Cycles break Gradle's incremental build and cause compilation failures:
❌ Cycle: :feature-a → :feature-b → :feature-a
✅ DAG:
:app
├── :feature-feed (:feed)
├── :feature-profile (:profile)
│
:feed → :domain-articles → :data-articles → :core-network
↘ :core-database
:profile → :domain-users → :data-users → :core-network
Defining Public API Contracts
Each module should expose a minimal, stable public API. Internal implementation details stay internal:
// :domain-articles module
// Public API — other modules depend on these
interface ArticleRepository {
fun getArticles(): Flow<List<Article>>
suspend fun getArticle(id: String): Article?
suspend fun saveArticle(article: Article)
}
data class Article(
val id: String,
val title: String,
val body: String,
val publishedAt: Long
)
// Internal — :data-articles implements this
internal class ArticleRepositoryImpl(
private val localDataSource: ArticleLocalDataSource,
private val remoteDataSource: ArticleRemoteDataSource
) : ArticleRepository { ... }
API Modules Pattern
For large teams, separate the public contract from the implementation:
:data:articles:api ← interfaces + domain models only
:data:articles:impl ← implementation of :data:articles:api
// :data:articles:api/build.gradle.kts
dependencies {
// No implementation dependencies — pure interfaces
api(project(":core:common")) // shared models
}
// :data:articles:impl/build.gradle.kts
dependencies {
implementation(project(":data:articles:api"))
implementation(project(":core:network"))
implementation(project(":core:database"))
}
// :app wires them together at DI time
@Module
@InstallIn(SingletonComponent::class)
object ArticlesModule {
@Provides
@Singleton
fun provideArticleRepository(impl: ArticleRepositoryImpl): ArticleRepository = impl
}
Features only depend on :data:articles:api — they never see the implementation.
Visualizing the Dependency Graph
# Generate a dependency graph with Gradle
./gradlew :app:dependencies --configuration debugRuntimeClasspath
For visual graphs, use the com.jakewharton.gradle.dependencies plugin or dependency-graph-generator:
// build.gradle.kts (root)
plugins {
id("com.vanniktech.dependency.graph.generator") version "0.8.0"
}
./gradlew generateDependencyGraph
# Outputs: build/reports/dependency-graph/dependency-graph.svg
Detecting Forbidden Dependencies
Use the Dependency Guard or forbiddenDependencies extension to enforce rules:
// custom Gradle check
tasks.register("checkModuleDependencies") {
doLast {
// Ensure :feature modules don't depend on each other
val featureModules = subprojects.filter { it.name.startsWith("feature") }
featureModules.forEach { module ->
module.configurations.forEach { config ->
config.dependencies.forEach { dep ->
check(dep !is ProjectDependency ||
!dep.name.startsWith("feature")) {
"Module ${module.name} cannot depend on ${dep.name}"
}
}
}
}
}
}
Gradle Build Cache and Parallel Builds
A good dependency graph unlocks Gradle parallelism:
// gradle.properties
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.caching=true
Independent modules (those not in each other's dependency chain) build in parallel. A flat, well-separated graph maximizes this.
Key Takeaways
| Concept | Rule |
|---|---|
| DAG requirement | No cycles; enforce with CI |
internal keyword | Hide implementation; expose only contracts |
| API modules | Separate interface (:api) from implementation (:impl) |
| Features ↔ Features | Never direct; communicate via navigation or shared domain |
| Build cache | Only works if module inputs don't change; keep modules focused |