The SDK Mindset: Why Your Code Isn't Your Own Anymore
A deep dive into the paradigm shift from application development to SDK design, and why building libraries requires a fundamentally different mental model
Table of contents
- The SDK Mindset: Why Your Code Isn’t Your Own Anymore
- The Crisis Scenario
- Part I: Two Worlds, One Codebase
- The Application Mindset vs. The SDK Mindset
- The Lego Brick Analogy
- Part II: Drawing the Responsibility Boundary
- What IS Your Responsibility
- What IS NOT Your Responsibility
- Part III: The Black Box Philosophy
- The Invisible Guest Principle
- Part IV: Fail Gracefully—Your Bugs Aren’t Their Crashes
- The Error Boundary Contract
- The Three Rules of SDK Error Handling
- Part V: The Non-Negotiables
- 1. Semantic Versioning and Backward Compatibility
- 2. Binary Size—The Hidden Tax
- 3. Dependency Hell and the Transitive Curse
- 4. ProGuard/R8—The Minification Maze
- Part VI: Control vs. Empowerment
- Opt-In by Default
- UI Customization—If You Must Provide UI
- Part VII: Architectural Patterns for SDK Design
- Why Clean Architecture Matters for SDKs
- The Three-Layer Architecture
- Part VIII: Resource Respect—The Ultimate Guest Behavior
- The Resource Audit
- Part IX: Real-World Lessons from SDK Failures
- Case Study: Facebook SDK Auto-Initialization Nightmare
- Case Study: Version Incompatibility Crashes
- Case Study: NimbleDroid’s Performance Analysis
- Part X: The Mindset Shift
- From Control to Trust
- The Lego Brick vs. The Model
- The Ultimate Test
- References
The SDK Mindset: Why Your Code Isn’t Your Own Anymore
Four years in the SDK trenches teaches you things no documentation ever will. Some lessons came from my own production incidents at 3 AM. Others came from analyzing why certain SDKs succeed while others accumulate GitHub issues faster than stars. This article captures both—the hard-won insights and the cautionary tales. Read on if you’re committed to building an SDK that stands the test of time.
The Crisis Scenario
Your clients mobile app, which has been running smoothly with 99.95% crash-free sessions, is now experiencing a spike in crashes. The error traces point to a newly integrated analytics SDK that’s holding onto Activity contexts, causing memory leaks. By morning, you’ve accumulated 847 one-star reviews, all with variations of “App crashes on startup after latest update.”
Your response? “We tested it thoroughly in our sample app. It worked fine.”
This scenario plays out more often than the mobile development community likes to admit. According to Instabug’s 2024 Mobile App Stability Report, while the median crash-free session rate hovers around 99.95%, the gap between top-performing apps (99.99%) and lagging apps (99.77%) often comes down to third-party SDK integration issues.
This is the moment when SDK developers realize a fundamental truth: Your code isn’t your own anymore.
The code you write as an SDK developer runs inside processes you don’t control, with architectures you didn’t design, alongside dependencies you didn’t choose, and on devices you’ve never tested. This reality demands a complete mindset shift from traditional application development.
Part I: Two Worlds, One Codebase
The Application Mindset vs. The SDK Mindset
When you build an application, you’re the homeowner. You decide where the furniture goes, control the thermostat, set the house rules, and invite guests into your carefully curated space. You own the architecture—whether it’s MVVM, MVP, MVI, or Clean Architecture. You dictate which version of Retrofit or OkHttp gets used. You control initialization timing, threading models, and performance budgets. You are, fundamentally, in control.
When you build an SDK, everything changes. You become a guest in someone else’s house.
As Kenneth Auchenberg, former Head of Developer Experience at Stripe, wrote about their API design philosophy: “At Stripe, we spend a lot of time agonizing over patterns and consistency across the API to ensure developers have a consistent DX across products and abstractions.” This obsessive attention to developer experience comes from understanding that Stripe’s SDKs must work flawlessly across hundreds of thousands of different application environments.
Consider the stark differences in these two development paradigms:
As an Application Developer:
- You rearrange the furniture (choose your architecture patterns)
- You control the thermostat (set performance budgets and optimization strategies)
- You invite specific guests (integrate SDKs of your choosing)
- You set house rules (define threading models, establish coding standards)
- You know your environment intimately (control minimum SDK versions, target devices)
As an SDK Developer:
- You bring a gift but never rearrange furniture (provide functionality without imposing architecture)
- You adapt to their temperature (work within their performance constraints)
- You coexist with other guests (compatibility with other SDKs)
- You follow their house rules (adapt to their threading models and patterns)
- You prepare for any environment (support wide SDK ranges, diverse device configurations)
- You clean up completely after yourself (rigorous resource management)
- You never break the plumbing (graceful failure that never crashes the host)
Square’s SDK philosophy, documented in their developer guidelines, emphasizes this guest mentality: SDKs should “insulate your code from the mechanics of API requests and replies and provide useful abstractions.” The key word here is “insulate” your SDK should be a black box that does its job without leaking implementation details or side effects into the host application.
The Lego Brick Analogy
Here’s another way to frame this fundamental difference: Applications are complete models; SDKs are Lego bricks.
When you build a Lego model—say, a detailed replica of the Millennium Falcon—you follow specific instructions, use predetermined pieces, and create a singular, opinionated structure. That’s application development. You’re building something complete and functional, with a clear purpose and defined user experience.
An SDK, on the other hand, is like a specialized Lego brick. It must:
- Fit seamlessly with thousands of other brick types
- Work regardless of whether it’s part of a spaceship, castle, or house
- Provide value without dictating the overall structure
- Be removable without causing the entire model to collapse
- Have well-defined connection points (your public API)
Firebase’s modular SDK design exemplifies this philosophy. As documented in their API guidelines, “operations are performed by passing objects to functions. This approach enables tree-shaking, reducing bundle size.” Each Firebase component is an independent Lego brick—you can use Cloud Firestore without Firebase Authentication, or Crashlytics without Analytics. The pieces compose but don’t entangle.
The moment you ship an SDK, you lose control. And that’s exactly the point. Your role shifts from architect to toolmaker. You’re no longer designing the house; you’re providing high-quality tools that work in any house, regardless of architectural style.
Part II: Drawing the Responsibility Boundary
The most critical skill for SDK developers is knowing where your job ends and the integrator’s job begins. This boundary isn’t just philosophical, it has concrete implications for every design decision you make.
What IS Your Responsibility
Joshua Bloch, author of “Effective Java” and architect of Java’s Collections Framework, established foundational principles for API design that apply directly to SDK development: “APIs should be easy to use and hard to misuse. Easy to do simple things; possible to do complex things; impossible, or at least difficult, to do wrong things.”
Your responsibilities as an SDK developer include:
Core Domain Logic: This is why your SDK exists. If you’re building a payment SDK, focus relentlessly on payment processing, tokenization, and transaction management. Stripe processes over 250 million API requests per day their SDK’s responsibility is to handle payments correctly, securely, and reliably. Nothing more, nothing less.
Internal State Management: Your SDK should manage its own data, lifecycle, and resources. Consider this clean boundary pattern:
// ✅ Clear responsibility: "I process data, you decide when"
interface DataProcessor {
/**
* Processes input data asynchronously.
*
* @param input The data to process
* @return Result containing processed output or error
* @throws IllegalStateException if processor not initialized
*/
suspend fun process(input: Data): Result<Output>
/**
* Releases all processor resources.
* After calling this, processor instance should not be reused.
*/
fun release()
}
// Implementation stays internal - host app never sees this
internal class DataProcessorImpl(
private val context: Context,
private val config: ProcessorConfig
) : DataProcessor {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override suspend fun process(input: Data): Result<Output> = withContext(Dispatchers.IO) {
try {
val result = performProcessing(input)
Result.success(result)
} catch (e: Exception) {
logger.error("Processing failed", e)
Result.failure(ProcessorException("Failed to process data", e))
}
}
override fun release() {
scope.cancel()
clearCache()
// Complete cleanup
}
}
This example demonstrates proper encapsulation. The public interface (DataProcessor) defines what the SDK does without revealing how it does it. The implementation (DataProcessorImpl) is marked internal, preventing host apps from depending on implementation details that might change.
Error Containment: As Google’s Android documentation on library optimization emphasizes, “prefer codegen over reflection” and always “keep error handling simple and clear.” Your SDK’s errors should never become the host app’s crashes.
API Stability: This is perhaps your most sacred commitment. Microsoft’s Azure SDK guidelines state bluntly: “Breaking changes are more harmful than most new features are beneficial.” Every public API you expose is a five-year minimum commitment. Amplitude’s SDK lifecycle policy exemplifies responsible maintenance: Developer Preview → General Availability → Maintenance Mode (lasting at least 12 months) → End-of-Support with clear migration paths.
Performance Footprint: You must document your cost. NimbleDroid’s analysis of mobile SDKs revealed that certain SDKs added 1,729ms to app startup time simply due to inefficient resource loading. Segment’s architecture explicitly states: “When it comes to Mobile SDKs, we know that minimizing size and complexity is a priority. That’s why our core Mobile SDKs are small and offload as much work as possible to our servers.” They achieved 2-3x energy overhead reduction through intelligent batching and compression.
What IS NOT Your Responsibility
This is where many SDK developers stumble. They try to be helpful by providing too much, inadvertently creating constraints that frustrate integrators.
// ❌ ANTI-PATTERN: Forcing architecture decisions
class ArchitectureDictatorSDK {
// Auto-initializes on first access - unpredictable timing!
init {
startBackgroundWork()
registerGlobalListeners()
}
// Holds Activity context - memory leak waiting to happen
fun setup(activity: Activity) {
this.activityContext = activity
}
}
Every line in this example violates the responsibility boundary. Let’s examine each problem:
Don’t Control Initialization Timing: Auto-initialization in init blocks runs on first class access, which is unpredictable. Android’s App Startup library documentation explicitly warns against ContentProvider-based initialization, showing it adds approximately 2ms per library to cold start time Let developers control when your SDK wakes up.
Don’t Hold Activity Contexts: This is SDK development 101. Activities are recreated on configuration changes (rotation, language switches, multi-window mode). Holding an Activity reference creates memory leaks. Always use applicationContext for long-lived objects.
The responsibility boundary can be summarized with one guiding principle: Provide capabilities, not constraints.
Part III: The Black Box Philosophy
The Invisible Guest Principle
Segment’s SDK philosophy provides an excellent framework: “Our core Mobile SDKs are small and offload as much work as possible to our servers.” This isn’t just about size—it’s about being invisible until needed.
Your SDK should embody three characteristics:
1. Arrive Quietly (Minimize Startup Impact)
Google’s research has shown that users perceive apps as faster when cold start time is under 5 seconds. Every SDK that auto-initializes chips away at this budget. The Firebase SDK moved away from ContentProvider initialization precisely because of this startup tax.
// ❌ ANTI-PATTERN: Eager initialization
object EagerSDK {
init {
// Runs on class load - when? Who knows!
performHeavySetup()
loadNativeLibraries()
initializeDatabase()
establishConnections()
}
}
// ✅ BEST PRACTICE: Lazy, explicit initialization
class LazySDK private constructor(
private val context: Context,
private val config: Configuration
) {
companion object {
/**
* Initializes SDK with provided configuration.
* Call this from Application.onCreate() or lazily before first use.
*
* @param context Application context
* @param config SDK configuration
* @return Initialized SDK instance
*/
fun initialize(context: Context, config: Configuration): LazySDK {
return LazySDK(context.applicationContext, config).apply {
setup()
}
}
}
private fun setup() {
// Initialization happens here, under developer's control
}
}
The difference is profound. With eager initialization, your SDK taxes every app launch, whether the app uses your functionality in that session or not. With lazy initialization, the cost is deferred until actually needed and can even be pushed to a background thread.
2. Work Quietly (Respect Threading and Resources)
Never touch the main thread unless you’re explicitly doing UI work. Android’s official guidance on library optimization states: “Avoid main thread operations in library initialization.”
class ResourceAwareSDK(
private val context: Context,
private val config: Config
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Syncs data with remote server.
*
* @param force Force sync even on metered connections
*/
suspend fun sync(force: Boolean = false) = withContext(Dispatchers.IO) {
// Check network conditions
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivityManager.activeNetwork ?: return@withContext
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
// Respect metered connections unless forced
if (!force && capabilities?.hasCapability(NET_CAPABILITY_NOT_METERED) == false) {
logger.info("Skipping sync on metered connection")
return@withContext
}
// Respect battery state
val batteryManager = context.getSystemService(BatteryManager::class.java)
val batteryPct = batteryManager.getIntProperty(BATTERY_PROPERTY_CAPACITY)
if (!force && batteryPct < 15) {
logger.info("Skipping sync on low battery")
return@withContext
}
performSync()
}
/**
* Handles system memory pressure.
* Register this with Application.registerComponentCallbacks()
*/
fun onTrimMemory(level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
// Clear non-essential caches
imageCache.evictAll()
}
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// Aggressive cleanup
clearAllCaches()
cancelNonCriticalOperations()
}
}
}
}
This SDK respects the host’s resources. It checks network conditions, battery levels, and responds to memory pressure. Square’s extensive research into Android’s main thread lifecycle shows that respecting system resources isn’t optional it’s fundamental to being a good citizen in the Android ecosystem.
3. Leave Quietly (Complete Cleanup)
Resource leaks are insidious. They accumulate slowly, causing OOM errors that are difficult to trace. Instabug’s 2024 report shows OOM errors occur at a median rate of 1.12 per 10,000 sessions a seemingly small number that represents real user frustration.
class WellBehavedSDK(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobs = mutableListOf<Job>()
private val receivers = mutableListOf<BroadcastReceiver>()
fun startMonitoring() {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Handle broadcast
}
}
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_SCREEN_ON))
receivers.add(receiver)
}
/**
* Releases all SDK resources.
* After calling this, SDK instance should not be reused.
* All pending operations will be cancelled.
*/
fun release() {
// Cancel all coroutines
scope.cancel()
jobs.forEach { it.cancel() }
jobs.clear()
// Unregister all receivers
receivers.forEach { receiver ->
try {
context.unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
// Receiver not registered - fine
}
}
receivers.clear()
// Close database connections
database?.close()
// Clear caches
cache.clear()
// Null out references
database = null
// SDK instance is now garbage-collectable
}
}
Lifecycle awareness is critical. Android’s DefaultLifecycleObserver interface provides hooks for automatic cleanup:
class LifecycleAwareSDK(
private val context: Context
) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
// Resume operations
}
override fun onStop(owner: LifecycleOwner) {
// Pause non-critical operations
}
override fun onDestroy(owner: LifecycleOwner) {
// Complete cleanup
release()
}
}
// Usage in Activity or Fragment
class MyActivity : AppCompatActivity() {
private lateinit var sdk: LifecycleAwareSDK
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sdk = LifecycleAwareSDK(applicationContext)
lifecycle.addObserver(sdk)
// SDK now automatically cleans up when Activity is destroyed
}
}
The host app doesn’t need to remember to call release()—the SDK ties into Android’s lifecycle system automatically.
Part IV: Fail Gracefully—Your Bugs Aren’t Their Crashes
Here’s a stark truth: If your SDK crashes, the one-star reviews go to the host app, not to you. Users don’t distinguish between “my app crashed” and “an SDK in my app crashed.” They just know the app failed.
The Error Boundary Contract
Kotlin’s Result type, introduced in Kotlin 1.3, provides an excellent pattern for error handling at SDK boundaries:
// ❌ ANTI-PATTERN: Silent failure
class SilentSDK {
fun processData(input: String): Data? {
return try {
heavyProcessing(input)
} catch (e: Exception) {
// Developer never knows this failed
null
}
}
}
// ❌ ANTI-PATTERN: Exception explosion
class ThrowingSDK {
fun processData(input: String): Data {
// Throws various exceptions that might crash the app
return heavyProcessing(input)
}
}
// ✅ BEST PRACTICE: Explicit error states
class ExplicitSDK(private val logger: Logger) {
/**
* Processes input data.
*
* @param input Data to process
* @return Result containing processed data or error information
*/
fun processData(input: String): Result<Data> {
return try {
val result = heavyProcessing(input)
Result.success(result)
} catch (e: IllegalArgumentException) {
logger.warn("Invalid input", e)
Result.failure(InvalidInputException("Input validation failed: ${e.message}", e))
} catch (e: IOException) {
logger.error("Network error during processing", e)
Result.failure(NetworkException("Failed to fetch remote data", e))
} catch (e: Exception) {
logger.error("Unexpected error during processing", e)
Result.failure(ProcessingException("Processing failed unexpectedly", e))
}
}
}
// Host app can handle errors explicitly
sdk.processData(input)
.onSuccess { data ->
updateUI(data)
}
.onFailure { error ->
when (error) {
is InvalidInputException -> showValidationError(error.message)
is NetworkException -> showRetryOption()
else -> showGenericError()
}
}
This pattern provides three critical benefits:
- No silent failures: The host app always knows whether the operation succeeded
- No surprise crashes: Errors are contained and reported through the Result type
- Actionable errors: The host app can make informed decisions about error handling
The Kotlin Result library by Michael Bull extends this pattern with additional utilities like runSuspendCatching that properly handles CancellationException in coroutine contexts a subtle detail that can prevent resource leaks.
The Three Rules of SDK Error Handling
Rule 1: Catch at Boundaries
Every public API method is a boundary between your SDK and the host app. Wrap all boundary methods in try-catch blocks:
class BoundaryProtectedSDK {
// Public API - boundary with host app
fun publicOperation(param: String): Result<Data> {
return try {
// Internal operations can throw
val validated = validateInput(param)
val processed = processInternal(validated)
Result.success(processed)
} catch (e: Exception) {
// Never let exceptions escape
logger.error("Operation failed", e)
Result.failure(SDKException("Operation failed", e))
}
}
// Internal method - can throw freely
private fun processInternal(data: ValidatedData): Data {
// Throws exceptions freely - safe because called within try-catch
if (networkUnavailable()) throw NetworkException()
return heavyProcessing(data)
}
}
Rule 2: Log Comprehensively, Fail Explicitly
Silent failures leave developers blind. Provide opt-in diagnostic logging:
class DiagnosticSDK(
private val config: SDKConfig
) {
private val logger: Logger = when (config.logLevel) {
LogLevel.NONE -> NoOpLogger()
LogLevel.ERROR -> ErrorLogger()
LogLevel.DEBUG -> if (BuildConfig.DEBUG) DebugLogger() else ErrorLogger()
LogLevel.VERBOSE -> VerboseLogger()
}
fun operation(): Result<Data> {
logger.debug("Starting operation")
return try {
val result = performOperation()
logger.debug("Operation completed successfully")
Result.success(result)
} catch (e: Exception) {
// Log with full context
logger.error("Operation failed", mapOf(
"error_type" to e::class.simpleName,
"error_message" to e.message,
"stack_trace" to e.stackTraceToString()
))
Result.failure(e)
}
}
}
Rule 3: Provide Recovery Mechanisms
Don’t just report errors—provide ways to recover when possible:
class RecoverableSDK {
private var cachedData: Data? = null
suspend fun fetchData(useCache: Boolean = true): Result<Data> {
return try {
val fresh = fetchFromNetwork()
cachedData = fresh // Update cache
Result.success(fresh)
} catch (e: NetworkException) {
// Network failed - try cache
cachedData?.let { cached ->
logger.warn("Network unavailable, using cached data")
Result.success(cached)
} ?: Result.failure(e)
}
}
}
This pattern follows the principle of “graceful degradation”—when the ideal path fails, provide a fallback rather than complete failure.
Part V: The Non-Negotiables
Certain responsibilities are non-negotiable for SDK developers. These commitments separate professional SDKs from hobby projects.
1. Semantic Versioning and Backward Compatibility
Amplitude’s SDK maintenance policy provides an industry-standard lifecycle model. Jeroen Mols, Principal Engineer at Philips Hue and prolific Android library author, emphasizes: “Breaking changes should be avoided at all costs. They create immediate friction for your users.”
Semantic versioning (MAJOR.MINOR.PATCH) is not optional:
- MAJOR: Breaking changes (increment when you remove or change public APIs)
- MINOR: New features (backward compatible additions)
- PATCH: Bug fixes (no API changes)
// Version 1.0.0
interface PaymentProcessor {
fun processPayment(amount: Double): Result<Transaction>
}
// Version 1.1.0 - Added feature, backward compatible
interface PaymentProcessor {
fun processPayment(amount: Double): Result<Transaction>
// New method - doesn't break existing code
fun processPaymentWithMetadata(
amount: Double,
metadata: Map<String, String>
): Result<Transaction> {
// Default implementation delegates to original method
return processPayment(amount)
}
}
// Version 2.0.0 - Breaking change (changed return type)
interface PaymentProcessor {
// Changed Result<Transaction> to Result<PaymentResult>
// This is MAJOR version bump territory
fun processPayment(amount: Double): Result<PaymentResult>
}
The deprecation cycle should span at least two major versions:
class EvolvingSDK {
// Version 1.0.0 - Original method
fun oldMethod() { }
// Version 1.1.0 - Introduce replacement
fun newMethod() { }
// Version 1.2.0 - Deprecate old method
@Deprecated(
message = "Use newMethod() instead",
replaceWith = ReplaceWith("newMethod()"),
level = DeprecationLevel.WARNING
)
fun oldMethod() { }
// Version 2.0.0 - Still deprecated but functional
@Deprecated(
message = "Use newMethod() instead. Will be removed in 3.0.0",
replaceWith = ReplaceWith("newMethod()"),
level = DeprecationLevel.ERROR
)
fun oldMethod() { }
// Version 3.0.0 - Finally removed
// oldMethod() is gone
}
This gives developers two full major versions to migrate—typically 12-24 months in production environments.
2. Binary Size—The Hidden Tax
Google’s research is damning: User uninstall probability increases 5% for every additional 6 MB of storage. Apps over 150MB see only 20% retention after 30 days, while apps under 50MB achieve 35% retention.
Your SDK contributes to this burden. A seemingly modest 5MB SDK becomes 5MB on every user’s device who installs the host app. Multiply that by millions of users, and you’re responsible for petabytes of storage consumption.
// Modular SDK structure reduces size impact
// Core module - minimal, required functionality
implementation("com.example:sdk-core:1.0.0") // 150 KB
// Optional modules - developers choose what they need
implementation("com.example:sdk-ui:1.0.0") // 450 KB
implementation("com.example:sdk-analytics:1.0.0") // 200 KB
implementation("com.example:sdk-push:1.0.0") // 300 KB
Stripe’s SDK architecture exemplifies this modular approach. The core Stripe Android SDK is remarkably lean, with optional modules for UI components, Google Pay integration, and additional payment methods.
Android’s official APK size reduction guide provides concrete optimization techniques:
- Remove unused resources with
shrinkResources true - Use vector drawables instead of bitmaps (90% size reduction)
- Use WebP image format (25-34% smaller than JPEG)
- Avoid including multiple drawable densities—let Android scale
For native libraries:
// Only include necessary ABIs
android {
defaultConfig {
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a'
// x86 and x86_64 only needed for emulators
}
}
}
Native libraries can balloon APK size. An arm64-v8a, armeabi-v7a, x86, and x86_64 combination multiplies library size by four. Including only the two ARM variants (which cover 99%+ of physical devices) dramatically reduces size.
3. Dependency Hell and the Transitive Curse
This is where many SDKs fail spectacularly. Jeroen Mols’ comprehensive guide on Android library dependencies documents the nightmare scenario: Your SDK depends on OkHttp 4.x, but the host app uses OkHttp 3.x. At runtime, the app crashes with NoSuchMethodError or ClassNotFoundException.
// ❌ ANTI-PATTERN: Forcing specific dependency versions
dependencies {
// This forces ALL host apps to use OkHttp 4.12.0
api("com.squareup.okhttp3:okhttp:4.12.0")
api("com.google.code.gson:gson:2.10.1")
}
// ✅ BEST PRACTICE: Minimize and isolate dependencies
dependencies {
// compileOnly - host app provides implementation
compileOnly("com.squareup.okhttp3:okhttp:4.9.0")
// Provide default implementation for convenience
// but don't force it
}
Better yet, provide a dependency injection interface:
// Define your own HTTP abstraction
interface HttpClient {
suspend fun get(url: String): HttpResponse
suspend fun post(url: String, body: String): HttpResponse
}
// Provide default implementation using HttpURLConnection (zero dependencies)
internal class DefaultHttpClient : HttpClient {
override suspend fun get(url: String): HttpResponse = withContext(Dispatchers.IO) {
val connection = URL(url).openConnection() as HttpURLConnection
try {
connection.requestMethod = "GET"
val code = connection.responseCode
val body = connection.inputStream.bufferedReader().readText()
HttpResponse(code, body)
} finally {
connection.disconnect()
}
}
}
// Host app can provide OkHttp implementation
class OkHttpClientAdapter(private val okHttpClient: OkHttpClient) : HttpClient {
override suspend fun get(url: String): HttpResponse {
val request = Request.Builder().url(url).build()
val response = okHttpClient.newCall(request).execute()
return HttpResponse(response.code, response.body?.string() ?: "")
}
}
// SDK accepts any implementation
class FlexibleSDK(
private val httpClient: HttpClient = DefaultHttpClient()
) {
// Works with default implementation or host's injected client
}
This pattern eliminates forced dependencies while still providing convenience for developers who don’t want to inject their own client.
4. ProGuard/R8—The Minification Maze
Android’s documentation on library optimization is explicit: “prefer codegen over reflection” because reflection breaks under aggressive optimization. More critically, you must ship consumer ProGuard rules with your SDK.
# consumer-rules.pro - Ships with your AAR
# Keeps your public API from being obfuscated/removed
# Keep all public classes and their public methods
-keep public class com.example.sdk.** {
public *;
}
# Keep data classes used in public API (for serialization)
-keepclassmembers class com.example.sdk.models.** {
<fields>;
}
# If using reflection internally (avoid if possible)
-keep class com.example.sdk.internal.ReflectionUtil {
*;
}
# If using Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
# If using coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
The critical distinction: proguardFiles in your library’s build.gradle are for your build-time optimization. consumerProguardFiles are bundled into the AAR and applied to the host app’s build. Never include aggressive rules like -dontobfuscate in consumer rules—that would disable obfuscation for the entire host app.
Testing your SDK in a fully obfuscated release build is non-negotiable. Many SDKs work perfectly in debug but crash mysteriously in release due to aggressive R8 optimization.
Part VI: Control vs. Empowerment
Perhaps the most counterintuitive aspect of SDK development: You succeed by providing control, not by taking control.
Opt-In by Default
Consider Firebase’s initialization strategy. Originally, Firebase used ContentProvider auto-initialization convenient but costly. Developers complained about the startup time tax. Firebase responded with an opt-out mechanism, and later made manual initialization the recommended approach:
// firebase-common build.gradle
// Disable auto-initialization
<application>
<meta-data
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
</application>
// Manual initialization - developer controls timing
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize Firebase when ready, possibly on background thread
lifecycleScope.launch(Dispatchers.Default) {
Firebase.initialize(this@MyApplication)
}
}
}
Every SDK feature should follow this pattern: opt-in, not opt-out.
// ❌ ANTI-PATTERN: Automatic behavior
class AutomaticSDK {
init {
startAnalytics() // Surprise data collection!
enableCrashReporting() // Surprise crash reporting!
beginLocationTracking() // Surprise location tracking!
}
}
// ✅ BEST PRACTICE: Explicit opt-in
class ExplicitSDK private constructor(
private val config: SDKConfig
) {
companion object {
fun create(context: Context, configure: SDKConfig.Builder.() -> Unit): ExplicitSDK {
val config = SDKConfig.Builder().apply(configure).build()
return ExplicitSDK(config)
}
}
init {
// Only initialize what was explicitly configured
if (config.analyticsEnabled) {
startAnalytics()
}
if (config.crashReportingEnabled) {
enableCrashReporting()
}
// Location tracking not started unless explicitly enabled
}
}
// Usage - crystal clear about what's enabled
val sdk = ExplicitSDK.create(context) {
enableAnalytics()
enableCrashReporting()
// Location tracking NOT enabled
}
UI Customization—If You Must Provide UI
The golden rule: If developers can’t customize your UI, don’t provide UI.
Provide headless core functionality separately from UI components:
// Modular SDK with separate UI
implementation("com.example:sdk-core:1.0.0") // Required, headless
implementation("com.example:sdk-ui:1.0.0") // Optional UI components
Resource Naming Conflicts
Prefix ALL resources to avoid conflicts:
<!-- ❌ Generic names will conflict -->
<string name="cancel">Cancel</string>
<drawable name="icon_close" />
<!-- ✅ Prefixed names prevent conflicts -->
<string name="mysdk_cancel">Cancel</string>
<drawable name="mysdk_icon_close" />
Resource conflicts cause obscure crashes. An app using your SDK plus another SDK with the same resource name will randomly use one or the other’s resource, depending on build order.
Part VII: Architectural Patterns for SDK Design
Clean Architecture isn’t just for apps—it’s even more critical for SDKs.
Why Clean Architecture Matters for SDKs
Testability: Your core business logic should work in pure JVM tests without an Android emulator. A payment processing SDK’s transaction validation logic should be testable on any machine, any platform.
Platform Agnosticism: Separating domain logic from Android-specific code makes it possible to share code with iOS (via Kotlin Multiplatform) or even support non-mobile platforms.
Dependency Inversion: Host apps can inject their own implementations of storage, networking, or other infrastructure concerns.
The Three-Layer Architecture
sdk-core/ (Pure Kotlin - zero Android dependencies)
├── domain/ (Business logic, use cases, domain models)
│ ├── models/ (Data classes representing domain concepts)
│ ├── usecases/ (Business operations)
│ └── repositories/ (Interface definitions)
└── util/ (Pure Kotlin utilities)
sdk-android/ (Android-specific implementations)
├── data/ (Repository implementations)
│ ├── local/ (SharedPreferences, Room, DataStore)
│ ├── remote/ (Network implementations)
│ └── cache/ (Caching strategies)
├── di/ (Dependency injection setup)
└── lifecycle/ (Android lifecycle integration)
sdk-ui/ (Optional UI components - separate artifact)
├── components/ (Reusable UI widgets)
├── screens/ (Complete screen implementations)
└── theme/ (Material Design theme support)
Example implementation:
// sdk-core/domain/models/Transaction.kt
// Pure Kotlin - works anywhere
data class Transaction(
val id: String,
val amount: Double,
val currency: String,
val timestamp: Long
) {
fun isValid(): Boolean {
return amount > 0 && currency.length == 3
}
}
// sdk-core/domain/repositories/TransactionRepository.kt
// Interface - no implementation details
interface TransactionRepository {
suspend fun saveTransaction(transaction: Transaction): Result<Unit>
suspend fun getTransaction(id: String): Result<Transaction>
}
// sdk-core/domain/usecases/ProcessPaymentUseCase.kt
// Business logic - pure Kotlin, fully testable
class ProcessPaymentUseCase(
private val repository: TransactionRepository,
private val validator: PaymentValidator
) {
suspend fun execute(amount: Double, currency: String): Result<Transaction> {
// Validate input
if (!validator.isValidAmount(amount)) {
return Result.failure(InvalidAmountException())
}
// Create transaction
val transaction = Transaction(
id = generateTransactionId(),
amount = amount,
currency = currency,
timestamp = System.currentTimeMillis()
)
// Save to repository
return repository.saveTransaction(transaction)
.map { transaction }
}
}
// sdk-android/data/TransactionRepositoryImpl.kt
// Android-specific implementation
internal class TransactionRepositoryImpl(
private val database: TransactionDatabase,
private val networkClient: NetworkClient
) : TransactionRepository {
override suspend fun saveTransaction(transaction: Transaction): Result<Unit> {
return try {
// Save to local database
database.transactionDao().insert(transaction.toEntity())
// Sync to server
networkClient.post("/transactions", transaction)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
The benefits are substantial:
ProcessPaymentUseCasecan be tested in pure JVM tests (runs in milliseconds)- Android-specific code is isolated to implementation classes
- Host apps can inject custom
TransactionRepositoryimplementations - Core logic can be shared across platforms via Kotlin Multiplatform
Part VIII: Resource Respect—The Ultimate Guest Behavior
You’re not the only guest at the party. Respecting shared resources is non-negotiable.
The Resource Audit
Main Thread: Every millisecond on the main thread delays frame rendering. Android’s strict mode will flag operations longer than 16ms on the main thread (causing frame drops). Your SDK should do precisely zero work on the main thread unless explicitly rendering UI.
Memory: Your SDK’s baseline memory footprint affects low-end devices most severely. Devices with 1GB RAM are still common in emerging markets. Your 50MB baseline memory usage might be acceptable on a flagship device but devastating on budget hardware.
Battery: Background operations drain battery. Segment reduced energy overhead by 2-3x through intelligent batching instead of making 100 separate network calls, batch them into 5-10 calls with larger payloads.
Network: Even on unmetered connections, respect the user’s data plan and bandwidth:
class NetworkAwareSDK(private val context: Context) {
private fun shouldSync(): Boolean {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
// Only sync on WiFi or unmetered connections
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
suspend fun sync() {
if (!shouldSync()) {
logger.info("Deferring sync until unmetered connection available")
// Queue for later
return
}
// Proceed with sync
performSync()
}
}
Storage: Cache aggressively but expire automatically:
class CacheAwareSDK(private val context: Context) {
private val cacheDir = File(context.cacheDir, "sdk_cache")
private val maxCacheSize = 10 * 1024 * 1024 // 10 MB
private val maxCacheAge = 7 * 24 * 60 * 60 * 1000L // 7 days
init {
// Clean expired cache on init
cleanCache()
}
private fun cleanCache() {
val now = System.currentTimeMillis()
cacheDir.listFiles()?.forEach { file ->
// Delete files older than maxCacheAge
if (now - file.lastModified() > maxCacheAge) {
file.delete()
}
}
// Ensure total size is under maxCacheSize
val totalSize = cacheDir.walkTopDown().sumOf { it.length() }
if (totalSize > maxCacheSize) {
// Delete oldest files first
cacheDir.listFiles()
?.sortedBy { it.lastModified() }
?.takeWhile { totalSize > maxCacheSize }
?.forEach { it.delete() }
}
}
}
Part IX: Real-World Lessons from SDK Failures
Learning from others’ mistakes is cheaper than making your own.
Case Study: Facebook SDK Auto-Initialization Nightmare
GitHub issue #879 in the Facebook Android SDK repository documents a painful lesson: auto-initialization via ContentProvider broke multi-process apps. Developers reported: “I and my teammates spent hours identifying the problem. I know that maintaining multi-process apps is not the best idea but…I believe there are plenty of other apps like this.”
The issue? Facebook’s SDK auto-initialized in the ContentProvider, which runs once per process. In multi-process apps, the SDK tried to initialize multiple times simultaneously, causing race conditions and crashes.
Lesson: Never assume single-process architecture. If you must auto-initialize, handle multi-process scenarios gracefully.
Case Study: Version Incompatibility Crashes
Facebook SDK issue #997 documents Android 12 FLAG_IMMUTABLE crashes. The SDK used PendingIntent without the new FLAG_IMMUTABLE flag required in Android 12+, causing widespread crashes.
Issue #1065 shows version 13.2.0 breaking apps that worked perfectly with 13.1.0. Comments reveal frustration: “We had to roll back to 13.1.0 immediately.”
Lesson: Test rigorously across Android versions, especially when targeting new API levels. Use @RequiresApi annotations and runtime checks for version-specific code.
Case Study: NimbleDroid’s Performance Analysis
NimbleDroid’s 2016 analysis remains relevant today. They found:
- Tapjoy SDK: 1,729ms initialization delay due to
ClassLoader.getResourceAsStreaminefficiency - AWS Android SDK: 2,371ms delay in credential provider initialization
- Multiple SDKs performing network calls synchronously on the main thread
These aren’t malicious bugs—they’re the result of not understanding the guest mindset. These SDKs were tested in isolation, where their initialization timing didn’t matter. In production apps with 5-10 SDKs competing for resources, these delays accumulate catastrophically.
Lesson: Measure your SDK’s performance in realistic conditions—apps with multiple SDKs, on low-end devices, with poor network conditions.
Part X: The Mindset Shift
We’ve covered technical patterns, architectural principles, and concrete anti-patterns. But ultimately, SDK development is about a fundamental mindset shift.
From Control to Trust
Application development asks: “What should my app do?”
SDK development asks: “How do I empower their app?”
This shift is profound. You’re no longer building a complete experience—you’re providing a tool that enables others to build their experience. Your success isn’t measured by features shipped; it’s measured by adoption rate, integration ease, and the absence of complaints.
Joshua Bloch captured this philosophy perfectly: “When in doubt, leave it out.” Every feature you add is a burden on integrators more documentation to read, more configuration to understand, more edge cases to consider. The best SDK is the one that does exactly what it needs to, no more and no less.
The Lego Brick vs. The Model
Remember: Applications are complete models; SDKs are Lego bricks.
Your SDK should:
- Fit seamlessly with thousands of other components
- Work regardless of the architectural style of the host
- Provide value without dictating overall structure
- Be removable without causing collapse
- Have crystal-clear connection points (your public API)
Firebase demonstrates this perfectly. You can use Cloud Firestore without Firebase Authentication. You can use Crashlytics without Analytics. Each component is an independent Lego brick that composes beautifully with others but functions independently.
The Ultimate Test
Here’s how you know you’ve succeeded as an SDK developer:
A developer integrates your SDK, forgets it exists (because it just works), and recommends it to colleagues.
Not because they’re impressed by your technical wizardry. Not because your documentation is beautifully written (though it should be). But because your SDK was invisible it arrived quietly, did its job flawlessly, and left quietly when no longer needed.
That’s the SDK mindset. Your code isn’t your own anymore. It belongs to the thousands of developers who integrate it, the millions of users whose apps depend on it, and the countless edge cases you never imagined.
Build with humility. Build with empathy. Build as a guest, not a dictator.
Review your current SDK or library against these principles:
- Responsibility Boundary: Can developers choose their own architecture, or do you force patterns?
- Black Box Philosophy: Does your SDK arrive quietly, work quietly, and leave quietly?
- Error Containment: Do your failures become their crashes, or do you handle errors gracefully?
- Dependencies: Are you creating transitive dependency hell?
- Binary Size: What’s your APK footprint? Can developers include only what they need?
- Resource Respect: Do you tax the main thread, memory, battery, or network unnecessarily?
- Opt-In Design: Do features enable themselves automatically, or do developers maintain control?
Ask yourself: Am I a good guest or a demanding dictator?
The best SDK is the one developers integrate once, curse never, and recommend often.
References
Share
More to explore
Keep exploring
1/11/2026
Kotlin to C/C++ Transition Guide: A Systems Programmer's Cheat Sheet
Preparing for a systems programming interview but haven't touched C/C++ since university? This guide bridges your Kotlin knowledge to C/C++ with side-by-side syntax comparisons, memory management deep dives, and critical undefined behaviors you need to know.
12/16/2025
Building a Frictionless Android Sample App: README Funnel, Doctor Script, and a CI Compatibility Matrix
My AI-assisted learning log on turning an Android SDK demo into a low-friction client experience: a decision-tree README, environment doctor scripts, and a GitHub Actions build matrix that generates a compatibility matrix.
12/15/2025
Android Learning Log: Compose Permissions, SavedStateHandle, and Testing Pitfalls
A developer-friendly learning log on fixing a Jetpack Compose permission flow, understanding SavedStateHandle/nav args, and debugging MockK + Robolectric Compose tests—complete with real-world analogies and code snippets.
Previous
Kotlin to C/C++ Transition Guide: A Systems Programmer's Cheat Sheet
Next