Patrones arquitectónicos en Rust
Diseño modular para aplicaciones robustas
La arquitectura de software en Rust se beneficia de patrones que promueven la separación de preocupaciones y la mantenibilidad, especialmente en aplicaciones de escala media a grande. Este capítulo explora patrones fundamentales que aprovechan las características del lenguaje, como los traits y la ownership, para estructurar código de manera limpia. Estos conceptos son esenciales para evitar el acoplamiento excesivo y facilitar la evolución del software, preparando el terreno para discusiones más avanzadas sobre concurrencia y rendimiento.
Capas en la arquitectura: domain e infra
En el diseño de aplicaciones Rust, la separación en capas arquitectónicas permite aislar la lógica de negocio de los detalles de implementación, lo que mejora la testabilidad y la portabilidad. La capa de domain (dominio) encapsula las entidades centrales del problema, como modelos de datos y reglas de negocio, sin dependencias externas. Por el contrario, la capa de infra (infraestructura) maneja interacciones con el mundo exterior, tales como bases de datos, APIs o sistemas de archivos, actuando como un adaptador para la capa de domain.
Esta división se basa en principios como el Clean Architecture o Hexagonal Architecture, adaptados a Rust mediante el uso de módulos y crates. En la capa de domain, se definen structs que representan entidades puras, con métodos que implementan lógica inmutable donde sea posible. Por ejemplo, considere una entidad simple para un usuario:
pub struct User {
id: u64,
name: String,
}
impl User {
pub fn new(id: u64, name: String) -> Self {
Self { id, name }
}
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
Err("Name cannot be empty".to_string())
} else {
Ok(())
}
}
}
Aquí, la validación es una regla de negocio pura, sin referencias a almacenamiento. La capa de infra, en cambio, podría implementar persistencia, pero siempre inyectada a través de abstracciones para evitar que el domain dependa de detalles concretos. Un caso borde importante surge cuando se manejan errores: los errores de infra deben mapearse a tipos de error del domain para mantener el aislamiento, utilizando enums como Result para propagar fallos de manera controlada.
Comparado con lenguajes como Java, donde las capas se definen mediante paquetes y anotaciones, Rust favorece la modularidad a través de crates separados, lo que enforces la separación en tiempo de compilación. Evite acoplar el domain a infra directamente, ya que esto viola el principio de inversión de dependencias, complicando los tests unitarios.
Traits como interfaces
Los traits en Rust actúan como interfaces polimórficas, permitiendo definir contratos de comportamiento sin herencia, lo que se alinea perfectamente con patrones arquitectónicos que requieren abstracción. Un trait declara un conjunto de métodos que tipos concretos pueden implementar, facilitando la extensibilidad y la inyección de dependencias. Esto es particularmente útil para desacoplar componentes, ya que un trait puede servir como punto de abstracción entre capas.
Por instancia, un trait para un repositorio genérico podría definirse así:
pub trait Repository<T> {
fn find_by_id(&self, id: u64) -> Option<T>;
fn save(&mut self, item: T) -> Result<(), String>;
}
Tipos concretos, como un repositorio en memoria o uno basado en SQL, implementan este trait, permitiendo que el código de domain interactúe con cualquier implementación sin conocer sus detalles. En comparación con interfaces en Go o Java, los traits de Rust soportan métodos por defecto y bounds genéricos, como where Self: Sized, lo que añade flexibilidad. Un detalle sutil es el manejo de lifetimes en traits: si un método devuelve referencias, el trait debe declarar lifetimes explícitos para evitar errores de borrowing.
Los traits también habilitan patrones como el strategy, donde algoritmos intercambiables se inyectan vía trait objects (dyn Trait). Sin embargo, el uso de trait objects incurre en overhead de vtable, por lo que se prefiere generics estáticos para rendimiento crítico. Este mecanismo refuerza la modularidad, asegurando que cambios en implementaciones no afecten a consumidores.
Inyección de dependencias manual
La inyección de dependencias (DI) en Rust se realiza manualmente, aprovechando la ownership y los traits para pasar dependencias explícitamente, en lugar de frameworks automáticos como en otros lenguajes. Esto promueve código explícito y predecible, donde un componente recibe sus dependencias a través de constructores o funciones, evitando globals o singletons que complican el testing.
Considere una servicio de domain que depende de un repositorio:
pub struct UserService<R: Repository<User>> {
repo: R,
}
impl<R: Repository<User>> UserService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub fn get_user(&self, id: u64) -> Option<User> {
self.repo.find_by_id(id)
}
}
Aquí, la inyección ocurre al crear la instancia, permitiendo tests con mocks. A diferencia de DI en Spring (Java), donde se usa inyección automática vía anotaciones, Rust exige manejo manual, lo que resalta casos borde como la propagación de ownership: si la dependencia es mutable, debe pasarse por referencia mutable o consumirse, respetando las reglas de borrowing.
Para inyección en capas, se construyen grafos de dependencias en el punto de entrada de la aplicación, como en main. No abuse de Arc para DI en contextos concurrentes, ya que introduce complejidad innecesaria; prefiera ownership lineal donde sea posible. Esta aproximación manual fortalece la robustez, ya que el compilador verifica dependencias en tiempo de compilación.
Repository pattern
El repository pattern abstrae el acceso a datos como una colección en memoria, ocultando detalles de persistencia y permitiendo múltiples backends. En Rust, se implementa mediante traits que definen operaciones CRUD, integrándose con capas de domain para mantener la lógica de negocio agnóstica al almacenamiento.
Un ejemplo básico de trait para repository:
pub trait UserRepository: Repository<User> {
fn find_by_name(&self, name: &str) -> Vec<User>;
}
Implementaciones concretas, como una para base de datos, manejarían conexiones internamente, mapeando resultados a tipos de domain. Comparado con patrones en C#, donde repositories usan Entity Framework, Rust enfatiza la inmutabilidad y el error handling explícito, utilizando Result para operaciones fallibles. Un caso borde crítico es el manejo de transacciones: en repositorios con estado, como aquellos con conexiones, se debe asegurar que el repository gestione scopes transaccionales, posiblemente vía traits adicionales.
Este patrón facilita la migración entre storages, como de SQL a NoSQL, sin alterar el domain. Evite exponer tipos de infra en el repository, para preservar el aislamiento; en su lugar, use DTOs o mapeos directos. Al combinarlo con DI, el repository pattern se convierte en un pilar para arquitecturas escalables en Rust.
Proyecto: Refactor completo de la API REST en capas limpias
Para ilustrar la aplicación integrada de estos patrones, se presenta a continuación un refactor completo de una API REST simple para gestión de usuarios. La estructura se divide en capas: domain para lógica de negocio, infra para persistencia (usando un mapa en memoria para simplicidad), y una capa de aplicación que orquesta servicios con inyección manual. Se evita cualquier framework full-stack, enfocándose en hyper para el servidor HTTP mínimo. La estructura de carpetas es la siguiente:
api-rest-refactor/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── domain/
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ └── user_service.rs
│ ├── infra/
│ │ ├── mod.rs
│ │ └── memory_repository.rs
│ └── application/
│ ├── mod.rs
│ └── server.rs
Contenido de Cargo.toml:
[package]
name = "api-rest-refactor"
version = "0.1.0"
edition = "2021"
[dependencies]
hyper = { version = "0.14", features = ["full"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Código en src/domain/mod.rs:
pub mod user;
pub mod user_service;
Código en src/domain/user.rs:
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: u64,
pub name: String,
}
impl User {
pub fn new(id: u64, name: String) -> Self {
Self { id, name }
}
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
Err("Name cannot be empty".to_string())
} else {
Ok(())
}
}
}
Código en src/domain/user_service.rs:
use crate::domain::user::User;
use crate::infra::UserRepository;
pub struct UserService<R: UserRepository> {
repo: R,
}
impl<R: UserRepository> UserService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub fn get_user(&self, id: u64) -> Option<User> {
self.repo.find_by_id(id)
}
pub fn create_user(&mut self, user: User) -> Result<(), String> {
user.validate()?;
self.repo.save(user)
}
}
Código en src/infra/mod.rs:
pub mod memory_repository;
use crate::domain::user::User;
pub trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<User>;
fn save(&mut self, user: User) -> Result<(), String>;
}
Código en src/infra/memory_repository.rs:
use std::collections::HashMap;
use crate::domain::user::User;
use crate::infra::UserRepository;
pub struct MemoryUserRepository {
users: HashMap<u64, User>,
}
impl MemoryUserRepository {
pub fn new() -> Self {
Self { users: HashMap::new() }
}
}
impl UserRepository for MemoryUserRepository {
fn find_by_id(&self, id: u64) -> Option<User> {
self.users.get(&id).cloned()
}
fn save(&mut self, user: User) -> Result<(), String> {
if self.users.contains_key(&user.id) {
Err("User already exists".to_string())
} else {
self.users.insert(user.id, user);
Ok(())
}
}
}
Código en src/application/mod.rs:
pub mod server;
Código en src/application/server.rs:
use hyper::{Body, Request, Response, Server, Method, StatusCode};
use hyper::service::{make_service_fn, service_fn};
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use crate::domain::{UserService, user::User};
use crate::infra::{MemoryUserRepository, UserRepository};
async fn handle(req: Request<Body>, service: Arc<Mutex<UserService<MemoryUserRepository>>>) -> Result<Response<Body>, Infallible> {
let mut response = Response::new(Body::empty());
match (req.method(), req.uri().path()) {
(&Method::GET, "/users") => {
<em>// Simulación simple: lista vacía para brevedad</em>
*response.body_mut() = Body::from("[]");
}
(&Method::GET, path) if path.starts_with("/users/") => {
let id_str = path.strip_prefix("/users/").unwrap();
let id: u64 = id_str.parse().unwrap_or(0);
let mut svc = service.lock().unwrap();
if let Some(user) = svc.get_user(id) {
let json = serde_json::to_string(&user).unwrap();
*response.body_mut() = Body::from(json);
} else {
*response.status_mut() = StatusCode::NOT_FOUND;
}
}
(&Method::POST, "/users") => {
let body_bytes = hyper::body::to_bytes(req.into_body()).await.unwrap();
let user: User = serde_json::from_slice(&body_bytes).unwrap();
let mut svc = service.lock().unwrap();
match svc.create_user(user) {
Ok(_) => *response.status_mut() = StatusCode::CREATED,
Err(e) => {
*response.status_mut() = StatusCode::BAD_REQUEST;
*response.body_mut() = Body::from(e);
}
}
}
_ => *response.status_mut() = StatusCode::NOT_FOUND,
}
Ok(response)
}
pub async fn start_server() {
let repo = MemoryUserRepository::new();
let service = UserService::new(repo);
let shared_service = Arc::new(Mutex::new(service));
let make_svc = make_service_fn(|_conn| {
let svc = shared_service.clone();
async move { Ok::<_, Infallible>(service_fn(move |req| handle(req, svc.clone()))) }
});
let addr = ([127, 0, 0, 1], 3000).into();
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
Código en src/main.rs:
use api_rest_refactor::application::server::start_server;
#[tokio::main]
async fn main() {
start_server().await;
}
Este refactor demuestra la separación: el domain maneja lógica pura, infra proporciona persistencia abstracta vía traits, y la aplicación inyecta dependencias manualmente en el servidor. El código es compilable y ejecutable con cargo run, sirviendo endpoints como GET /users/{id} y POST /users.
Habiendo explorado estos patrones fundamentales, el siguiente capítulo profundizará en técnicas de concurrencia que se integran con arquitecturas modulares para manejar cargas de trabajo paralelas de manera segura.