Capitulo 3: Arquitectura Hexagonal aplicada a Rust
Que es la Arquitectura Hexagonal
La arquitectura hexagonal (tambien llamada Ports & Adapters) separa la logica de negocio de los detalles tecnicos. La idea central es: el dominio no sabe como se almacenan los datos ni como llegan las peticiones HTTP.
Las reglas son simples:
- El dominio define entidades y contratos (ports/traits)
- La aplicacion orquesta los use cases usando esos contratos
- La infraestructura implementa los contratos con tecnologias concretas
Las 3 capas
Domain (centro)
Contiene las entidades, value objects y ports (traits). No depende de nada externo: ni de Axum, ni de SQLx, ni de ninguna libreria de infraestructura.
En nuestro proyecto:
// domain/entities/card.rs
pub struct Card {
pub id: CardId,
pub title: String,
pub description: String,
pub lane: Lane,
pub position: i32,
pub completed: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Los value objects encapsulan validaciones y comportamiento:
// domain/value_objects/lane.rs
pub enum Lane {
Todo,
Doing,
Done,
}
impl Lane {
pub fn is_done(&self) -> bool {
matches!(self, Lane::Done)
}
}
Los ports son traits que definen contratos:
// domain/ports/card_repository.rs
pub trait CardRepository: Send + Sync {
fn find_all(&self)
-> impl Future<Output = Result<Vec<Card>, DomainError>> + Send;
fn find_by_id(&self, id: &CardId)
-> impl Future<Output = Result<Card, DomainError>> + Send;
fn save(&self, card: &Card)
-> impl Future<Output = Result<(), DomainError>> + Send;
fn update(&self, card: &Card)
-> impl Future<Output = Result<(), DomainError>> + Send;
fn delete(&self, id: &CardId)
-> impl Future<Output = Result<(), DomainError>> + Send;
}
El trait CardRepository es un puerto: define QUE operaciones necesita el dominio, sin decir COMO se implementan.
Application (use cases)
Los use cases orquestan la logica de negocio. Reciben un &impl CardRepository (cualquier implementacion del trait) y operan sobre entidades del dominio.
// application/use_cases/create_card.rs
pub async fn execute(
repo: &impl CardRepository,
title: String,
description: String,
) -> Result<Card, DomainError> {
let card = Card::new(title, description);
repo.save(&card).await?;
Ok(card)
}
// application/use_cases/move_card.rs
pub async fn execute(
repo: &impl CardRepository,
card_id: &CardId,
lane: Lane,
position: i32,
) -> Result<(), DomainError> {
let mut card = repo.find_by_id(card_id).await?;
card.move_to(lane, position);
repo.update(&card).await
}
Observa que move_card primero busca la card, luego ejecuta logica de dominio (move_to), y finalmente persiste. El use case no sabe que la persistencia es SQLite.
Infrastructure (adapters)
Aqui viven las implementaciones concretas. Los adapters implementan los ports del dominio:
// infrastructure/persistence/sqlite_card_repo.rs
impl CardRepository for SqliteCardRepo {
async fn find_all(&self) -> Result<Vec<Card>, DomainError> {
let rows = sqlx::query("SELECT * FROM cards ORDER BY lane, position")
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.iter().map(row_to_card).collect()
}
// ... demas metodos
}
Los HTTP handlers tambien son infraestructura: adaptan peticiones HTTP a llamadas de use cases:
// infrastructure/http/card_handler.rs
pub async fn create_card(
State(state): State<AppState>,
Json(dto): Json<CreateCardDto>,
) -> Result<(StatusCode, Json<Card>), DomainError> {
let desc = dto.description.unwrap_or_default();
let card = use_cases::create_card::execute(
&state.repo, dto.title, desc
).await?;
Ok((StatusCode::CREATED, Json(card)))
}
Regla de dependencia
Las dependencias siempre apuntan hacia adentro:
Infrastructure → Application → Domain
(usa) (usa) (no depende de nada)
infrastructureimportaapplicationydomainapplicationimporta solodomaindomainno importa nada externo
Esto se verifica con los use de Rust. Un archivo en domain/ nunca tiene use crate::infrastructure::....
Diagrama de la arquitectura
flowchart TB
subgraph Infrastructure
AX["Axum HTTP Handlers"]
SQLR["SQLiteCardRepo (implementa CardRepository)"]
subgraph Application
CC["create_card"]
MC["move_card"]
DC["delete_card"]
subgraph Domain
ENT["Card, CardId, Lane"]
PORT["CardRepository (trait/port)"]
ERR["DomainError"]
end
end
AX --> Application
SQLR --> Application
end
Estructura de carpetas real
Backend (app-backend)
src/
├── main.rs
├── domain/
│ ├── mod.rs
│ ├── entities/
│ │ ├── mod.rs
│ │ └── card.rs # Entidad Card con logica de negocio
│ ├── value_objects/
│ │ ├── mod.rs
│ │ ├── card_id.rs # Value object para ID unico
│ │ └── lane.rs # Enum Todo/Doing/Done
│ ├── ports/
│ │ ├── mod.rs
│ │ └── card_repository.rs # Trait (puerto)
│ └── errors.rs # Errores de dominio
├── application/
│ ├── mod.rs
│ └── use_cases/
│ ├── mod.rs
│ ├── create_card.rs
│ ├── list_cards.rs
│ ├── move_card.rs
│ └── delete_card.rs
└── infrastructure/
├── mod.rs
├── config/
│ ├── mod.rs
│ ├── app_state.rs # Estado compartido (repo)
│ └── router.rs # Definicion de rutas Axum
├── http/
│ ├── mod.rs
│ ├── card_handler.rs # Handlers HTTP
│ └── error_handler.rs # Conversion DomainError → HTTP
└── persistence/
├── mod.rs
└── sqlite_card_repo.rs # Adapter SQLite
Frontend (app-frontend)
src/
├── main.rs
├── domain/
│ ├── mod.rs
│ ├── entities/
│ │ ├── mod.rs
│ │ └── card.rs # Card simplificada (Strings)
│ └── ports/
│ ├── mod.rs
│ └── card_gateway.rs # Trait para comunicacion HTTP
├── infrastructure/
│ ├── mod.rs
│ └── http_card_gateway.rs # Adapter gloo-net
└── ui/
├── mod.rs
├── app.rs # Componente raiz
├── pages/
│ ├── mod.rs
│ └── board.rs # Pagina del tablero Kanban
└── components/
├── mod.rs
├── card_item.rs # Tarjeta individual (draggable)
├── create_card_form.rs # Formulario de creacion
└── lane.rs # Columna del tablero (drop zone)
Flujo de un request completo
Veamos que pasa cuando el usuario crea una tarjeta:
- UI: el usuario llena el formulario y presiona “Agregar”
- Leptos ejecuta el callback
on_createcon(title, description) - HttpCardGateway envia
POST /api/cardscon JSON via gloo-net - Axum recibe la peticion, el extractor
Json<CreateCardDto>parsea el body - card_handler::create_card llama a
use_cases::create_card::execute - create_card crea
Card::new()y llama arepo.save(&card) - SqliteCardRepo::save ejecuta el INSERT en SQLite
- La
Cardcreada se serializa a JSON y viaja de vuelta al frontend - Leptos re-obtiene la lista completa y actualiza los signals
- El DOM se actualiza automaticamente donde se lee el signal
cards
Por que hexagonal para una POC
Podria parecer excesivo para 856 lineas. Pero la arquitectura hexagonal aporta:
- Testabilidad: el trait
CardRepositoryse puede mockear conmockallsin tocar SQLite - Claridad: cada archivo tiene una responsabilidad unica y un lugar claro en la estructura
- Educacion: demuestra que Rust facilita esta arquitectura gracias a los traits
- Escalabilidad: agregar un nuevo adapter (PostgreSQL, REST distinto) no toca el dominio
Los traits de Rust son el mecanismo perfecto para ports: definen contratos en compilacion, sin overhead en runtime.
← Anterior: Analisis de Frameworks | Siguiente: Setup del Workspace →