Deep Dive: One Rust Core Across Android and iOS
A deep technical breakdown of how one Rust workspace can expose a unified core to Android and iOS through a facade crate, UniFFI, and platform-specific packaging without duplicating behavior.
Cross-platform stories are usually told too cleanly. “Write it once in Rust and call it from Android and iOS” sounds elegant, but the real work lives in the seams: symbol collisions, generated bindings, packaging, initialization order, ABI outputs, and keeping the domain model coherent while two native shells expect to feel idiomatic.
What I find interesting about this implementation is that it does not stop at sharing a cryptography primitive or a tiny parser. It pushes a more ambitious idea: a Rust workspace can own meaningful application behavior, then surface that same behavior to Android and iOS without splitting the logic into two separate maintenance stories.
In practice, that means one Rust core is responsible for things like state checks, request assembly, network-derived calculations, release validation, and other domain rules, while the native apps stay focused on lifecycle, UI, and platform integration. That boundary is where the design gets good.
01. The Real Problem Is Not “Can Rust Run on Mobile?”
The easy answer is yes. Rust can compile to Android shared objects and iOS static libraries. That part is almost boring now. The harder question is: can one Rust project be consumed as a coherent product surface on both platforms?
This implementation solves a very specific version of that problem. The workspace exposes multiple capability areas through UniFFI, but it avoids turning each one into its own independently-linked native package. That matters because separate UniFFI outputs tend to drag shared runtime scaffolding with them, and once multiple packages land in the same native target, collisions become a build-system problem, not a business-logic problem.
What the architecture is really optimizing for
- One source of truth for core behavior.
- One native artifact per platform boundary, not one per feature crate.
- Generated bindings that feel modular without linking modular native runtimes.
- App code that calls focused functions instead of knowing Rust internals.
02. Workspace Design: Public FFI Surface, Private Domain Core
The workspace is organized around a clean split between what needs to cross the language boundary and what should stay purely Rust. Public crates own UniFFI-facing exports. Internal crates own the real domain mechanics, pipelines, and infrastructure abstractions.
crates/
public/
lifecycle
feature_a
feature_b
feature_c
internal/
domains/
orchestrators/
core/
infra/
That split is more than cosmetic. The internal side gets to behave like a real Rust system: traits in a shared core crate, concrete implementations in infra, and domain crates that depend on abstractions rather than platform code. The public side becomes the export membrane. Only the APIs that truly need to be visible to Swift or Kotlin are promoted.
This is one of the reasons the project feels sustainable. Rust remains the place where the architecture is allowed to be serious, while the FFI boundary stays intentionally small.
03. The Facade Crate Is the Core Trick
The design would be much less interesting if every exported crate produced its own native library. That would fragment distribution, duplicate runtime pieces, and make both Android and iOS packaging noisier than they need to be.
Instead, the workspace introduces a facade crate that builds as a regular Rust library, a static library, and a cdylib, then re-exports the public UniFFI crates through one place.
[lib]
crate-type = ["lib", "staticlib", "cdylib"]
[dependencies]
feature_a = { path = "../crates/public/feature_a" }
feature_b = { path = "../crates/public/feature_b" }
feature_c = { path = "../crates/public/feature_c" }
pub use feature_a;
pub use feature_b;
pub use feature_c;
That looks almost too simple, but it changes the whole packaging story. One facade means one linked native core. The generated bindings can still be per-domain, but the compiled runtime is unified. The practical win is avoiding duplicate UniFFI runtime symbols and avoiding redefinition of shared FFI types in incompatible native contexts.
In other words, the facade crate is not an abstraction for elegance. It is an abstraction for link sanity.
04. Android: Build Once, Bind Many, Load One .so
The Android integration is where the implementation becomes concrete. The app does not treat Rust as a sidecar experiment. It wires the shared core directly into the Android build through a dedicated Gradle script.
apply(from = "$projectDir/gradle/shared-core.gradle.kts")
java.srcDir(layout.buildDirectory.dir("generated/source/shared"))
jniLibs.srcDir(layout.buildDirectory.dir("generated/jniLibs/shared"))
That script does three important things:
- Syncs facade dependencies so the exported crate list stays aligned with the workspace.
- Builds one shared
.sofor every Android ABI usingcargo ndk. - Generates Kotlin UniFFI bindings from a host build, then post-processes them so every generated crate points at the same library name.
// Post-process generated Kotlin so every crate resolves to one runtime.
content = content.replace(
"return \"feature_a\"",
"return \"shared_core\""
)
That little replacement step is one of my favorite details in the whole system. It captures the actual architecture in one line: many Kotlin namespaces, one native runtime. The generated APIs stay nicely separated, but all of them resolve to the same Rust artifact.
In the application layer, the library is initialized once during app startup, and failures are treated as app-critical rather than recoverable. That is the right stance when the native shell now depends on Rust for core behavior.
After that, the app can call real production functions directly from Kotlin: request building, validity checks, cost estimation, remote update checks, and other domain-level operations that would otherwise be duplicated in platform code.
05. iOS: One XCFramework, Multiple Swift Modules
On iOS, the output shape is different, but the philosophy stays the same. The build script compiles the facade crate for device, simulator, and Apple Silicon macOS, generates Swift bindings for each exported crate, then packages the whole thing as a single XCFramework.
cargo build \
--target aarch64-apple-ios \
--target aarch64-apple-ios-sim \
--target aarch64-apple-darwin \
-p shared_core
The build then generates a Swift package with a binary target for the unified native core and separate Swift targets layered on top. The result is the same as Android in spirit: modular surface area, unified runtime.
.binaryTarget(
name: "SharedCoreLib",
path: "dist/ios/SharedCore.xcframework"
)
.target(
name: "SharedCoreFFI",
dependencies: ["SharedCoreLib"],
path: "dist/ios/Sources/SharedCoreFFI",
publicHeadersPath: "include"
)
.target(name: "FeatureA", dependencies: ["SharedCoreFFI"])
.target(name: "FeatureB", dependencies: ["SharedCoreFFI"])
There is also a subtle but important header strategy. The iOS build creates an umbrella FFI module so Swift sees a single coherent C-facing boundary instead of a pile of overlapping per-crate headers. That reduces the chance of scaffolding collisions and makes the Swift layer feel like one package instead of an accidental pile of generated fragments.
06. The Most Interesting Part: Rust Owns Behavior, Not Just Math
A lot of cross-platform Rust examples stop at deterministic leaf logic: parsing, crypto, serialization, maybe some request encoding. This implementation goes further. It models sources, transforms, sinks, effects, and pipelines in Rust, then lets the native app call those results.
The internal architecture is expressed in data-flow terms: read data → transform data → write data. Underneath that are traits like HTTP clients, app context, and infrastructure boundaries, so the domain layer can perform meaningful work without coupling itself directly to a platform SDK.
Why that matters
It means the portable unit is not just an algorithm. It is a behavior slice. A state query, request-cost computation, or release-check flow can be designed once, tested once in Rust, and then called from both mobile stacks with much thinner native glue.
That is the real leverage. Once Rust becomes the place where these behaviors live, the native apps can become smaller and more honest. They still own UX and lifecycle, but they stop pretending to own business logic they are really just re-expressing.
07. What Makes This Harder Than It Looks
None of this is free. The repo contains the evidence of the complexity tax:
- Facade sync scripts to keep exports aligned with crate visibility.
- Version-pinned binding generation to avoid generator drift.
- Platform-specific packaging rules for Android ABIs and iOS XCFramework outputs.
- Symbol handling on Apple targets to avoid duplicate Rust runtime symbols during linking.
- Lifecycle initialization that must happen before any feature crate tries to use shared context.
This is why I do not think of a system like this as “just a Rust library.” It is closer to a product runtime with carefully managed native delivery paths.
If you skip that discipline, the dream of shared logic collapses into fragile build scripts, mysterious linker failures, and two platforms that technically share code but operationally still behave like separate systems.
08. Why This Pattern Is Worth It
What I like about this implementation is not the language novelty. It is the architectural consequence. A well-designed Rust workspace can become the stable center of gravity for mobile behavior, while Android and iOS stay genuinely native at the edges.
The payoff is not “write once, forget forever.” The payoff is tighter semantic consistency, clearer ownership boundaries, and less duplicated reasoning. When one platform needs a bug fix in request assembly, validation, or state handling, you do not negotiate two rewrites. You move the core once.
That is why this pattern is interesting to me. It proves that sharing Rust across Android and iOS can be more than a performance trick. It can be an architecture decision about where the application is actually allowed to think.