Multi-module Android builds feel wonderfully clean until one library needs another. The Gradle line is easy; the architectural promise behind implementation or api is the part that follows your team for years. This example connects a small :math library to :calculator, then makes :app consume only the public calculator surface.
The build graph we are creating
:app
└── implementation(project(":calculator"))
└── implementation(project(":math"))Read the arrows as compile relationships
:appknows the calculator API but does not need to know how calculations are implemented.:calculatorcompiles against and calls:math.implementationkeeps math types off the app compile classpath unless another direct dependency supplies them.Gradle still packages required runtime code into the final application.
A dependency graph must remain acyclic; two modules cannot depend on each other.
1. Include all modules in the build
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
include(":app", ":calculator", ":math")Settings defines project identity
includemakes each path addressable as a Gradle project.The physical directories normally match the paths, though advanced builds can map them explicitly.
Repository declarations resolve external artifacts; project dependencies resolve modules in this build.
Keep plugin and dependency repositories centralized when the project’s repository mode requires it.
A missing include produces “project not found” before Android compilation begins.
2. Make both reusable modules Android libraries
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.math"
compileSdk = 35
defaultConfig {
minSdk = 23
}
}A library produces an AAR, not an APK
com.android.librarycreates Android library variants and AAR outputs.A library declares a namespace but not an application ID.
minSdkconstrains consuming apps because the final app must support every included library requirement.Use the project’s approved compile SDK and version convention rather than copying these sample numbers blindly.
Give every module a unique namespace and prefix public resources to reduce merge collisions.
3. Give the lower-level module a narrow API
package com.example.math
class IntMath {
fun add(left: Int, right: Int): Int = Math.addExact(left, right)
}Small APIs are easier to own
The function takes and returns ordinary Kotlin/JVM
Intvalues, so no Android context leaks into the domain module.Math.addExactreports integer overflow withArithmeticExceptioninstead of silently wrapping.The class is public because another module constructs it.
Real libraries should document overflow, threading, nullability, and error contracts at their boundary.
If no Android resources or APIs are needed, consider a Kotlin/JVM module instead of an Android library.
4. Add the project dependency to the consuming library
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.calculator"
compileSdk = 35
defaultConfig {
minSdk = 23
}
}
dependencies {
implementation(project(":math"))
}Implementation is the healthy default
project(":math")selects another module in the same Gradle build.implementationputs math on calculator’s compile and runtime paths without exporting its types to upstream compile classpaths.Keeping internal dependencies private reduces accidental coupling and unnecessary recompilation.
The dependency belongs in
calculator/build.gradle.kts, because calculator is the consumer.Use variant-specific configurations only when the dependency truly differs by flavor or build type.
5. Wrap the internal dependency behind your own API
package com.example.calculator
import com.example.math.IntMath
class Calculator(
private val math: IntMath = IntMath(),
) {
fun total(subtotal: Int, shipping: Int): Int =
math.add(subtotal, shipping)
}The boundary hides the implementation type
The public constructor has a default but exposes
IntMathin its signature; if strict encapsulation is required, make the injectable constructor internal and offer a public no-argument factory/constructor.The public
totalmethod uses only stable primitive types.Constructor injection keeps the calculation testable without global state.
Exceptions from
addExactremain part of runtime behavior and should be documented or translated.Avoid exposing an internal module type accidentally through properties, parameters, return values, supertypes, or generic bounds.
A fully encapsulated public surface
package com.example.calculator
import com.example.math.IntMath
class Calculator private constructor(
private val math: IntMath,
) {
constructor() : this(IntMath())
fun total(subtotal: Int, shipping: Int): Int =
math.add(subtotal, shipping)
}Now implementation matches the dependency declaration
The public constructor does not mention
IntMath.The private constructor remains available for internal composition; tests can target behavior or use an internal test seam.
Callers depend only on
Calculatorand primitive values.The math module can later be replaced without requiring app source changes.
Binary compatibility still requires deliberate evolution of calculator’s public classes and methods.
6. Let the application depend only on calculator
dependencies {
implementation(project(":calculator"))
}Do not repeat hidden dependencies in the app
The app declares what it directly uses.
Gradle carries calculator’s runtime dependency into the final build.
Adding
:mathdirectly would let app code bypass the intended boundary.If app code genuinely uses math APIs, make that direct dependency explicit instead of relying on accidental transitivity.
Implementation versus api
Use
implementationwhen dependency types stay behind private/internal code and behavior.Use
apiwhen consumers are intentionally expected to compile against dependency types exposed by your public API.apiwidens compile classpaths and can cause more upstream recompilation when the dependency changes.Changing an exposed dependency or its types can be a source and binary compatibility event.
compileOnlyis not a substitute for an AAR runtime dependency and Android documentation does not permit it for AAR dependencies.
dependencies {
api(project(":math"))
}Export only by design
apimakes math available to modules compiling against calculator.It does not mean every internal dependency should be exported.
Review public signatures before choosing the configuration.
API validation tools can catch accidental public-surface changes in published libraries.
Test at both module boundaries
package com.example.calculator
import kotlin.test.Test
import kotlin.test.assertEquals
class CalculatorTest {
@Test
fun totalAddsShippingToSubtotal() {
assertEquals(125, Calculator().total(100, 25))
}
}A boundary test catches wiring regressions
The test calls only calculator’s public surface.
It proves the project dependency is present on the calculator test runtime path.
Add overflow and negative-value cases according to the product contract.
Keep separate unit tests in
:mathfor its arithmetic behavior.Instrumented tests are necessary only when Android framework behavior/resources are involved.
Build the graph, not just one source file
./gradlew :calculator:testDebugUnitTest :calculator:assembleRelease :app:assembleDebug
./gradlew :app:dependencies --configuration debugRuntimeClasspathTests and both consumers should build. The dependency report should show calculator and its runtime relationship to math.Verification follows the dependency downstream
The wrapper preserves the project Gradle version.
The calculator release AAR build catches library-specific packaging problems.
The app build exercises final manifest/resource/dex packaging.
The runtime classpath report confirms selected components and variants.
Also build minified release and all published flavors in CI.
Publication changes the rules
Project dependencies work because all modules exist in the same build. When distributing :calculator to another repository or company, publish artifacts through a Maven repository with Gradle Module Metadata/POM dependency information. Copying only calculator-release.aar does not give the consumer a reliable transitive dependency graph.
Publish both calculator and math with stable coordinates and versions.
Verify generated metadata records the intended implementation/api relationship.
Test consumption from a separate sample project using only published coordinates.
A loose AAR requires consumers to manage missing transitive dependencies manually.
If one physical AAR is a contractual requirement, evaluate the official fused-library capability and its limitations rather than assuming normal AAR publication embeds dependencies.
Common failures
Project with path could not be found: the module is missing from settings or the project path is misspelled.
Unresolved reference in calculator: the dependency is declared in the wrong module/configuration or source uses a different variant.
App cannot resolve a math type: calculator exposes that type while math is hidden with implementation; hide the type or use api deliberately.
Duplicate class/resource: the app declares the same artifact through multiple paths or libraries collide during packaging.
Circular dependency: extract shared contracts into a lower-level module so dependencies point in one direction.
Works in monorepo, fails after publishing: only the AAR was copied and dependency metadata/artifacts were not published.
Manifest merge/minSdk failure: a transitive library requires components, attributes, or a higher platform baseline.
Production checklist
Every module is included once and has a unique namespace.
Dependency arrows form an acyclic architecture.
implementationis the default and public signatures are checked for leaked types.Direct consumers declare direct dependencies; no module relies on accidental compile visibility.
Unit, app packaging, lint, minified release, and supported variant tests pass.
Published artifacts include correct metadata and are verified from an external consumer project.
Licenses, vulnerability ownership, version alignment, resource prefixes, minSdk, consumer rules, and release compatibility are documented.
Official references
Add build dependencies defines project dependencies and the
implementation/apiconfigurations.Create an Android library explains same-build module dependencies, AAR publication, transitive metadata, and resource/manifest behavior.
Tool and library interdependencies explains transitive graphs, SDK constraints, and dependency scopes.
Configure publication variants covers variant-aware library metadata.
Comments and corrections