Fat AAR: Bundling Transitive Dependencies for Zero-Config Android SDK Consumers
Your SDK has 12 transitive dependencies. Your consumer’s build.gradle shouldn’t know about any of them.
Why This Problem Is Uniquely Hard in KMP
If you’re building a Kotlin Multiplatform SDK, you’re already dealing with complexity that traditional Android libraries never face. Your commonMain code compiles to both JVM bytecode (Android) and native binaries (iOS). On iOS, the XCFramework is self-contained - all Kotlin/Native dependencies compile into a single static binary. There's no "dependency resolution" on the Swift side.
But on Android? You’re back in Gradle-land. Your KMP module produces a standard .aar, and every api() or implementation() dependency in your build.gradle.kts becomes a transitive edge in the consumer's dependency graph. The irony: the platform with the better tooling has the worse distribution story.
This asymmetry is what makes Fat AAR essential for KMP SDK teams. iOS gets zero-config by default (static XCFramework). Android needs us to build it ourselves.
The Problem Nobody Talks About
You’ve built a beautiful Android SDK. Clean API. Well-tested. You publish it as an AAR and send integration docs to your consumer team.
Then the Slack messages begin:
“Hey, I’m getting
ClassNotFoundException: io.ktor.client.HttpClient"“We added your AAR but now we have a version conflict with kotlinx-serialization”
“Which Koin version does your SDK need? Our app uses Dagger.”
Sound familiar?
A standard AAR only contains your code. Every library you depend on — Ktor, kotlinx-serialization, Koin — becomes the consumer’s problem. They must add those dependencies manually, match your exact versions, and pray nothing conflicts with their existing dependency tree.
This is the #1 integration pain point for SDK teams. And Fat AAR solves it completely.
What Is a Fat AAR?
A Fat AAR is a standard Android Archive (.aar) that bundles selected transitive dependencies inside its own classes.jar. The consumer adds one file, writes one Gradle line, and everything works:
implementation(files("libs/shared-release-fat.aar"))That’s it. No Ktor dependency. No serialization library. No Koin import. Zero configuration.
Lean vs. Fat: When to Use Which
Before we dive into implementation, let’s be clear — Fat AAR isn’t always the right choice:
Use Fat AAR when:
- Your SDK is distributed as a local
.aarfile (not Maven) - Consumer apps don’t use the same libraries (Ktor, Koin, kotlinx-serialization)
- You want zero-friction integration — “drop in and go”
- You’re distributing to teams outside your organization
Use Lean AAR when:
- You publish to Maven (Gradle resolves transitive deps automatically via POM)
- Consumer apps already depend on Ktor/Koin/kotlinx-serialization
- You need consumers to control dependency versions (e.g., security patches)
The class conflict rule: If your Fat AAR bundles Ktor 3.1.3 and the consumer’s app already has Ktor 3.0.0 on its classpath → duplicate class errors at compile time, or worse, unpredictable behavior at runtime. In that case, use the Lean variant.
The Architecture
Here’s what happens inside the Fat AAR build:
┌──────────────────────────────────────────────────────────────────────┐
│ KMP Module: shared/build.gradle.kts │
│ │
│ commonMain { │
│ api(libs.ktor.client.core) ← platform-agnostic │
│ api(libs.serialization.json) ← compile-time codegen │
│ api(libs.koin.core) ← no reflection on iOS │
│ } │
│ androidMain { │
│ api(libs.ktor.client.okhttp) ← OkHttp engine for Android │
│ } │
│ iosMain { │
│ implementation(libs.ktor.client.darwin) ← baked into XCFramework │
│ } │
└──────────────────────────────────────────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ iOS: XCFramework │ │ Android: .aar │
│ (self-contained) │ │ (dependencies leak out) │
│ ✅ Zero-config │ │ ❌ Consumer must add │
│ │ │ Ktor, Koin, etc. │
└──────────────────────┘ └──────────────────────────┘
│
▼ packageFatAar
┌──────────────────────────┐
│ Fat AAR │
│ ✅ Zero-config │
│ (parity with iOS!) │
└──────────────────────────┘The Fat AAR gives Android the same zero-config distribution story that iOS gets for free from static linking. Here’s what’s inside:
┌─────────────────────────────────────────────────────┐
│ shared-release.aar (Lean - your code only) │
│ ┌───────────────┐ │
│ │ classes.jar │ ← SDK classes only (~115 KB) │
│ └───────────────┘ │
└─────────────────────────────────────────────────────┘
│
▼ packageFatAar task
┌─────────────────────────────────────────────────────┐
│ shared-release-fat.aar (Fat - all-in-one) │
│ ┌───────────────────────────────────────────────┐ │
│ │ classes.jar (merged) │ │
│ │ ├── com/yourcompany/sdk/** (SDK classes) │ │
│ │ ├── io/ktor/** (~1,550 classes)│ │
│ │ ├── kotlinx/serialization/** (~365 classes) │ │
│ │ └── org/koin/** (~233 classes) │ │
│ └───────────────────────────────────────────────┘ │
│ consumer-rules.pro (ProGuard rules - auto-applied)│
│ AndroidManifest.xml │
└─────────────────────────────────────────────────────┘The key insight: we don’t bundle everything. We’re selective about what goes in and what stays out.
What Gets Bundled (And What Doesn’t)
This is the most critical design decision. Bundle too little → consumers get ClassNotFound. Bundle too much → you cause version conflicts with libraries every Android app already has.
Bundled (consumer unlikely to have these):
io.ktor → HTTP client + OkHttp engine (~1,550 classes)
kotlinx-serialization → JSON parsing (~365 classes)
io.insert-koin → DI container (~233 classes)NOT bundled (every Android app already has these):
kotlin-stdlib → Comes with the Kotlin plugin
kotlinx-coroutines → Every modern Android app uses this
OkHttp → Almost every app has it via Retrofit
Okio → Comes with OkHttp
SLF4J → Logging facade, extremely commonThe logic is simple: if the host app already has it via AndroidX or Retrofit, don’t bundle it. If it’s something only your SDK cares about, bundle it.
The Implementation
Here’s the complete Gradle task. It’s a single doLast block - no plugins, no third-party Gradle tools, no magic:
tasks.register("packageFatAar") {
description = "Repackages the release AAR with all runtime dependencies bundled in"
group = "build"
val aarDir = layout.buildDirectory.dir("outputs/aar")
val fatWorkDir = layout.buildDirectory.dir("fat-aar-work")
outputs.file(aarDir.map { it.file("shared-release-fat.aar") })
doLast {
// Step 1: Find the lean AAR
val inputAar = aarDir.get().asFile.resolve("shared-release.aar")
require(inputAar.exists()) {
"shared-release.aar not found. Run :shared:assembleRelease first."
}
val workDir = fatWorkDir.get().asFile
workDir.deleteRecursively()
workDir.mkdirs()
// Step 2: Unzip the AAR
val aarUnpackDir = workDir.resolve("aar-contents")
aarUnpackDir.mkdirs()
project.copy {
from(project.zipTree(inputAar))
into(aarUnpackDir)
}
// Step 3: Unpack SDK's own classes.jar
val mergedClassesDir = workDir.resolve("merged-classes")
mergedClassesDir.mkdirs()
project.copy {
from(project.zipTree(aarUnpackDir.resolve("classes.jar")))
into(mergedClassesDir)
}
// Step 4: Resolve which dependencies to bundle
val runtimeCp = configurations.findByName("releaseRuntimeClasspath")
?: error("Cannot find runtime classpath configuration")
val bundlePrefixes = listOf(
"io.ktor",
"org.jetbrains.kotlinx:kotlinx-serialization",
"io.insert-koin",
)
val excludeModules = setOf(
"ktor-websockets-jvm",
"ktor-websocket-serialization-jvm",
"ktor-serialization-jvm",
)
val artifactsToBundle = runtimeCp.resolvedConfiguration
.resolvedArtifacts
.filter { artifact ->
val id = artifact.moduleVersion.id
val coords = "${id.group}:${id.name}"
bundlePrefixes.any { prefix -> coords.startsWith(prefix) }
&& id.name !in excludeModules
}
// Step 5: Merge dependency classes into the merged dir
artifactsToBundle.forEach { artifact ->
project.copy {
from(project.zipTree(artifact.file))
into(mergedClassesDir)
exclude(
"META-INF/*.SF",
"META-INF/*.DSA",
"META-INF/*.RSA",
"META-INF/MANIFEST.MF",
"META-INF/LICENSE*",
"META-INF/NOTICE*",
"META-INF/versions/**",
"META-INF/proguard/**",
"module-info.class"
)
}
}
// Step 6: Repackage into new classes.jar
val fatClassesJar = aarUnpackDir.resolve("classes.jar")
fatClassesJar.delete()
project.ant.invokeMethod("jar", mapOf(
"destfile" to fatClassesJar.absolutePath,
"basedir" to mergedClassesDir.absolutePath
))
// Step 7: Zip everything back as the fat AAR
val fatAarFile = aarDir.get().asFile.resolve("shared-release-fat.aar")
fatAarFile.delete()
project.ant.invokeMethod("zip", mapOf(
"destfile" to fatAarFile.absolutePath,
"basedir" to aarUnpackDir.absolutePath
))
}
}Run it:
./gradlew :shared:assembleRelease :shared:packageFatAarOutput:
📦 Fat AAR - bundling 14 dependency JARs:
✓ io.insert-koin:koin-core:4.1.0
✓ io.ktor:ktor-client-core-jvm:3.1.3
✓ io.ktor:ktor-client-okhttp-jvm:3.1.3
✓ io.ktor:ktor-http-jvm:3.1.3
✓ io.ktor:ktor-io-jvm:3.1.3
✓ io.ktor:ktor-utils-jvm:3.1.3
✓ org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0
✓ org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0
... (more)✅ Fat AAR created successfully!
📄 shared-release.aar → 115 KB (lean, needs transitive deps)
📦 shared-release-fat.aar → 3,340 KB (fat, zero deps needed)The META-INF Exclusions — Why They Matter
You’ll notice we exclude several META-INF entries. Here’s why each matters:
exclude(
"META-INF/*.SF", // JAR signature files - invalid after repackaging
"META-INF/*.DSA", // Digital signature - same reason
"META-INF/*.RSA", // RSA signature - same reason
"META-INF/MANIFEST.MF", // Each JAR has its own - keep only ours
"META-INF/LICENSE*", // Avoid duplicate license files
"META-INF/NOTICE*", // Avoid duplicate notice files
"META-INF/versions/**", // Multi-release JAR entries - not needed in Android
"META-INF/proguard/**", // We provide our own consumer-rules.pro
"module-info.class" // Java 9 module descriptors - Android doesn't use them
)If you skip these exclusions, you’ll get build errors like Duplicate entry: META-INF/MANIFEST.MF or broken JAR signing that confuses the build toolchain.
ProGuard Consumer Rules — R8 Compatibility Out of the Box
A Fat AAR is useless if R8 strips the bundled classes during the consumer’s release build. That’s why we ship consumer-rules.pro inside the AAR:
# ── SDK public API surface ──────────────────────────────────────────
-keep class com.cardinalhealth.vantus.sdk.core.SDKInitializer { public *; }
-keep class com.cardinalhealth.vantus.sdk.core.SDKState { public *; }
-keep class com.cardinalhealth.vantus.sdk.core.SDKState$* { public *; }
-keep class com.cardinalhealth.vantus.sdk.core.ApiResult { public *; }
-keep class com.cardinalhealth.vantus.sdk.core.ApiResult$* { public *; }
# ── Keep facades (public entry points per feature) ──────────────────
-keep class com.cardinalhealth.vantus.sdk.features.**.facade.** { public *; }
# ── Bundled libraries that need reflection ──────────────────────────
-keep class org.koin.core.** { *; }
-keep class org.koin.dsl.** { *; }
-dontwarn org.koin.**
# ── kotlinx.serialization - keep @Serializable classes ──────────────
-keepattributes *Annotation*
-keepclassmembers class com.cardinalhealth.vantus.sdk.**.models.** { *; }
-keep class com.cardinalhealth.vantus.sdk.**.models.**$$serializer { *; }
# ── Ktor - keep what the SDK uses ───────────────────────────────────
-keep class io.ktor.client.HttpClient { *; }
-keep class io.ktor.client.engine.** { *; }
-keep class io.ktor.client.plugins.** { *; }
-dontwarn io.ktor.**
# ── OkHttp (transitive) ────────────────────────────────────────────
-dontwarn okhttp3.**
-dontwarn okio.**=The beauty: consumer apps don’t need to write a single ProGuard rule. The AAR file format automatically applies consumer-rules.pro when the host app enables R8 shrinking.
Binary Size Budget — Preventing Silent Bloat
Fat AARs grow over time. A new Ktor plugin here, an extra serialization format there — suddenly your 3 MB AAR is 8 MB and nobody noticed.
We enforce a hard size budget in CI:
val AAR_SIZE_BUDGET_BYTES = 6 * 1024 * 1024L // 6 MB
val checkAarSize by tasks.registering {
doLast {
val aarFile = layout.buildDirectory
.dir("outputs/aar").get().asFile
.listFiles()?.firstOrNull {
it.extension == "aar" && it.name.contains("release")
} ?: return@doLast
val sizeMb = aarFile.length() / 1_048_576.0
val budgetMb = AAR_SIZE_BUDGET_BYTES / 1_048_576.0
check(aarFile.length() <= AAR_SIZE_BUDGET_BYTES) {
"❌ AAR size (${sizeMb} MB) exceeds budget (${budgetMb} MB). " +
"Check for new transitive dependencies or bloated resources."
}
}
}
// Auto-run after every release build
afterEvaluate {
tasks.matching { it.name.contains("ReleaseAar") }
.configureEach { finalizedBy(checkAarSize) }
}If a PR pushes the AAR over budget, CI fails immediately. No surprises in production.
Consumer Integration — The Entire Setup
Here’s what your consumer’s integration looks like. This is the entire thing:
Step 1: Drop the AAR into their project:
app/
└── libs/
└── shared-release-fat.aarStep 2: One line in build.gradle.kts:
dependencies {
implementation(files("libs/shared-release-fat.aar"))
}Step 3: Use the SDK:
// Initialize
SDKInitializer.init(
baseUrl = "https://api.example.com",
authToken = token,
apiGuid = guid,
clientId = "my-app",
apiKey = "key-123"
)// Call a feature
val result = AppFacadePhysicalInventory.getInventories(customerNo = "123")No dependency resolution. No version conflicts. No “which Ktor engine do I need?” questions. It just works.
Selective Feature Builds + Fat AAR = Minimal Footprint
Here’s where KMP’s compile-time feature selection pairs beautifully with Fat AAR. You don’t just bundle all dependencies — you bundle only what the enabled features require:
# Build Fat AAR with only the inventory feature
./gradlew :shared:assembleRelease :shared:packageFatAar -Psdk.features=physicalinventoryThe disabled features never compile. Their dependencies never resolve. The Fat AAR only contains what’s actually needed:
All features enabled: Fat AAR → ~3.3 MB (14 bundled JARs)
Single feature: Fat AAR → ~2.8 MB (11 bundled JARs, fewer Ktor plugins)This is the KMP advantage — you’re not just tree-shaking at the consumer’s R8 step. You’re eliminating code at build time, before it ever enters the AAR.
Excluding Unused Transitive Modules
Not every transitive dependency is needed. Ktor pulls in WebSocket support by default, but if your SDK only does REST calls, those classes are dead weight:
val excludeModules = setOf(
"ktor-websockets-jvm",
"ktor-websocket-serialization-jvm",
"ktor-serialization-jvm",
)This shaves ~200 KB off the Fat AAR and removes classes the SDK never calls. Always audit what’s getting pulled in — ./gradlew :shared:dependencies --configuration releaseRuntimeClasspath is your friend.
Common Pitfalls and How to Avoid Them
1. Duplicate classes with consumer’s existing dependencies
The consumer adds your Fat AAR and has implementation("io.ktor:ktor-client-core:3.x") in their build file → compile error.
Solution: Ship both variants. Document clearly: “Use shared-release-fat.aar for zero-config. Use shared-release.aar if you already depend on Ktor/Koin."
2. Missing kotlin-stdlib classes
You excluded kotlin-stdlib from bundling (correct), but the consumer uses an older Kotlin version that doesn’t have APIs your SDK compiled against.
Solution: Document minimum Kotlin version in your SDK’s README. Set jvmTarget = JVM_1_8 to maximize compatibility.
3. R8 strips bundled classes
The consumer’s minifyEnabled = true build removes Ktor/Koin classes because their app doesn't directly reference them.
Solution: consumer-rules.pro inside the AAR handles this automatically. If the consumer uses a custom ProGuard config that conflicts, they need to add -dontwarn rules.
4. AAR works in debug but not release
Almost always a ProGuard issue. The debug build doesn’t shrink, so everything works. The release build strips “unused” classes.
Solution: Test with minifyEnabled = true during development. Catch it early, not in QA.
The Build Script — One Command Does Everything
For your CI/CD pipeline and developer convenience:
#!/bin/bash
set -e
echo "🚀 Building Android AAR..."
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
cd "$ROOT_DIR"
# Build lean AAR + package fat variant
./gradlew :shared:clean \
:shared:assembleRelease \
:shared:packageFatAar
echo ""
echo "✅ Build complete!"
echo " Lean: shared/build/outputs/aar/shared-release.aar"
echo " Fat: shared/build/outputs/aar/shared-release-fat.aar"One command. Two output variants. Both ready to ship.
Size Comparison: Real Numbers
From our production SDK with one feature module (Physical Inventory):
The 3.3 MB includes ~2,148 classes from bundled libraries. That sounds like a lot, but remember — R8 will strip whatever the consumer’s app doesn’t actually call. The final contribution to APK size is significantly smaller.
Key Takeaways
1. Fat AAR is a distribution strategy, not a build format. The file format is identical to a regular AAR — you’re just putting more classes inside classes.jar.
2. Be selective about what you bundle. Never bundle kotlin-stdlib, coroutines, or OkHttp — they’re already on every Android classpath. Bundle the things only your SDK cares about.
3. Ship both variants. Fat for zero-config consumers. Lean for consumers who already have your dependencies and want version control.
4. Consumer ProGuard rules are non-negotiable. Without them, your Fat AAR breaks every release build that uses R8.
5. Enforce a size budget in CI. Fat AARs grow silently. A hard limit catches regressions before they ship.
6. Exclude META-INF aggressively. Signature files, duplicate manifests, and module descriptors from merged JARs will break your build or your consumer’s build.
7. Document the class conflict tradeoff. Be explicit in your integration docs: “If you already use Ktor/Koin, use the lean variant to avoid duplicate class errors.”
What About Maven Publishing?
If you publish to a Maven repository (Artifactory, Maven Central, GitHub Packages), you typically don’t need a Fat AAR. Gradle resolves transitive dependencies automatically via the POM file.
But here’s the reality for many enterprise SDK teams: your consumers are in a different org, behind a different VPN, using a different artifact repository. Sending them a .aar file over a secure channel is often simpler than setting up cross-org Maven repository access.
That’s where Fat AAR shines. It’s the “just email the file” distribution model — but production-grade.
The KMP Symmetry: Platform Distribution Parity
Let’s zoom out and see the full picture of how a KMP SDK ships to both platforms:
┌─────────────────────────────────────────────────────────────┐
│ KMP Shared Module │
│ (commonMain + platform sources) │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────┐
│ assembleRelease │ │ assembleSharedXCFramework │
│ + packageFatAar │ │ (Gradle KMP task) │
└──────────────────────────┘ └──────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────┐
│ shared-release-fat.aar │ │ Shared.xcframework │
│ (~3.3 MB) │ │ (~8 MB, arm64+sim) │
│ │ │ │
│ Consumer adds: │ │ Consumer adds: │
│ implementation(files()) │ │ Link Binary in Xcode │
│ │ │ │
│ Extra deps: NONE │ │ Extra deps: NONE │
└──────────────────────────┘ └──────────────────────────────┘Both platforms get the same developer experience: one artifact, zero extra dependencies, works out of the box. That’s the parity we’re aiming for. Fat AAR is what delivers it for Android.
This symmetry matters when you’re shipping an SDK to teams that work on both platforms. The iOS dev and the Android dev open their respective integration docs and see the same simplicity. No one feels like a second-class citizen.
This is part 2 of a series on building production-ready Kotlin Multiplatform SDKs. Part 1 covers the full architecture: [Building a Production-Ready Kotlin Multiplatform SDK for Android & iOS]
If you found this useful, connect with me on Twitter/X and LinkedIn. I write about Kotlin, Android architecture, and multiplatform development.
Comments
Post a Comment