Morteza Taghdisi

Writing10 min read
Architecture-style illustration for a mobile SDK public API design article
Architecture & Platform ThinkingApril 24, 2026

The Public API Surface Is the Product

Every public class, method, enum, callback, and result type your SDK exposes becomes part of the developer's mental model.

sdkmobileandroidiosapi-designdeveloper-experience

This article focuses on Inside-Out Exposure at the level of individual API decisions. Inside-Out Exposure is what happens when an SDK's public surface reflects the team's internal architecture rather than the caller's mental model. Article 1 in this series covers the broader mindset and first-run experience. This article focuses on the mechanics: naming, types, autocomplete, and the choices that make an API feel obvious before documentation.

Every Public Type Is a Decision

When an SDK ships, every public class, method, enum, callback, and result type becomes part of the developer's mental model. That mental model travels into every host app that integrates the SDK. It appears in stack traces, code reviews, and support tickets.

The question to ask about each public type is: does this name and shape reflect what the integrator is trying to do, or does it reflect how the SDK is internally organized?

The difference shows up immediately:

kotlin
// Bad: public types that expose internal structure
class SDKModuleRegistry
class AuthModuleConfig(val tokenEndpoint: String, val scopes: List<String>)
class AnalyticsFlushManager
 
// Good: public types that reflect the caller's task
class SDKConfig
class PaymentRequest(val amount: Money, val method: PaymentMethod)
class PaymentResult

The first set requires a developer to understand that the SDK has a module registry, that auth is configured separately from the main SDK, and that analytics has a concept called a flush manager. None of this helps the developer get a payment processed. It is the internal team structure made visible.

Single Primary Entry Point

An SDK's entry point is the first thing a developer encounters. It should communicate everything needed to start without requiring them to find additional classes first.

kotlin
// Bad: entry point requires assembling several collaborators
val config = SDKConfig.Builder()
    .setApiKey("key")
    .setRegion(Region.US_EAST)
    .addModule(AuthModule.default())
    .addModule(AnalyticsModule.Builder().build())
    .build()
SDK.init(applicationContext, config)
 
// Good: entry point takes what is genuinely required
SDK.initialize(context) {
    apiKey = "key"
    region = Region.US_EAST
}

The entry point in the good example has a single name (initialize), takes the application context because that is always required, and uses a configuration DSL for everything else. Modules are an implementation detail. The developer does not assemble them.

Article 1 in this series covers the two-layer bootstrap/configure pattern. This article focuses on what the entry class and surrounding types look like from the outside.

Required vs Optional Configuration

Every parameter in the SDK configuration surface should answer one question: is the SDK wrong to start without this, or is there a safe default?

Parameters that are genuinely required belong in the primary initialization call. Parameters that have sensible defaults belong in optional configuration.

kotlin
// apiKey and context are required: the SDK cannot run without them
// environment has a safe default (production) and should not be mandatory
SDK.initialize(context) {
    apiKey = BuildConfig.SDK_API_KEY  // required: no SDK functionality without this
    // environment defaults to PRODUCTION, override only if needed:
    // environment = Environment.STAGING
}

A configuration surface that requires developers to specify the HTTP timeout, the retry policy, the cache size, and the log level before they can make a first call is optimizing for the SDK team's deployment flexibility at the cost of every integrator's first-run experience.

Designing Autocomplete as Documentation

In most SDK integrations, autocomplete is the primary interface between the SDK and the developer. Developers type the entry point class name and work from what appears in the IDE. The quality of the parameter names, KDoc summaries, and type shapes visible in autocomplete determines the first-run experience more than the README does for most developers.

Compare two variants of the same operation:

kotlin
// Bad: requires opening documentation to understand what any parameter means
sdk.process(id = "user_123", mode = 2, flags = 0x04)

When a developer sees this in autocomplete, the questions stack up. What does process do? What is mode = 2? What do the flags represent? They must leave the IDE and open documentation.

kotlin
// Good: intent is readable from the signature alone
 
/**
 * Initiates a payment for the given customer.
 *
 * @param customerId The customer's account identifier from your backend.
 * @param method The payment method to use. Use [PaymentMethod.Card] for card payments.
 * @param options Override default payment behavior. Defaults to [PaymentOptions.default].
 * @return The completed payment result.
 * @throws SDKException on authorization failure, network unavailability, or configuration issues.
 */
suspend fun requestPayment(
    customerId: String,
    method: PaymentMethod,
    options: PaymentOptions = PaymentOptions.default()
): PaymentResult

The method name, parameter names, types, and KDoc summary together communicate what this does, what it needs, and what it returns. A developer can form a correct mental model without opening the documentation.

On iOS, DocC serves the same role:

swift
/// Initiates a payment for the given customer.
///
/// - Parameters:
///   - customerId: The customer's account identifier from your backend.
///   - method: The payment method to use.
///   - options: Override default payment behavior. See `PaymentOptions` for available settings.
/// - Returns: The completed payment result.
/// - Throws: `SDKError` on authorization failure, network unavailability, or configuration issues.
func requestPayment(
    for customerId: String,
    method: PaymentMethod,
    options: PaymentOptions = .default()
) async throws -> PaymentResult

The argument label for makes the call site read naturally: try await sdk.requestPayment(for: customerId, method: .card). This is an iOS convention worth preserving. Android convention uses named parameters directly in Kotlin, without the prefix argument label.

The JVM Overload Problem

Kotlin's default parameters produce clean autocomplete in Kotlin, but they introduce a specific problem when Java callers use the SDK in a mixed-language host app.

kotlin
// Kotlin SDK method with default parameters
fun initialize(
    context: Context,
    apiKey: String,
    environment: Environment = Environment.PRODUCTION,
    timeout: Duration = 30.seconds,
    retryPolicy: RetryPolicy = RetryPolicy.default()
)

Without @JvmOverloads, Java callers must specify every parameter. With @JvmOverloads, Kotlin generates one overload per optional parameter starting from the rightmost:

java
// Java sees this explosion of overloads after @JvmOverloads
initialize(context, apiKey)
initialize(context, apiKey, environment)
initialize(context, apiKey, environment, timeout)
initialize(context, apiKey, environment, timeout, retryPolicy)

Five overloads for one function. Java developers in a mixed-language project see a crowded autocomplete menu that communicates nothing about which overload is the right starting point.

If the SDK must serve both Kotlin and Java callers, design the idiomatic surface for each language separately rather than applying @JvmOverloads broadly:

kotlin
// Kotlin-idiomatic: DSL configuration
SDK.initialize(context) {
    apiKey = "key"
    environment = Environment.PRODUCTION
}
java
// Java-friendly: explicit builder, no overload explosion
new SDK.Builder()
    .apiKey("key")
    .environment(Environment.PRODUCTION)
    .build()
    .initialize(context);

The builder adds surface area, but it communicates intent clearly in both languages and avoids autocomplete noise in Java.

Naming Conventions by Platform

Android and iOS have established naming conventions that developers expect SDK types to follow. Deviating from them creates friction, because the SDK then feels like a foreign object in the host app's codebase.

Android (Kotlin) conventions:

  • Types and classes in PascalCase: PaymentResult, SdkConfig
  • Methods and properties in camelCase: initialize(), processPayment()
  • Boolean accessors using is or has prefix: isReady(), hasError(), not getIsReady()
  • Interfaces named by role, not protocol: PaymentListener not IPaymentListener
  • Constants in companion objects: SCREAMING_SNAKE_CASE for primitive constants, PascalCase for sealed class members

iOS (Swift) conventions:

  • Types and classes in PascalCase: PaymentResult, SDKConfig
  • Methods and properties in camelCase: initialize(), processPayment()
  • Boolean properties with is or has prefix but no get: isReady, hasError
  • Argument labels that read naturally at call sites: processPayment(for customer:) not processPayment(customerId:)
  • Prefer initializers over factory methods where the initializer communicates intent clearly
  • No get prefix on property accessors, and prefer properties over zero-argument methods: result not getResult() or result()

A developer who has integrated other well-designed Android SDKs should find your Android SDK names unsurprising. The same is true on iOS. Platform-idiomatic naming is not cosmetic polish. It determines how fast a developer can build a correct mental model from autocomplete alone.

Typed Results Instead of Stringly-Typed Outcomes

One of the clearest signals of inside-out API design is a result type that requires string parsing rather than pattern matching.

kotlin
// Bad: caller must parse errors from a string code
fun processPayment(id: String): String  // returns "SUCCESS", "ERROR_NETWORK", "ERROR_AUTH_EXPIRED"
 
// Also bad: wraps an undiscriminated string in a class without improving clarity
data class PaymentResult(val status: String, val errorCode: String?)

These force the caller to remember what string values are valid and handle unrecognized values defensively. They cannot be checked exhaustively by the compiler.

kotlin
// Good: compiler enforces exhaustive handling
sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Money) : PaymentResult()
    data class Declined(val reason: DeclineReason, val code: Int) : PaymentResult()
    data class NetworkError(val retryable: Boolean) : PaymentResult()
    data class ConfigurationError(val cause: SDKException) : PaymentResult()
}

The sealed class communicates what states are possible, what data is available in each state, and which states are terminal. A when expression over a sealed class is exhaustive. If a new case is added, the compiler tells the caller.

The Swift equivalent:

swift
enum PaymentResult {
    case success(transactionId: String, amount: Money)
    case declined(reason: DeclineReason, code: Int)
    case networkError(retryable: Bool)
    case configurationError(SDKError)
}

A switch over this enum is exhaustive. The same design principle applies across both platforms even though the syntax differs.

Keeping Advanced Features Secondary

Every SDK has advanced configuration: custom log interceptors, feature flags, alternative backends, extended timeouts. The mistake is making these discoverable on the primary entry class at the same level as essential configuration.

kotlin
// Bad: advanced configuration mixed into the primary entry point
SDK.initialize(context) {
    apiKey = "key"
    environment = Environment.PRODUCTION
    logLevel = LogLevel.VERBOSE               // advanced
    customHttpClientFactory = OkHttpFactory() // advanced
    experimentFlags = mapOf(...)              // advanced
    metricsReporter = CustomReporter()        // advanced
}

When a developer opens the configuration DSL and sees twenty properties at the same level, they have to figure out which ones matter for a basic integration. Advanced options should live in a secondary configuration namespace or be accessible only via dedicated methods:

kotlin
SDK.initialize(context) {
    apiKey = "key"
    environment = Environment.PRODUCTION
 
    advanced {
        httpClient = customClient
        metricsReporter = CustomReporter()
    }
}

This is not about hiding functionality. It is about communicating which choices matter for a standard integration and which are for specialized scenarios. Advanced features should be easy to find once a developer needs them, but invisible until then.

A Public Surface Checklist

Before shipping a new SDK version, use this checklist to identify API surface problems before they reach host app developers.

  • Is there exactly one primary entry point?
  • Can a developer initialize the SDK without understanding any internal module name?
  • Do autocomplete suggestions on the entry class reveal the right next step without documentation?
  • Are all public names platform-idiomatic (PascalCase types, camelCase methods, no get prefix on iOS)?
  • Do result types use sealed classes or enums rather than stringly-typed codes?
  • Are all error cases typed with enough information to identify the cause and next action?
  • If the SDK uses Kotlin default parameters, have the Java-facing overloads been reviewed for clarity?
  • Can every required parameter be justified as genuinely required?
  • Are advanced configuration options separated from the primary path?
  • Does every parameter name communicate its purpose without documentation?
  • Do KDoc and DocC summaries describe what the method does from the caller's perspective?

The next article in this series examines what happens after initialization: the state machine, thread contracts, and lifecycle boundaries that determine whether the SDK is safe to use under production conditions.