Engineering

The iOS framework you export is an API, and nobody reviews it

Every public declaration in the shared module becomes Swift API. internal is the design tool.

MobileByteSensei 9 min read
kmpiosarchitecture

TL;DR

In Kotlin, public is the default visibility. In a shared module, public means exported to Swift. Those two facts together mean your iOS API surface is "everything you did not think to mark otherwise" — and nobody reviews it, because it never appears in a diff.

The consequences are not aesthetic. iOS engineers autocomplete into your internals and build against them; the framework binary carries every exported symbol; and you cannot refactor a class that three Swift files now depend on without it being a breaking change you did not know you published.

internal is the design tool. The fix is a deliberately small exported surface, and a committed snapshot of it so changes show up in review.

The case this post is about

A shared module after six months of normal, careful work. Layered sensibly:

// commonMain — all of this is public, because public is the default
class SearchViewModel(private val useCase: SearchUseCase) { … }
class SearchUseCase(private val repo: SearchRepository) { … }
class SearchRepository(private val api: SearchApi, private val cache: SearchCache) { … }
class SearchApi(private val client: HttpClient) { … }
data class SearchDto(val id: String, val raw: String) { … }
object ScoreMath { fun bm25(tf: Int, df: Int, n: Int): Double = … }

Nothing wrong with any of it. The intent was that iOS uses SearchViewModel and nothing else. The intent was never written down anywhere a compiler could read.

The symptom

Three findings, escalating.

The header. Someone finally looked at what the framework exports:

$ grep -c '^@interface\|^@protocol' shared.framework/Headers/shared.h
412

Four hundred and twelve exported types from a module whose intended API was one class.

An iOS engineer had built on ScoreMath. Entirely reasonably — it was public, it had a clear name, and it did exactly what they needed for a client-side sort. They had no way to know it was meant to be internal.

A refactor became a cross-team breaking change. Renaming SearchDto.raw to rawJson — a two-minute change to a type that exists to parse a payload — broke the iOS build, because a Swift view was reading .raw directly.

error: value of type 'SharedSearchDto' has no member 'raw'

The Kotlin change was correct, small, and local. Its blast radius was another team's codebase, and the first anyone knew was a red CI job.

Why the obvious fix failed

"Tell the iOS team to only use SearchViewModel." A convention with no enforcement, competing against autocomplete. Xcode offers every exported symbol with equal confidence; a documented preference loses to the thing the IDE suggests.

"Mark the leaked types @InternalApi with an opt-in annotation." Kotlin's opt-in mechanism is a Kotlin construct. It does not survive the Obj-C export, so Swift sees a perfectly ordinary class with no warning attached. I shipped this and it accomplished nothing on the consuming side.

"Make everything internal." The direction is right and the blunt version does not compile: SearchViewModel's constructor takes a SearchUseCase, so making the use case internal while the ViewModel is public is a visibility error. Shrinking the surface requires changing the shape, not just the modifiers.

"Use export = false on the framework dependencies." Useful for transitively exported libraries — it is why HttpClient and friends were not also in the header. It does nothing about your own module's declarations.

The actual mechanism

Two defaults colliding.

Kotlin's default visibility is public. This is a reasonable language default. In an application module it is harmless, because nothing consumes the module from outside.

A shared module's public is a publication event. The Kotlin/Native compiler exports every public declaration into the framework's Obj-C header, which is the Swift API. There is no second list, no explicit export manifest, no api {} block — the set of public declarations is the contract.

Three properties follow:

The surface grows silently. A new public class is a new exported type. The diff shows a new file; it does not show "the iOS API gained 6 symbols", because nothing renders that.

Consumers cannot distinguish intent. SearchViewModel and ScoreMath arrive in Swift identically — same prefix, same autocomplete, same apparent blessing. "Public but not for you" is not expressible across the boundary.

Everything exported is load-bearing once used. The moment a Swift file references a symbol, changing it is a breaking change. You published an API by not typing a keyword.

A shared module's declarations passing through the export: every public declaration becomes an exported Obj-C type and therefore Swift API, with no manifest and no diff rendering the change, so a layered module intended to expose one class publishes four hundred and twelve — and Swift cannot distinguish the one that was meant from the four hundred that were not

The rule:

The exported surface is the set of declarations you did not mark internal. That is a list assembled by omission, which is the one way nobody reviews anything.

And the bind that makes internal an incomplete answer: Kotlin's internal is scoped to the Gradle module, while the framework is assembled from all of them. A repository in :core:data that :feature:search must construct has to be public — there is no visibility that means "public to my own modules, invisible to Swift". So in any layered project, the declarations most in need of hiding are exactly the ones that cannot use the keyword:

internal class SearchCache            // :core:data — now unusable from :feature:search
public class SearchCache              // visible to siblings, and exported to Swift

Two ways out, and they cost different things. @HiddenFromObjC marks a public declaration as not-exported, which solves it per symbol and has to be remembered per symbol — the same assembled-by-omission problem one layer down. Or you introduce an umbrella module that is the only thing the framework is built from, re-exporting a deliberate list, which costs a module and makes the surface a file somebody reviews.

The second is more work and it is the one that converts the list from omission into intent, which is the actual defect here.

The fix

Make the surface explicit, small, and visible in review.

internal by default, in practice. Everything below the intended boundary becomes internal, and the public layer is deliberately thin:

// commonMain — the ONLY intended iOS entry points
class SearchViewModel internal constructor(private val useCase: SearchUseCase) {
    companion object { fun create(): SearchViewModel = … }   // iOS constructs via this
}

// everything below the boundary
internal class SearchUseCase(…)
internal class SearchRepository(…)
internal class SearchApi(…)
internal data class SearchDto(…)
internal object ScoreMath { … }

Note internal constructor plus a factory. That is what resolves the visibility error from the failed attempt: the constructor's parameter types no longer need to be public, because the constructor is not. Swift gets create() and cannot see the dependency graph behind it.

Export view types, not domain types. SearchDto leaked because iOS needed data out of it. The fix is to give iOS a type that exists for iOS:

// commonMain — public, stable, designed. This IS the API.
data class SearchResultView(val id: String, val title: String, val scoreLabel: String)

Now renaming SearchDto.raw is genuinely local. The view type changes only when the contract with iOS changes, which is exactly when a breaking change should be visible.

Commit a snapshot of the exported surface, so it appears in diffs. This is the part that makes the fix durable rather than a one-time cleanup:

# tools/export-surface.sh — run after the framework build
grep -E '^@interface|^@protocol' \
  shared/build/bin/iosArm64/debugFramework/shared.framework/Headers/shared.h \
  | sed -E 's/ *:.*//; s/^@(interface|protocol) //' \
  | sort -u > ios/EXPORTED_SURFACE.txt

With a CI check that fails when the committed file and the freshly generated one differ:

$ git diff --exit-code ios/EXPORTED_SURFACE.txt
+ SharedScoreMath
❌ exported iOS surface changed — review and commit ios/EXPORTED_SURFACE.txt

Accidentally making something public is now a line in a pull request. That is the whole goal: not preventing exports, but ensuring they are decisions.

@ObjCName the things that stay, since you are designing the API anyway:

@ObjCName("SearchViewModel")   // not SharedSearchViewModel
class SearchViewModel internal constructor(…)

How we proved it

The measurement is the surface count, before and after, read from the generated header rather than from the source:

$ bash tools/export-surface.sh && wc -l < ios/EXPORTED_SURFACE.txt

before   412
after     11

Eleven: the ViewModel, the view types, the state enum, the subscription handle, and the error type. Every one of them is a declaration someone chose.

Then the check that the shrink did not break the consumer — because a surface reduction is a breaking change for anything that was using the removed symbols, and pretending otherwise would be exactly the mistake this post is about:

xcodebuild   FAILED
  error: cannot find 'SharedScoreMath' in scope        (SortHelper.swift:14)
  error: value of type '…' has no member 'raw'         (ResultRow.swift:22)

Two call sites, both known, both migrated to the view type — and that is the honest cost of this fix. Shrinking a surface people are already using requires a coordinated change, and the right time to have a small surface is before anyone depends on it.

The CI guard, verified by deliberately breaking it:

// make ScoreMath public again
$ bash tools/export-surface.sh && git diff --exit-code ios/EXPORTED_SURFACE.txt
+SharedScoreMath
exit 1    ← PR blocked, as intended

One limit: the snapshot is a list of type names. It catches a new or removed type, and it does not catch a changed method signature on a type that stays. Catching that needs a diff of the full header, which is noisier — we chose the cheaper guard deliberately, and the signature class is still caught by the iOS build itself.

What this generalizes to

A default that is harmless in one context is a publication event in another. public in an app module versus a shared module; a pub in a Rust binary versus a library; a non-underscored Python name in a script versus a package. The same keyword, two different meanings, and only one of them is reviewed.

An API assembled by omission is an API nobody designed. If the contract is "whatever was not marked otherwise", it will accrete. Make the boundary something you have to actively add to, and the surface stays intentional.

Render invisible changes into the diff. The exported surface, the generated schema, the public dependency tree, the set of permissions in a manifest — if a change does not appear in review, it is not reviewed. A committed snapshot plus a CI equality check is a cheap, general way to make any derived surface visible.

Export view types, not domain types. Anything that crosses a team boundary should exist for that boundary. It decouples your internal refactoring from someone else's build, and it means the breaking changes you do make are the ones that genuinely changed the contract.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS