Архитектура как конструктор LEGO: строим тестируемый Rust-сервис с абстракцией блокчейна

Представьте, что вы строите сервис для выдачи цифровых дипломов, который записывает хеши документов в блокчейн Solana. Все работает отлично, пока вы не пытаетесь написать первый юнит-тест. Внезапно оказывается, что для тестирования простой бизнес-логики нужно поднимать локальный валидатор Solana, иметь тестовые токены и молиться, чтобы сеть не упала посреди CI/CD пайплайна. А что если завтра заказчик попросит добавить поддержку Ethereum? Переписывать половину кодовой базы?

В этой статье я покажу, как мы решили эту проблему в реальном проекте, используя мощь системы типов Rust и паттерн "Стратегия". Вы узнаете, как правильно готовить async-trait, почему dyn Trait иногда лучше дженериков, и как написать тесты, которые выполняются за миллисекунды вместо минут.

Если вы предпочитаете читать код, а не статьи, я подготовил готовый шаблон архитектуры на GitHub. Там реализована вся описываемая здесь логика, плюс Docker, PostgreSQL и интеграционные тесты. testable-rust-architecture-template

Анти-паттерн: как мы страдали ДО рефакторинга

Давайте честно - первая версия нашего кода выглядела примерно так:

// ПЛОХО: Прямая зависимость от Solana везде
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> {
    // Бизнес-логика намертво связана с Solana
    let instruction = /* создаем Solana-специфичную инструкцию */;
    let transaction = Transaction::new(/* ... */);
    
    // Прямой вызов Solana RPC
    let signature = state.solana_client
        .send_and_confirm_transaction(&transaction)?;
    
    // А теперь попробуйте это протестировать...
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn test_issue_diploma() {
        // Для теста нужен реальный Solana!
        // Варианты:
        // 1. Поднять solana-test-validator (медленно)
        // 2. Использовать devnet (нестабильно)
        // 3. Захардкодить моки... везде (кошмар поддержки)
        
        // В итоге: тест либо медленный, либо хрупкий, либо оба
    }
}

Что здесь не так?

  • Vendor lock-in: Хотите перейти на Ethereum? Удачи с переписыванием всего кода

  • Медленные тесты: Каждый тест ждет реальные транзакции (30-60 секунд)

  • Дорогие тесты: На devnet нужны тестовые SOL, есть rate limits

  • Нестабильный CI/CD: Упала сеть? Все тесты красные

  • Спагетти-код: Бизнес-логика перемешана с деталями блокчейна

Проблема: когда блокчейн становится якорем

При разработке нашего сервиса верификации дипломов мы столкнулись с классической проблемой tight coupling (жесткой связи). Наш код напрямую зависел от Solana RPC клиента, что создавало целый букет проблем:

  • Тесты-мазохисты: Каждый тест требовал реального подключения к блокчейну

  • Медленная разработка: Ждать подтверждения транзакции при каждом запуске теста - удовольствие ниже среднего

  • Хрупкий CI/CD: Тесты падали из-за проблем с сетью, а не из-за багов в коде

  • Vendor lock-in: Переход на другой блокчейн означал бы переписывание большей части сервиса

Архитектура решения: визуальный гайд

Прежде чем погружаться в код, давайте посмотрим на общую картину нашей архитектуры:

Поток данных в разных окружениях:

Решение: трейт ChainClient как контракт с блокчейном

Вместо того чтобы везде таскать конкретную реализацию Solana-клиента, мы создали абстракцию - трейт, который описывает, что должен уметь делать любой блокчейн-клиент, не уточняя как:

// ./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>;
}

Почему async_trait?

Заметили макрос #[async_trait]? Это не прихоть, а необходимость. На момент написания статьи, async функции в трейтах все еще не стабилизированы в Rust (хотя работа идет полным ходом). Библиотека async-trait решает эту проблему элегантным способом: под капотом она преобразует:

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

В примерно такой код:

fn write_hash(&self, ...) -> Box<dyn Future<Output = Result<...>> + Send>

Да, это добавляет небольшой оверхед из-за динамической диспетчеризации и аллокации на куче, но для операций ввода-вывода (а работа с блокчейном - это всегда I/O) это абсолютно незаметно.

Production-реализация: SolanaChainClient

Теперь давайте посмотрим на конкретную реализацию для Solana. Она инкапсулирует всю сложность работы с RPC и транзакциями:

// ./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> {
        // Используем Memo Program для записи хеша
        let memo_program_id = 
            Pubkey::from_str("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")?;
        
        let instruction = Instruction::new_with_bytes(
            memo_program_id,
            hash.as_bytes(),
            vec![],
        );

        // Получаем последний blockhash и отправляем транзакцию
        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()),
        })
    }
    
    // Остальные методы...
}

Вся магия Solana (работа с инструкциями, подписями, blockhash) спрятана внутри реализации. Для остального кода это просто "что-то, что умеет записывать хеши в блокчейн".

MockChainClient: тестовый рай разработчика

А вот где начинается настоящее волшебство - наша мок-реализация для тестов:

// ./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(()) // Всегда здоров как бык!
    }
}

Вместо реального блокчейна используем простой HashMap в памяти. Транзакции "подтверждаются" мгновенно, никаких сетевых задержек, никаких комиссий. Теперь можно написать тест для хендлера issue_diploma, который будет выполняться за миллисекунды:

#[cfg(test)]
mod tests {
    use super::*;
    
    #[tokio::test]
    async fn test_issue_diploma_creates_blockchain_record() {
        // Arrange: создаем мок-клиент вместо реального
        let mock_client = Arc::new(MockChainClient::new());
        let app_state = Arc::new(AppState {
            chain_client: mock_client.clone(),
            // ... другие поля
        });
        
        // Act: вызываем бизнес-логику
        let result = issue_diploma(State(app_state), test_multipart).await;
        
        // Assert: проверяем, что хеш записан
        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 через AppState

Теперь самое интересное - как все это собирается вместе в реальном приложении. Мы используем Axum (отличный веб-фреймворк для Rust), и вся магия происходит в AppState:

// ./internal/api/router.rs
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()?;
    
    // Создаем конкретную реализацию для production
    let solana_client = SolanaChainClient::new(
        config.solana_rpc_url, 
        client_keypair
    )?;
    
    // Упаковываем в AppState
    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))
}

Что такое dyn ChainClient?

Заметили Arc<dyn ChainClient>? Это trait object - способ хранить любой тип, реализующий трейт ChainClient, без знания конкретного типа на этапе компиляции.

  • dyn говорит компилятору: "это будет какой-то тип, реализующий ChainClient, но какой именно - узнаем в рантайме"

  • Arc нужен для безопасного разделения между потоками (Axum обрабатывает запросы параллельно)

dyn Trait vs Generics: битва титанов

Вы могли бы спросить: "А почему не использовать дженерики?" Отличный вопрос! Давайте сравним:

Вариант с дженериками (статическая диспетчеризация):

pub struct AppState<C: ChainClient> {
    pub chain_client: Arc<C>,
    // ...
}

Плюсы:

  • Максимальная производительность (компилятор может заинлайнить вызовы)

  • Нет оверхеда на vtable lookup

Минусы:

  • Мономорфизация "раздувает" бинарник (для каждого типа C генерируется отдельная копия кода)

  • Нельзя поменять реализацию в рантайме

  • Все хендлеры тоже должны стать дженерик-функциями

Наш выбор: trait objects (динамическая диспетчеризация):

pub struct AppState {
    pub chain_client: Arc<dyn ChainClient>,
    // ...
}

Плюсы:

  • Гибкость: можем выбирать реализацию в рантайме (например, based on environment variable)

  • Меньший размер бинарника

  • Проще интегрировать с веб-фреймворками

Минусы:

  • Небольшой оверхед на вызовы (vtable lookup)

  • Нужен Arc для работы с trait objects

Для нашего случая выбор очевиден: накладные расходы на vtable lookup ничтожны по сравнению с временем сетевых вызовов к блокчейну. А гибкость, которую мы получаем, бесценна.

Заключение: архитектура, которая эволюционирует вместе с вами

Что мы получили в итоге:

Молниеносные тесты: Юнит-тесты выполняются за миллисекунды, не требуя реального блокчейна

Легкая замена блокчейна: Хотите добавить Ethereum? Просто напишите EthereumChainClient implementing ChainClient

Четкое разделение ответственности: Бизнес-логика не знает ничего о деталях работы с Solana

Упрощенная отладка: Можно использовать MockChainClient даже в development окружении для быстрой итерации

Этот подход - не серебряная пуля, но для сервисов, взаимодействующих с внешними системами (будь то блокчейн, платежные системы или сторонние API), он дает огромную гибкость и тестируемость.

Помните: хорошая архитектура - это не та, которая предугадывает все будущие изменения, а та, которая позволяет легко их внести, когда они понадобятся. Трейты в Rust дают нам именно такую возможность.

P.S. Если вы все еще пишете тесты, которые требуют реального подключения к внешним сервисам - попробуйте этот подход. Ваши коллеги (и ваш CI/CD пайплайн) скажут вам спасибо!

Полезные ссылки

Комментарии (19)


  1. sdramare
    10.01.2026 21:55

    Где здесь гексагональная архитектура? Автор просто применил DI рефакторинг и на этом закончил


    1. Berektassuly Автор
      10.01.2026 21:55

      А как, по-вашему, реализуется Гексагональная архитектура без dependency injection?


      1. sdramare
        10.01.2026 21:55

        А где я написал что эта архитектура реализуется без DI?


  1. Gorthauer87
    10.01.2026 21:55

    async_trait это таки прихоть и сейчас можно без него и даже лучше без него делать и юзать подход dynosaur.


    1. Berektassuly Автор
      10.01.2026 21:55

      глянул dynosaur. хороший подход, если нужно сохранить zero cost для статической диспетчеризации

      В моем случае AppState использует только dyn поэтому оверхед async_trait не критичен, а его простота и популярность в экосистеме сыграли в его пользу для туториала
      Но за альтернативу спасибо, полезно знать


      1. Gorthauer87
        10.01.2026 21:55

        Там он не такой уж простой, достаточно сделать cargo doc и охренеть, поэтому я стараюсь все же вручную писать трейты


        1. Berektassuly Автор
          10.01.2026 21:55

          Понимаю вашу боль. Разворот макроса в документации действительно выглядит устрашающе, и дебажить типы, если что-то пошло не так, бывает непросто

          Но тут вопрос приоритетов. Если писать вручную возвращаемые типы Pin<Box<dyn Future...>> в каждом методе доменного слоя, это сильно повышает порог входа и зашумляет бизнес логику техническими деталями. async_trait стал стандартом дефакто в веб бэкенде на Rust именно потому, что позволяет писать сигнатуры почти как в синхронном коде, пряча эту сложность от разработчика прикладного уровня

          Для обучающего материала мне показалось важнее, чтобы читатель сфокусировался на архитектуре и потоках данных, а не на борьбе с лайфтаймами в боксовых футурах)


  1. miklin-ag
    10.01.2026 21:55

    Довольно интересный пример с блокчейном! Аккуратно и занимательно.

    Суть же подхода, описанного в статье, кажется, можно выразить короче: "Изоляция бизнес-логики от тяжёлых сервисов способствует её тестируемости и поддерживаемости, а также расширяемости сервисов" - что является здоровой практикой.

    Небольшое замечание к схеме архитектуры: указано неверное отношение implements от AppState к ChainClient. Implements должно быть от конкретных реализаций же ниже к интерфейсу - но здесь, видимо, указан просто путь запроса, а не иерархия сущностей.

    Подскажите пожалуйста, почему в статье используется solana_sdk, а в репозитории по ссылке - rpc via http? Чем они отличаются?


    1. Berektassuly Автор
      10.01.2026 21:55

      Спасибо за фидбек и отличные вопросы.

      Вы правы на счет схемы она больше про поток данных, чем про строгую UML. Сделал для наглядности

      solana_sdk и RPC это не разные вещи а части одного целого. solana_sdk - большой набор крейтов который дает и "инструктор" для сборки транзакций (solana_sdk::transaction) и сам RPC клиент для их отправки (solana_client). Этот клиент как раз работает по JSON-RPC (поверх HTTP). Так что в статье и репе используется один и тот же подход - официальный SDK клиент


  1. Dhwtj
    10.01.2026 21:55

    Попробуйте в C# сделать порт абстрактного Excel движка чтобы юзать или epplus или closexml. Вам не захочется: тяжёлые объекты sheet, разные концепции.

    Трейты Rust как порты ложатся естественно, без церемоний с ООП интерфейсами и DI-контейнерами. И трейты склонны к более мелким абстракциям чем ООП.

    ООП интерфейсы часто описывают "кто ты", трейты - "что умеешь". Второе лучше композируется.

    Примерно так

    IWorksheet - "ты лист Excel". Тащишь сразу всё что лист "должен уметь". Разделить интерфейсы можно, но использовать многословно

    Трейт ReadCells, WriteCells, MergeCells - отдельные способности, комбинируй как нужно.


    1. Berektassuly Автор
      10.01.2026 21:55

      Ваше различие между ООП интерфейсами и трейтами самое лаконичное объснение сути которое я слышал)

      Именно об этом и вся статья, бизнес логике не нужен весь "тяжелый" клиент соланы, ей нужна лишь одна его способность записать хеш. Трейты в Rust позволяют выразить эту потребность без лишнего багажа

      Пример с C# библиотеками для Excel точно в точку, эта боль заставляет искать более гибкие подходы


  1. Dhwtj
    10.01.2026 21:55

    Асинхроннонность разве не так?

    pub trait ChainClient: Send + Sync {
        fn write_hash(&self, hash: &str, meta: &Diploma)
            -> impl Future<Output = Result<BlockchainRecord, AppError>> + Send;
    
        fn find_by_hash(&self, hash: &str) 
            -> impl Future<Output = Result<Option<BlockchainRecord>, AppError>> + Send;
    
        fn health_check(&self) -> impl Future<Output = Result<(), AppError>> + Send;
    }
    ...
    impl ChainClient for MyClient {
        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> {
            // ...
        }
    }

    Вроде, стабилизировали уже


    1. Berektassuly Автор
      10.01.2026 21:55

      Отличный поинт. Для статической диспетчеризации дженериков это теперь стандарт

      Но этот подход к сожелению не object-safe. Его нельзя использовать с dyn Trait

      А у меня вся архитектура завязана на Arc<dyn ChainClient> и AppState, поэтому без async_trait (который боксит футуры) пока не обойтись

      Так что это осознанный выбор именно под dyn


      1. Dhwtj
        10.01.2026 21:55

        А зачем dyn?

        Вы же не собираетесь использовать набор контрактов одни солана, другие нет.

        вся архитектура завязана на Arc<dyn ChainClient> и AppState

        Архитектура или код?


        1. Berektassuly Автор
          10.01.2026 21:55

          Выбор между dyn и дженериками обычный вопрос в Rust

          Я не храню реализации в одной коллекции. Но я выбрал dyn Trait по нескольким причинам которые являются для меня архитектурными

          1)Скорость компиляции. Дженерики приводят к мономорфизации изза чего в больших проектах это может замедлить инкрементальные сборки на минуты.

          2) Эргономика и невирусность. Если AppState становится AppState<c: ChainClient> , то все хендлеры, которые его используют тоже должны стать дженериками. А это переусложняет сигнатуры и код веб слоя. dyn Trait позволяет сохранить AppState и хендлеры простыми и чистыми создавая четкую компиляционную границу которую дженерики размывают

          3) Гибкость. С dyn Trait я могу собрать один бинарник который при старте читает конфиг или переменную окружения и решает какую реализацию поднять. SolanaChainClient для прода, MockChainClient для тестов или стейджинга. С дженериками это сделать не сложнее, и часто требует перекомпиляции с разными #[cfg] флагами.

          Архитектура или код?

          Архитектура набор осознанных ограничечий и компромиссов. Жертва наносекундами производительности на vtable вызовах ради ускорения разработки, упрощения кода и гибкости конфигурации


          1. Dhwtj
            10.01.2026 21:55

            Думаю, может тоже Соланой заняться? Контракты писать или что там..

            Денежная тема или уже нет?


            1. Berektassuly Автор
              10.01.2026 21:55

              Если вы уже пишете на Rust, то порог входа будет низким. В solana смарт контракты пишутся на Rust, де-факто стандарт сейчас фреймворк Anchor, он очень производительный

              Насчет "денежная или нет" рынок стал взрослее. Времена шальных денег на простых форках прошли, но спрос на сильных инженеров, способных пилить сложный DeFi, инфраструктуру или DePIN (физические сети), огромный. И рейты там часто выше, чем в классическом вебе

              Главный плюс для разработчика даже если хайп спадет или вы решите уйти из Web3, опыт написания строгого, высокопроизводительного кода на Rust и понимание работы state machines останутся с вами. Это отличная инвестиция в hard skills


  1. AlexanderSolo
    10.01.2026 21:55

    При таком подходе удобно использовать библиотеку mockall https://docs.rs/mockall/latest/mockall/

    Вдруг кому пригодится


    1. Berektassuly Автор
      10.01.2026 21:55

      Я написал ручные моки для наглядности. Но для реальных проектов где требуется много моков и сложные проверки то mockall будет must have
      Спасибо что дополнили!