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

# 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:

```rust
// ❌ 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:

```mermaid
graph TD
    HTTP[HTTP Requests] --> AxumRouter

    %% =====================
    %% Axum Router
    %% =====================
    subgraph AxumRouter [Axum Router]
        direction TB
        Issue[/issue/]:::router
        Verify[/verify/]:::router
        Creds[/credentials/]:::router
    end

    AxumRouter --> AppState

    %% =====================
    %% AppState
    %% =====================
    subgraph AppState
        direction TB
        ChainClient["chain_client:
Arc(dyn ChainClient)"]
        IssuerKey["issuer_keypair:
Keypair"]
        DBClient["db_client:
Postgrest"]
    end

    AppState -->|implements| ChainClientTrait

    %% =====================
    %% ChainClient trait
    %% =====================
    subgraph ChainClientTrait ["trait ChainClient"]
        direction TB
        WriteHash["async write_hash()
-> Result"]
        FindByHash["async find_by_hash()
-> Result"]
        HealthCheck["async health_check()
-> Result"]
    end

    ChainClientTrait --> SolanaClient
    ChainClientTrait --> MockClient
    ChainClientTrait --> EthereumClient

    %% =====================
    %% Implementations
    %% =====================
    subgraph Production
        SolanaClient["SolanaClient
Real RPC
Real txs
Real fees"]
    end

    subgraph Testing
        MockClient["MockClient
HashMap
Instant
No network"]
    end

    subgraph Future
        EthereumClient["EthereumClient
(easily added)"]
    end

    %% =====================
    %% External systems
    %% =====================
    SolanaClient --> SolanaBlockchain["Solana Blockchain"]
    MockClient --> InMemory["In-memory storage"]

    %% =====================
    %% Styles
    %% =====================
    classDef router fill:#f0f0f0,stroke:#333,stroke-width:1px
```

### Data Flow in Different Environments:

```mermaid
graph BT
    subgraph TESTING
        T1[Test] --> T2[Handler]
        T2 --> T3[AppState]
        T3 --> T4[MockClient]
        T4 --> T5[HashMap in Memory]
        T3 -.-> T6["(dyn ChainClient)"]
    end
    
    subgraph PRODUCTION
        P1[Request] --> P2[Handler]
        P2 --> P3[AppState]
        P3 --> P4[SolanaClient]
        P4 --> P5[Solana Network]
        P3 -.-> P6["(dyn ChainClient)"]
    end
     
    style PRODUCTION fill:#e1f5ff
    style TESTING fill:#fff4e1
    style P6 fill:#ffebee
    style T6 fill:#ffebee
```

## 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*:

```rust
// ./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:

```rust
async fn write_hash(&self, ...) -> Result<...>
```

Into something like:

```rust
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:

```rust
// ./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:

```rust
// ./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:

```rust
#[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`:

```rust
// ./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.

* `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):

```rust
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):

```rust
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 `Arc` to 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!
