Архитектура как конструктор 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 пайплайн) скажут вам спасибо!
Полезные ссылки
Источник - berektassuly.com
Репозиторий с примером - testable-rust-architecture-template
LinkedIn автора
Комментарии (19)

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

Berektassuly Автор
10.01.2026 21:55глянул dynosaur. хороший подход, если нужно сохранить zero cost для статической диспетчеризации
В моем случае AppState использует только dyn поэтому оверхед async_trait не критичен, а его простота и популярность в экосистеме сыграли в его пользу для туториала
Но за альтернативу спасибо, полезно знать
Gorthauer87
10.01.2026 21:55Там он не такой уж простой, достаточно сделать cargo doc и охренеть, поэтому я стараюсь все же вручную писать трейты

Berektassuly Автор
10.01.2026 21:55Понимаю вашу боль. Разворот макроса в документации действительно выглядит устрашающе, и дебажить типы, если что-то пошло не так, бывает непросто
Но тут вопрос приоритетов. Если писать вручную возвращаемые типы
Pin<Box<dyn Future...>>в каждом методе доменного слоя, это сильно повышает порог входа и зашумляет бизнес логику техническими деталями.async_traitстал стандартом дефакто в веб бэкенде на Rust именно потому, что позволяет писать сигнатуры почти как в синхронном коде, пряча эту сложность от разработчика прикладного уровняДля обучающего материала мне показалось важнее, чтобы читатель сфокусировался на архитектуре и потоках данных, а не на борьбе с лайфтаймами в боксовых футурах)

miklin-ag
10.01.2026 21:55Довольно интересный пример с блокчейном! Аккуратно и занимательно.
Суть же подхода, описанного в статье, кажется, можно выразить короче: "Изоляция бизнес-логики от тяжёлых сервисов способствует её тестируемости и поддерживаемости, а также расширяемости сервисов" - что является здоровой практикой.
Небольшое замечание к схеме архитектуры: указано неверное отношение implements от AppState к ChainClient. Implements должно быть от конкретных реализаций же ниже к интерфейсу - но здесь, видимо, указан просто путь запроса, а не иерархия сущностей.
Подскажите пожалуйста, почему в статье используется solana_sdk, а в репозитории по ссылке - rpc via http? Чем они отличаются?

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

Dhwtj
10.01.2026 21:55Попробуйте в C# сделать порт абстрактного Excel движка чтобы юзать или epplus или closexml. Вам не захочется: тяжёлые объекты sheet, разные концепции.
Трейты Rust как порты ложатся естественно, без церемоний с ООП интерфейсами и DI-контейнерами. И трейты склонны к более мелким абстракциям чем ООП.
ООП интерфейсы часто описывают "кто ты", трейты - "что умеешь". Второе лучше композируется.
Примерно так
IWorksheet - "ты лист Excel". Тащишь сразу всё что лист "должен уметь". Разделить интерфейсы можно, но использовать многословно
Трейт ReadCells, WriteCells, MergeCells - отдельные способности, комбинируй как нужно.

Berektassuly Автор
10.01.2026 21:55Ваше различие между ООП интерфейсами и трейтами самое лаконичное объснение сути которое я слышал)
Именно об этом и вся статья, бизнес логике не нужен весь "тяжелый" клиент соланы, ей нужна лишь одна его способность записать хеш. Трейты в Rust позволяют выразить эту потребность без лишнего багажа
Пример с C# библиотеками для Excel точно в точку, эта боль заставляет искать более гибкие подходы

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> { // ... } }Вроде, стабилизировали уже

Berektassuly Автор
10.01.2026 21:55Отличный поинт. Для статической диспетчеризации дженериков это теперь стандарт
Но этот подход к сожелению не object-safe. Его нельзя использовать сdyn Trait
А у меня вся архитектура завязана наArc<dyn ChainClient>иAppState, поэтому безasync_trait(который боксит футуры) пока не обойтись
Так что это осознанный выбор именно подdyn
Dhwtj
10.01.2026 21:55А зачем dyn?
Вы же не собираетесь использовать набор контрактов одни солана, другие нет.
вся архитектура завязана на
Arc<dyn ChainClient>иAppStateАрхитектура или код?

Berektassuly Автор
10.01.2026 21:55Выбор между
dynи дженериками обычный вопрос в RustЯ не храню реализации в одной коллекции. Но я выбрал
dyn Traitпо нескольким причинам которые являются для меня архитектурными1)Скорость компиляции. Дженерики приводят к мономорфизации изза чего в больших проектах это может замедлить инкрементальные сборки на минуты.
2) Эргономика и невирусность. Если
AppStateстановитсяAppState<c: ChainClient>, то все хендлеры, которые его используют тоже должны стать дженериками. А это переусложняет сигнатуры и код веб слоя.dyn Traitпозволяет сохранитьAppStateи хендлеры простыми и чистыми создавая четкую компиляционную границу которую дженерики размывают
3) Гибкость. СdynTrait я могу собрать один бинарник который при старте читает конфиг или переменную окружения и решает какую реализацию поднять.SolanaChainClientдля прода,MockChainClientдля тестов или стейджинга. С дженериками это сделать не сложнее, и часто требует перекомпиляции с разными#[cfg]флагами.Архитектура или код?
Архитектура набор осознанных ограничечий и компромиссов. Жертва наносекундами производительности на vtable вызовах ради ускорения разработки, упрощения кода и гибкости конфигурации

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

Berektassuly Автор
10.01.2026 21:55Если вы уже пишете на Rust, то порог входа будет низким. В solana смарт контракты пишутся на Rust, де-факто стандарт сейчас фреймворк Anchor, он очень производительный
Насчет "денежная или нет" рынок стал взрослее. Времена шальных денег на простых форках прошли, но спрос на сильных инженеров, способных пилить сложный DeFi, инфраструктуру или DePIN (физические сети), огромный. И рейты там часто выше, чем в классическом вебе
Главный плюс для разработчика даже если хайп спадет или вы решите уйти из Web3, опыт написания строгого, высокопроизводительного кода на Rust и понимание работы state machines останутся с вами. Это отличная инвестиция в hard skills

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

Berektassuly Автор
10.01.2026 21:55Я написал ручные моки для наглядности. Но для реальных проектов где требуется много моков и сложные проверки то mockall будет must have
Спасибо что дополнили!
sdramare
Где здесь гексагональная архитектура? Автор просто применил DI рефакторинг и на этом закончил
Berektassuly Автор
А как, по-вашему, реализуется Гексагональная архитектура без dependency injection?
sdramare
А где я написал что эта архитектура реализуется без DI?