Seguimos con la serie.
Con la arquitectura ya definida y las fronteras trazadas, tocaba bajar al barro y construir la parte más crítica de nuestro sistema: el Dominio.
El objetivo principal de esta capa es modelar el negocio de tal forma que las reglas sean evidentes, estrictas y, sobre todo, no dependan de que el programador “se acuerde” de validarlas en el resto del código. Si el dominio lo permite, es válido; si no, ni siquiera debería compilar (o debería fallar con un error explícito).

Código de referencia:
Entidad Task: diseño orientado a invariantes #
El dominio es el corazón de nuestra arquitectura hexagonal. Y dentro de este corazón, la entidad Task es la pieza fundamental. ¿De qué sirve una aplicación de gestionar tareas si no tenemos una definición robusta y cristalina de qué es exactamente una tarea?
En nuestro contexto, una tarea no es solo una estructura de datos plana o un registro en una base de datos. Es un elemento con un ciclo de vida. Tiene atributos descriptivos (título, descripción), estado (Todo, Done), e información de auditoría (created_at, modified_at). Todo bajo un identificador único.
La definición base de Task tiene esta pinta:
id: Identificador único (hemos usado el crateuuidpara generar UUIDs v4).title: El nombre de la tarea (String).status: Unenumfuertemente tipado (TaskStatus) que restringe los estados posibles (Todo,Done).created_at/modified_at: Fechas de auditoría manejadas con el cratechrono(DateTime<Utc>).
Para que esta entidad pueda viajar desde o hacia la capa de persistencia y de presentación sin esfuerzo manual, nos apoyamos en el omnipresente crate serde derivando Serialize y Deserialize.
Hasta aquí no estamos inventando la rueda. Lo verdaderamente relevante empieza cuando decidimos cómo muta esa tarea en el tiempo. Nuestra aplicación va a realizar acciones que cambiarán el estado de la entidad, y es precisamente aquí donde definimos el flujo de interacción que rige las reglas de negocio.
Transiciones inmutables (self -> Result<Self>)
#
Las acciones obvias que podemos hacer con una tarea son: completarla, reabrirla o editar su título. En muchos sistemas, estas acciones se modelarían simplemente alterando el campo correspondiente, es decir, mutando la instancia directamente en memoria.
Sin embargo, en este proyecto elegimos que los cambios de estado se hagan mediante funciones inmutables que consumen la tarea actual y devuelven una copia modificada (o un error):
pub fn mark_done(self) -> DomainResult<Self>
pub fn mark_todo(self) -> DomainResult<Self>
pub fn edit_title(self, title: String) -> DomainResult<Self>¿Por qué self inmutable en lugar de &mut self?
#
Si utilizamos referencias mutables (&mut self), abrimos la puerta a una deriva peligrosa del código. Cuando permites que cualquier parte del sistema mute libremente la entidad, ocurre lo siguiente:
- Un caso de uso (
UseCaseA) cambia el estadostatus = Donepero olvida actualizar elmodified_at. - Otro caso de uso (
UseCaseB) cambia el título, y además hace sus propias validaciones directamente en la capa de aplicación. - Con el tiempo, aparecen setters sueltos como
pub fn set_status(&mut self, status: Status)que puentean cualquier validación de negocio.
Resultado: Las reglas de negocio acaban esparcidas por toda la aplicación, y el comportamiento se vuelve inconsistente.
Al exigir self (que consume el objeto por valor en Rust) y devolver un Result<Self>, logramos varias cosas:
- Evitamos efectos secundarios: Una tarea no muta de forma transparente debajo de la mesa. O tienes la tarea original, o tienes la nueva versión.
- Forzamos la validación (Gatekeeping): Toda modificación tiene que pasar por una única función que actúa como aduana. Si la transición no tiene sentido (ej: editar el título con un texto vacío), la función devuelve un error explícito.
- Rust nos ayuda: Al consumir
self, el compilador evita que uses la “versión vieja” de la tarea por accidente en líneas posteriores, forzando a reasignarla.
Ejemplo concreto: validando reglas compuestas #
El valor real de encapsular esto en el dominio brilla cuando las reglas de negocio dejan de ser simples setters. No basta con decir “el estado es Done”, hay que definir las restricciones ligadas al comportamiento natural del objeto.
Imaginemos la lógica interna de transicionar una tarea. Cuando llamamos a mark_as(status) ocurren varias cosas simultáneas en un solo punto de verdad:
- Evita transiciones inútiles: Verifica si el nuevo estado es el mismo que el actual (
Todo -> Todo,Done -> Done). Si es así, no hacemos nada y devolvemos un error de dominio indicando que la transición es inválida. - Actualización ligada: Si y solo si el estado cambia, generamos un nuevo timestamp para el
modified_atusandoUtc::now(). - Preservación (Struct Update Syntax): En Rust es muy idiomático crear una nueva instancia copiando los campos no modificados de la antigua usando
..self. Así clonamos elcreated_aty elidoriginal de forma intacta y limpia:
fn mark_as(self, status: TaskStatus) -> DomainResult<Self> {
if status == self.status {
Err(DomainError::InvalidStatusTransition { /* ... */ })
} else {
Ok(Self {
status,
modified_at: Utc::now(),
..self // Mágico: copia el id, title y created_at de la tarea original
})
}
}Encapsulando esto evitamos bugs sutiles pero molestos, como el clásico problema de auditoría donde el “último modificado” de un registro ha cambiado en base de datos sin que realmente se haya tocado ningún dato útil. Todo ocurre dentro del anillo seguro del dominio.
Taxonomía de errores: Un tipo para cada capa #
En Rust, el manejo de errores mediante Result<T, E> es excepcional. Pero esa herramienta pierde todo su poder si abusamos del infame Result<T, String> o de usar un único GlobalError para todo el proyecto.
Saber qué ha fallado es vital, pero saber dónde y por qué ha fallado es lo que separa un buen diseño de uno doloroso de depurar. Por ello, hemos creado una taxonomía estricta de errores, apoyándonos muchísimo en el genial crate thiserror. Con él, derivamos fácilmente std::error::Error y formateamos los mensajes automáticamente (#[error("task title cannot be empty")]), dándole una semántica única a cada nodo de la arquitectura.
Errores mapeados por responsabilidad:
- Dominio:
DomainError-> Ej: “Título vacío”, “Transición de estado inválida”. Son errores de negocio puros. - Repositorio:
RepoError-> Ej: “No se pudo leer el archivo JSON”, “Error de conexión a BD”. Son errores de infraestructura técnica. - Aplicación:
ApplicationError-> Ej: “Tarea no encontrada en el repositorio”, “Fallo al ejecutar el flujo X”. Agrupa y orquesta errores de capas inferiores añadiéndoles contexto funcional. - CLI:
CliError-> Ej: “Formato de argumento inválido”. Es el último eslabón, pensado para darle un mensaje claro (y bonito) al usuario en la terminal.
¿Por qué tanto tipo de error? #
Imagina un caso real: El usuario ejecuta done 123 y el ID no existe.
Si todo termina encapsulado en un mensaje de “Error al procesar”, has perdido información crítica. ¿Falló el parseo de comandos? ¿Explotó el disco al abrir el JSON? ¿O es que simplemente el ID no estaba?
- Que el ID no exista en BD no es un fallo técnico ni de infraestructura, es un error esperado del flujo funcional (un
TaskNotFoundde la capa de aplicación). - Que el usuario intente marcar como completada una tarea que ya está completada es una violación de las reglas (un
DomainError). - Que no se pueda acceder al archivo de persistencia es un error de sistema (
RepoError).
Al tipar cada error por capa, un caso de uso puede capturar un RepoError, envolverlo en un ApplicationError y pasárselo al adaptador CLI para que decida si muestra el error en rojo, si lanza un log al sistema o si hace un exit con código 1. Todo esto hace que testear fallos específicos (ej. assert_eq!(err, ApplicationError::TaskNotFound)) sea trivial y predecible.
Use cases: Orquestadores aburridos #
Una vez tenemos un dominio robusto que se protege a sí mismo, y un sistema de errores claro, ¿qué le queda hacer a la capa de Aplicación (Casos de Uso)? Principalmente: ser aburrida.
Los casos de uso deben ser simples orquestadores. Relacionan las entidades del dominio con los recursos del exterior (puertos de repositorios), pero nunca toman decisiones de negocio ni mutan cosas “a mano”. Para aislar aún más la entrada, aplicamos el Command Pattern: el caso de uso no recibe variables sueltas (id, status), sino que recibe un struct inmutable (MarkTaskDoneCommand), lo que nos da muchísima más limpieza si en un futuro la acción requiere más parámetros.
Un caso de uso típico de nuestra CLI quedó reducido a un flujo lineal y predecible: recibir Command -> buscar en repo -> transicionar entidad -> guardar en repo.
Puedes ver un par de ejemplos aquí:
Si te fijas, el caso de uso mark_task_done no verifica si la tarea ya estaba done o no. Simplemente inyecta las dependencias usando un Trait TaskRepository e invoca a task.mark_done(). Es el dominio el que decide si la acción es válida, y si falla, el caso de uso propaga limpiamente el DomainError hacia arriba usando el operador ? (Ok(task.mark_done()?)).
La señal definitiva de madurez en el diseño #
La prueba de fuego para este diseño inmutable y de errores aislados ocurrió cuando empezamos a enchufar piezas distintas. Cuando pasamos de usar salidas de texto simple a devolver objetos JSON por terminal, y cuando conectamos la persistencia real a disco… no tuvimos que tocar ni una sola línea de las reglas de estado en Task.
Eso es exactamente lo que buscas en Arquitectura Hexagonal. Te indica que tu dominio está libre de distracciones externas y que la inversión de dependencias está haciendo su trabajo.
Conclusión: El valor de la disciplina #
Diseñar el dominio no es simplemente agrupar datos en structs. Es codificar las leyes inquebrantables de nuestro sistema.
Puede parecer tentador (y más rápido) exponer todos los campos como pub y mutarlos desde el main.rs, pero ese camino siempre cobra un peaje alto en deuda técnica. Al elegir el camino “largo”, entidades inmutables, transiciones explícitas y errores tipados por capa, hemos ganado algo invaluable: tranquilidad.
Ahora sabemos que es imposible corromper el estado de una tarea desde la UI. Sabemos que si algo falla, tendremos un error preciso indicando si fue culpa del usuario, del sistema o de la lógica. Hemos construido un núcleo blindado.
Con este corazón latiendo y protegido, ya estamos listos para darle un hogar donde guardar las tareas entre ejecución y ejecución. En el siguiente post abriremos la despensa: veremos cómo diseñar el contrato de repositorio, implementar persistencia JSON a disco, construir un adaptador in-memory para tests y tomar decisiones que preparen el terreno para escalar.
¡Nos vemos en la capa de persistencia!