Building Production Rust sdk

Building a Production Ready Rust SDK: A Practical Guide

How to design SDKs that developers actually want to use


Introduction

When I set out to build a Rust SDK for a payment API, I had one clear goal: make it easy for Rust developers to integrate payments into their applications. But “easy” is harder than it sounds.

This post walks through the key decisions I made, the problems I solved, and the patterns I discovered while building a production-ready SDK. Whether you’re building your first SDK or your tenth, I hope my journey helps yours.

Why Rust?

Q: Why build a Rust SDK when there’s already a TypeScript SDK?

Good question! Here’s my thinking:

  1. Performance: Rust compiles to native code. For high-throughput systems, this matters.
  2. Safety: Rust’s compiler catches bugs at compile-time. No null pointer errors in production.
  3. Growing ecosystem: More companies are adopting Rust for backend services.
  4. Developer experience: Rust developers expect high-quality, type-safe libraries.

But the real reason? Users asked for it.


Architecture Overview

Let’s start with the big picture. Here’s how I structured the SDK:

payment-sdk/
├── client.rs          # Main entry point
├── error.rs           # Error handling
├── types.rs           # Shared types
└── resources/         # API resources
    ├── receivers.rs
    ├── payouts.rs
    ├── bank_accounts.rs
    └── ...

Simple, right? That’s intentional.

Principle #1: Keep it flat

I could have created deeply nested modules, but I didn’t. Why?

// Bad: Too nested
use payment_sdk::api::v1::resources::payments::receivers::types::Receiver;

// Good: Flat and discoverable
use payment_sdk::resources::receivers::Receiver;

Developers using your SDK shouldn’t need a map. Keep the structure obvious.


The Client: Your SDK’s Front Door

Every SDK needs an entry point. For me, that’s the main client struct:

pub struct ApiClient {
    client: Client,
    api_key: String,
    instance_id: String,
    base_url: String,
}

Q: Why store configuration in the struct instead of using global variables?

Because global state is a nightmare:

  • Hard to test
  • Impossible to use multiple configurations
  • Breaks in concurrent code

Instead, I make the client clonable and thread-safe:

impl ApiClient {
    pub fn new(api_key: impl Into<String>, instance_id: impl Into<String>) -> Result<Self> {
        // Validate early, fail fast
        let api_key = api_key.into();
        if api_key.is_empty() {
            return Err(SdkError::MissingApiKey);
        }
        
        // ... more validation
        
        Ok(Self { /* ... */ })
    }
}

Key lesson: Validate configuration at construction time, not at first use. Users prefer early errors.


Error Handling: Make Failures Obvious

Here’s my error type:

#[derive(Error, Debug)]
pub enum SdkError {
    #[error("API error: {0}")]
    ApiError(String),
    
    #[error("HTTP request failed: {0}")]
    RequestFailed(#[from] reqwest::Error),
    
    #[error("JSON error: {0}")]
    SerializationError(#[from] serde_json::Error),
    
    #[error("Missing API key")]
    MissingApiKey,
    
    #[error("Missing instance ID")]
    MissingInstanceId,
}

Q: Why use an enum instead of a single error struct?

Because developers need to handle different errors differently:

match client.receivers().list().await {
    Ok(receivers) => println!("Got {} receivers", receivers.len()),
    Err(SdkError::ApiError(msg)) => {
        // Maybe retry?
        eprintln!("API error: {}", msg);
    }
    Err(SdkError::RequestFailed(_)) => {
        // Network issue, definitely retry
        eprintln!("Network error, retrying...");
    }
    Err(e) => eprintln!("Error: {}", e),
}

Q: Why use thiserror instead of implementing errors manually?

Compare these:

// Without thiserror: ~30 lines of boilerplate per error type
impl std::fmt::Display for SdkError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            // ... lots of match arms
        }
    }
}
impl std::error::Error for SdkError { /* ... */ }
impl From<reqwest::Error> for SdkError { /* ... */ }

// With thiserror: Done
#[derive(Error, Debug)]
pub enum SdkError {
    #[error("API error: {0}")]
    ApiError(String),
    #[error("HTTP request failed: {0}")]
    RequestFailed(#[from] reqwest::Error),
}

Less code, fewer bugs.


Type Safety: Let the Compiler Help

One of Rust’s superpowers is its type system. I use it heavily:

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Network {
    Base,
    Polygon,
    Ethereum,
    Stellar,
    // ...
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StablecoinToken {
    USDC,
    USDT,
    USDB,
}

Q: Why use enums instead of strings?

Let me show you:

// With strings: Compiles fine, fails at runtime
let network = "polygoon";  // Typo!
let result = create_payout(network).await; // Runtime error

// With enums: Won't compile
let network = Network::Polygoon;  // Compile error!
let result = create_payout(network).await;

The compiler becomes your spell-checker. Typos become compile errors, not production bugs.

Q: What about the #[serde(rename_all = "snake_case")] thing?

That’s the bridge between Rust’s naming (PascalCase enums) and the API’s naming (snake_case):

// Rust code
let network = Network::BaseSepolia;

// JSON sent to API
{ "network": "base_sepolia" }

Serde handles the conversion automatically.


The Resource Pattern: Organizing Your API

Each API resource gets its own module. Here’s the pattern:

// In resources/receivers.rs
pub struct ReceiversResource {
    client: ApiClient,
}

impl ReceiversResource {
    pub(crate) fn new(client: ApiClient) -> Self {
        Self { client }
    }
    
    pub async fn list(&self) -> Result<Vec<Receiver>> {
        let path = format!("/instances/{}/receivers", self.client.instance_id());
        self.client.get(&path).await
    }
    
    pub async fn create(&self, input: CreateReceiverInput) -> Result<Receiver> {
        // ...
    }
}

Q: Why create separate resource structs instead of putting everything in the main client?

Imagine if I didn’t:

impl ApiClient {
    pub async fn list_receivers(&self) -> Result<Vec<Receiver>> { }
    pub async fn create_receiver(&self, input: CreateReceiverInput) -> Result<Receiver> { }
    pub async fn list_payouts(&self) -> Result<Vec<Payout>> { }
    pub async fn create_payout(&self, input: CreatePayoutInput) -> Result<Payout> { }
    pub async fn list_bank_accounts(&self, receiver_id: &str) -> Result<Vec<BankAccount>> { }
    // ... 50+ more methods
}

That’s overwhelming. Instead:

// Clear, organized, discoverable
client.receivers().list().await?;
client.payouts().create(input).await?;
client.receivers().bank_accounts().list("re_123").await?;

Key lesson: Group related functionality. It’s easier to discover and understand.


Sub-Resources: Handling Nested APIs

Some resources have sub-resources. Like receivers have bank accounts:

/receivers/{id}/bank-accounts

Here’s how I handle it:

impl ReceiversResource {
    pub fn bank_accounts(&self) -> BankAccountsResource {
        BankAccountsResource::new(self.client.clone())
    }
}

Now users can do:

let accounts = client
    .receivers()
    .bank_accounts()
    .list("re_123")
    .await?;

Q: Why clone the client?

In Rust, I need to either:

  1. Clone the client (cheap, it’s just an Arc internally)
  2. Use lifetimes (complex, hard to use)
  3. Use references (limits flexibility)

I chose cloning because it’s simple and efficient.


Async/Await: Making Network Calls Smooth

All my methods are async:

pub async fn list(&self) -> Result<Vec<Receiver>> {
    self.client.get(&path).await
}

Q: Why async instead of blocking calls?

Modern Rust applications use async I/O. Blocking calls would:

  • Block the entire thread
  • Waste resources
  • Not play well with async frameworks like Tokio

With async, you can make thousands of API calls concurrently:

let futures: Vec<_> = receiver_ids
    .iter()
    .map(|id| client.receivers().get(id))
    .collect();

let receivers = futures::future::try_join_all(futures).await?;

Try that with blocking calls!


HTTP Layer: Choosing the Right Tools

I use reqwest for HTTP:

async fn request<T: DeserializeOwned>(
    &self,
    method: Method,
    path: &str,
    body: Option<impl Serialize>,
) -> Result<T> {
    let url = format!("{}{}", self.base_url, path);
    
    let mut request = self.client
        .request(method, &url)
        .header("Authorization", format!("Bearer {}", self.api_key))
        .header("Content-Type", "application/json");
    
    if let Some(body) = body {
        request = request.json(&body);
    }
    
    let response = request.send().await?;
    
    // Handle errors
    if !response.status().is_success() {
        let error: ErrorResponse = response.json().await?;
        return Err(SdkError::ApiError(error.message));
    }
    
    // Parse response
    let data = response.json().await?;
    Ok(data)
}

Q: Why reqwest instead of hyper or ureq?

  • hyper: Too low-level. I’d have to handle connection pooling, retries, etc.
  • ureq: Synchronous. Doesn’t play well with async code.
  • reqwest: Perfect balance. High-level async HTTP with good defaults.

Serialization: JSON Made Easy

I use serde for JSON:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receiver {
    pub id: String,
    pub email: String,
    pub kyc_status: String,
    #[serde(rename = "type")]
    pub account_type: AccountClass,
}

Q: What’s that #[serde(rename = "type")] doing?

In the API, the field is called "type". But type is a reserved keyword in Rust! So I:

  • Name the field account_type in Rust
  • Tell serde to use "type" in JSON

This happens automatically:

// Rust struct
Receiver {
    id: "re_123".to_string(),
    account_type: AccountClass::Individual,
}

// JSON
{
    "id": "re_123",
    "type": "individual"
}

Q: Why use serde instead of manual JSON parsing?

Have you ever written this?

// Manual JSON parsing (painful)
let json: serde_json::Value = response.json().await?;
let id = json["id"].as_str().ok_or(Error::MissingField)?;
let email = json["email"].as_str().ok_or(Error::MissingField)?;
let status = json["kyc_status"].as_str().ok_or(Error::MissingField)?;

let receiver = Receiver { id, email, kyc_status: status };

With serde:

// Automatic (beautiful)
let receiver: Receiver = response.json().await?;

Way better.


Documentation: Examples That Work

Every public function has documentation:

/// List all receivers
///
/// # Example
/// ```no_run
/// # use payment_sdk::ApiClient;
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = ApiClient::new("api-key", "instance-id")?;
/// let receivers = client.receivers().list().await?;
/// println!("Found {} receivers", receivers.len());
/// # Ok(())
/// # }
/// ```
pub async fn list(&self) -> Result<Vec<Receiver>> {
    // ...
}

Q: Why include examples in documentation?

Because developers learn by example:

  • They copy-paste examples
  • Examples show real usage
  • Examples catch doc bugs

Q: What’s no_run doing?

It tells cargo test to compile the example but not run it (since it needs real API keys).


Testing: Making Sure It Works

I have multiple test layers:

1. Unit Tests

#[test]
fn test_client_creation() {
    let client = ApiClient::new("test-key", "test-instance");
    assert!(client.is_ok());
}

#[test]
fn test_missing_api_key() {
    let client = ApiClient::new("", "instance-id");
    assert!(matches!(client, Err(SdkError::MissingApiKey)));
}

2. Integration Tests

#[tokio::test]
async fn test_error_handling() {
    let client = ApiClient::new("invalid-key", "invalid-instance").unwrap();
    let result = client.receivers().list().await;
    assert!(result.is_err());
}

3. Example Code

My examples folder contains real, runnable code that users can try.

Q: Why not mock the HTTP layer?

I could, but:

  • Mocks can drift from reality
  • Mocks are maintenance burden
  • Integration tests catch more bugs

For a payment SDK, real API testing (in staging) is worth it.


Versioning: Planning for Change

I use semantic versioning:

  • 0.1.0: Initial release
  • 0.2.0: New features, backward compatible
  • 1.0.0: Stable API, production ready
  • 1.1.0: New features
  • 2.0.0: Breaking changes

Q: When should you bump the major version?

When you break backward compatibility:

  • Removing public functions
  • Changing function signatures
  • Renaming types

Q: How do you avoid breaking changes?

Add, don’t replace:

// Bad: Breaking change
pub async fn list(&self) -> Result<Vec<Receiver>>  // Old
pub async fn list(&self, page: u32) -> Result<Vec<Receiver>>  // New - BREAKS CODE!

// Good: Add new method
pub async fn list(&self) -> Result<Vec<Receiver>>  // Keep this
pub async fn list_paginated(&self, page: u32) -> Result<Vec<Receiver>>  // Add this

Common Patterns I Use

Pattern 1: Builder Inputs

For complex inputs, I use structs:

pub struct CreateReceiverInput {
    pub email: String,
    pub first_name: String,
    pub last_name: String,
    pub tax_id: String,
    // ... many more fields
}

// Usage
let input = CreateReceiverInput {
    email: "user@example.com".to_string(),
    first_name: "John".to_string(),
    // ...
};

client.receivers().create(input).await?;

Q: Why not use separate parameters?

Compare:

// Bad: Hard to read, easy to mix up
create_receiver(
    "user@example.com",
    "John",
    "Doe",
    "123-45-6789",
    "123 Main St",
    None,
    "New York",
    // ... 15 more parameters
)

// Good: Clear and self-documenting
CreateReceiverInput {
    email: "user@example.com".to_string(),
    first_name: "John".to_string(),
    last_name: "Doe".to_string(),
    // ...
}

Pattern 2: Optional Fields

Use Option<T> for optional fields:

pub struct CreateReceiverInput {
    pub email: String,              // Required
    pub phone_number: Option<String>,  // Optional
}

Pattern 3: Type Aliases for Clarity

pub type Result<T> = std::result::Result<T, SdkError>;

// Now I can write:
pub async fn list(&self) -> Result<Vec<Receiver>>

// Instead of:
pub async fn list(&self) -> std::result::Result<Vec<Receiver>, SdkError>

Performance Considerations

Connection Pooling

reqwest handles this automatically:

let client = Client::builder()
    .pool_max_idle_per_host(10)  // Reuse connections
    .build()?;

Avoiding Clones

I clone the client freely because it’s cheap:

#[derive(Clone)]
pub struct ApiClient {
    client: Client,  // This is Arc<...> internally - cheap to clone!
    api_key: String,
    instance_id: String,
}

Zero-Cost Abstractions

Rust’s abstractions compile away:

// This looks high-level...
client.receivers().bank_accounts().list("id").await?;

// ...but compiles to efficient machine code with no overhead!

Lessons Learned

1. Start Simple, Add Complexity Later

I started with just receivers and payouts. Then added:

  • Bank accounts
  • Quotes
  • Virtual accounts
  • Webhooks

Lesson: Build the foundation first, then expand.

2. User Feedback is Gold

My users asked for:

  • Better error messages
  • More examples
  • Webhook support

I listened and shipped it.

3. Documentation Sells

A well-documented SDK gets adopted. A poorly documented one doesn’t.

Spend time on:

  • Clear README
  • Working examples
  • API reference docs
  • Usage guides

4. Type Safety Catches Bugs

Every time I used an enum instead of a string, I caught bugs at compile time instead of runtime.

Lesson: Use the type system. It’s your friend.

5. Make the Simple Case Simple

// Simple case: Just works
let client = ApiClient::new("api-key", "instance-id")?;
let receivers = client.receivers().list().await?;

// Complex case: Still possible
let client = ApiClient::new("api-key", "instance-id")?;
let receivers = client.receivers()
    .list_paginated(page, limit)
    .with_filters(filters)
    .await?;

Don’t sacrifice simplicity for power.


What I’d Do Differently

1. More Integration Tests Earlier

I added them later. Should have started with them.

2. Better Response Pagination

I could make pagination more ergonomic:

// Current
let page1 = client.payouts().list(Some(params)).await?;

// Better
let mut pages = client.payouts().list_pages();
while let Some(page) = pages.next().await? {
    // Process page
}

3. Retry Logic Built-In

Right now users handle retries. I could build it in:

let client = ApiClient::builder()
    .with_retries(3)
    .with_backoff(ExponentialBackoff::default())
    .build()?;

Design Checklist

When building your SDK, ask yourself:

  • [ ] Can a new user get started in under 5 minutes?
  • [ ] Are errors helpful and actionable?
  • [ ] Does the type system prevent common mistakes?
  • [ ] Is the API discoverable (good autocomplete)?
  • [ ] Are there working examples?
  • [ ] Is documentation clear and complete?
  • [ ] Does it handle errors gracefully?
  • [ ] Is it async-friendly?
  • [ ] Can users customize behavior when needed?
  • [ ] Is versioning strategy clear?

Conclusion

Building a great SDK is about:

  • Clarity: Make the API obvious
  • Safety: Use types to prevent bugs
  • Documentation: Help users succeed
  • Simplicity: Make common cases easy
  • Flexibility: Make complex cases possible

Rust gives you amazing tools for this:

  • Type safety
  • Great error handling
  • Zero-cost abstractions
  • Excellent tooling

But remember: The best SDK is one people actually use. Keep it simple, document everything, and listen to your users.


Resources


Questions or feedback? Feel free to leave a comment bellow.


This post is based on my experience building a Rust SDK.Your mileage may vary, but these patterns have worked well for me.

Leave a Reply

Your email address will not be published. Required fields are marked *