Building Android Libraries: Best Practices
Introduction
Building a well-crafted Android library is fundamentally different from building an app. You are not just shipping code—you are designing an API that other engineers will depend on, often in ways you never anticipated. A library lives inside another project, inherits all of its build configurations, and must gracefully degrade when consumers strip away unused resources. This guide walks through the critical decisions you will face when publishing a production-quality Android library, from module scaffolding through R8 shrinkage, variant propagation, and Maven Central distribution. Each section is paired with real, runnable snippets so you can apply the lessons directly.
Setting Up an Android Library Module
The first step is choosing the right module type. Android Studio offers an "Android Library" template, but under the hood you need to be explicit about your build.gradle.kts (or .gradle) configuration. A library module must use the com.android.library plugin rather than com.android.application. This switch changes how the AGP handles output types: instead of producing an .apk, it produces an AAR (Android Archive) that bundles compiled classes, resources, and a manifest.
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.example.mylibrary'
compileSdk 34
defaultConfig {
minSdk 24
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
This configuration defines a clean library module with a modern namespace, SDK targets, and Java/Kotlin compatibility set to version 17. Notice that minifyEnabled defaults to false here—library authors typically leave it off because shrinking is the consumer's responsibility. The namespace property replaces the legacy packageName and gives Gradle a stable identifier for generated R classes and resource prefixes.
Dependencies declared in a library module behave differently depending on their scope. The implementation configuration hides transitive dependencies from consumers, while api exposes them. For a library, you should declare everything that is part of your public API surface using api, and hide internal or optional dependencies with implementation. This distinction directly controls what Gradle will transitively pass to the consuming project.
dependencies {
api("androidx.core:core-ktx:1.13.1")
api("com.google.code.gson:gson:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
In this dependency block, core-ktx and gson are marked as api because they appear in your public type signatures. If a consumer calls a method that returns a Gson-related type or uses a core-ktx extension, the consumer must have access to those libraries at compile time. Coroutines and OkHttp, however, are internal implementation details—marking them implementation lets the library use them without forcing the consumer to pull them in, which keeps the final app smaller and avoids version conflicts.
Controlling Your API Surface
A library's public API is its contract. The stricter you are about what is exposed, the more room you have to refactor internals without breaking downstream consumers. In Android, the most common way to leak unintended APIs is through resource names, activity declarations, or public classes that were meant to be internal.
package com.example.mylibrary.internal
import android.content.Context
import android.util.Log
internal class ApiClient(
private val context: Context,
) {
fun fetchData(): String {
Log.d("ApiClient", "Fetching data from ${context.packageName}")
return "result"
}
}
This internal class is scoped to the Kotlin module, meaning it is invisible to any code outside the library AAR. Kotlin's internal visibility is enforced at compile time and stripped from the emitted bytecode, so it does not add any runtime overhead. If you need Java interoperability, you must instead rely on Java's access modifiers and be careful: private and package-private (no modifier) are not accessible to consumers, but protected and public are.
Resource names in Android libraries are another hidden surface area. By default, every resource in your library is available to the consumer application, and during merging the consumer's resources take precedence. This can cause accidental overrides or conflicts. To protect your resource naming, you should adopt a unique prefix convention—typically derived from your library's namespace.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="mylib_error_network">Network request failed. Please try again.</string>
<string name="mylib_error_timeout">The request timed out. Check your connection.</string>
<dimen name="mylib_padding_small">8dp</dimen>
<dimen name="mylib_padding_medium">16dp</dimen>
</resources>
By prefixing every string and dimension with mylib_, you dramatically reduce the chance of collisions with the app that consumes your library. The prefix also makes it obvious at a glance when a resource belongs to your library versus the host app during code review or crash analysis.
Control over which assets, native libraries, and resources are packaged can also be expressed through the library's manifest merger rules. The tools:node="replace" and tools:replace attributes let you control exactly how your library's manifest entries merge with the consumer's, preventing unexpected activity or provider declarations from leaking into the final APK.
Consumer R8/ProGuard Rules
One of the most common reasons Android libraries fail in production is shrinkage. When a consumer enables R8 or ProGuard—which is standard for release builds—the obfuscator may strip classes, methods, or fields that your library relies on at runtime. You cannot control whether your consumer shrinks their app, so you must ship your own rules.
# Keep the public API entry points
-keep class com.example.mylibrary.** { *; }
# Preserve specific reflection-sensitive classes
-keepclassmembers class com.example.mylibrary.internal.ApiClient {
public <methods>;
}
# Keep Gson type adapters used by the library
-keep class com.example.mylibrary.model.** { *; }
-keepclassmembers class ** implements com.google.gson.TypeAdapterFactory {
<init>(...);
}
-keepclassmembers class ** implements com.google.gson.JsonDeserializer {
<init>(...);
}
These rules tell R8 to preserve the entire public API package and specific internal classes that are accessed through reflection or serialization frameworks like Gson. The keep directives prevent the optimizer from renaming or removing types that are loaded dynamically at runtime. Without these rules, a release build could silently corrupt your library's behavior by stripping a method that your own code calls reflectively.
If your library uses Kotlin, the rules become slightly more nuanced because the Kotlin compiler adds synthetic methods and properties that consumers may reference. Kotlin-generated metadata and inline functions also require special handling.
# Required for all Kotlin libraries
-keepattributes Signature,InnerClasses,EnclosingMethod
-keepattributes Annotations
# Keep coroutine-related classes from kotlinx.coroutines
-keep class kotlinx.coroutines.** { *; }
-keep interface kotlinx.coroutines.InternalCoroutinesApi
# Do not obfuscate sealed class hierarchies
-keep class com.example.mylibrary.model.MySealedClass { *; }
The -keepattributes lines preserve critical bytecode metadata that both the Kotlin compiler and the JVM runtime depend on. Stripping Signature or InnerClasses can break reflection-based code and cause ClassNotFoundException errors at runtime even when nothing appears wrong in your source code. Sealed classes in particular require their hierarchy to remain intact, because the Kotlin compiler generates synthetic bridging methods that rely on those class relationships being present in the final DEX.
To make this process less painful, the Android team recommends using the -dontwarn flag selectively and, where possible, relying on R8's rule inference. When you declare your dependencies correctly using api and implementation scopes, R8 can often infer that certain types must be kept without any manual rules. Always verify your shrinkage rules by building your own library in release mode with minification enabled and running the full test suite.
Variant-Aware Dependencies
Android's Build Variants system (debug vs. release, plus product flavors) creates a matrix that libraries must navigate carefully. Your library might need different dependencies or configurations depending on whether the consumer is in debug or release, or whether they are using a specific flavor. Modern AGP supports declaring variant-aware dependencies that automatically resolve based on the consumer's build configuration.
dependencies {
// Always available regardless of variant
api("androidx.core:core-ktx:1.13.1")
// Only included in the consumer's debug builds
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
// Only included when the consumer uses the 'production' flavor
productionImplementation("com.example:analytics-sdk:3.0.0")
// Available in all variants but replaced by release-specific impl
api("com.google.code.gson:gson:2.11.0")
}
In this configuration, debugImplementation means the LeakCanary dependency is only pulled in when the consumer builds a debug variant. This is ideal for debugging-only tools that you want available to library developers but not in production APKs. The productionImplementation line demonstrates flavor-aware resolution: if the consumer has no production flavor configured, this dependency is simply ignored. This kind of conditional inclusion keeps your library flexible without forcing unnecessary bloat on consumers who do not need it.
Variant-aware configuration extends beyond dependencies to source sets as well. A library can declare multiple source directories that map to different consumer variants, allowing you to ship different implementations for debug and release without exposing conditional logic in your public API.
// In src/debug/java/com/example/mylibrary/DebugLogger.kt
package com.example.mylibrary
object DebugLogger {
fun log(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
// In src/release/java/com/example/mylibrary/DebugLogger.kt
package com.example.mylibrary
object DebugLogger {
fun log(tag: String, message: String) {
// No-op in release
}
}
This pattern gives consumers a single, consistent API while letting the library swap in a no-op implementation for release builds. The consumer never needs to know whether they are using a debug or release version of your library—the variant selection happens entirely within the library module. When the consumer compiles a release build, the release source set takes priority during the variant-aware merge, and the debug log method is simply never included in the final DEX. This approach also means your library can avoid adding a feature-flag or build-config check throughout the entire codebase, keeping the public API clean and simple.
Metadata and Configuration Files
Beyond source code and resources, Android libraries need metadata files that help the build system and runtime understand how to use them. The AndroidManifest.xml is the most visible of these, but it requires careful attention because every element in it merges with the consumer's manifest.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<provider
android:name=".MyContentProvider"
android:authorities="${applicationId}.mylibrary.provider"
android:exported="false"
tools:replace="android:authorities" />
</application>
</manifest>
This manifest declares an Internet permission (which the system may prompt the user for at runtime on Android 13+) and a content provider whose authority is dynamically resolved using the ${applicationId} placeholder. The tools:replace attribute ensures that if the consumer's manifest also declares a provider for the same component, your library's configuration wins during the merge. The placeholder mechanism is essential: hardcoding an authority string would create collisions when two libraries both register providers with the same fixed authority.
For libraries that expose a public API through a separate Kotlin module (a common pattern for avoiding API leaks), you should also configure your Gradle setup to publish both the implementation AAR and the API jar. This two-module pattern ensures consumers only see the types you explicitly expose.
// build.gradle.kts for the api module
plugins {
id("com.android.library")
kotlin("android")
}
android {
namespace = "com.example.mylibrary.api"
compileSdk = 34
sourceSets {
getByName("main") {
java.srcDirs("src/main/java")
}
}
}
dependencies {
implementation(project(":mylibrary"))
}
The API module depends on the implementation module and re-exports only the types placed in its own source directory. This creates a hard boundary: any class that lives only in the implementation module and not in the API module's sources is invisible to Gradle consumers, regardless of its Kotlin visibility modifier. The API module itself becomes the dependency that consumers declare, while the implementation module remains an internal detail. This pattern is widely used by major Android libraries including Jetpack components.
Publishing to Maven Central
Publishing your library to Maven Central requires a Sonatype account, a properly configured Gradle publication block, and alignment with the Central Repository Publishing Policy. The modern approach uses the maven-publish plugin alongside the signing plugin for artifact verification.
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'maven-publish'
id 'signing'
}
publishing {
publications {
release(MavenPublication) {
from components.release
groupId = 'com.example'
artifactId = 'my-library'
version = '1.0.0'
pom {
name = 'My Library'
description = 'A sample Android library demonstrating best practices'
url = 'https://github.com/example/my-library'
licenses {
license {
name = 'The Apache License, Version 2.0'
url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
}
}
developers {
developer {
id = 'example-dev'
name = 'Example Developer'
email = '[email protected]'
}
}
scm {
connection = 'scm:git:git://github.com/example/my-library.git'
developerConnection = 'scm:git:ssh://github.com/example/my-library.git'
url = 'https://github.com/example/my-library'
}
}
}
}
repositories {
maven {
name = "sonatype"
url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username = sonatypeUsername
password = sonatypePassword
}
}
}
}
signing {
sign publishing.publications.release
}
This publication block configures a single release publication that packages the release variant of your library AAR and attaches the signed POM metadata. Maven Central requires a valid POM with license, developer, and SCM information—that is why the pom block is so detailed. The signing plugin applies GPG signatures to your artifacts, which Sonatype mandates for all published libraries. The credentials are read from local.properties or environment variables and should never be committed to version control.
For the actual release process, you run ./gradlew publishReleasePublicationToSonatypeRepository, which uploads your artifacts to the Sonatype staging repository. From there, you close the staging repository through the Sonatype web UI, wait for the automated validation to pass, and then promote the artifacts to the production repository. This two-step process gives Sonatype a chance to verify your POM, signatures, and artifact contents before they become publicly available.
Common Pitfalls
Even experienced engineers make the same mistakes when building Android libraries for the first time. Understanding these pitfalls in advance can save you from painful breaking changes and support requests.
The first and most common pitfall is leaking implementation classes into the public API. If a method signature references a class that is not meant to be part of your library's contract, consumers will import and depend on it. When you later rename or remove that class, every consumer breaks. Always audit your public API surface and move anything that is not strictly necessary to the consumer out into an internal package or an implementation-only module.
A second pitfall is incorrect dependency scope. Declaring a dependency as api when it should be implementation forces every consumer to download and include that library, inflating APK size and increasing the chance of version conflicts. Conversely, declaring something as implementation when it should be api causes compile-time errors for consumers that need to reference the type. Run ./gradlew :mylibrary:dependencies regularly to audit what is actually exposed transitively.
Resource and manifest merging issues form a third category of problems. If your library declares activities, receivers, or providers without using tools:node rules appropriately, the manifest merger may duplicate entries or silently drop them. Always use the manifest merger documentation to verify how your entries will combine, and test with a sample consumer app that has its own manifest entries for the same components.
Finally, forgetting to ship R8 rules is a silent killer. Your library may work perfectly in debug builds and during your own testing, but break catastrophically in a consumer's release build when R8 strips classes that are accessed through reflection, serialization, or dynamic loading. Always ship a proguard-rules.pro file and test it against an actual minified build of your library.
Conclusion
Building a production-quality Android library requires thinking beyond your own code. You are designing for unknown consumers who will merge your resources, shrink your classes, and depend on your API in ways you cannot fully predict. By controlling your API surface carefully, declaring dependencies with the correct scope, shipping appropriate R8 rules, respecting variant awareness, and publishing with proper metadata, you create a library that is both powerful and resilient. The patterns described in this guide—namespace management, internal visibility, flavor-aware dependencies, the two-module API pattern, and formal Maven Central publication—are the same ones used by the teams behind Jetpack and other core Android libraries. Apply them consistently, and your library will serve its consumers well for years to come.