How We Built Multi-Language SDKs for Prism, a Payment Library, Using FFI
Prism is a payment library designed to handle the full payment lifecycle — authorize, capture, refund, and void — across multiple connectors like Stripe, Adyen, and others.
While the core is written in Rust, we needed SDKs that work seamlessly in Node.js, Python, and Java. The challenge was straightforward: how do we share business logic across languages without rewriting it multiple times?
The Design Choice: gRPC vs FFI
We initially evaluated two approaches:
- HTTP/gRPC-based communication
- Foreign Function Interface (FFI)
gRPC worked, but it came with trade-offs:
- Running a sidecar service
- Additional network hops
- Extra operational overhead
For something running on the same machine, this felt unnecessary.
FFI, on the other hand, allowed:
- In-process execution
- Zero network overhead
- No additional runtime to manage
That made it the clear choice.
A Simple Pattern Behind Every Handler
Before exposing anything over FFI, we broke down how Prism handlers actually work.
Every operation — whether authorize, capture, refund, or void — follows the same structure:
fn authorize(req) {
// 1. Transform input → connector-specific request
// 2. Make HTTP call → send to connector
// 3. Transform response → normalize output
}In essence:
transform → call → transform
This realization was key.
Instead of exposing entire handlers over FFI, we only needed to expose the transformation logic:
- Request transformation
- Response transformation
The HTTP call stays within the SDK, written in the target language.
This keeps:
- The FFI surface minimal
- Responsibilities cleanly separated
- Future extensions predictable
Using UniFFI
To expose Rust functions across languages, we used UniFFI.
With simple annotations, UniFFI generates bindings and compiles platform-specific binaries (.so, .dylib, .dll) that can be loaded from other languages.
Example:
#[derive(uniffi)]
fn authorize_req_transformer(...) { ... }#[derive(uniffi)]
fn authorize_res_transformer(...) { ... }These same functions are reused across:
- HTTP handlers
- gRPC handlers
- FFI-based SDKs
One implementation, multiple entry points.
How the SDK Uses FFI
Each SDK client corresponds to a service — for example, PaymentClient for payment operations.
Get Explorejeeva’s stories in your inbox
Join Medium for free to get updates from this writer.
The compiled Rust binary is loaded once, and FFI calls are made as part of each request:
export class PaymentClient {
async authorize(msg) {
const req = ffi.authorize_req_transformer(msg);
const result = await fetch(req);
const res = ffi.authorize_res_transformer(result);
return res;
}
}Loading the binary is straightforward:
const ffi = require('./libprism_ffi.so'); // .dylib on macOS, .dll on WindowsFrom the SDK’s perspective, these functions behave like local calls.
Crossing the Language Boundary
One challenge with FFI is data transfer — JSON doesn’t map cleanly across language boundaries.
We solved this using Protobuf as the interchange format.
Flow:
User SDK FFI Rust
│ │ │ │
│ JSON │ │ │
│────────────▶│ │ │
│ │ Protobuf │ │
│ │ Bytes │ │
│ │───────────────▶│ │
│ │ │ Bytes │
│ │ │─────────────▶│
│ │ │ Process │
│ │ │◀─────────────│
│ │◀───────────────│ │
│◀────────────│ │ │Why Protobuf?
- Efficient binary format
- Strong typing across languages
- Shared schema via
.protofiles
This ensures type safety in Node, Python, and Java — all from a single source of truth.
What the Developer Sees
All of this complexity is hidden from the end user.
After installing the SDK:
import { PaymentClient, types } from 'hyperswitch-prism';const paymentClient = new PaymentClient(connectorConfig);
const response = await paymentClient.authorize(authorizeRequest);if (response.status === types.PaymentStatus.CHARGED) {
console.log("Payment successful");
}
That’s it.
No awareness of:
- Rust internals
- FFI boundaries
- Binary loading
- Serialization formats
Just a clean, simple API.
Final Thoughts
By narrowing the FFI surface to just transformation logic, we achieved:
- Maximum code reuse
- Minimal overhead
- Clean separation of concerns
Rust handles business logic.
SDKs handle I/O.
Protobuf ensures consistency.
The result is a scalable, multi-language SDK architecture without duplication or unnecessary complexity.
The full implementation is available here:





