Crossplatform Business Logic in Rust
One of Rust’s promises, besides being fast and reliable, is its portability. Its lack of a large runtime makes it suitable for various environments, including embedded platforms with tight memory constraints.
In this blog post, we explore a practical use case: writing common business logic in Rust and sharing it across different programming languages. This approach is especially useful in scenarios such as:
- Mobile development, where you want to share logic between iOS and Android while keeping native UIs.
- SDK development, where you want to support multiple languages without rewriting core logic for each one.
Interfacing with Other Languages: C FFI
Historically, the most common way to interface between languages has been through the C Foreign Function Interface (FFI). Rust makes it relatively easy to expose functions, globals, constants, and certain types via C FFI. Here’s how:
Step 1
Annotate your function to use the C calling convention:
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
Step 2
In your Cargo.toml, set the crate type to build it as a C-compatible library:
[lib]
crate-type = ["cdylib"]
This produces a dynamic library (e.g., .so on Linux, .dll on Windows).
Step 3
In your C code, declare the function in a source with:
extern int add(int a, int b);
Or declare it in a header file, e.g. add.h (you may also need extra bits to prevent multiple header inclusions or name mangling in C++):
int add(int a, int b);
To avoid issues with multiple inclusions or C++ name mangling, you may need additional macros. While you can write headers manually, the cbindgen tool can generate them automatically.
Beyond C: Language-Specific Bindings
While C FFI works almost everywhere, it often requires boilerplate wrappers to make the interface idiomatic in higher-level languages. Fortunately, the Rust ecosystem offers tools to simplify this:
These tools make interoperability easier by generating idiomatic bindings with minimal annotations.
Targeting Multiple Languages: UniFFI and Diplomat
What if we wanted to target multiple languages at once? Even that is possible. In particular, we will look at two projects that handle this automation: UniFFI and Diplomat.
Let’s compare them using a simple example: exposing a Rust function that takes a string and returns a trimmed string. As you may be aware, typical C strings are represented as null-terminated character arrays which is not how “default” strings are represented in Rust or other higher level languages. A plain C FFI would, thus, lead to boilerplate of converting string inputs and outputs on both sides of language bindings. Anyway, let’s see how this ends up in UniFFI and Diplomat for comparisons.
UniFFI
UniFFI, developed by Mozilla, was designed to share Rust modules across Firefox platforms. It supports Kotlin, Swift, Python, and Ruby, with third-party bindings for C# and Go.
The centrepiece of UniFFI are external UDL files. These files have special Interface Definition Language (IDL) definitions in them that are used to drive the generation of bindings in different languages. Let’s see that in our example.
We create a new Rust library:
[package]
name = "unistrings"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"
[lib]
crate-type = ["cdylib"]
name = "unistrings"
[dependencies]
uniffi = { version = "0.29.4", features = [ "cli" ] }
[build-dependencies]
uniffi = { version = "0.29.4", features = [ "build" ] }
If we wanted to support iOS in Xcode, we would need to also build it to a staticlib. The uniffi-bindgen.rs file is a simple one to invoke the UniFFI binding generation tool:
fn main() {
uniffi::uniffi_bindgen_main()
}
The tool could also be installed externally, but for the ease of having the same dependencies, we have it as a binary in our project. In our src/lib.rs, we would have the following code:
uniffi::setup_scaffolding!();
#[uniffi::export]
pub fn trimstr(inputstr: &str) -> String {
inputstr.trim().to_string()
}
More complex projects may need build.rs and extra parameters to specify the scaffolding code. In our src/unistrings.udl, we would then have the corresponding interface definition:
namespace unistrings {
string trimstr([ByRef] string inputstr);
};
We could then run the binding generation tool to generate bindings in our target language, e.g. for Kotlin:
cargo run --bin uniffi-bindgen generate src/unistrings.udl --language kotlin --out-dir out
We would then get a Kotlin file with over 1000 lines (uniffi/unistrings/unistrings.kt) that contain the boilerplate for FFI. In it, we could then see the function that invokes our Rust code via FFI and converts its result back to Kotlin:
// ...
fun `trimstr`(`inputstr`: kotlin.String): kotlin.String {
return FfiConverterString.lift(
uniffiRustCall() { _status ->
UniffiLib.INSTANCE.uniffi_unistrings_fn_func_trimstr(
FfiConverterString.lower(`inputstr`),_status)
}
)
}
We could do the equivalent step for Swift:
cargo run --bin uniffi-bindgen generate src/unistrings.udl --language swift --out-dir out
In that case, we get several files with the FFI boilerplate, and one of them (unistrings.swift) contains the function that invokes our Rust code via FFI and converts its result back to Swift:
// ...
public func trimstr(inputstr: String) -> String {
return try! FfiConverterString.lift(try! rustCall() {
uniffi_unistrings_fn_func_trimstr(
FfiConverterString.lower(inputstr),$0
)
})
}
We only scratched the surface of UniFFI which can do a lot more. For example, it supports exposing async Rust functions over FFI which get mapped to their equivalents in target languages (async/await in Python/Swift, suspend fun in Kotlin etc.).
Diplomat
Diplomat takes a different approach. It fully avoids external IDL files and uses Rust proc macros instead. It supports C, C++, JavaScript/TypeScript, Dart, Kotlin, and Python. (Plus, it has other language backeds in progress.)
We first need to install a tool for generating the language bindings: cargo install diplomat-tool
In our library, we need to specify the Diplomat dependencies:
[package]
name = "diplomatstrings"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
name = "diplomatstrings"
[dependencies]
diplomat = "0.12.0"
diplomat-runtime = "0.12.0"
Then, in our src/lib.rs, we add the following code:
#[diplomat::bridge]
mod diplomatstrings {
use diplomat_runtime::DiplomatWrite;
use std::fmt::Write;
// just exists so we can get methods
#[diplomat::opaque]
pub struct StringThing;
impl StringThing {
pub fn trimstr(inputstr: &str, writeable: &mut DiplomatWrite) {
let _ = write!(writeable, "{}", inputstr.trim());
}
}
}
We notice two main differences from UniFFI:
-
Diplomat does not bind to standalone functions, but only to objects with methods. So for this example, we need an extra struct to hold the method.
-
Diplomat uses a dedicated type
DiplomatWrite(formerlyDiplomatWriteable) to write return strings and avoid unnecessary allocations.
After this, we specify the language binding config.toml, e.g.:
lib-name = "diplomatstrings"
[kotlin]
domain = "dev.diplomatstrings"
Finally, we can use the installed tool to generate bindings, for example for Kotlin:
diplomat-tool -e src/lib.rs -c config.toml kotlin out/
This produces Kotlin files build files, Lib.kt and StringThing.kt, containing the FFI scaffolding and exposed methods. Our Rust function is called in StringThing.kt:
package dev.diplomatstrings.diplomatstrings;
import com.sun.jna.Callback
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
internal interface StringThingLib: Library {
fun StringThing_destroy(handle: Pointer)
fun StringThing_trimstr(inputstr: Slice, write: Pointer): Unit
}
class StringThing internal constructor (
internal val handle: Pointer,
// These ensure that anything that is borrowed is kept alive and not cleaned
// up by the garbage collector.
internal val selfEdges: List<Any>,
) {
internal class StringThingCleaner(val handle: Pointer, val lib: StringThingLib) : Runnable {
override fun run() {
lib.StringThing_destroy(handle)
}
}
companion object {
internal val libClass: Class<StringThingLib> = StringThingLib::class.java
internal val lib: StringThingLib = Native.load("diplomatstrings", libClass)
@JvmStatic
fun trimstr(inputstr: String): String {
val (inputstrMem, inputstrSlice) = PrimitiveArrayTools.readUtf8(inputstr)
val write = DW.lib.diplomat_buffer_write_create(0)
val returnVal = lib.StringThing_trimstr(inputstrSlice, write);
val returnString = DW.writeToString(write)
return returnString
}
}
}
Again, we only scratched the surface of what Diplomat could do. In some backends, for example, it has an experimental support for exposing and working with function callbacks.
Conclusion
Rust’s portability makes it an excellent choice for writing shared business logic. Tools such as UniFFI and Diplomat simplify the process of exposing Rust code to multiple languages, reducing boilerplate and improving developer productivity.
They’re not the only options. Other projects such as Interoptopus also exist, but UniFFI and Diplomat stand out for their versatile language support and long-term maintainance.