Managing Module Dependencies
Keep the graph clean and acyclic.
Managing Module Dependencies is a free Android Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Android Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
The Dependency Graph
Every module declares which other modules it depends on. Together these form a dependency graph. A healthy graph is a DAG (directed acyclic graph): dependencies point in one direction and never form a loop.
In this lesson you will learn how to declare dependencies cleanly, choose the right Gradle configuration, share versions, and prevent cycles.
Declaring a Module Dependency
You add a dependency on another module with project(":path:to:module") inside the dependencies block. The path mirrors the folders and matches what you declared in settings.gradle.kts.
// feature/profile/build.gradle.kts
dependencies {
implementation(project(":core:data"))
implementation(project(":core:designsystem"))
implementation(project(":core:model"))
}implementation vs api
The configuration you choose controls what leaks to consumers:
implementation: the dependency is private. Modules that depend on you cannot see it. This is the default choice.api: the dependency is re-exposed (transitive). Use it only when your public types come from that dependency.
Prefer implementation almost always — it improves build speed because changing a hidden dependency does not force consumers to recompile.
// core/data/build.gradle.kts
dependencies {
// Repository signatures return :core:model types,
// so consumers need to SEE it -> api
api(project(":core:model"))
// Network is an internal detail -> implementation
implementation(project(":core:network"))
}Why implementation Speeds Up Builds
With implementation, Gradle knows that a change to a hidden dependency cannot affect the module's public ABI. So consumers do not need to recompile. With api, a change ripples through every transitive consumer.
Rule of thumb: a dependency goes in api only if it appears in the module's public types (return types, public parameters). Otherwise use implementation.
// Public -> needs api
fun observeUser(): Flow<User> // Flow and User leak out
// Internal -> implementation is enough
private val client: OkHttpClient // never exposedCentralize Versions: The Version Catalog
With many modules you do not want to repeat library versions everywhere. Gradle's version catalog (gradle/libs.versions.toml) defines versions and aliases once. Every module references the same alias.
# gradle/libs.versions.toml
[versions]
compose-bom = "2024.09.00"
retrofit = "2.11.0"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }Using the Catalog in a Module
Modules then reference libraries through the generated libs accessor. No version numbers in the build file means upgrades happen in one place.
// core/network/build.gradle.kts
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.retrofit)
}The Cardinal Sin: Cycles
A cycle is when module A depends on B and B depends back on A (directly or through a chain). Gradle refuses to build a circular dependency. It also signals a design problem: the boundary between the two modules is wrong.
// :feature:cart -> implementation(project(":feature:checkout"))
// :feature:checkout -> implementation(project(":feature:cart"))
//
// Gradle error:
// Circular dependency between the following tasks:
// :feature:cart:compile -> :feature:checkout:compile -> :feature:cart:compileBreaking a Cycle
To break a cycle, extract the shared piece into a lower module that both can depend on. If two features need each other's data, that shared contract belongs in core, not in either feature.
This restores the downward flow: both features point down at core, and core never points back up.
// Before: cart <-> checkout (cycle)
// After: cart -> :core:order <- checkout
// core/order/OrderContract.kt
data class Order(val items: List<CartItem>, val total: Double)
// feature/cart -> implementation(project(":core:order"))
// feature/checkout-> implementation(project(":core:order"))Inversion: Depend on Abstractions
Sometimes a low-level module needs behavior that lives higher up. Instead of depending upward, define an interface in the low module and let the high module provide the implementation via dependency injection. This is dependency inversion.
// core/analytics defines the contract
interface AnalyticsLogger {
fun log(event: String)
}
// :app provides the real implementation and injects it down
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {
@Provides
fun logger(impl: FirebaseAnalyticsLogger): AnalyticsLogger = impl
}Visualize and Guard the Graph
You can ask Gradle to print or render the module graph, and even add a test that fails the build if a forbidden dependency appears (for example, a core module depending on a feature). Tools like the module-graph plugin generate a diagram automatically.
# Print the project structure
./gradlew projects
# Inspect why :feature:home pulls in a library
./gradlew :feature:home:dependencies --configuration debugRuntimeClasspathA Clean, Acyclic Example
Here is a healthy graph. Read it top to bottom; no arrow ever points back up, and no two modules point at each other. This is exactly what you are aiming for.
// :app
// -> :feature:home -> :core:data -> :core:network -> :core:model
// -> :feature:profile -> :core:data -> :core:database -> :core:model
// -> :core:designsystem
//
// Every path ends at :core:model. No cycles. Builds in parallel.Quick Check
Your :core:network module uses OkHttpClient only internally — it never appears in any public function signature. Which Gradle configuration should you use to declare the OkHttp dependency?
Recap: Managing Module Dependencies
You learned how to keep the module graph healthy:
- Declare module deps with
project(":path"). - Default to
implementation; useapionly for types in your public surface. - Centralize versions in a version catalog (
libs.versions.toml). - Never create cycles — Gradle rejects them; break them by extracting shared code downward or inverting with interfaces.
Next, you will connect features to each other through navigation without coupling them.
Frequently asked questions
Is the “Managing Module Dependencies” lesson free?
Yes — the full text of “Managing Module Dependencies” is free to read here on the web, and the Android Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Android Academy course, upgrade to CoddyKit PRO.
What will I learn in “Managing Module Dependencies”?
Keep the graph clean and acyclic. You practise Android Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Android Academy?
No prior experience is required. Android Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Managing Module Dependencies” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Android Academy lesson?
Yes. Every Android Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Why Modularize
- Feature and Core Modules
- Managing Module Dependencies
- Navigation Across Modules