← Volver al listado de tecnologías

Capitulo 3: Arquitectura Hexagonal aplicada a Rust

Por: SiempreListo
rustwasmaxumleptos

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:

  1. El dominio define entidades y contratos (ports/traits)
  2. La aplicacion orquesta los use cases usando esos contratos
  3. 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)

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:

  1. UI: el usuario llena el formulario y presiona “Agregar”
  2. Leptos ejecuta el callback on_create con (title, description)
  3. HttpCardGateway envia POST /api/cards con JSON via gloo-net
  4. Axum recibe la peticion, el extractor Json<CreateCardDto> parsea el body
  5. card_handler::create_card llama a use_cases::create_card::execute
  6. create_card crea Card::new() y llama a repo.save(&card)
  7. SqliteCardRepo::save ejecuta el INSERT en SQLite
  8. La Card creada se serializa a JSON y viaja de vuelta al frontend
  9. Leptos re-obtiene la lista completa y actualiza los signals
  10. 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:

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 →