Rust shared core, part 1: what to share and where to draw the line
Building for three platforms (iOS, Android, and the web) produces three copies of the logic. It is natural for the UI, the storage method, and the lifecycle to differ by platform. The trouble starts when puzzle rules, state changes, offline decisions, price calculation, cryptography, and sync conflict handling are also implemented three times. Then defects have to be managed per platform as well.
I consider sharing logic through Rust a practical choice as of 2026. The focus is not on merging the whole app. It is on extracting an engine that owns the common rules. As the first part of a series, this post covers what to share and where to draw the boundary.
What is written here is a design proposal. Sudoku is an example for explanation, and this post does not include measured results or benchmarks from a specific app. Whether this structure really builds is something I did check with a small example on October 4, 2026, and the result and the tool versions are in the section “The structure, built for real.” The Rust linkage, UniFFI, and WebAssembly documentation explains what each tool does. How to split the layers and the criterion for sharing are my own design choices.
Why it gets complicated when Rust owns everything
Each platform has constraints to follow.
- iOS: the UIKit and SwiftUI lifecycle, MainActor, App Extensions, and App Store review
- Android: Activity, ViewModel, Compose state, Gradle packaging, and Play policy
- Web: routing, hydration, browser storage, the bundler, and the deployment target
If the Rust side takes on UI state, analytics events, storage I/O, networking, and permissions, it takes on these constraints along with them. So what to share is decided by how the maintenance responsibility is divided.
What goes into the engine and what stays in the app
With Sudoku as the example, the candidates for the engine are these.
- Puzzle generation and the solver
- Judging user input
- Changes to notes and values
- Computing the seed of the daily puzzle
- Rating the difficulty
- Computing score and streak
- A reducer that can replay actions
What stays in the app is rendering, accessibility, ads, payments, push notifications, the analytics SDK, the choice of storage, network retries, and the setup for review and release.
The criterion is not whether the code looks similar. It is whether the result has to be the same. The valid and invalid judgment for the same board and the same input has to match across platforms, and the daily puzzle that comes from the same seed has to match.
The presentation can differ. On iOS a UIKit collection view may feel natural, and on Android Compose state may fit well. Keyboard shortcuts and pointer interaction on the web have to be designed separately. Sharing too much can cost the interaction that fits each platform.
Candidates for the core are filtered by four conditions.
- The result is determined by the input alone.
- It is not tied directly to the current time, the locale, storage, or the network.
- It knows nothing about a UI framework or a platform SDK.
- It can be tested with Rust alone.
A candidate that does not meet the conditions is reconsidered. How to pass a time value for time-related candidates such as the daily seed or the streak is not covered in this post.
A structure in three layers
I propose three layers: domain, binding, and app.
core/
sudoku-rs/
crates/
sudoku-core/ # 순수 Rust 도메인 로직
sudoku-uniffi/ # iOS/Android 바인딩 표면
sudoku-wasm/ # WebAssembly 바인딩 표면
apps/
ios/ # Swift/UIKit 또는 SwiftUI 앱
android/ # Kotlin/Compose 앱
web/ # TypeScript/React 앱
sudoku-core is centered on Rust’s own internal model. It keeps out the representation constraints of Swift, Kotlin, TypeScript, and FFI as far as possible, and the tests are concentrated here.
sudoku-uniffi is the conversion boundary for mobile. It manages the DTOs shaped for Swift and Kotlin, error mapping, serialization, and the names of public functions.
sudoku-wasm is the calling boundary on the web side, using wasm-bindgen or wasm-pack. Its goal is to provide a TypeScript API and a package that the app imports.
With this split, the core is less tied to the binding tools. Even when the way UniFFI generates code or the web bundler setup changes, I expect the domain rules to stay relatively stable.
The app does not call generated code directly
If a ViewController, a ViewModel, a Composable, or a React component is tied directly to generated code, a change in a function name or a DTO affects the whole UI. So the app has a layer in between.
iOS ViewModel
-> SudokuEngineClient protocol
-> UniFFI generated binding
-> Rust core
Android ViewModel
-> SudokuEngineClient interface
-> UniFFI generated binding
-> Rust core
Web state layer
-> SudokuEngineClient
-> wasm package
-> Rust core
SudokuEngineClient is a protocol on iOS and an interface on Android, and on the web it is a client that sits between the state layer and the wasm package. The values exposed to the UI are types of the language that app uses. In tests, the adapter can be swapped for a fake. Which layers are allowed to use the generated binding needs to be written down as a team rule.
The structure, built for real
I built the three layers above as a Cargo workspace. It is a minimal example made to check the structure, not the code of a real app. sudoku-core holds four rules (start, apply an action, validate, generate the daily puzzle), and sudoku-uniffi and sudoku-wasm only wrap those functions. The environment is macOS 27.0.1, Xcode 27.0, and cargo 1.96.0.
Listing the direct dependencies of each crate with cargo tree --depth 1 gives this.
sudoku-core v0.1.0
├── serde v1.0.229
└── serde_json v1.0.151
sudoku-uniffi v0.1.0
├── sudoku-core v0.1.0
└── uniffi v0.29.5
sudoku-wasm v0.1.0
├── sudoku-core v0.1.0
└── wasm-bindgen v0.2.129
The core depends only on serde and serde_json, and the two binding crates call the core. This output confirms that no dependency runs from the core toward the bindings. The core of the example does take on JSON serialization (serde_json) as well, though. What was assigned to the binding layer above is kept in the core in the example, to keep it short.
The tests of the core run with cargo test -p sudoku-core. They finish with no simulator and no browser. Only the part of the output where the five tests ran is shown, and the lines before and after it that report 0 tests are left out.
running 5 tests
test same_seed_gives_same_snapshot ... ok
test given_cell_is_rejected ... ok
test snapshot_validation_reports_errors_as_values ... ok
test daily_puzzle_depends_only_on_the_date ... ok
test fixtures_match_expected ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The five tests check that the same seed gives the same result, that an action on a given cell is rejected, and that the daily puzzle depends only on the date string. How this workspace was turned into artifacts for iOS, Android, and the web is spread over parts 3 to 7 of this series.
The order of moving code
- List the domain logic that is duplicated across platforms.
- From that list, pick the candidates whose results have to match.
- Keep the core from depending on UI, storage, network, analytics, or payments.
- Separate the internal model from the binding model.
- Put an adapter on each platform and set the rule for access to generated code.
- Move small, deterministic logic first.
Good first candidates are the daily seed calculation, puzzle validation, and the reducer. Before setting up a Cargo workspace, decide whether sharing is really needed. Moving every candidate at once makes the scope large.
As a way to check, I suggest tests that run with Rust alone and a fake for the app adapter. The Rust tests were run in the example above. A fake for the app adapter was not built, and performance numbers are not part of this post.
Even with this structure, the responsibility for platform development, review, and release stays where it was. If the team does not keep the rules, dependencies can come back between the separated layers. Measurements that back the expectation of easier and more stable adoption are not included in this post. Once the boundary is set, the next step is to look at how to use UniFFI, XCFramework, the Android .so, and WebAssembly, and the next part covers the design of the FFI API that is called from outside.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.