Classes
BoltFFI has two ways to expose a struct: as data or as a class.
Data (#[data]) is for plain values. A Point { x, y } or a User { id, name, email }. Data is copied when it crosses the boundary. The target language gets a struct or record with public fields. You can also attach methods and constructors with #[data(impl)]. See Records.
Classes (#[export] impl) are for objects with behavior. A DatabaseConnection or a HttpClient. The object lives in Rust, the target language holds a reference to it. Methods operate on that reference. The object is not copied - there’s one instance, and both sides point to it.
Use data when you’re passing values around. Use classes when you’re managing state or resources.
Defining a class
Put #[export] on the impl block, not the struct. The struct stays private; only the methods you define in the impl block are exposed.
Constructors
Methods that return Self become constructors. How they appear in the target language depends on the method name and parameters.
The new() method
A method named new() becomes the primary constructor.
Named constructors with parameters
Methods with parameters that return Self become additional constructors. In Swift, they become convenience init. In Kotlin, they go in the companion object. Java and C# expose them as static factory methods on the generated class.
Factory methods (no parameters)
Methods with no parameters and a name other than new become factory methods.
Fallible constructors
Constructors can return Result<Self, E>. The error type must be marked with #[error]. The constructor becomes throwing in the target language.
Methods
Once you have a class, you define methods on it. Any pub fn in the #[export] impl block that takes &self becomes an instance method in the target language. The target language calls the method, BoltFFI routes it to Rust, and Rust executes it on the actual object.
Because the target language can call your methods from any thread at any time, BoltFFI requires &self (shared reference), not &mut self (exclusive reference). If your method needs to change state, use interior mutability (Mutex, RwLock, atomics, or whatever synchronization fits your use case). This way multiple threads can safely call methods on the same object without data races. The &self vs &mut self section below covers this in detail.
&self vs &mut self
When the target language calls a method, there’s no guarantee which thread it comes from. Swift might dispatch from the main thread, a GCD queue, or a Task. Kotlin might call from a coroutine on any dispatcher. Java might call from any thread in a thread pool. C# might call from the thread pool or any Task continuation. You don’t control this.
That’s the problem with &mut self. It requires exclusive access to the object. If two threads call a &mut self method at the same time, you get undefined behavior. BoltFFI catches this at compile time:
#[export]
impl Counter {
pub fn increment(&mut self) { // Compile error
self.value += 1;
}
}
error: BoltFFI: `&mut self` methods are not thread-safe in FFI contexts
Two threads calling `&mut self` on the same instance = undefined behavior.
Options:
1. Use `&self` with interior mutability (Mutex, RefCell, etc.) [recommended]
2. Add #[export(single_threaded)] ONLY if you enforce thread safety in the target
language and want to avoid synchronization overhead you don't need
Use &self instead, and move the synchronization inside your struct. Mutex, RwLock, atomics, channels, or any other mechanism that makes concurrent access safe:
use std::sync::Mutex;
pub struct Counter {
value: Mutex<i32>,
}
#[export]
impl Counter {
pub fn new() -> Self {
Counter { value: Mutex::new(0) }
}
pub fn increment(&self) {
*self.value.lock().unwrap() += 1;
}
pub fn get(&self) -> i32 {
*self.value.lock().unwrap()
}
}
If you know the object will only ever be accessed from a single thread and want to skip the synchronization overhead, see Single-threaded mode.
Static methods
Methods without self become static methods on the class.
Async methods
Mark a method async and it becomes an async method in the target language. BoltFFI has no built-in executor. You choose your Rust async runtime (Tokio, async-std, etc.), and the target language’s async system coordinates with it automatically. See Async for more.
Methods that take or return classes
Methods can accept or return other class instances.
Skipping methods
Use #[skip] to exclude a method from FFI export. The method stays in Rust but isn’t exposed to the target language. The skipped method is still callable from Rust, just not from the target language.
Thread safety
BoltFFI requires exported classes to be Send + Sync by default. This is a compile-time check. If your struct isn’t thread-safe, compilation fails.
If your struct contains types that aren’t thread-safe (like RefCell, Rc, or raw pointers), you have two options:
-
Make it thread-safe using synchronization primitives like
Mutex,RwLock, or atomics. For shared ownership across threads, combine withArc(e.g.,Arc<Mutex<T>>). -
Add
#[export(single_threaded)]. This disables theSend + Synccheck, but you’re responsible for ensuring the class is only used from a single thread.
Single-threaded mode
By default, BoltFFI enforces two safety rules:
- Classes must be
Send + Sync - Methods cannot take
&mut self
Both rules exist because the target language can call your methods from any thread. Without synchronization, concurrent &mut self calls cause undefined behavior.
But synchronization has a cost. If you control thread access in the target language - for example, you only use the object from the main thread, or you wrap it in your own synchronization - you’re paying for locks you don’t need.
#[export(single_threaded)] disables both checks:
pub struct FastCounter {
value: i32,
}
#[export(single_threaded)]
impl FastCounter {
pub fn new() -> Self {
FastCounter { value: 0 }
}
pub fn increment(&mut self) {
self.value += 1;
}
pub fn get(&self) -> i32 {
self.value
}
}
No synchronization overhead. The tradeoff is that thread safety is now your responsibility. If two threads call methods on this object at the same time, that’s undefined behavior.
When to use single_threaded
Use single_threaded when:
- The object is only accessed from the main thread (UI components, view models)
- You wrap the object in your own synchronization in the target language
- You’re building a single-threaded application (WASM, embedded)
- Profiling shows synchronization is a bottleneck and you can guarantee single-threaded access
Don’t use it just to avoid writing thread-safe code. The default (&self + Mutex) is safer and the overhead is often negligible.
Performance comparison
In benchmarks, single_threaded mode is roughly 4x faster for method calls that would otherwise need mutex locks:
| Mode | Time per 1000 increments |
|---|---|
&self + Mutex | ~5 μs |
&mut self + single_threaded | ~1 μs |
The difference matters in tight loops. For most applications, 5 microseconds per thousand calls is not a bottleneck.
Memory management
The Rust struct lives in Rust’s heap. The target language holds a reference to it. When the target language’s object is deallocated (garbage collected, reference count hits zero, etc.), BoltFFI drops the Rust struct.
You don’t need to manually free anything. But be aware: the Rust object stays alive as long as the target language holds a reference. If you store a class instance in a long-lived collection, the Rust memory stays allocated.