Don't let blockchain slow down your CI/CD. Learn how to use Rust traits, Arc, and Dependency Injection to decouple business logic from Solana for millisecond unit tests.
Imagine you're building a service for issuing digital diplomas that records document hashes on the Solana blockchain. Everything works great until you try to write your first unit test. Suddenly, you realize that testing simple business logic requires spinning up a local Solana validator, obtaining test tokens, and praying the network doesn't crash in the middle of your CI/CD pipeline. And what happens when your client asks for Ethereum support tomorrow? Rewrite half your codebase?
In this article, I'll show you how we solved this problem in a real production project using the power of Rust's type system and the Strategy pattern. You'll learn how to properly leverage async-trait, why dyn Trait sometimes beats generics, and how to write tests that run in milliseconds instead of minutes.
The Anti-Pattern: How We Suffered BEFORE Refactoring
Let's be honest - our first version looked something like this:
use solana_client::rpc_client::RpcClient;
use solana_sdk::{signature::Keypair, transaction::Transaction};
pub struct AppState {
pub solana_client: RpcClient,
pub keypair: Keypair,
pub db_client: Postgrest,
}
pub async fn issue_diploma(
State(state): State<Arc<AppState>>,
) -> Result<Json<IssueResponse>, AppError> {
let instruction = ;
let transaction = Transaction::new();
let signature = state.solana_client
.send_and_confirm_transaction(&transaction)?;
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_issue_diploma() {
}
}
What's wrong here?
๐ Vendor lock-in: Want to switch to Ethereum? Good luck rewriting everything
๐ Slow tests: Each test waits for real transactions (30-60 seconds)
๐ธ Expensive tests: Devnet requires test SOL, has rate limits
๐ฒ Unstable CI/CD: Network down? All tests red
๐ Spaghetti code: Business logic tangled with blockchain details
The Problem: When Blockchain Becomes an Anchor
While developing our diploma verification service, we hit the classic tight coupling problem. Our code directly depended on the Solana RPC client, creating a whole bouquet of issues:
Masochistic tests: Every test required a real blockchain connection
Slow development: Waiting for transaction confirmations on every test run is a special kind of hell
Brittle CI/CD: Tests failed due to network issues, not actual bugs
Vendor lock-in: Switching to another blockchain meant rewriting most of the service
Solution Architecture: A Visual Guide
Before diving into code, let's look at the big picture of our architecture:
Data Flow in Different Environments:
The Solution: ChainClient Trait as Our Contract with the Blockchain
Instead of dragging a concrete Solana client implementation everywhere, we created an abstraction - a trait that describes what any blockchain client should be able to do, without specifying how:
use async_trait::async_trait;
#[async_trait]
pub trait ChainClient: Send + Sync {
async fn write_hash(&self, hash: &str, meta: &Diploma)
-> Result<BlockchainRecord, AppError>;
async fn find_by_hash(&self, hash: &str)
-> Result<Option<BlockchainRecord>, AppError>;
async fn health_check(&self) -> Result<(), AppError>;
}
Why async_trait?
Notice the #[async_trait] macro? This isn't a whim - it's a necessity. At the time of writing, async functions in traits are still not stabilized in Rust (though work is progressing rapidly). The async-trait library solves this elegantly: under the hood, it transforms:
async fn write_hash(&self, ...) -> Result<...>
Into something like:
fn write_hash(&self, ...) -> Box<dyn Future<Output = Result<...>> + Send>
Yes, this adds a small overhead due to dynamic dispatch and heap allocation, but for I/O operations (and blockchain work is always I/O), it's absolutely negligible.
Production Implementation: SolanaChainClient
Now let's look at the concrete implementation for Solana. It encapsulates all the complexity of working with RPC and transactions:
pub struct SolanaChainClient {
rpc_client: RpcClient,
issuer_keypair: Keypair,
}
impl SolanaChainClient {
pub fn new(rpc_url: String, issuer_keypair: Keypair) -> Result<Self, AppError> {
let rpc_client = RpcClient::new(rpc_url);
Ok(Self {
rpc_client,
issuer_keypair,
})
}
}
#[async_trait]
impl ChainClient for SolanaChainClient {
async fn write_hash(
&self,
hash: &str,
_meta: &Diploma,
) -> Result<BlockchainRecord, AppError> {
let memo_program_id =
Pubkey::from_str("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")?;
let instruction = Instruction::new_with_bytes(
memo_program_id,
hash.as_bytes(),
vec![],
);
let latest_blockhash = self.rpc_client.get_latest_blockhash()?;
let message = Message::new(&[instruction], Some(&self.issuer_keypair.pubkey()));
let mut transaction = Transaction::new_unsigned(message);
transaction.sign(&[&self.issuer_keypair], latest_blockhash);
let signature = self.rpc_client
.send_and_confirm_transaction(&transaction)?;
Ok(BlockchainRecord {
tx_id: signature.to_string(),
block_time: None,
raw_meta: Some(hash.to_string()),
})
}
}
All the Solana magic (working with instructions, signatures, blockhashes) is hidden inside the implementation. For the rest of the code, it's just "something that can write hashes to a blockchain."
MockChainClient: A Developer's Testing Paradise
And here's where the real magic begins - our mock implementation for tests:
pub struct MockChainClient {
storage: Mutex<HashMap<String, BlockchainRecord>>,
}
impl MockChainClient {
pub fn new() -> Self {
Self {
storage: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl ChainClient for MockChainClient {
async fn write_hash(
&self,
hash: &str,
_meta: &Diploma,
) -> Result<BlockchainRecord, AppError> {
let mut storage = self.storage.lock().unwrap();
let record = BlockchainRecord {
tx_id: format!("mock_tx_{}", hash),
block_time: Some(Utc::now()),
raw_meta: Some(hash.to_string()),
};
storage.insert(hash.to_string(), record.clone());
Ok(record)
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<BlockchainRecord>, AppError> {
let storage = self.storage.lock().unwrap();
Ok(storage.get(hash).cloned())
}
async fn health_check(&self) -> Result<(), AppError> {
Ok(())
}
}
Instead of a real blockchain, we use a simple in-memory HashMap. Transactions are "confirmed" instantly, no network delays, no fees. Now we can write a test for the issue_diploma handler that runs in milliseconds:
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_issue_diploma_creates_blockchain_record() {
let mock_client = Arc::new(MockChainClient::new());
let app_state = Arc::new(AppState {
chain_client: mock_client.clone(),
});
let result = issue_diploma(State(app_state), test_multipart).await;
assert!(result.is_ok());
let response = result.unwrap();
let record = mock_client.find_by_hash(&response.hash).await.unwrap();
assert!(record.is_some());
assert_eq!(record.unwrap().tx_id, format!("mock_tx_{}", response.hash));
}
}
Dependency Injection via AppState
Now for the interesting part - how it all comes together in a real application. We're using Axum (an excellent web framework for Rust), and all the magic happens in AppState:
pub struct AppState {
pub chain_client: Arc<dyn ChainClient>,
pub issuer_keypair: Keypair,
pub db_client: Postgrest,
}
pub async fn create_router() -> Result<Router, AppError> {
let config = Config::from_env()?;
let solana_client = SolanaChainClient::new(
config.solana_rpc_url,
client_keypair
)?;
let app_state = Arc::new(AppState {
chain_client: Arc::new(solana_client),
issuer_keypair,
db_client,
});
Ok(Router::new()
.route("/issue", post(issue_diploma))
.route("/verify/:hash", get(verify_diploma))
.with_state(app_state))
}
What's dyn ChainClient?
Notice Arc<dyn ChainClient>? This is a trait object - a way to store any type implementing the ChainClient trait without knowing the concrete type at compile time.
dyn tells the compiler: "this will be some type implementing ChainClient, but which one exactly - we'll find out at runtime"
Arc is needed for safe sharing between threads (Axum handles requests in parallel)
dyn Trait vs Generics: Battle of the Titans
You might ask: "Why not use generics?" Excellent question! Let's compare:
Option with Generics (Static Dispatch):
pub struct AppState<C: ChainClient> {
pub chain_client: Arc<C>,
}
Pros:
Cons:
Monomorphization bloats the binary (separate code copy generated for each type C)
Can't change implementation at runtime
All handlers must also become generic functions
Our Choice: Trait Objects (Dynamic Dispatch):
pub struct AppState {
pub chain_client: Arc<dyn ChainClient>,
}
Pros:
Flexibility: can choose implementation at runtime (e.g., based on environment variable)
Smaller binary size
Easier to integrate with web frameworks
Cons:
For our use case, the choice is clear: the overhead of vtable lookup is negligible compared to network call times to the blockchain. And the flexibility we gain is priceless.
Conclusion: Architecture That Evolves With You
What we achieved:
Lightning-fast tests: Unit tests run in milliseconds without requiring a real blockchain
Easy blockchain swapping: Want to add Ethereum? Just write EthereumChainClient implementing ChainClient
Clear separation of concerns: Business logic knows nothing about Solana implementation details
Simplified debugging: Can use MockChainClient even in development environment for rapid iteration
This approach isn't a silver bullet, but for services interacting with external systems (whether blockchains, payment systems, or third-party APIs), it provides enormous flexibility and testability.
Remember: good architecture isn't one that anticipates all future changes, but one that makes them easy to implement when needed. Rust traits give us exactly this capability.
P.S. If you're still writing tests that require real connections to external services - give this approach a try. Your colleagues (and your CI/CD pipeline) will thank you!