Designing an Internal SDK Other Teams Won't Hate
Internal libraries get resented for five predictable reasons: no test fake, leaked internals, a long happy path, opaque errors, and deprecations with no migration. Fix those and the wrappers your consumers built around you disappear.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A captive audience doesn't file bug reports; it builds wrappers. Five fixes cover most internal-SDK resentment: ship a fake so consumers can test without mocking your internals, don't leak your DI/serialization/threading choices into the public surface, make the happy path one call, model errors as a sealed type rather than exceptions, and never deprecate without a working replacement. Enforce the surface mechanically with explicit API mode and binary compatibility validation.
The captive-audience problem
An internal library has properties a public one doesn't: consumers can't choose a competitor, can't fork you meaningfully, and often can't even file an issue anywhere you'll see it. That sounds like it makes design easier. It does the opposite — it removes your feedback signal.
What you get instead of complaints:
- A wrapper class in their module, "just to simplify the API".
- Copy-pasted code because integrating was harder than reimplementing.
- A
@Suppressand a comment saying "the SDK forces this". - A meeting, two years later, where you learn three teams did the same thing independently.
None of that reaches your backlog. So the design has to be right by construction rather than by iteration.
1. Ship a fake — the highest-leverage decision
If you ship only interfaces and implementations, every consumer must mock you to test their own code:
// In every consumer's test suite. Now they depend on your internals.
val analytics = mockk<Analytics>()
every { analytics.track(any(), any()) } just Runs
verify { analytics.track("checkout_started", mapOf("cart_id" to "abc")) }
That test asserts your call shape. Change the signature, add a parameter, rename an event field — and you break tests in five modules you don't own. Consumers learn that upgrading your library costs a day, so they stop upgrading.
Ship the fake yourself:
// published alongside the real thing
class FakeAnalytics : Analytics {
val tracked = mutableListOf<Event>()
override fun track(name: String, props: Map<String, Any>) {
tracked += Event(name, props)
}
}
// consumer's test — asserts behaviour, survives your refactors
analytics.tracked.assertContains(name = "checkout_started")
Now the fake is part of your API contract. You maintain it, you keep it honest, and your consumers'
tests survive your internal changes. Publish it as a separate artifact (analytics-testing) or via
Gradle test fixtures so it doesn't ship in production builds.
The second-order benefit: writing the fake forces you to notice when your API is untestable by design. If the fake is hard to write, the interface is wrong.
2. Don't leak your choices
Every type in your public signature is a decision you're imposing:
// Leaks: DI framework, coroutine dispatcher, JSON model, threading
class ImageLoader @Inject constructor(
private val client: OkHttpClient, // now they depend on your OkHttp version
private val io: CoroutineDispatcher,
) {
fun load(req: GsonRequest, cb: Callback<JsonObject>) // and your JSON library
}
// Doesn't leak
interface ImageLoader {
suspend fun load(url: String, options: LoadOptions = LoadOptions()): ImageResult
}
Rules that follow from this:
- Your own domain types or stdlib types only in signatures. No third-party types unless the whole point of the library is that dependency.
- Interface for the contract, implementation internal. Consumers depend on the interface; you're free behind it.
- Suspend functions over callbacks, and let the caller choose the scope. Don't create scopes inside a library — that's a lifecycle you can't see.
- Don't require a specific DI framework. Offer a plain constructor or factory; provide a Hilt module as an optional extra artifact.
The version-conflict case is the one that hurts most in practice: if your public API exposes OkHttp types, every consumer is pinned to your OkHttp version, and your upgrade becomes a company-wide migration.
3. Make the happy path one call
// Eleven lines before anything happens
val config = TrackerConfig.Builder()
.setEndpoint(Endpoint.PROD)
.setBatchSize(20)
.setFlushInterval(30_000)
.build()
val tracker = Tracker.getInstance(context, config)
tracker.initialize()
tracker.setUserProperties(...)
// What it should be
analytics.track("checkout_started", cartId = id)
Defaults for everything, configuration for the exceptions. If the common case needs a builder, you've made every consumer read your documentation to do the obvious thing. Kotlin default arguments make the builder pattern mostly unnecessary here — use them.
4. Model errors as values
@Throws(IOException::class) tells a caller nothing actionable. Is it retryable? Is it their fault?
Should the user see something?
sealed interface UploadResult {
data class Success(val id: String) : UploadResult
data class RetryableFailure(val retryAfter: Duration) : UploadResult
data class InvalidInput(val field: String, val reason: String) : UploadResult
data object Unauthorized : UploadResult
}
The compiler now lists the cases at every call site, and each case carries what the caller needs to
decide. A new case in a sealed hierarchy becomes a compile error in consumers — which is exactly the
feedback you want, delivered at upgrade time rather than in production.
Reserve exceptions for programmer error: a malformed argument, calling a method before init. Those should be loud and unrecoverable, not modelled.
5. Deprecate with a migration, not a note
@Deprecated(
"Use track(name, props) — the callback variant never reported batching failures.",
ReplaceWith("track(name, props)"),
DeprecationLevel.WARNING,
)
fun trackWithCallback(name: String, cb: (Boolean) -> Unit)
ReplaceWith makes it a one-click IDE fix. State why, so consumers can judge urgency. Give at least
two release cycles at WARNING before ERROR, and never remove in the same release you deprecate.
For anything larger, write the migration guide and — if it's mechanical — ship the lint rule or refactoring script yourself. "Migrate to the new API" as a Slack message is homework you assigned to five teams simultaneously.
Enforce the surface mechanically
Intentions decay; checks don't.
kotlin { explicitApi() } // every public declaration needs an explicit visibility + return type
Plus the binary compatibility validator, which turns your public API into a checked-in .api file. A
PR that changes the public surface now shows that change in the diff — so surface growth becomes a
review decision rather than an accident.
Version with real semver, publish changelogs consumers can actually read, and treat a major bump as something you have to justify.
The rule
Your consumers can't fork you, but they can route around you — and a wrapper written in silence is the failure mode you'll never be told about. Design it like it's public. The only real difference is that a public API gets honest feedback, and yours won't.