# Rust shared core, part 2: keep the API across the boundary coarse

> Types that suit a Rust engine differ from types that are easy to use from Swift, Kotlin, and TypeScript. Expose coarse action-level functions under a contract.

- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-02-ffi-api-design/
- Published: 2026.06.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Android, #WebAssembly, #FFI

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](https://mozilla.github.io/uniffi-rs/latest/) and [wasm-bindgen](https://wasm-bindgen.github.io/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.

```text
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.

```text
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.

```text
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.

```text
현재 상태 + 사용자 액션 + 설정
  -> 다음 상태 + 효과 + 에러 또는 경고
```

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.

```text
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.

```text
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.

```rust
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 `…`.

```text
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.
