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. No behavior, no methods, just data.

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.

RustSource
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()
  }
}
public class Counter {
  public init()
  public func increment()
  public func get() -> Int32
}

let counter = Counter()
counter.increment()
counter.increment()
print(counter.get())  // 2

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.

RustSource
#[export]
impl Counter {
  pub fn new() -> Self {
      Counter { value: 0 }
  }
}
public class Counter {
  public init()
}

let c = Counter()

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.

RustSource
#[export]
impl Database {
  pub fn new() -> Self {
      Database { path: ":memory:".into() }
  }
  
  pub fn open(path: &str) -> Self {
      Database { path: path.into() }
  }
  
  pub fn with_options(
      path: &str,
      read_only: bool
  ) -> Self {
      Database {
          path: path.into(),
          read_only,
      }
  }
}
public class Database {
  public init()
  public convenience init(path: String)
  public convenience init(path: String, readOnly: Bool)
}

let db1 = Database()
let db2 = Database(path: "data.db")
let db3 = Database(path: "data.db", readOnly: true)

Factory methods (no parameters)

Methods with no parameters and a name other than new become factory methods.

RustSource
#[export]
impl Config {
  pub fn new() -> Self {
      Config::default_config()
  }
  
  pub fn production() -> Self {
      Config { debug: false, timeout: 30 }
  }
  
  pub fn development() -> Self {
      Config { debug: true, timeout: 120 }
  }
}
public class Config {
  public init()
  public static func production() -> Config
  public static func development() -> Config
}

let cfg = Config.production()

Fallible constructors

Constructors can return Result<Self, E>. The error type must be marked with #[error]. The constructor becomes throwing in the target language.

RustSource
#[error]
pub enum DbError {
  NotFound,
  PermissionDenied,
}

#[export]
impl Database {
  pub fn open(path: &str) -> Result<Self, DbError> {
      if !path_exists(path) {
          return Err(DbError::NotFound);
      }
      Ok(Database { path: path.into() })
  }
}
public class Database {
  public convenience init(path: String) throws
}

do {
  let db = try Database(path: "data.db")
} catch DbError.notFound {
  print("file not found")
}

Methods

Methods take &self. For mutation, use interior mutability (Mutex, atomics, etc.).

RustSource
use std::sync::Mutex;

pub struct Account {
  balance: Mutex<i64>,
}

#[export]
impl Account {
  pub fn balance(&self) -> i64 {
      *self.balance.lock().unwrap()
  }
  
  pub fn deposit(&self, amount: i64) {
      *self.balance.lock().unwrap() += amount;
  }
  
  pub fn withdraw(
      &self,
      amount: i64
  ) -> Result<(), AccountError> {
      let mut balance = self.balance.lock().unwrap();
      if amount > *balance {
          return Err(AccountError::Insufficient);
      }
      *balance -= amount;
      Ok(())
  }
}
public class Account {
  public func balance() -> Int64
  public func deposit(amount: Int64)
  public func withdraw(amount: Int64) throws
}

let acc = Account()
acc.deposit(amount: 100)
print(acc.balance())

do {
  try acc.withdraw(amount: 50)
} catch {
  print(error)
}

&self vs &mut self

&self takes a shared reference. &mut self takes an exclusive reference.

&mut self methods are blocked by default. Two threads calling &mut self on the same instance is undefined behavior. BoltFFI prevents 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

The recommended approach: use &self for all methods and handle mutability with Mutex, RwLock, or atomics. This is thread-safe by default and works everywhere.

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, not &mut self
        *self.value.lock().unwrap() += 1;
    }
    
    pub fn get(&self) -> i32 {
        *self.value.lock().unwrap()
    }
}

If you control thread access from the target language and want to avoid synchronization overhead, see Single-threaded mode.

Static methods

Methods without self become static methods on the class.

RustSource
#[export]
impl Config {
  pub fn default_timeout() -> u32 {
      30
  }
  
  pub fn max_connections() -> u32 {
      100
  }
}
public class Config {
  public static func defaultTimeout() -> UInt32
  public static func maxConnections() -> UInt32
}

let t = Config.defaultTimeout()

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.

RustSource
#[export]
impl HttpClient {
  pub fn new() -> Self {
      HttpClient { client: reqwest::Client::new() }
  }
  
  pub async fn get(&self, url: &str) -> Result<String, HttpError> {
      let resp = self.client.get(url).send().await?;
      let body = resp.text().await?;
      Ok(body)
  }
  
  pub async fn post(
      &self,
      url: &str,
      body: &str
  ) -> Result<String, HttpError> {
      let resp = self.client
          .post(url)
          .body(body.to_string())
          .send()
          .await?;
      Ok(resp.text().await?)
  }
}
public class HttpClient {
  public init()
  public func get(url: String) async throws -> String
  public func post(url: String, body: String) async throws -> String
}

let client = HttpClient()
let data = try await client.get(url: "https://api.example.com")

Methods that take or return classes

Methods can accept or return other class instances.

RustSource
#[export]
impl Session {
  pub fn new(user: &User) -> Self {
      Session { user_id: user.id() }
  }
  
  pub fn user(&self) -> User {
      User::find(self.user_id)
  }
}

#[export]
impl User {
  pub fn id(&self) -> u64 {
      self.id
  }
}
public class Session {
  public init(user: User)
  public func user() -> User
}

let user = User(name: "alice")
let session = Session(user: user)
let u = session.user()

Skipping methods

Use #[skip] to exclude a method from FFI export. The method stays in Rust but isn’t exposed to the target language.

#[export]
impl MyClass {
    pub fn exported(&self) -> i32 {
        self.helper() * 2
    }
    
    #[skip]
    pub fn helper(&self) -> i32 {
        42
    }
}

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:

  1. Make it thread-safe using synchronization primitives like Mutex, RwLock, or atomics. For shared ownership across threads, combine with Arc (e.g., Arc<Mutex<T>>).

  2. Add #[export(single_threaded)]. This disables the Send + Sync check, but you’re responsible for ensuring the class is only used from a single thread.

RustSource
use std::sync::atomic::{AtomicI32, Ordering};

pub struct SafeCounter {
  value: AtomicI32,
}

#[export]
impl SafeCounter {
  pub fn new() -> Self {
      SafeCounter {
          value: AtomicI32::new(0)
      }
  }
  
  pub fn increment(&self) {
      self.value.fetch_add(1, Ordering::SeqCst);
  }
  
  pub fn get(&self) -> i32 {
      self.value.load(Ordering::SeqCst)
  }
}
public class SafeCounter {
  public init()
  public func increment()
  public func get() -> Int32
}

let counter = SafeCounter()
DispatchQueue.concurrentPerform(iterations: 100) { _ in
  counter.increment()
}

Single-threaded mode

By default, BoltFFI enforces two safety rules:

  1. Classes must be Send + Sync
  2. 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
    }
}

This compiles. No Mutex, no atomic operations, no overhead. But you’re making a promise: you will not call methods on this object from multiple threads simultaneously. Breaking that promise is 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:

ModeTime 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.

When to use classes vs data structs

Use #[data] structs when:

  • The data is small and copied frequently
  • There’s no behavior, just fields
  • You’re passing data in and out of functions

Use #[export] classes when:

  • The object has internal state that changes over time
  • The object manages resources (files, connections, memory)
  • You want to hide implementation details behind methods
  • The object is expensive to copy

A Point { x, y } is data. A DatabaseConnection is a class.