Architecture as LEGO: Building a Testable Rust Service with Blockchain Abstraction

Building production-grade distributed systems at the intersection of Rust, AI, and Web3. My passion is tackling the fundamental challenges of data consistency, security, and performance in decentralized environments. As the solo founder of Legacy, a blockchain-based document verification platform, I architect and engineer systems designed for cryptographic eternity and bulletproof reliability. Through my articles, I explore the trade-offs of architectural patterns and share battle-tested insights from building real-world systems. My goal is to deconstruct complex topics - from asynchronous transaction handling to API design - into clear, actionable principles. Always open to discussing system design, cryptography, and the future of the decentralized web.
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:
// ❌ BAD: Direct dependency on Solana everywhere
use solana_client::rpc_client::RpcClient;
use solana_sdk::{signature::Keypair, transaction::Transaction};
pub struct AppState {
pub solana_client: RpcClient, // <- Concrete implementation!
pub keypair: Keypair,
pub db_client: Postgrest,
}
pub async fn issue_diploma(
State(state): State<Arc<AppState>>,
// ...
) -> Result<Json<IssueResponse>, AppError> {
// Business logic tightly coupled to Solana
let instruction = /* create Solana-specific instruction */;
let transaction = Transaction::new(/* ... */);
// Direct Solana RPC call
let signature = state.solana_client
.send_and_confirm_transaction(&transaction)?;
// Now try testing this...
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_issue_diploma() {
// 😱 Test needs real Solana!
// Options:
// 1. Spin up solana-test-validator (slow)
// 2. Use devnet (unstable)
// 3. Hardcode mocks... everywhere (maintenance nightmare)
// Result: test is either slow, brittle, or both
}
}
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:
// ./internal/blockchain/mod.rs
use async_trait::async_trait;
#[async_trait]
pub trait ChainClient: Send + Sync {
/// Writes a hash to the blockchain.
async fn write_hash(&self, hash: &str, meta: &Diploma)
-> Result<BlockchainRecord, AppError>;
/// Looks up a hash on the blockchain.
async fn find_by_hash(&self, hash: &str)
-> Result<Option<BlockchainRecord>, AppError>;
/// Performs a health check on the connection.
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:
// ./internal/blockchain/solana.rs
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> {
// Using Memo Program to record the hash
let memo_program_id =
Pubkey::from_str("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")?;
let instruction = Instruction::new_with_bytes(
memo_program_id,
hash.as_bytes(),
vec![],
);
// Get latest blockhash and send transaction
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()),
})
}
// Other methods...
}
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:
// ./internal/blockchain/mock.rs
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(()) // Always healthy!
}
}
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() {
// Arrange: create mock client instead of real one
let mock_client = Arc::new(MockChainClient::new());
let app_state = Arc::new(AppState {
chain_client: mock_client.clone(),
// ... other fields
});
// Act: call business logic
let result = issue_diploma(State(app_state), test_multipart).await;
// Assert: verify hash was written
assert!(result.is_ok());
let response = result.unwrap();
// We can even verify the hash was actually saved
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:
// ./internal/api/router.rs
pub struct AppState {
pub chain_client: Arc<dyn ChainClient>, // <- There it is!
pub issuer_keypair: Keypair,
pub db_client: Postgrest,
}
pub async fn create_router() -> Result<Router, AppError> {
// Load configuration
let config = Config::from_env()?;
// Create concrete implementation for production
let solana_client = SolanaChainClient::new(
config.solana_rpc_url,
client_keypair
)?;
// Package into AppState
let app_state = Arc::new(AppState {
chain_client: Arc::new(solana_client), // <- Dependency injection!
issuer_keypair,
db_client,
});
// Create router with injected state
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.
dyntells the compiler: "this will be some type implementingChainClient, but which one exactly - we'll find out at runtime"Arcis 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:
Maximum performance (compiler can inline calls)
No vtable lookup overhead
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:
Small overhead for calls (vtable lookup)
Need
Arcto work with trait objects
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!



