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