Seguimos con la serie.
En los capítulos anteriores montamos los cimientos de la cocina (arquitectura hexagonal) y preparamos los ingredientes principales (dominio inmutable y errores tipados). Ahora toca abrir la despensa: el lugar donde guardamos todo entre servicio y servicio. Porque una cocina sin despensa es una cocina que empieza de cero cada día.
La persistencia es donde muchos proyectos pequeños se tuercen. No por la complejidad del almacenamiento en sí, guardar JSON en un archivo no tiene misterio, sino por cómo se conecta con el resto del sistema. Si la capa de negocio conoce rutas de archivos, formatos de serialización o errores de I/O, deja de ser lógica de negocio y se convierte en un script de infraestructura con aires de grandeza.
Este capítulo tiene un eje central: la diferencia entre contrato e implementación. En Rust, esa diferencia se materializa con traits. Definimos qué necesita la aplicación (el trait) antes de decidir cómo lo resolvemos (los adapters). Y esa separación no es ceremonia académica, es lo que permite tener dos implementaciones del mismo contrato sin tocar una línea de lógica de negocio.

Código de referencia:
- src/tasks/ports/outputs/task_repository.rs
- src/tasks/ports/outputs/errors.rs
- src/tasks/adapters/persistence/in_memory_task_repository.rs
- src/tasks/adapters/persistence/json_file_task_repository.rs
El contrato: un trait que no sabe de JSON ni de discos #
Antes de tocar disco, antes de pensar en JSON, antes de importar serde, definimos el contrato. En arquitectura hexagonal, el puerto de salida es el trait que la capa de aplicación necesita para funcionar, sin saber ni importarle quién lo implementa.
pub trait TaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()>;
fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>>;
fn find_by_id(&self, id: Uuid) -> RepoResult<Option<Task>>;
fn delete(&mut self, id: Uuid) -> RepoResult<bool>;
}
#[derive(Debug, Clone, Copy)]
pub enum TaskQuery {
All,
ByStatus(TaskStatus),
}Cuatro operaciones. Un enum de consulta. Eso es todo lo que la aplicación necesita saber sobre persistencia. Fíjate en lo que no aparece: no hay PathBuf, no hay serde::Serialize, no hay std::fs, no hay HashMap. El trait es agnóstico a la tecnología de almacenamiento. Es pura semántica de negocio expresada como interfaz.
Esta es la diferencia fundamental entre contrato e implementación: el trait dice qué se puede hacer, no cómo se hace. Y en Rust, esa separación tiene una ventaja adicional que no existe en lenguajes con interfaces clásicas (Java, Go): el compilador verifica en tiempo de compilación que cada implementación cumple exactamente el contrato, incluyendo tipos de retorno, mutabilidad y ownership.
Por qué un trait y no llamar JSON directamente desde casos de uso #
La tentación natural en un proyecto pequeño es ir directo: serde_json::from_str() dentro del caso de uso, fs::write() al final, y listo. Funciona. Pero el coste aparece cuando quieres cambiar algo:
Si el caso de uso conoce JSON:
- depende de
serde: un cambio en el formato de serialización rompe lógica de negocio. - depende de rutas del filesystem: no puedes testear sin disco.
- depende de errores de I/O:
std::io::Errorse mezcla con errores de dominio. - y deja de ser caso de uso para convertirse en un script de infraestructura que hace todo y no se puede testear en partes.
Con el trait, la capa de aplicación solo conoce operaciones de negocio: “guarda esta tarea”, “dame las tareas filtradas”, “busca por ID”, “borra por ID”. El cómo lo resuelve quien implemente el trait.
En la práctica, esto se ve en los casos de uso. Por ejemplo, AddTaskService recibe un genérico R: TaskRepository:
pub struct AddTaskService<R: TaskRepository> {
repo: R,
}El servicio no sabe si R es un HashMap en memoria o un archivo JSON en disco. No le importa. Solo sabe que puede llamar a .save(), .list(), .find_by_id() y .delete(). Si mañana añades un PostgresTaskRepository, el servicio funciona sin cambiar una línea.
Esta es la inversión de dependencias materializada en código Rust: la capa interna (aplicación) define el contrato, la capa externa (infraestructura) lo implementa. La flecha de dependencia apunta hacia adentro, no hacia afuera.
Anatomía del contrato: cada firma tiene una decisión #
No es casualidad que las firmas del trait sean como son. Cada una codifica una decisión de diseño intencionada:
fn save(&mut self, task: Task) -> RepoResult<()>
Recibe Task por valor, no por referencia. ¿Por qué? Porque el repositorio toma propiedad de la tarea. Una vez guardada, el caller no debería seguir mutándola sin pasar por el repositorio de nuevo. Esto es coherente con el dominio inmutable del Post 2: las transiciones de estado producen nuevas instancias, y el repositorio guarda la nueva versión. Si la firma fuera fn save(&mut self, task: &Task), el caller conservaría la referencia y podría asumir que “ya está guardada” mientras sigue mutando una copia local. Con ownership transfer, esa confusión no es posible.
El &mut self indica que guardar es una operación que modifica estado interno. En el JSON adapter, modifica el archivo. En el in-memory, modifica el HashMap. El compilador te impide llamar a save desde un contexto que solo tiene &self.
fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>>
Usa &self, no &mut self. Listar tareas es una operación de lectura pura. Esto permite que el compilador verifique que no estás modificando estado interno al consultar. En un contexto de concurrencia (que no aplicamos aquí, pero sería el siguiente paso), esto permitiría múltiples lectores simultáneos.
El TaskQuery es un enum Copy con dos variantes (All, ByStatus(TaskStatus)). Es suficiente para las necesidades actuales y extensible sin romper firmas: añadir ByDateRange(DateTime, DateTime) mañana no cambia la firma de list, solo amplía el enum.
fn find_by_id(&self, id: Uuid) -> RepoResult<Option<Task>>
Aquí la decisión más importante está en el tipo de retorno: Option<Task>, no Result<Task, NotFoundError>. “No encontrado” no es un error de infraestructura. Es un resultado esperable. El disco funcionó, la lectura fue correcta, simplemente no había ninguna tarea con ese ID.
El caso de uso decide qué hacer con None. En MarkTaskDoneService, por ejemplo, convierte None en DomainError::TaskNotFound. Pero en otro contexto podría ser perfectamente válido que no exista (por ejemplo, antes de crear una tarea, verificar que no hay duplicados).
Esta distinción entre “la operación falló” (Err) y “la operación funcionó pero no había datos” (Ok(None)) es sutil pero crítica para mantener la cadena de errores limpia. Si find_by_id devolviera Err(NotFound), cada caller tendría que distinguir “¿falló el disco o simplemente no existe?”, y eso es mezclar niveles de abstracción.
fn delete(&mut self, id: Uuid) -> RepoResult<bool>
Devuelve bool, no Option<Task>. El booleano indica si se borró algo (true) o no había nada que borrar (false). Ninguno de los dos es un error. Este diseño simplifica enormemente la capa CLI: true se muestra como DELETED, false como NOT_FOUND, ambos con exit code 0. Si delete devolviera Err para “no existe”, la CLI tendría que decidir si un delete idempotente es un error o no, y esa decisión de UX estaría contaminando la capa de persistencia.
El tipo de error: genérico pero suficiente #
pub type RepoResult<T> = Result<T, RepoError>;
#[derive(Debug, Error)]
pub enum RepoError {
#[error("internal error: {error}")]
InternalError { error: String },
}RepoError es intencionalmente genérico. Tiene una sola variante (InternalError) con un campo String libre. ¿Es ideal? No. ¿Es suficiente para el alcance actual? Sí.
La alternativa sería tener variantes como IoError(std::io::Error), SerializationError(serde_json::Error), DirectoryNotFound(PathBuf). Pero eso filtraría detalles de implementación (I/O, serde, paths) al puerto de salida, que pertenece a la capa de dominio/aplicación. Un trait que vive en ports/outputs/ no debería importar std::io::Error, eso acoplaría el contrato a una tecnología concreta.
Con InternalError { error: String }, cada adapter traduce sus errores específicos a un string con contexto. No es tipado fuerte, pero preserva la frontera de abstracción. Es una decisión de deuda técnica explícita que discutimos en detalle en el Post 3.1.
El type alias RepoResult<T> simplifica las firmas, igual que CliResult<T> en la capa CLI y ApplicationResult<T> en la de aplicación. Tres capas, tres type aliases, tres enums de error. Consistencia que reduce carga cognitiva.
Dónde vive el contrato en la arquitectura #
src/tasks/
├── domain/ # Entidades, errores de dominio
├── application/ # Casos de uso, errores de aplicación
├── ports/
│ └── outputs/
│ ├── task_repository.rs # ← EL TRAIT VIVE AQUÍ
│ └── errors.rs # ← RepoError, RepoResult
└── adapters/
└── persistence/
├── mod.rs # pub mod declarations
├── in_memory_task_repository.rs # ← IMPLEMENTACIÓN 1
└── json_file_task_repository.rs # ← IMPLEMENTACIÓN 2Observa la separación: el trait vive en ports/outputs/ (pertenece a la capa de aplicación/dominio), mientras que las implementaciones viven en adapters/persistence/ (pertenecen a la capa de infraestructura).
Esta es la inversión de dependencias visualizada en el árbol de directorios. La capa interna (ports/) define qué necesita. La capa externa (adapters/) decide cómo lo provee. Las flechas de importación van de adapters/ hacia ports/, nunca al revés. Si un adapter importa el trait, eso es correcto. Si el trait importara un adapter, la arquitectura estaría rota.
Las implementaciones: dos adapters, una interfaz #
Con el contrato definido, tenemos dos implementaciones. No es casualidad que sean dos: cada una tiene un propósito distinto y complementario.
src/tasks/adapters/persistence/
├── mod.rs # pub mod declarations
├── in_memory_task_repository.rs # HashMap en memoria
└── json_file_task_repository.rs # JSON a discoEl mod.rs es mínimo:
pub mod in_memory_task_repository;
pub mod json_file_task_repository;Dos módulos, dos líneas, dos responsabilidades completamente distintas.
InMemoryTaskRepository: la doble de riesgos
#
En cocina, antes de servir un plato nuevo al cliente se prueba con el equipo. El InMemoryTaskRepository es esa prueba: rápido, descartable, sin efectos secundarios. Es la doble de riesgos de la persistencia real.
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct InMemoryTaskRepository {
cache: HashMap<Uuid, Task>,
}
impl InMemoryTaskRepository {
pub fn new() -> Self {
Self {
cache: HashMap::default(),
}
}
}Un HashMap<Uuid, Task>. Nada más. Sin archivos, sin serialización, sin latencia de filesystem. Pero implementa exactamente el mismo trait TaskRepository:
impl TaskRepository for InMemoryTaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()> {
Self::add_task(self, task)
}
fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>> {
let result = match query {
TaskQuery::All => self.cache.values().cloned().collect(),
TaskQuery::ByStatus(task_status) => self.get_task_by_status(task_status),
};
Ok(result)
}
fn find_by_id(&self, task_id: Uuid) -> RepoResult<Option<Task>> {
Ok(self.get_task_by_id(task_id).cloned())
}
fn delete(&mut self, task_id: Uuid) -> RepoResult<bool> {
match self.delete_task_by_id(task_id) {
Some(_) => Ok(true),
None => Ok(false),
}
}
}Fíjate: save delega a add_task, que internamente usa HashMap::insert. insert hace upsert por naturaleza, si la key ya existe, reemplaza el valor. Eso significa que el comportamiento de “guardar una tarea modificada” y “guardar una tarea nueva” es el mismo. Exactamente lo que definimos en el contrato.
¿Para qué sirve una implementación que pierde los datos al terminar el proceso? Para dos cosas:
-
Tests de lógica de negocio. Cuando ejecutas los tests de
AddTaskServiceoListTasksService, esos tests usanInMemoryTaskRepository. No están testeando persistencia; están testeando que el caso de uso se comporta correctamente. Sin disco, sin latencia, sin setup. -
Validación del contrato. Si el in-memory cumple el mismo trait que el JSON, y los casos de uso funcionan con el in-memory, entonces funcionarán con cualquier implementación que cumpla el trait. El in-memory es la prueba viviente de que la abstracción funciona.
Aquí está la potencia de separar contrato de implementación: puedes verificar toda la lógica de negocio contra una implementación trivial, y confiar en que la implementación real (JSON a disco) solo necesita pasar sus propios tests de infraestructura.
JsonFileTaskRepository: la despensa real
#
Este es el adapter que el usuario toca. Guarda las tareas en un archivo JSON, sobrevive entre ejecuciones, y maneja todo lo que eso implica: crear directorios, leer archivos, serializar, deserializar, y fallar con contexto cuando algo sale mal.
#[derive(Debug, Clone)]
pub struct JsonFileTaskRepository {
file_path: PathBuf,
}Solo un PathBuf. El repositorio no mantiene estado en memoria más allá de la ruta del archivo. Cada operación lee el archivo, modifica los datos y escribe de vuelta. ¿Es lo más eficiente? No. ¿Es lo más simple y correcto para un CLI que se ejecuta, hace una operación y termina? Absolutamente.
Compara con el in-memory: allí el estado vive en un HashMap en el heap. Aquí el estado vive en un archivo en disco. Pero ambos implementan el mismo contrato. El caso de uso no tiene forma de distinguirlos, y esa es exactamente la idea.
La construcción: directories y rutas de plataforma
#
El constructor new() resuelve dónde vive el archivo de datos:
impl JsonFileTaskRepository {
pub fn new() -> RepoResult<Self> {
let project_dirs = ProjectDirs::from("com", "org", "todo-cli")
.ok_or_else(|| RepoError::InternalError {
error: "could not resolve project directories".to_string(),
})?;
let data_dir = project_dirs.config_dir().join("data");
fs::create_dir_all(&data_dir).map_err(|e| RepoError::InternalError {
error: format!(
"could not create data directory '{}': {e}",
data_dir.display()
),
})?;
let file_path = data_dir.join("tasks.json");
Ok(Self { file_path })
}
}Aquí hay varias decisiones que merecen explicación:
El crate directories. En vez de hardcodear una ruta como ~/.todo-cli/tasks.json, usamos ProjectDirs::from("com", "org", "todo-cli") que resuelve la ruta según la plataforma:
- Linux:
~/.config/todo-cli/data/tasks.json - macOS:
~/Library/Application Support/com.org.todo-cli/data/tasks.json - Windows:
C:\Users\<user>\AppData\Roaming\org\todo-cli\data\tasks.json
¿Por qué importa? Porque un CLI que guarda datos en una ruta estándar de la plataforma es un CLI que respeta las convenciones del sistema operativo del usuario. Las herramientas de backup, los gestores de dotfiles y los scripts de limpieza saben dónde buscar.
create_dir_all preventivo. El constructor crea el directorio data/ si no existe. Esto hace que la primera ejecución funcione sin setup previo: cargo run -- add "Mi primera tarea" crea todo lo necesario automáticamente. Sin esto, el usuario tendría que ejecutar mkdir -p ~/.config/todo-cli/data/ antes de usar la herramienta. Mala experiencia de primer uso.
Constructor alternativo using(). Además de new(), hay un constructor using(file_path: PathBuf) que acepta una ruta arbitraria:
pub fn using(file_path: PathBuf) -> Self {
Self { file_path }
}Este constructor existe exclusivamente para tests. Permite crear un repositorio que apunte a un archivo dentro de un tempdir(), aislando completamente los tests del filesystem del usuario. No crea directorios, no resuelve rutas de plataforma, simplemente usa la ruta que le des. Es una seam de testing limpia: misma API, diferente configuración.
Lectura y escritura: el ciclo de vida de los datos #
El repositorio tiene dos métodos internos (privados) que encapsulan toda la interacción con disco:
fn read_task_file(&self) -> RepoResult<TasksFile> {
if !self.file_path.exists() {
Ok(TasksFile::default())
} else {
let file = fs::read_to_string(&self.file_path)
.map_err(|e| RepoError::InternalError {
error: format!("Reading data from file. E: {e:?}"),
})?;
serde_json::from_str(file.as_str())
.map_err(|e| RepoError::InternalError {
error: format!("Parsing data from file to tasks. E: {e:?}"),
})
}
}
fn write_tasks_file(&self, tasks_file: &TasksFile) -> RepoResult<()> {
if let Some(parent) = self.file_path.parent() {
fs::create_dir_all(parent).map_err(|e| RepoError::InternalError {
error: format!(
"could not create parent directory '{}': {e}",
parent.display()
),
})?;
}
let payload = serde_json::to_string(tasks_file)
.map_err(|e| RepoError::InternalError {
error: format!("Serializing data. E: {e:?}"),
})?;
fs::write(&self.file_path, payload)
.map_err(|e| RepoError::InternalError {
error: format!("Writing data. E: {e:?}"),
})
}Estos dos métodos son detalles de implementación que no aparecen en el trait. El contrato dice “guarda una tarea”; cómo se traduce eso a leer un archivo, parsearlo, modificar un vector y escribirlo de vuelta es asunto exclusivo del adapter. Si mañana cambiaras a SQLite, reemplazarías estos métodos por queries SQL sin tocar el trait.
Archivo ausente = estado vacío #
La decisión más importante de read_task_file está en la primera línea: si el archivo no existe, devuelve TasksFile::default() (un struct con un Vec<Task> vacío). No falla, no crea un archivo vacío, no imprime warnings. Simplemente asume que “no hay archivo” equivale a “no hay tareas”.
Esto cubre el caso de primera ejecución de forma transparente. El usuario ejecuta todo list por primera vez y obtiene una lista vacía, no un error de “file not found”. El archivo se crea la primera vez que se escribe (save o delete exitoso).
JSON inválido = error explícito #
Si el archivo existe pero contiene JSON inválido, serde_json::from_str falla y se convierte en RepoError::InternalError con el detalle del error de parsing. No hay autocorrección silenciosa, no hay “crear archivo nuevo porque el viejo estaba roto”.
¿Por qué no autocorregir? Porque “arreglar” un JSON corrupto automáticamente suele equivaler a perder datos sin aviso. Imagina que el archivo tiene 50 tareas y un byte se corrompió: ¿truncamos, descartamos, creamos uno nuevo? Todas esas opciones destruyen información. Preferimos fallar con contexto y dejar al usuario decidir la recuperación. Un CLI honesto dice “tu archivo de datos está corrupto, aquí está el error de parsing”, no borra 50 tareas silenciosamente y finge que todo está bien.
El struct intermedio TasksFile
#
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TasksFile {
tasks: Vec<Task>,
}
impl From<Vec<Task>> for TasksFile {
fn from(value: Vec<Task>) -> Self {
Self { tasks: value }
}
}¿Por qué un struct wrapper en vez de serializar Vec<Task> directamente? Dos razones:
- Extensibilidad. Si mañana necesitas añadir un campo
version: u32olast_modified: DateTimeal archivo, solo modificasTasksFile. El cambio no afecta al trait ni a los casos de uso. - Semántica del JSON. Serializar un
Vec<Task>produce[{...}, {...}]como JSON root. SerializarTasksFileproduce{"tasks": [{...}, {...}]}. La segunda forma es un JSON object en el root, que es más fácil de extender (añadir keys) sin romper parsers existentes. Es una convención sutil pero ampliamente recomendada: los JSON de configuración y datos deben tener un object como root, no un array.
TasksFile es un tipo que vive dentro del adapter, no en el trait. Es otro detalle de implementación invisible para el resto del sistema.
Implementación del trait: las cuatro operaciones #
Con los métodos internos de lectura/escritura resueltos, la implementación del trait es directa. Pero cada operación tiene matices que merecen atención:
save: upsert por ID
#
impl TaskRepository for JsonFileTaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()> {
let mut tasks_file = self.read_task_file()?;
if let Some(index) = tasks_file
.tasks
.iter()
.position(|stored| stored.task_id() == task.task_id())
{
tasks_file.tasks[index] = task;
} else {
tasks_file.tasks.push(task);
}
self.write_tasks_file(&tasks_file)
}
}save implementa semántica de upsert: si ya existe una tarea con el mismo task_id(), la reemplaza; si no, la añade al final. Esto es coherente con el diseño inmutable del dominio: cuando ejecutas mark_done, produces una nueva instancia de Task con el mismo ID pero estado diferente. El repositorio guarda la nueva versión sin necesidad de un método update separado.
Compara con la implementación in-memory: allí el upsert es gratis porque HashMap::insert lo hace por naturaleza. Aquí necesitamos buscarlo explícitamente con position(). Misma semántica, diferente mecanismo. Eso es lo que significa implementar un contrato.
El patrón position() + indexación directa es más eficiente que remove + push porque no desplaza elementos en el vector. Es el tipo de detalle que muestra que las decisiones no son accidentales.
list: filtrado en el adapter
#
fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>> {
let TasksFile { tasks } = self.read_task_file()?;
match query {
TaskQuery::All => Ok(tasks),
TaskQuery::ByStatus(status) => Ok(tasks
.iter()
.filter(|&t| t.status() == status)
.cloned()
.collect()),
}
}El filtrado ocurre en el adapter, no en el caso de uso. ¿Es esto correcto desde el punto de vista de hexagonal? Estrictamente, podrías argumentar que el filtrado es lógica de negocio y debería vivir en la capa de aplicación. Pero hay un argumento práctico: si mañana cambias a una base de datos SQL, el filtrado lo haría la query (WHERE status = ?). Ponerlo en el adapter permite que cada implementación filtre de la manera más eficiente para su tecnología.
Observa el destructuring let TasksFile { tasks } = .... Este patrón extrae el campo tasks del struct directamente, consumiéndolo. Es más limpio que let tasks = tasks_file.tasks y deja claro que no necesitamos el wrapper después.
find_by_id: resultado esperable
#
fn find_by_id(&self, id: Uuid) -> RepoResult<Option<Task>> {
let TasksFile { tasks } = self.read_task_file()?;
Ok(tasks.iter().find(|&t| t.task_id() == id).cloned())
}Tres líneas. Lee el archivo, busca por ID, devuelve Option. El .cloned() es necesario porque .find() devuelve Option<&Task> (una referencia al elemento del vector), pero necesitamos devolver Option<Task> (valor propio) porque el vector se destruye al salir de la función.
delete: idempotencia con visibilidad
#
fn delete(&mut self, id: Uuid) -> RepoResult<bool> {
let mut tasks_file = self.read_task_file()?;
let initial_len = tasks_file.tasks.len();
tasks_file.tasks.retain(|task| task.task_id() != id);
if tasks_file.tasks.len() == initial_len {
return Ok(false);
}
self.write_tasks_file(&tasks_file)?;
Ok(true)
}La implementación usa retain(), que filtra in-place manteniendo solo los elementos que cumplen el predicado. Si el tamaño del vector no cambió, la tarea no existía: devuelve false sin escribir a disco. Si cambió, escribe el archivo actualizado y devuelve true.
Hay un detalle sutil: si la tarea no existe, no se escribe a disco. Esto es una micro-optimización pero también una decisión de corrección: escribir un archivo idéntico al que ya estaba es un efecto secundario innecesario que podría confundir a herramientas de monitorización de archivos o triggers de backup.
Compara de nuevo con el in-memory: allí delete usa HashMap::remove, que devuelve Option<Task>. Aquí usamos retain + comparación de longitud. Dos mecanismos completamente distintos, misma semántica del contrato (bool). La implementación es intercambiable. El contrato no lo es.
La cadena de errores: del disco a la aplicación #
Vale la pena ver cómo los errores de persistencia fluyen hacia arriba. En el Post 2 definimos la cadena completa de errores por capas. La parte de persistencia encaja así:
// Puerto de salida (ports/outputs/errors.rs)
#[derive(Debug, Error)]
pub enum RepoError {
#[error("internal error: {error}")]
InternalError { error: String },
}
// Capa de aplicación (application/errors.rs)
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error(transparent)]
Domain(#[from] DomainError),
#[error(transparent)]
Repository(#[from] RepoError),
}Cuando JsonFileTaskRepository falla leyendo un archivo, produce un RepoError::InternalError. El caso de uso propaga ese error con ? y se convierte automáticamente en ApplicationError::Repository gracias al #[from]. Luego en main.rs, se convierte en CliError::Application. En ningún punto de la cadena hay un unwrap() o un panic!(). El usuario ve un mensaje de error legible y el proceso termina con código 1.
Observa que el InMemoryTaskRepository nunca produce errores (todas sus operaciones devuelven Ok(...)). Pero el trait obliga a devolver RepoResult. ¿Es overhead? No: es el coste de tener un contrato que cubra implementaciones reales donde el disco puede fallar. El in-memory simplemente nunca ejerce ese camino de error, pero lo respeta.
Complemento con historial del repo #
Si quieres ver el orden evolutivo en el repositorio:
Se ve claramente cómo primero se implementa el comportamiento completo y luego se endurece con pruebas, tema que cubrimos a fondo en el siguiente capítulo.
De la despensa al control de calidad #
En este capítulo hemos abierto la despensa y la hemos equipado con dos soluciones de almacenamiento que comparten un mismo contrato. El trait TaskRepository define qué necesita la aplicación; los adapters InMemoryTaskRepository y JsonFileTaskRepository deciden cómo lo proveen. La inversión de dependencias no es un diagrama en una pizarra, es un trait en ports/outputs/ y dos impl en adapters/persistence/.
Pero tener una despensa equipada no es suficiente. ¿Cómo sabemos que los ingredientes están en buen estado? ¿Cómo verificamos que las conservas no se han echado a perder entre usos? En el siguiente capítulo nos ponemos el gorro de control de calidad: estrategia de pruebas por comportamiento, aislamiento con tempdir, tests que validan el contrato sin acoplarse a la implementación, y la deuda técnica que decidimos documentar en vez de esconder.
¡Nos vemos en la cocina!