Rust и Hexagonal архитектура: чистый код без компромиссов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Rust и Hexagonal архитектура: пишем код, который не страшно менять

Каждый разработчик хотя бы раз сталкивался с ситуацией, когда небольшое изменение в базе данных или смена HTTP-фреймворка превращались в адский рефакторинг. Бизнес-логика переплетается с деталями реализации, и код превращается в "спагетти". Для Rust, с его фокусом на безопасность и производительность, такое смешение — настоящее преступление.

Решение этой проблемы — Hexagonal архитектура (также известная как Ports and Adapters). В этой статье мы разберем, как применить этот паттерн в Rust, используя мощь системы типов и трейтов. Вы узнаете, как изолировать доменную логику от внешнего мира, писать тестируемый код и легко менять драйверы базы данных или веб-сервера. Это обязательный навык для любого, кто хочет строить сложные, поддерживаемые системы на Rust.

Что такое Hexagonal архитектура? (Очень кратко)

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

Ключевая идея: домен ничего не знает о том, как именно сохраняются данные или отправляются запросы. Он знает только о портах — трейтах с методами. Адаптеры (например, PostgreSQL-репозиторий или HTTP-контроллер) реализуют эти трейты.

В Rust эта концепция ложится идеально благодаря трейтам (аналог интерфейсов) и дженерикам. Компилятор проверяет все связи на этапе сборки, а не в рантайме.

Структура проекта на Rust

Для начала создадим структуру папок, которая визуально отделит домен от инфраструктуры:

src/
├── domain/          # Чистая бизнес-логика (никаких зависимостей!)
│   ├── mod.rs
│   ├── models.rs    # Сущности и Value Objects
│   └── services.rs  # Сервисы, работающие с портами
├── ports/           # Трейты (интерфейсы) для внешнего мира
│   ├── mod.rs
│   └── repositories.rs
├── adapters/        # Реализации портов (базы, HTTP, и т.д.)
│   ├── mod.rs
│   ├── postgres_repo.rs
│   └── in_memory_repo.rs  # Для тестов
├── main.rs          # Сборка приложения (Wiring)
└── lib.rs           # Публичный API библиотеки

Шаг 1: Домен — сердце системы

Начнем с простой модели пользователя. В домене нет ни одной зависимости от внешних библиотек (даже от serde, если это не требуется бизнес-логикой).

// src/domain/models.rs
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
}

impl User {
    pub fn new(id: String, name: String, email: String) -> Self {
        // Здесь может быть валидация
        Self { id, name, email }
    }
}

Теперь определим доменный сервис. Он будет работать с пользователями, но не будет знать, откуда они берутся.

// src/domain/services.rs
use crate::ports::repositories::UserRepository;

pub struct UserService {
    repo: Box<dyn UserRepository>,
}

impl UserService {
    pub fn new(repo: Box<dyn UserRepository>) -> Self {
        Self { repo }
    }

    pub fn create_user(&mut self, name: String, email: String) -> Result<User, String> {
        let id = uuid::Uuid::new_v4().to_string(); // Уже зависимость? Да, но это деталь
        let user = User::new(id, name, email);
        self.repo.save(&user)?;
        Ok(user)
    }

    pub fn get_user(&self, id: &str) -> Result<Option<User>, String> {
        self.repo.find_by_id(id)
    }
}

Важно: Мы используем Box<dyn UserRepository> для динамической диспетчеризации. В Rust это нормально для сложных приложений. Для простых случаев можно использовать дженерики.

Шаг 2: Порты — контракты для внешнего мира

Порт — это трейт, который описывает, что домен ожидает от внешнего мира.

// src/ports/repositories.rs
use crate::domain::models::User;

pub trait UserRepository {
    fn save(&mut self, user: &User) -> Result<(), String>;
    fn find_by_id(&self, id: &str) -> Result<Option<User>, String>;
    fn delete(&mut self, id: &str) -> Result<(), String>;
}

Обратите внимание: трейт использует тип User из домена. Это нормально — домен зависит от порта, а порт зависит от домена. Адаптеры же зависят от порта.

Шаг 3: Адаптеры — конкретные реализации

Теперь самое интересное — реализация порта. Сначала сделаем in-memory хранилище для тестов:

// src/adapters/in_memory_repo.rs
use std::collections::HashMap;
use crate::domain::models::User;
use crate::ports::repositories::UserRepository;

pub struct InMemoryUserRepository {
    users: HashMap<String, User>,
}

impl InMemoryUserRepository {
    pub fn new() -> Self {
        Self { users: HashMap::new() }
    }
}

impl UserRepository for InMemoryUserRepository {
    fn save(&mut self, user: &User) -> Result<(), String> {
        self.users.insert(user.id.clone(), User::new(
            user.id.clone(),
            user.name.clone(),
            user.email.clone(),
        ));
        Ok(())
    }

    fn find_by_id(&self, id: &str) -> Result<Option<User>, String> {
        Ok(self.users.get(id).cloned())
    }

    fn delete(&mut self, id: &str) -> Result<(), String> {
        self.users.remove(id);
        Ok(())
    }
}

А вот адаптер для PostgreSQL (используем sqlx):

// src/adapters/postgres_repo.rs
use sqlx::PgPool;
use crate::domain::models::User;
use crate::ports::repositories::UserRepository;

pub struct PostgresUserRepository {
    pool: PgPool,
}

impl PostgresUserRepository {
    pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
        let pool = PgPool::connect(database_url).await?;
        Ok(Self { pool })
    }
}

#[async_trait]
impl UserRepository for PostgresUserRepository {
    async fn save(&mut self, user: &User) -> Result<(), String> {
        sqlx::query("INSERT INTO users (id, name, email) VALUES ($1, $2, $3)")
            .bind(&user.id)
            .bind(&user.name)
            .bind(&user.email)
            .execute(&self.pool)
            .await
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    async fn find_by_id(&self, id: &str) -> Result<Option<User>, String> {
        let user = sqlx::query_as::&                

Блоги

Рекомендаций по книгам