Capítulo 6: Dominio - Ports y Errores Tipados
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:
- La mutabilidad interna la maneja la implementación (SQLite usa un pool de conexiones que es internamente mutable).
- Con
&selfpodemos compartir el repositorio entre múltiples handlers sin necesidad deMutex. - Es el patrón estándar en Rust para repositorios async.
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étodo | Entrada | Salida | Operación |
|---|---|---|---|
find_all | - | Vec<Card> | Listar todas las tarjetas |
find_by_id | &CardId | Card | Buscar 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:
- Pattern matching exhaustivo: El compilador te obliga a manejar cada variante.
- Contexto semántico:
CardNotFoundcomunica qué falló sin leer el mensaje. - Composición: Puedes convertir
DomainErrora errores HTTP conimpl 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
| Variante | Cuándo se usa | Ejemplo |
|---|---|---|
CardNotFound | find_by_id no encuentra la tarjeta | CardNotFound("abc-123".into()) |
InvalidLane | String no mapea a un Lane válido | InvalidLane("invalid".into()) |
Persistence | Error de SQLite u otro storage | Persistence(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
- CardRepository es un trait (Port) que define las 5 operaciones CRUD sin conocer la implementación.
- Los métodos usan
&selfpara permitir concurrencia y RPITIT para futures conSend. - DomainError tipifica los tres escenarios de error posibles usando
thiserror. #[cfg_attr(test, mockall::automock)]genera mocks automáticos solo en compilación de tests.- La capa de dominio completa no depende de ningún framework: solo de crates de utilidad.
← Capítulo 5: Dominio - Entidades y Value Objects | Capítulo 7: Application - Casos de Uso →