Morteza Taghdisi

Writing10 min read
Architecture-style illustration for a mobile SDK design article
Architecture & Platform ThinkingJanuary 20, 2026

Designing a Mobile SDK That Developers Actually Want to Use

Series

Mobile SDK Design

1 of 7 in the series

Article 1 of 7

Most mobile SDKs are built from the inside out, designed around what the team needs to ship rather than what developers need to adopt.

sdkmobileandroidiosdeveloper-experience

Building an SDK is not only an engineering problem. It is a product and communication problem.

When your SDK ships, you are not shipping code. You are shipping an interface that developers will form opinions about within the first 10 minutes of trying it. Those opinions travel. They appear in code reviews, Slack channels, and GitHub issues. They determine adoption speed more than any feature your SDK offers.

Most SDK teams understand this in principle. Few practice it in design.

The Inside-Out Problem

The most common failure in SDK design is what this series calls Inside-Out Exposure: the SDK's API surface reflects the architecture of the team that built it rather than the mental model of the developer who will use it.

This produces initialization sequences that look like this:

kotlin
// Bad: forces the caller to understand internal modules before doing anything useful
val authModule = AuthModule.Builder()
    .setConfig(AuthConfig(clientId = "abc", environment = Environment.PRODUCTION))
    .build()
 
val analyticsModule = AnalyticsModule.Builder()
    .setFlushInterval(30_000)
    .build()
 
val sdk = SDK.Builder()
    .addModule(authModule)
    .addModule(analyticsModule)
    .setApiKey("xyz")
    .build()
 
SDK.initialize(sdk)

The developer is assembling your internal architecture by hand. They need to know that AuthModule exists, that it requires AuthConfig, that AuthConfig needs an Environment enum, and that all of this must be assembled before initialization. One wrong step and the SDK throws at runtime.

Compare this to an API designed from the outside in:

kotlin
// Good: one entry point, sensible defaults, explicit only where it matters
SDK.initialize(context) {
    apiKey = "xyz"
    environment = Environment.PRODUCTION
}

The defaults cover 80% of use cases. The developer does not need to know what modules exist. If they need to configure auth or analytics specifically, the SDK exposes extension points, but those are secondary paths rather than the primary path.

This shift is not a refactoring exercise. It is a change in design philosophy: build from the integration code a host app developer wants to write, not from the internal architecture you need to implement.

The Initialization Surface as Diagnostic

The initialization sequence is the best diagnostic for SDK design quality because it is the first surface every developer touches.

A well-designed initialization sequence has one entry point with a clear and unsurprising name. Required parameters are genuinely required: the SDK cannot function without them. Optional parameters have sensible defaults the caller does not need to think about. Async operations are made explicit with a clear contract rather than hidden. And the developer never needs to understand your internal dependency graph.

A poorly designed initialization sequence reveals one of three things: the API was designed while building the internals so internal structure leaked outward; features were added over time without refactoring the entry point so requirements accumulated; or the team never watched a new developer use the SDK from scratch.

The third is the most common and the most fixable.

There is a related failure this series calls Initialization Ambiguity: the SDK does not clearly define when it is ready, what is required, what can be deferred, and what happens when methods are called out of order. The initialization surface is where this ambiguity usually shows up first. Article 3 in this series covers initialization state design in full.

Error Messages Are Part of the API

Most SDK teams treat error messages as an afterthought. They are not. They are part of the contract between your SDK and the developer.

When something goes wrong, the developer is not in a position to read your source code. They have a stack trace, your error message, and their own knowledge of what they were trying to do. Your error message needs to bridge the gap between what broke internally and what the developer needs to do next.

Consider the difference:

kotlin
// Bad: describes internal state, not the developer's problem
throw IllegalStateException("Module registry not initialized")
 
// Good: describes what went wrong, what to do, and where to do it
throw SDKNotInitializedException(
    "SDK.initialize() must be called before using any SDK features. " +
    "Call SDK.initialize() in your Application.onCreate() before starting any Activity."
)

The second message gives the developer three things: what went wrong, what to do, and where to do it. The cost of writing it is five minutes. The cost of a developer debugging the first version is often 30 minutes or more, multiplied across every developer who integrates your SDK.

Treating errors as internal state descriptions rather than developer guidance is what this series calls Opaque Failure Surface: the SDK throws errors that say what broke inside rather than naming the developer's mistake, the likely cause, and the next action. Article 4 covers error model design in full.

The Two-Layer Initialization Pattern

For SDKs that require significant configuration, the two-layer initialization pattern separates what is required at startup from what can be deferred.

The bootstrap layer handles the minimum required to put the SDK into a usable state. It runs at app startup, should complete synchronously, and needs only the information the SDK cannot function without.

The configure layer handles everything that can be deferred: user context, feature flags, remote configuration, experiment assignments. It runs asynchronously, can be retried on failure, and does not block the app's critical startup path.

kotlin
// Layer 1: bootstrap - synchronous, minimal, run in Application.onCreate()
SDK.bootstrap(context, apiKey = BuildConfig.SDK_API_KEY)
 
// Layer 2: configure - async, deferrable, non-blocking
lifecycleScope.launch {
    SDK.configure {
        userId = auth.currentUser?.id
        analyticsEnabled = preferences.analyticsConsent
        featureFlags = remoteConfig.snapshot()
    }
}

This pattern has two properties that matter in production. First, core SDK APIs are usable immediately after bootstrap. Callers are never blocked waiting for remote configuration to complete. Context-dependent APIs that require user identity or remote configuration return a typed error until configure completes rather than blocking or crashing. Second, configuration can be updated without reinitializing the SDK, which matters when user context changes after authentication.

The module structure below reflects this separation in practice. Bootstrap and configuration are explicitly separated at the module boundary, and the public API surface is isolated from internal implementation.

payments-sdk - module layout
payments-sdk/
api/# public surface: what callers import
src/main/kotlin/
PaymentsSDK.ktentry# single entry point: bootstrap + configure
PaymentsConfig.kt# configuration DSL
models/# DTOs, enums, sealed results
PaymentResult.kt
PaymentMethod.kt
SDKError.kt
core/internal# internal implementation: not exported
src/main/kotlin/
bootstrap/
BootstrapCoordinator.kt
BootstrapState.kt
network/
PaymentsApiService.kt
AuthInterceptor.kt
session/
SessionManager.kt
testing/# test utilities for host apps
PaymentsSDKTestRule.kttest
FakePaymentsSDK.kttest
build.gradle.kts
README.mdrequired# integration guide: must work standalone
directory.kt.md / .txt.yaml / .gradle.xml.ts / .jsother

The api/ module is the only module a host app imports. If a developer cannot understand it from its public types alone, without opening core/, the design has leaked internal concerns into the caller's model.

Cross-Platform Consistency

If your SDK ships on both Android and iOS, the API surface should feel consistent even though the implementations differ. Developers who integrate on both platforms carry their mental model across them.

swift
// iOS: mirrors the conceptual structure of the Android bootstrap/configure pattern
SDK.bootstrap(apiKey: "xyz")
 
Task {
    try await SDK.configure(
        userId: auth.currentUser?.id,
        analyticsEnabled: preferences.analyticsConsent
    )
}

Cross-platform consistency is not about identical syntax. It is about identical concepts: the same initialization model, the same error handling pattern, and the same documentation structure. A developer who has already integrated your Android SDK should feel oriented when opening your iOS documentation, even if the language looks different.

When Android and iOS SDKs expose different names, different error models, or different initialization sequences for the same product concept, the result is Cross-Platform Drift. It doubles the support surface, creates confusion in teams working across both platforms, and signals that the SDK was built by independent teams without shared API ownership.

Testing the SDK Surface

The most valuable exercise in SDK design is to write the integration code yourself: in a blank project, with no IDE autocomplete hinting at your internals, and no prior knowledge of how the SDK was built.

This series uses a concrete standard for this:

Starting from a blank host app, using only the README, a developer should install the SDK, initialize it, and call one SDK method successfully within five minutes. If any step requires leaving the README, asking for help, guessing lifecycle order, or debugging an unclear error, the test fails at that step.

Questions to ask while running this test:

  1. How many lines of code are required before the SDK does something useful? If the answer is more than five, investigate why.
  2. How many concepts does a developer need to understand before initializing? Each concept is a cognitive cost that either justifies itself or represents unnecessary coupling.
  3. What happens if methods are called in the wrong order? If the answer is a cryptic runtime exception, add state validation at entry points and throw early with a clear message.

An SDK that passes this test is one developers will recommend. An SDK that fails it is one they will route around.

The Failure Modes This Series Addresses

SDK design failures follow recognizable patterns. This series uses a named taxonomy to track them across articles.

Inside-Out Exposure: the SDK forces integrators to understand internal modules, backend concepts, or team boundaries before doing useful work. This article is primarily about this failure mode.

Initialization Ambiguity: the SDK does not define when it is ready, what is required, what can be deferred, or what happens when methods are called out of order. Article 3 covers this alongside thread safety.

Thread Contract Omission: the SDK does not say which APIs are main-thread only, what thread callbacks arrive on, or how shared state is protected. Article 3 covers this alongside initialization.

Opaque Failure Surface: the SDK throws errors that describe internal state rather than the developer's mistake, the likely cause, and the next action. Article 4 covers error model design.

Distribution Friction: the SDK works in the author's sample app but fails in real host apps because packaging, shrinker rules, binary size, or dependency policy were treated as afterthoughts. Article 5 covers shipping and adoption.

Unsafe Defaults: the SDK makes unsafe behavior easy to adopt accidentally, or requires the integrator to opt into basic safety rather than making safe behavior the path of least resistance. This failure mode is the focus of the optional security companion in this series.

Cross-Platform Drift: Android and iOS expose different names, states, result models, or documentation structures for the same product concept.

These failure modes are not independent. An SDK with poor initialization design usually has poor error messages too. Both problems tend to have the same root: the API was designed for the implementer rather than the caller.

A First-Run Standard

Before moving to Article 2, which covers public API surface design in detail, here is the practical standard this article points toward.

One entry point: a developer should be able to call one method to put the SDK in a usable state.

Sensible defaults: required parameters should be genuinely required. Everything else should have a default the caller does not need to think about.

Clear errors: every error should say what went wrong, why it likely happened, and what to do next.

Fast path from install to first call: the README should get a developer to their first successful SDK call without consulting any other resource.

Consistent mental model across platforms: Android and iOS should share the same initialization concepts, error hierarchy, and documentation structure even if the syntax differs.

The next article examines what happens after the entry point: how every public type, method name, callback, and result object that the SDK exposes becomes part of the developer's mental model.