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

Module dependency graphtext
:app
  └── implementation(project(":calculator"))
        └── implementation(project(":math"))

Read the arrows as compile relationships

  • :app knows the calculator API but does not need to know how calculations are implemented.

  • :calculator compiles against and calls :math.

  • implementation keeps 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

settings.gradle.ktskotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
 
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
 
include(":app", ":calculator", ":math")

Settings defines project identity

  • include makes 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

math/build.gradle.ktskotlin
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.library creates Android library variants and AAR outputs.

  • A library declares a namespace but not an application ID.

  • minSdk constrains 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

math/src/main/kotlin/com/example/math/IntMath.ktkotlin
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 Int values, so no Android context leaks into the domain module.

  • Math.addExact reports integer overflow with ArithmeticException instead 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

calculator/build.gradle.ktskotlin
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.

  • implementation puts 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

calculator/src/main/kotlin/com/example/calculator/Calculator.ktkotlin
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 IntMath in its signature; if strict encapsulation is required, make the injectable constructor internal and offer a public no-argument factory/constructor.

  • The public total method uses only stable primitive types.

  • Constructor injection keeps the calculation testable without global state.

  • Exceptions from addExact remain 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

calculator/src/main/kotlin/com/example/calculator/Calculator.ktkotlin
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 Calculator and 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

app/build.gradle.ktskotlin
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 :math directly 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 implementation when dependency types stay behind private/internal code and behavior.

  • Use api when consumers are intentionally expected to compile against dependency types exposed by your public API.

  • api widens 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.

  • compileOnly is not a substitute for an AAR runtime dependency and Android documentation does not permit it for AAR dependencies.

calculator/build.gradle.kts (only for an exposed API)kotlin
dependencies {
    api(project(":math"))
}

Export only by design

  • api makes 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

calculator/src/test/kotlin/com/example/calculator/CalculatorTest.ktkotlin
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 :math for its arithmetic behavior.

  • Instrumented tests are necessary only when Android framework behavior/resources are involved.

Build the graph, not just one source file

Android project rootbash
./gradlew :calculator:testDebugUnitTest :calculator:assembleRelease :app:assembleDebug
./gradlew :app:dependencies --configuration debugRuntimeClasspath
Tests 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.

  • implementation is 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