Mobile Computing · Cross-Platform Development
The Portable Core Boundary: What Shared Code Must Not Reference
Portability follows from the set of symbols a module names rather than the language it is written in, so a shared core is built by inverting platform dependencies at the edge.
A module is portable when it can be compiled for a second target without editing it. That definition sounds tautological, but it locates the property precisely: portability is not a quality of a language, a framework, or a coding style. It is a property of the set of external symbols the module references. A file of pure arithmetic in C is portable to any target with a conforming compiler. The same file becomes non-portable the moment it calls pthread_create, reads a path from /proc, or asks the window system for the display scale. Nothing about the syntax changed; the reference set did.
Cross-platform mobile codebases fail at exactly this line. The shared half starts as portable logic and accretes platform calls one at a time, each individually reasonable, until the module graph contains a path from business logic to UIApplication or android.content.Context. The build for the second platform then either breaks or, worse, compiles and misbehaves because a conditional branch was written against an API that exists on both platforms with different semantics.
Portability is a property of the reference set
It helps to make the reference set explicit. For a compiled language, the linker answers the question directly: the undefined symbols of an object file are the complete list of things it needs from outside. A module whose undefined symbols are all satisfied by its own target-independent dependencies is portable by construction. A module that names open, stat, dlopen, or a platform UI class is bound to whatever provides those.
The same analysis applies to a language with a large ambient standard library. JavaScript running in a mobile runtime can reference Date.now, Math.random, fetch, and console, none of which is guaranteed to behave identically across hosts. Dart code can reach dart:io, which exists on mobile but not on the web target. Kotlin can reach java.io.File on Android and, through a compatibility layer, on other JVM targets, with different filesystem semantics underneath. The language’s standard library is not a portability guarantee; it is a portability claim that has to be checked per symbol. Even arithmetic is target-dependent at the edges: long is 64 bits on the 64-bit mobile targets and 32 bits on the older 32-bit ones, so code that names nothing outside its own translation unit can still be wrong about overflow.
This is why the useful design question is not “which language should the shared core be written in” but “what is the smallest set of effects the shared core needs, and who provides them”. The answer is usually smaller than expected. A typical application core needs: a monotonic clock, a wall clock, a source of randomness, persistent key-value storage, an HTTP client, and a logger. Six ports. Everything else, including navigation, formatting, validation, state machines, and most business rules, is computation over data the ports deliver.
Ambient authority is what breaks the second build
The term for the alternative, where a module can simply call whatever the host offers, is ambient authority. It is convenient and it is the mechanism by which a codebase stops being shareable.
Consider a shared function that decides whether a cached session has expired. Written against the platform, it reads the current time directly:
// Bound to the host: the module now depends on the platform clock.
function sessionExpired(session: Session): boolean {
return Date.now() > session.expiresAt;
}
Two problems appear immediately, and neither is about compiling. First, Date.now is wall-clock time, which can jump backwards when the device synchronises with a network time source or when the user changes the timezone offset. A session that expires in 90 seconds can appear to have expired 8 hours ago, or to have hours left when it has none. Second, the function cannot be tested for the boundary condition without waiting or without patching a global.
The portable form takes the clock as a parameter or from an injected port:
interface Clock {
// Milliseconds since an unspecified epoch; must not go backwards.
monotonic(): number;
// Milliseconds since the Unix epoch, subject to adjustment.
wall(): number;
}
function sessionExpired(session: Session, clock: Clock): boolean {
// Elapsed time is measured on the monotonic clock so that a wall-clock
// correction cannot resurrect or expire a session by itself.
return clock.monotonic() - session.startedAtMono > session.ttlMs;
}
The second version has no undefined symbols beyond the interface it declares. It compiles for any target, and it can be exercised with a fake clock that returns chosen values. The distinction between the two clock readings is not cosmetic: POSIX defines CLOCK_MONOTONIC as a clock that cannot be set and is unaffected by discontinuous changes to CLOCK_REALTIME, and every mobile platform exposes both. Choosing the wrong one is a defect that only appears when the device’s clock is corrected, which is precisely the case that a shared core is supposed to get right once for both platforms.
Inverting the dependency at the edge
The pattern that keeps the reference set small is dependency inversion: the core declares the interfaces it needs, and each platform supplies an implementation at startup. In the ports-and-adapters formulation, the core is the inside of the hexagon and the platform code is the adapter layer.
// core/ports.ts - owned by the shared module, implemented per platform
export interface Storage {
read(key: string): Promise<Uint8Array | null>;
write(key: string, value: Uint8Array): Promise<void>;
remove(key: string): Promise<void>;
}
// core/session.ts - depends only on the interface above
export async function persistSession(storage: Storage, s: Session): Promise<void> {
await storage.write('session', encodeSession(s));
}
The direction of the source dependency now points inward. The core does not know that Android’s implementation writes into a SharedPreferences file or that the iOS one writes into the application support directory, and it does not need to. What it does need is a contract specific enough that both implementations can honour it, which is the hard part.
The tempting alternative is conditional compilation. A shared file guarded by platform macros is cheap for the first divergence and expensive thereafter, because the variants multiply: two platforms times two OS versions times two build configurations is 2 × 2 × 2 = 8 branches, and the compiler only ever checks the one being built. Nothing verifies that the branch taken on iOS and the branch taken on Android satisfy the same postcondition. The ports approach pays an interface-design cost up front and gets a compiler-checked contract for every implementation, which is the trade that makes it worth doing.
Capabilities, not just operations
An interface that mirrors POSIX is the most common way to build a port that leaks. A FileSystem with open, read, write, rename looks neutral and is not, because the platforms differ on properties the signature does not mention: whether a rename is atomic across directories, whether writes are durable before fsync, whether a file is included in the device backup, whether the file is readable while the device is locked, and whether the path is even a path rather than an opaque document identifier.
Android’s storage model hands out content URIs for user-selected documents, which are not filesystem paths and cannot be opened with open. iOS separates the sandbox into 3 writable classes, application support, caches, and temporary, and each has a different deletion policy: the system may reclaim caches and temporary storage under pressure without warning, and marks files excluded from backup with an extended attribute. A port that promises “a path” forces one platform’s model onto the other.
The fix is to expose capabilities as part of the contract rather than discovering them at runtime. An operation carries a declaration of what it requires: durable across reboot, excluded from backup, readable while locked, or replaceable atomically. The adapter then either satisfies the requirement or fails at the point of the call with a typed error, and the shared code can branch on a documented capability instead of on a platform identifier. The same reasoning applies to the clock port above: monotonic is a capability, and a platform that cannot provide it should fail loudly rather than silently substituting wall time.
What the indirection costs
A virtual or interface dispatch costs a few nanoseconds; a syscall costs hundreds of nanoseconds to a few microseconds; a cross-thread hop costs more than both. Against a 16.6 ms frame budget at 60 Hz, the ports layer therefore does not cost anything measurable relative to the work the ports perform, and an argument against the pattern on performance grounds is almost always an argument about the wrong layer. Where the port is implemented across a language boundary rather than in the same runtime, that reasoning stops holding and the crossing cost described in Marshalling Across the Language Boundary dominates.
The real costs are elsewhere. Interfaces designed before the second adapter exists tend to be either too narrow, forcing the core to reach around them, or too wide, forcing every adapter to implement operations one platform does poorly. Both failures are discovered late, when the second implementation is written. The mitigation is to write the second adapter early, even as a stub, so the contract is exercised by two consumers before the core depends on it heavily. A port with one implementation is a hypothesis; a port with two is a design.
There is also a testing cost that turns into a benefit: every port needs a fake for the shared test suite, and writing the fake forces the contract to be stated precisely enough to be faked. A storage port whose semantics include “a write is visible to a subsequent read in the same process, and a completed write survives process termination” can be faked with a map and tested; a port that says only “write” cannot. The fake is where the ambiguity in the interface becomes visible, which is why the fakes belong in the shared repository rather than beside each adapter.
The boundary is never perfectly clean. Some behaviour is genuinely platform-shaped, such as the back gesture discussed in Back Navigation When the Two Platforms Disagree, and forcing it through a common interface produces a lowest-common-denominator API that satisfies neither platform. The practical rule is to invert dependencies for anything the core must decide, and to leave the platform to own anything that is presentation. Deciding which is which is the design work, and the test is the reference set: if the module cannot be linked without the platform, the decision has already been made by accident.
References
- POSIX.1-2017: Base Specifications, Issue 7 — IEEE and The Open Group
- Hexagonal Architecture (Ports and Adapters) — Alistair Cockburn
- RFC 3339: Date and Time on the Internet: Timestamps — IETF