Engineering

Write it in commonMain until the compiler stops you

Starting in a platform source set means shared code never gets written. Default to commonMain and let the compiler decide what genuinely cannot live there.

MobileByteSensei 10 min read
One screen, and the small fraction that genuinely will not pass it
kotlinmultiplatformarchitecture

A Kotlin Multiplatform project has a shared source set and one per platform. Which one you reach for first is a habit, and it decides what fraction of the project is ever actually shared.

Reach for the platform one and the shared source set stays thin — not by anyone's decision, but because nothing ever moves back.

The case this post is about

One class, SessionStore, which saves an auth session — and the single line in it that cannot compile on both platforms:

// commonMain/SessionStore.kt — everything except the one call that cannot compile
class SessionStore(private val keystore: SecureKeystore) {
    fun save(session: Session) {
        require(session.token.isNotBlank()) { "refusing to store a blank token" }
        keystore.put(KEY, Json.encodeToString(session))
    }

The validation, the serialisation, the key constant and the error message are all platform-agnostic. Exactly one thing is not: where the bytes go.

Written the other way round — starting from "this needs the Keychain, so it is platform code" — the whole class ends up duplicated per platform, and the require that refuses a blank token exists twice and drifts. This post follows that one class, because nobody decides to build a project where most of the code is platform-specific; it accretes one reasonable file at a time.

The symptom

What you see is a multiplatform project whose shared module is small. The build is genuinely multiplatform, the targets are configured, everything works. It is simply that most of the code lives in a platform source set, and nobody chose that.

Each individual file got there for a defensible reason. This one needed a platform context. That one used a date formatter the author already knew. A third called a storage API that was right there. None of those is a bad decision in isolation, and the aggregate is a project that cannot share its logic.

Material taken to the coarse side by habit, and the fine screen standing unused

The second thing is the phrase that does the damage: I will move it to common later. It is said honestly and it is almost never true, because the cost is asymmetric in a way that is not obvious at the moment you say it. Writing the code in the shared source set initially is free — you are writing it anyway. Moving it afterwards means finding every platform API you reached for, locating a multiplatform equivalent, and rewriting the tests, which were also written against the platform.

The third is that the tests make it worse rather than better. Code written in a platform source set gets tested with platform test infrastructure, so the test suite becomes a second thing anchored to that platform. By the time somebody wants to move the code, the test burden exceeds the code burden, and the refactor that was always going to happen later now needs its own justification.

The fourth is the moment of discovery, which is the second platform. You add another target expecting to reuse the shared module and find that the majority of the behaviour is not in it. The project was multiplatform in its build configuration and not in its code, and nothing had surfaced that gap because one platform never asks.

Why the obvious fix failed

Set a shared-code target. Pick a percentage and track it. The number is easy to produce and hard to act on: it tells you the ratio and not which code should move, and it is trivially satisfied by moving whatever is easiest rather than whatever matters. A metric that can be improved without improving anything is worse than no metric.

Catch it in review. A reviewer sees one file added to a platform source set with a plausible reason attached. There is nothing to object to in that file. The problem is a property of the accumulated distribution, which is invisible from any individual change — the same reason this is hard to notice in the first place.

Move it later, deliberately. Scheduled refactors of this shape lose to feature work every time, and they lose for a good reason: moving working code with no behaviour change is hard to justify against anything that users will see.

Forbid the platform source set. Cannot be done. Platform code is genuinely necessary — there is no multiplatform way to request a permission or read a keychain — so an outright ban would simply be ignored, and a rule that gets ignored erodes the ones around it.

The actual mechanism

Invert the default: write it in the shared source set, and let the compiler be the thing that moves you out.

The question "does this need a platform API" is one people answer from habit, under time pressure, having just written similar code somewhere else. The compiler answers the same question from fact, instantly, with no ambiguity. If it compiles in the shared source set, it did not need the platform — and that is a proof rather than an opinion.

write in commonMain
  compiles      → it was never platform-specific. Done.
  does not      → you have found the actual boundary

That turns a judgement call into a mechanical test, which is the entire value. Nobody has to be disciplined, estimate correctly, or resist a habit. They have to try the shared source set first and read the error.

What passes the screen, and the retained fraction that genuinely cannot

The sub-case that determines how much you actually share: when you do hit the boundary, make it as narrow as you can. The instinct on meeting a platform requirement is to move the containing feature to the platform source set. Almost always the thing that genuinely cannot be shared is one function — a permission request, a file path, a keychain read — and the feature around it can stay common with a small expect declaration for the part that cannot.

The narrow thing that cannot pass, and everything around it that can

The difference between those two responses is most of the shared percentage. A feature moved wholesale takes its logic, its state handling and its tests with it; a feature that stays common with a three-line platform boundary shares all of that.

There is also a category of reach that is habit rather than necessity, and it is larger than people expect. Date and time, serialisation, concurrency, key-value storage — all have multiplatform libraries that are perfectly good. Reaching for the platform API in those cases is not a constraint being respected, it is familiarity, and the compiler will not stop you because you never asked it.

The fix

default     every new file starts in the shared source set
move out    only on a compile error, never on an assumption
boundary    expect/actual for the minimum that cannot compile, not the feature around it
libraries   prefer multiplatform equivalents over platform APIs you happen to know

The third line is where the practice is won or lost, and it is easiest to see as a diff. A feature that needs the device's secure storage looks, on the first attempt, like something that has to live per-platform. Almost none of it does:

// commonMain/SessionStore.kt — everything except the one call that cannot compile
class SessionStore(private val keystore: SecureKeystore) {
    fun save(session: Session) {
        require(session.token.isNotBlank()) { "refusing to store a blank token" }
        keystore.put(KEY, Json.encodeToString(session))
    }

    fun load(): Session? =
        keystore.get(KEY)?.let { Json.decodeFromString<Session>(it) }

    fun clear() = keystore.remove(KEY)

    private companion object { const val KEY = "session.v1" }
}

// commonMain/SecureKeystore.kt — the boundary, three functions wide
expect class SecureKeystore {
    fun put(key: String, value: String)
    fun get(key: String): String?
    fun remove(key: String)
}

The validation, the serialisation, the key name and its version suffix, the null handling — all shared. What is platform-specific is storing a string, which is three signatures. The instinct that produced the alternative puts SessionStore itself behind expect, and then the v1 key suffix exists twice and drifts the first time somebody bumps it on one platform.

What the boundary costs, counted:

$ cloc --include-lang=Kotlin core/session/src
commonMain       78 lines      # validation, serialisation, key versioning
androidMain      14 lines      # EncryptedSharedPreferences
iosMain          17 lines      # Keychain
                 ----
                109 lines      # 72% shared, and the 28% is two keystore wrappers

That ratio is the one to hold onto rather than the percentage, which moves with the feature. The shape is what repeats: the platform half is a thin adapter over one API, and everything that could be got wrong — the validation, the key version, the null handling — is written once.

The third line is the one worth enforcing explicitly, because the compiler does not help with it. The compiler tells you that something cannot be shared; it has no opinion about how much you move in response. That decision is still a judgement, and it is where the practice either works or quietly does not.

The fourth line deserves one caution, because it can be taken too far. A multiplatform library is not automatically the better choice — it can be less mature, less documented or wrong for your case, and "it is multiplatform" is not an argument on its own. The point is narrower than it first appears: the decision should be made on the library's merits rather than settled by which API you happened to learn first. Often the platform one does win, and it should win for a reason you could state.

It is worth noticing that this rule costs nothing when it is wrong. If you start in the shared source set and the code genuinely needs the platform, you find out in seconds and move it — the loss is one failed compile. The asymmetry against the alternative is enormous: starting in the platform source set and being wrong costs a refactor that never happens.

How we proved it

The mechanical claim is the one I can stand behind fully. Whether code compiles in the shared source set is decided by the compiler, not by anybody's estimate, and that makes the test both free and trustworthy. Every other approach in this post requires somebody to judge correctly under pressure; this one requires them to try the shared source set and read the result.

The narrow-boundary claim is a design position rather than a measurement. I have argued that most platform requirements are one function deep and that moving the containing feature is the expensive mistake. That matches what I have found in the features I have written this way, and I do not have a survey across a codebase to put a number on it. Treat it as a strong prior rather than a result.

There is a second-order effect worth naming that I did not anticipate and would now count as the main benefit. Code written in the shared source set is testable without a platform runtime, because it has no platform dependency to stand up. That makes its tests fast and ordinary rather than instrumented, which changes how many of them get written — and the gap between a test suite you run constantly and one you run in CI is a difference in kind rather than in speed.

What I have not measured is the shared percentage before and after adopting this, which is the obvious evidence and the one I did not collect. The honest position is that I changed a default because the asymmetry argument is compelling, not because I ran the experiment — and the asymmetry argument would have been just as compelling if it were wrong.

What I have not solved: existing code. Everything here governs new files. A project that already accreted a platform-heavy distribution does not improve because the default changed, and the retrospective move is the same expensive refactor the post describes as never happening. I have not done it, which means my own shared percentage reflects the habit more than the rule.

Everything presented to the screen, and only what cannot pass retained

And a boundary on the rule itself: compiling in the shared source set is necessary and not sufficient. Code can compile commonly and still behave differently per platform — a file path separator, a text rendering difference, a threading assumption that holds on one runtime. The compiler proves portability of the API surface you used, not of the behaviour, and treating a successful compile as proof of correctness is a mistake the rule makes easy.

What this generalizes to

When you have two places to put something and one is strictly more constrained, defaulting to the constrained one makes the constraint do the deciding — and a constraint is a better decider than a person who is busy.

The pattern shows up well beyond source sets. The stricter type, the narrower interface, the read-only connection, the lower-privilege credential, the dependency-free module: in each case, starting at the restrictive end means you discover the genuine requirement by hitting it, rather than by predicting it. And predicting it is the part people are bad at, not because they are careless but because the prediction happens before the information arrives.

The test for whether this applies is the cost of being wrong in each direction. If starting constrained and being wrong is cheap and quickly discovered, while starting permissive and being wrong is expensive and discovered late, the default belongs at the constrained end — and that asymmetry is far more common than the symmetric framing most defaults are chosen under.

One post a week, no digest

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

Follow by RSS