← Volver al listado de tecnologías

Capítulo 6: Dominio - Ports y Errores Tipados

Por: SiempreListo
rustwasmaxumleptos

Capítulo 6: Dominio - Ports y Errores Tipados

Los Ports son la pieza que hace posible la arquitectura hexagonal. En este capítulo definimos la interfaz del repositorio como un trait de Rust y los errores tipados del dominio.

Qué es un Port

En arquitectura hexagonal, un Port es una interfaz que el dominio declara para comunicarse con el mundo exterior sin conocer la implementación concreta.

flowchart TB
    subgraph DOMINIO
        Entities["Card, Lane, CardId"]
        Port["trait CardRepository\n---\nfind_all()\nfind_by_id()\nsave()\nupdate()\ndelete()"]
        Entities --- Port
    end
    Adapter["SqliteCardRepository\n(infrastructure/)"] -->|implementa| Port
    style DOMINIO fill:#f9f9f9,stroke:#333
    style Port fill:#dbeafe,stroke:#2563eb
    style Adapter fill:#fef3c7,stroke:#d97706

El dominio define qué necesita (el trait), y la capa de infraestructura provee cómo se hace (la implementación). Esto es el Principio de Inversión de Dependencias (la D de SOLID).

Trait CardRepository

use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::value_objects::CardId;

#[cfg_attr(test, mockall::automock)]
pub trait CardRepository: Send + Sync {
    fn find_all(
        &self,
    ) -> impl std::future::Future<Output = Result<Vec<Card>, DomainError>> + Send;

    fn find_by_id(
        &self,
        id: &CardId,
    ) -> impl std::future::Future<Output = Result<Card, DomainError>> + Send;

    fn save(
        &self,
        card: &Card,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;

    fn update(
        &self,
        card: &Card,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;

    fn delete(
        &self,
        id: &CardId,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;
}

Desglose del trait

#[cfg_attr(test, mockall::automock)]

Esta anotación condicional le dice a Rust: “solo en modo test, genera automáticamente una implementación mock de este trait usando la crate mockall”. En compilación de producción, no existe código de mocking. Esto genera una struct MockCardRepository con métodos como expect_find_all() para configurar respuestas en tests.

Send + Sync

pub trait CardRepository: Send + Sync {

Estos bounds son necesarios porque Axum es un servidor async multi-hilo. Send permite mover el repositorio entre hilos y Sync permite compartir referencias entre hilos. Sin estos bounds, el compilador rechazaría usar el repositorio en handlers async.

&self en lugar de &mut self

Todos los métodos reciben &self (referencia inmutable), incluso save, update y delete que modifican datos. Esto es intencional:

Métodos async con RPITIT

fn find_all(
    &self,
) -> impl std::future::Future<Output = Result<Vec<Card>, DomainError>> + Send;

Esta sintaxis usa RPITIT (Return Position Impl Trait In Traits), estable desde Rust 1.75. Es equivalente a escribir async fn find_all(&self) -> Result<Vec<Card>, DomainError> pero con el bound + Send explícito, necesario para compatibilidad con runtimes multi-hilo como Tokio.

Los 5 métodos CRUD

MétodoEntradaSalidaOperación
find_all-Vec<Card>Listar todas las tarjetas
find_by_id&CardIdCardBuscar una tarjeta por ID
save&Card()Persistir tarjeta nueva
update&Card()Actualizar tarjeta existente
delete&CardId()Eliminar por ID

Todos retornan Result<_, DomainError>, nunca panic!. Los errores fluyen hacia arriba para que el handler HTTP los convierta en respuestas apropiadas.

Errores de Dominio con thiserror

use thiserror::Error;

#[derive(Debug, Error)]
pub enum DomainError {
    #[error("Card no encontrada: {0}")]
    CardNotFound(String),

    #[error("Lane invalido: {0}")]
    InvalidLane(String),

    #[error("Error de persistencia: {0}")]
    Persistence(String),
}

Por qué un enum de errores tipado

Comparemos con la alternativa de usar String:

// MAL: error genérico
fn find_by_id(&self, id: &CardId) -> Result<Card, String>;

// BIEN: error tipado
fn find_by_id(&self, id: &CardId) -> Result<Card, DomainError>;

Con DomainError obtienes:

  1. Pattern matching exhaustivo: El compilador te obliga a manejar cada variante.
  2. Contexto semántico: CardNotFound comunica qué falló sin leer el mensaje.
  3. Composición: Puedes convertir DomainError a errores HTTP con impl From<DomainError> for StatusCode.

Cómo funciona thiserror

La crate thiserror genera automáticamente la implementación de std::fmt::Display a partir de la anotación #[error("...")]. Por ejemplo:

#[error("Card no encontrada: {0}")]
CardNotFound(String),

Genera:

impl fmt::Display for DomainError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            DomainError::CardNotFound(id) => write!(f, "Card no encontrada: {}", id),
            // ...
        }
    }
}

También implementa std::error::Error automáticamente. Sin thiserror tendrías que escribir todo esto manualmente.

Las tres variantes

VarianteCuándo se usaEjemplo
CardNotFoundfind_by_id no encuentra la tarjetaCardNotFound("abc-123".into())
InvalidLaneString no mapea a un Lane válidoInvalidLane("invalid".into())
PersistenceError de SQLite u otro storagePersistence(e.to_string())

Persistence es genérico a propósito: envuelve cualquier error de infraestructura como String para no contaminar el dominio con tipos de SQLx o cualquier otra crate.

Organización de módulos

// domain/mod.rs
pub mod entities;
pub mod errors;
pub mod ports;
pub mod value_objects;

Este archivo es el punto de entrada de la capa de dominio. Declara los cuatro submódulos sin lógica adicional. Cada submódulo a su vez tiene su propio mod.rs con re-exportaciones:

// domain/ports/mod.rs
pub mod card_repository;
pub use card_repository::CardRepository;

La convención es: mod.rs declara y re-exporta, los archivos individuales contienen la implementación. Esto mantiene los imports limpios en el resto de la aplicación.

Árbol completo del dominio

domain/
├── mod.rs                    # Declara entities, errors, ports, value_objects
├── entities/
│   ├── mod.rs                # pub use card::Card
│   └── card.rs               # Struct Card + new() + move_to()
├── value_objects/
│   ├── mod.rs                # pub use CardId, Lane
│   ├── card_id.rs            # Wrapper de UUID
│   └── lane.rs               # Enum Todo/Doing/Done
├── errors.rs                 # DomainError enum
└── ports/
    ├── mod.rs                # pub use CardRepository
    └── card_repository.rs    # Trait del repositorio

Resumen


← Capítulo 5: Dominio - Entidades y Value Objects | Capítulo 7: Application - Casos de Uso →