Rust shared core, part 2: keep the API across the boundary coarse
When a calculation engine shared by several platforms is written in Rust, the data types that suit the inside of the engine differ from the types that are easy to handle in an app. Following the convenience of each platform can also pull the design of the core toward outside concerns.
Part 2 asks two questions: how large the externally called functions should be, and which data types the values crossing the boundary should use. Sudoku is the running example.
The inside model and the outside model
Inside Rust, the rules are expressed with types such as Grid, Cell, CandidateSet, PuzzleSeed, Difficulty, Move, GameState, and ValidationError. Enums, pattern matching, ownership, and traits can be used as they are.
Crossing the boundary changes the picture. Generics, lifetimes, nested enums, complex collections, and borrowed references are represented differently when passed to another language. Even when the generator supports something, whether the API is easy to understand and convenient to test and debug has to be judged separately. How bindings are made is in the UniFFI and wasm-bindgen documentation. Sizing the calls and the contract as shown below is my own design choice, not a requirement of that documentation. An example that implements this contract and was actually run, with its environment, is in the section “The contract, checked with an example.”
So the roles are split in two.
sudoku-core
- Rust 내부 도메인 모델
- Rust 테스트
- 순수 계산 로직
sudoku-uniffi / sudoku-wasm
- 외부 공개 DTO
- serialization
- error 변환
- coarse-grained 함수
The Korean lines under sudoku-core read “internal Rust domain model,” “Rust tests,” and “pure calculation logic.” Under the binding crates, they read “externally exposed DTOs” and “error conversion.”
The core is designed so that it does not depend on a binding tool, and the domain logic is kept as independent as possible. I recommend putting DTOs, serialization, error conversion, and coarse functions in the binding crates.
A few large functions over many small ones
Exposing functions that each handle one cell gives this shape.
get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)
That increases the number of trips across the boundary. The state the app holds and the state the engine holds can drift apart, and calling per cell for rendering makes the flow hard to trace.
The alternative is to expose units of work.
start_game(request) -> GameSnapshot
apply_action(snapshot, action) -> TransitionResult
validate_snapshot(snapshot) -> ValidationReport
generate_daily_puzzle(request) -> PuzzleEnvelope
The engine computes how the state changes for an action, and the app converts the returned value into state for the screen. I expect fewer calls and a clearer scope for tests. An experiment comparing the two approaches is not part of this post.
This structure is the same as a reducer.
현재 상태 + 사용자 액션 + 설정
-> 다음 상태 + 효과 + 에러 또는 경고
The block reads: current state plus user action plus settings gives the next state plus effects plus an error or a warning.
This model fits the ViewModel on iOS and Android and the state layer on the web. Each platform can convert the result into UI state.
Data types that cross the boundary
The candidates for DTOs are simple ones: string, integer, boolean, a flat array, an enum with clear cases, an optional value, and a JSON string when needed.
JSON has a benefit and a cost. It is convenient for passing state or actions in large units. In return it has a serialization cost, and type safety at compile time can get weaker. Calls made per frame for rendering and calls made when the user acts are judged separately. For a unit that applies one user action, the gain from a simpler boundary can outweigh the serialization cost. Numbers that measure how large each cost is are not in this post.
A contract built on JSON has this shape.
apply_action(
snapshot_json: String,
action_json: String,
environment_json: String
) -> transition_json: String
On the app side, the shape of that JSON can be expressed with Codable, kotlinx.serialization, Zod, or TypeScript types, and the Rust side handles it with serde. I suggest that the team keep a JSON schema and fixtures together. In my view, fixtures can be a more accurate reference than documents.
Errors come back as values
When Rust’s Result<T, E> is connected to the outside, its representation has to be decided early. Putting error handling off makes it more expensive later. The options are a thrown error, a result object, a nullable, and an error in a callback. For a domain engine, I prefer returning an explicit error object.
TransitionResult
- ok: Boolean
- snapshot: String?
- effects: [Effect]
- error: EngineError?
The roles are divided like this. Expressing why something failed is the job of the core. Whether to ignore the action, show an alert, give haptic feedback, or send an analytics event is chosen by the app.
Messages are divided as well. Codes such as INVALID_MOVE, PUZZLE_ALREADY_COMPLETED, and SEED_OUT_OF_RANGE are stable machine-readable codes, and they are separated from the text people read. Korean and English translation, and localization, belong to the app.
The contract, checked with an example
On October 4, 2026, I implemented this contract in a small example and ran it. This small workspace exists to check the structure and is not the code of a real app. The environment is cargo 1.96.0, serde 1.0.229, and serde_json 1.0.151. The function at the boundary takes three strings and returns one. In the example this function sits inside the sudoku-core crate. Under the structure laid out earlier, serialization and error conversion belong to the binding side, so this is a place where the example departs from the design to stay small. A proper split would move the JSON-facing functions into the binding crates, or into a separate crate shared by the two bindings.
pub fn apply_action_json(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
// 이 예제의 규칙은 environment 를 쓰지 않는다. 형식만 확인한다.
if let Err(e) = serde_json::from_str::<Environment>(environment_json) {
return to_json(&fail("invalid_environment", &e.to_string()));
}
let result = match (serde_json::from_str(snapshot_json), serde_json::from_str(action_json)) {
(Ok(snapshot), Ok(action)) => apply_action(&snapshot, &action),
(Err(e), _) => fail("invalid_snapshot", &e.to_string()),
(_, Err(e)) => fail("invalid_action", &e.to_string()),
};
to_json(&result)
}
The Korean comment says that the rules of this example do not use the environment and only its format is checked. When the input cannot be parsed, the function does not throw. It returns a result of the same shape. fail builds a result with ok set to false and a code and message in error, and to_json serializes with serde_json. Below is the output of five calls through the Wasm package from Node. In order they are an action that succeeds, an action that conflicts, an action on a given cell, an action that cannot be parsed, and the validation of a broken snapshot. Long lines are cut at 150 characters and marked with ….
valid : {"ok":true,"snapshot":"{\"version\":1,\"seed\":0,\"givens\":\"530070000600195000098000060800060003400803001700020006060000280000419005000080079\",\"ce …
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
given : {"ok":false,"snapshot":null,"effects":[],"error":{"code":"given_cell","message":"a given cell cannot be changed"}}
bad json: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"invalid_action","message":"unknown variant `fly`, expected `set_value` or `clear` at line 1 …
validate: {"valid":false,"errors":[{"code":"invalid_snapshot","message":"missing field `version` at line 1 column 2"}]}
In this example, when ok is true, snapshot holds the JSON string of the next state and error is null. When ok is false, snapshot is null and error is present. The error codes are lowercase, such as conflict, given_cell, and out_of_range, and the message exists in English only. No translation was added.
Keeping the contract with versions and fixtures
After an app ships on the App Store and the Play Store, different releases stay on different devices. The web and mobile also update at different speeds. So I recommend putting a version in the request and the response, and keeping fixtures per version.
When there is a breaking change, the Rust core, the Swift adapter, the Kotlin adapter, and the web package are checked in the same PR. The diff of generated bindings can be large and hard to read. I recommend confirming the contract from the wrapper or the schema in review, not from that diff alone. Two items can go into the PR template: a rule that a fixture is added when the public API changes, and a question on whether the tests of the Swift, Kotlin, and TypeScript adapters changed. The request and response fixtures serve directly as contract tests.
I think a simple outside contract also helps the work of connecting UniFFI, wasm-bindgen, XCFramework, and the Android .so. Calling this API from iOS and Android through UniFFI is the subject of part 3.
That is the shape of the design, and some things remain undecided. The fixture tests were run in the example from both Rust and Node. Five Rust tests and two Node tests passed. Adapter tests in Swift and Kotlin were not written. The combinations of ok, snapshot, and error are what I observed in the example, and they are not written down as a contract document.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.