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:
- Performance: Rust compiles to native code. For high-throughput systems, this matters.
- Safety: Rust’s compiler catches bugs at compile-time. No null pointer errors in production.
- Growing ecosystem: More companies are adopting Rust for backend services.
- 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:
- Clone the client (cheap, it’s just an Arc internally)
- Use lifetimes (complex, hard to use)
- 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_typein 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
- Rust API Guidelines
- The Cargo Book
- Serde Documentation
- Tokio Tutorial
- reqwest Documentation
- thiserror Documentation
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