study.plynte.com
@franguh
Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)

14 · Límites, dependencias y efectos visibles#

Una frontera arquitectónica útil no es un árbol caprichoso de directorios ni un conjunto de abstracciones vacías: es una membrana de protección que hace explícito quién decide las reglas de negocio y quién ejecuta los efectos secundarios con el mundo exterior (red, disco, reloj y base de datos). Esta lección enseña a trazar límites rigurosos para que puedas probar cualquier política de dominio en nanosegundos en tu máquina sin levantar un servidor HTTP, sin conectarte a PostgreSQL y sin caer en la trampa de inventar interfaces genéricas antes de necesitarlas.

Objetivo#

Al terminar esta lección podrás:

  • Trazar límites arquitectónicos en Rust separando formalmente la lógica de dominio pura de los adaptadores de infraestructura y transporte.
  • Explicar la diferencia crucial entre la dirección de dependencia estática (en tiempo de compilación) y el flujo de control y datos (en tiempo de ejecución).
  • Diseñar puertos mediante trait y adaptadores desacoplados, permitiendo sustituir adaptadores reales de base de datos por implementaciones simuladas en memoria (fakes) para pruebas instantáneas y deterministas.
  • Modelar errores de dominio tipados (enum) que no filtren dependencias externas como sqlx::Error o actix_web::HttpResponse hacia el núcleo del negocio.
  • Identificar y mitigar la vulnerabilidad de carrera TOCTOU (Time-of-Check to Time-of-Use) demostrando por qué una regla pura en memoria no sustituye una restricción transaccional atómica en la base de datos.
  • Evaluar con honestidad técnica cuándo NO introducir un trait o un repositorio genérico para no incurrir en costos de abstracción prematura.

Antes de empezar#

Necesitas haber comprendido el manejo de tipos de retorno Result<T, E>, el despachador de errores ?, la definición e implementación de trait en Rust y la ejecución de pruebas unitarias (rustc --test).

En esta lección utilizaremos la biblioteca estándar de Rust (std-only). Aunque mencionaremos bibliotecas comunes del ecosistema como Actix Web, Axum o SQLx para contextualizar las decisiones de diseño reales en aplicaciones web, los programas completos y verificables de esta lección compilan de forma autónoma con:

BASH
rustc --test boundary.rs

Si encuentras términos desconocidos durante la lectura, consulta el glosario técnico.


Diccionario de conceptos de la lección (Glosario desde cero)#

Para dominar la arquitectura de límites desde primeros principios, analizamos cada concepto bajo cuatro dimensiones: intuición cotidiana, definición técnica formal, ejemplo concreto en código y concepción errónea frecuente.

1. Límite arquitectónico (Architectural Boundary)#

  • Intuición cotidiana: La frontera aduanera de un país: los viajeros entregan su pasaporte en la caseta; la aduana revisa los sellos y traduce los datos a los registros oficiales de inmigración, pero las leyes de la constitución nacional no dependen de qué idioma hable el turista ni de si llegó en avión o en barco.
  • Definición técnica precisa: Línea divisoria formal entre subsistemas de software que separa la política de negocio central de los detalles de entrega, persistencia y comunicación externa. Define un contrato estricto de entrada y salida mediante el cual el dominio opera ajeno a los cambios de formato, protocolos de red o motores de almacenamiento.
  • Ejemplo en código:
RUST
  // Dentro del límite de dominio: no se importa HTTP ni SQL
  pub struct Task {
      pub id: u64,
      pub title: String,
  }

  pub fn can_add_task(current_count: usize, max_capacity: usize) -> bool {
      current_count < max_capacity
  }
  • Concepción errónea: Pensar que crear un límite arquitectónico consiste simplemente en crear carpetas llamadas controllers/, services/ y models/. Si un servicio dentro de services/ importa directamente tipos de Actix Web o de PostgreSQL, el límite arquitectónico no existe; el código está fuertemente acoplado sin importar la estructura de directorios en disco.

2. Dirección de dependencia estática vs Flujo de ejecución (Runtime Flow)#

  • Intuición cotidiana:
  • Flujo de ejecución: Una carta viaja desde el remitente en París, pasa por tres oficinas de correo y llega al buzón del destinatario en Tokio.
  • Dependencia estática: La oficina de correos de Tokio no necesita saber fabricar el papel de la carta; ambos siguen el estándar postal universal fijado por la Unión Postal Internacional. Todos los actores dependen de la norma, no del cartero específico.
  • Definición técnica precisa:
  • *Flujo de ejecución (Runtime Flow):* El orden temporal en el que las instrucciones de CPU se ejecutan cuando una petición arriba al sistema: Cliente HTTP arrow Framework web arrow Adaptador arrow Servicio de Dominio arrow Adaptador SQL arrow Driver arrow Motor PostgreSQL.
  • *Dirección de dependencia estática (Compile-time Dependency): La relación formal declarada en el código fuente mediante sentencias use o imports. En una arquitectura desacoplada, la flecha de compilación apunta hacia adentro*: tanto el adaptador HTTP como el adaptador SQL dependen del contrato fijado por el dominio (Adaptador -> Trait de Puerto <- Dominio). El dominio jamás importa a sus adaptadores.
  • Ejemplo en código:
RUST
  // In the web adapter (depends on domain):
  use crate::domain::create_task; // Valid: adapter knows domain

  // In the domain module:
  // use actix_web::HttpResponse; // ARCHITECTURAL VIOLATION: domain must not know HTTP
  • Concepción errónea: Confundir las flechas del diagrama: asumir que porque una petición HTTP llama al dominio y el dominio luego guarda en la base de datos, el código de dominio debe "conocer" o "importar" a la base de datos. Gracias a la inversión de dependencias, el flujo de ejecución viaja hacia la base de datos, pero la dependencia de código fuente apunta en sentido contrario.

3. Inversión de dependencias (Dependency Inversion Principle - DIP)#

  • Intuición cotidiana: Enchufar una lámpara a la toma de corriente de la pared: la lámpara no está soldada a los cables de cobre del tendido municipal. Tanto la lámpara como la instalación eléctrica de la casa se acoplan a una interfaz intermedia estandarizada (la clavija de pared de 110V/220V).
  • Definición técnica precisa: Principio que estipula dos reglas formales:
  1. Los módulos de alto nivel (reglas de negocio) no deben depender de los módulos de bajo nivel (I/O, drivers, red). Ambos deben depender de abstracciones (interfaces o traits).
  2. Las abstracciones no deben depender de los detalles de implementación; los detalles de implementación deben depender de las abstracciones.
  • Ejemplo en código:
RUST
  // Domain defines the contract it requires (abstraction):
  pub trait TaskStore {
      fn save(&mut self, title: String) -> Result<(), String>;
  }

  // High-level policy consumes the trait:
  pub fn register_task<S: TaskStore>(store: &mut S, title: String) -> Result<(), String> {
      store.save(title)
  }
  • Concepción errónea: Creer que la inversión de dependencias requiere obligatoriamente un contenedor de inyección de dependencias mágico (DI Container) con reflexión en tiempo de ejecución como en Java o .NET. En Rust, DIP se expresa de forma natural y sin costo en tiempo de ejecución mediante traits estáticos y parámetros genéricos.

4. Arquitectura Hexagonal (Ports & Adapters)#

  • Intuición cotidiana: Una consola de videojuegos: tiene puertos de entrada (para los mandos o controles) y puertos de salida (HDMI para la pantalla, audio óptico). El motor del juego corre idénticamente sin importar si conectas un control oficial por cable, un control inalámbrico genérico, una pantalla 4K o unos auriculares.
  • Definición técnica precisa: Patrón arquitectónico formulado por Alistair Cockburn que ubica a la lógica de la aplicación en el centro de un hexágono. El núcleo interactúa con el exterior exclusivamente a través de:
  • *Puertos (Ports):* Contratos de interfaz (trait en Rust) que definen las operaciones requeridas o expuestas por la aplicación.
  • *Adaptadores (Adapters):* Componentes concretos (struct en Rust) que traducen entre el mundo externo y los puertos (ej. HttpController traduce JSON a tipos de dominio; PostgresRepository traduce tipos de dominio a consultas SQL).
  • Ejemplo en código:
RUST
  // Port (defined by application):
  pub trait NotificationSender {
      fn send_alert(&self, msg: &str);
  }

  // Concrete adapter (external detail):
  pub struct EmailSender;
  impl NotificationSender for EmailSender {
      fn send_alert(&self, msg: &str) {
          println!("Sending SMTP email: {msg}");
      }
  }
  • Concepción errónea: Creer que la arquitectura hexagonal es una receta obligatoria para cualquier script o prototipo. Introducir puertos y adaptadores para una aplicación trivial de lectura de un solo archivo añade capas de indirección inútiles si nunca existirá más de una implementación ni necesidad real de aislamiento para pruebas.

5. Función pura y Transparencia referencial (Pure Function & Referential Transparency)#

  • Intuición cotidiana: Una calculadora de bolsillo: si tecleas 5 + 7, la pantalla siempre mostrará 12. No importa si es de día o de noche, si está nublado, o si la batería se cambió ayer; la suma depende única y exclusivamente de los dos números ingresados.
  • Definición técnica precisa:
  • Función pura: Función cuyo valor de retorno está determinado exclusivamente por sus argumentos de entrada, sin producir ningún efecto secundario observable fuera de su propio marco de pila.
  • Transparencia referencial: Propiedad de una expresión que permite sustituirla por su valor evaluado sin alterar en absoluto el comportamiento del programa.
  • Ejemplo en código:
RUST
  // Pure function: referentially transparent
  pub fn calculate_subtotal(unit_price: u32, quantity: u32) -> u32 {
      unit_price * quantity
  }
  • Concepción errónea: Pensar que las funciones puras son solo un capricho académico de la programación funcional. En Rust, maximizar la proporción de funciones puras en el núcleo de dominio es la estrategia más eficaz para lograr pruebas unitarias instantáneas que se ejecutan a la velocidad del procesador sin necesidad de mocks complejos ni bases de datos.

6. Efecto secundario (Side Effect)#

  • Intuición cotidiana: Pagar un billete de autobús en efectivo: no solo recibes un boleto en la mano (retorno), sino que tu billetera tiene menos billetes y el conductor registra una venta en la caja de la empresa (mutación del estado del mundo).
  • Definición técnica precisa: Cualquier alteración del estado observable del sistema o cualquier interacción con el hardware exterior que ocurra durante la ejecución de una función y que no esté descrita de forma transparente en su valor de retorno: operaciones de I/O en disco, paquetes enviados por la tarjeta de red, mutación de variables globales o estáticas, o lectura de fuentes no deterministas como el reloj del sistema (SystemTime::now()) o generadores de números aleatorios.
  • Ejemplo en código:
RUST
  // Function with hidden side effects:
  pub fn get_user_discount(user_id: u64) -> u32 {
      // Hidden side effect 1: synchronous network I/O query
      let is_vip = query_database_externally(user_id);
      // Hidden side effect 2: global log mutation
      println!("Discount queried for {user_id}");
      if is_vip { 20 } else { 0 }
  }
  • Concepción errónea: Considerar que los efectos secundarios son "malos" o "prohibidos". El software existe precisamente para generar efectos secundarios (escribir en disco, encender píxeles en la pantalla, mover motores o enviar transferencias bancarias). El principio de diseño correcto es hacerlos visibles y empujarlos a las fronteras, manteniendo el núcleo de decisión puro.

7. Despacho estático vs Despacho dinámico (Static vs Dynamic Dispatch)#

  • Intuición cotidiana:
  • Despacho estático: Comprar un billete de avión con asiento numerado impreso de antemano (Fila 12, Asiento B): al subir al avión caminas directo a tu asiento sin consultar a nadie.
  • Despacho dinámico: Llegar a un hotel y preguntar al conserje en recepción qué habitación te corresponde: el conserje consulta un libro de registro (vtable) y te indica hacia dónde caminar.
  • Definición técnica precisa:
  • Despacho estático (impl Trait / genéricos): El compilador de Rust conoce el tipo concreto en tiempo de compilación y genera una copia especializada de la función para ese tipo exacto mediante un proceso llamado monomorfización (monomorphization). Sobrecoste de llamadas: cero nanosegundos (permite inlining completo de instrucciones de máquina).
  • Despacho dinámico (dyn Trait / Box<dyn Trait>): El tipo concreto se oculta tras un puntero gordo (fat pointer) que contiene la dirección del dato en memoria y un puntero a una tabla de funciones virtuales (vtable). La invocación del método requiere una indirección de puntero en tiempo de ejecución.
  • Ejemplo en código:
RUST
  // Static dispatch: monomorphized at compile time
  pub fn run_static<S: TaskRepository>(store: &S) { store.count(); }

  // Dynamic dispatch: vtable lookup at runtime
  pub fn run_dynamic(store: &dyn TaskRepository) { store.count(); }
  • Concepción errónea: Asumir que dyn Trait debe usarse siempre que se trabaje con interfaces como en Java o C#. En Rust, el despacho estático con parámetros genéricos es la opción idiomática por defecto; solo se recurre a dyn Trait cuando se requiere almacenar colecciones heterogéneas de tipos en tiempo de ejecución o reducir el tamaño del binario generado.

8. Vulnerabilidad TOCTOU (Time-of-Check to Time-of-Use)#

  • Intuición cotidiana: Mirar por la ventana del restaurante y ver una única mesa libre (tiempo de verificación, Check); decides entrar al local, pero mientras cruzas la puerta y llegas al salón, otra persona entró por la puerta lateral y ocupó la mesa (tiempo de uso, Use).
  • Definición técnica precisa: Condición de carrera que surge en sistemas concurrentes cuando un programa verifica el estado de un recurso (por ejemplo, comprobar si una cuenta tiene saldo disponible o si el número de tareas no ha superado el límite permitido) y luego ejecuta una acción basándose en esa verificación, pero el estado del recurso es alterado por otro hilo o proceso en el intervalo de tiempo transcurrido entre la verificación y el uso.
  • Ejemplo en código:
RUST
  // Execution sequence with TOCTOU race condition:
  // Thread A: checks count < limit (3 < 4) -> true
  // Thread B: checks count < limit (3 < 4) -> true
  // Thread A: executes insert() -> total is now 4
  // Thread B: executes insert() -> total is now 5 (INVARIANT VIOLATED!)
  • Concepción errónea: Creer que una función pura de validación como can_add(current, limit) protege al sistema contra sobrecupos en un servidor web concurrente. La función pura valida la regla matemática en memoria, pero no garantiza atomicidad; la protección en sistemas concurrentes exige obligatoriamente restricciones transaccionales a nivel de adaptador (como transacciones SQL SERIALIZABLE, cláusulas SELECT ... FOR UPDATE o restricciones UNIQUE y CHECK en PostgreSQL).

Diagrama#

El siguiente diagrama ilustra las dependencias de código entre adaptadores de entrada y salida, reglas de dominio y puertos:

DIAGRAMA MERMAID
flowchart LR
  H[Adaptador HTTP] -->|usa tipos| F[Framework HTTP]
  H -->|llama| D[Regla de dominio]
  H -->|usa contrato| P[Puerto de persistencia]
  A[Adaptador de base de datos] -->|implementa| P
  A -->|usa driver| DB[Driver de base de datos]

Dependencias de código entre adaptadores, regla, puerto y driver
Dependencias de código entre adaptadores, regla, puerto y driver
Fuente editable: Mermaid.

🛡️ Información Crítica

Observa con detenimiento la orientación de las flechas: representan dependencias estáticas de código fuente (use o implementaciones de traits en tiempo de compilación), no el orden temporal de las peticiones en runtime.

  • La Regla de dominio no tiene flechas salientes hacia frameworks ni bases de datos: es totalmente autónoma.
  • El Adaptador HTTP depende del framework HTTP y llama a la regla de dominio.
  • El Adaptador de base de datos depende del driver y apunta hacia el Puerto de persistencia implementando su contrato.

Modelo mental y desempacado paso a paso#

1. Inversión de dependencias en Rust: desmitificando las flechas de compilación#

En arquitecturas tradicionales acopladas, el módulo de negocio crea y llama directamente a la base de datos:

Dominio longrightarrow PostgreSQL Driver

Esto significa que si cambias la versión de SQLx, el tipo de conexión o la biblioteca de serialización, el código que calcula tus comisiones y valida tus pedidos debe recompilarse y corre el riesgo de romperse.

Para invertir esta dependencia, introducimos un Puerto (un trait en Rust):

RUST
// Domain defines what it needs, not how it is implemented
pub trait TaskRepository {
    fn exists_by_title(&self, title: &str) -> Result<bool, StorageError>;
    fn save(&mut self, title: String) -> Result<(), StorageError>;
}

Ahora la relación de código fuente se invierte:

Dominio longrightarrow [Puerto Trait] longleftarrow Adaptador Postgres

El adaptador concreto en infrastructure/postgres.rs implementa el trait definido por el dominio. Si mañana decides cambiar PostgreSQL por SQLite o por un archivo en memoria para correr pruebas instantáneas, el código de dominio permanece 100% idéntico y no requiere recompilarse.

2. Modelado de errores en la frontera: impidiendo la fuga de abstracciones#

Uno de los errores arquitectónicos más graves en sistemas Rust es filtrar tipos de bibliotecas externas a través de las firmas de funciones de dominio:

RUST
// ANTIPATTERN: Leaky abstraction!
// Domain leaks sqlx::Error in its public Result contract
pub fn register_task(title: &str) -> Result<(), sqlx::Error> { /* ... */ }

Si haces esto:

  1. El módulo de dominio queda encadenado a la biblioteca sqlx.
  2. Para probar register_task en una prueba unitaria pura en memoria, estás obligado a fabricar instancias artificiales de sqlx::Error.
  3. El controlador web receptor no puede distinguir semánticamente si el error fue un título duplicado o una caída del cable de red sin desarmar los códigos de error internos de PostgreSQL.

La solución correcta es definir un tipo de error de dominio expresivo:

RUST
#[derive(Debug, PartialEq, Eq)]
pub enum CreateTaskError {
    EmptyTitle,
    DuplicateTitle,
    StorageUnavailable(String),
}

El adaptador de infraestructura es el responsable de traducir los errores crudos del driver hacia los errores semánticos del dominio.

3. La trampa de la función pura frente a la carrera TOCTOU#

Considera esta regla de negocio pura:

RUST
pub fn can_add(current_tasks: usize, max_limit: usize) -> bool {
    current_tasks < max_limit
}

Esta función es elegante, referencialmente transparente y se prueba en 2 nanosegundos en un test unitario. Sin embargo, no garantiza la integridad del sistema en producción por sí sola.

Imagina un servidor con 8 hilos de Actix Web procesando peticiones simultáneas:

  1. El límite máximo de tareas permitidas es $10$. Actualmente existen $9$ tareas en la base de datos.
  2. La Petición 1 (Hilo A) y la Petición 2 (Hilo B) llegan exactamente en el mismo milisegundo para crear una tarea.
  3. Hilo A consulta la base de datos: SELECT COUNT(*) FROM tasks; arrow obtiene $9$.
  4. Hilo B consulta la base de datos: SELECT COUNT(*) FROM tasks; arrow obtiene $9$.
  5. Ambos hilos ejecutan la función de dominio: can_add(9, 10) arrow ambos obtienen true.
  6. Hilo A ejecuta INSERT INTO tasks ...; arrow total de tareas pasa a ser $10$.
  7. Hilo B ejecuta INSERT INTO tasks ...; arrow total de tareas pasa a ser $11$.

¡La invariante de negocio se ha violado a pesar de que la función pura de dominio era matemáticamente impecable!

⚠️ Advertencia

La regla pura decide si una operación es admisible bajo un estado dado. La infraestructura de persistencia debe garantizar que la comprobación y la mutación ocurran atómicamente mediante transacciones aisladas, bloqueos pesimistas (FOR UPDATE) o restricciones de integridad en el motor relacional.

4. Cuándo NO introducir un puerto: el costo de la abstracción prematura#

La inversión de dependencias no es una doctrina religiosa. Introducir un trait para cada estructura de tu sistema tiene costos concretos:

  • Aumenta el número de archivos, nombres y conceptos que un nuevo desarrollador debe retener en su memoria de trabajo.
  • Complica la navegación del código en el editor (saltar a la definición te lleva a la firma del trait en lugar de la implementación concreta).
  • Puede generar complejidad innecesaria en la gestión de tiempos de vida (lifetimes) o límites de sincronización (Send + Sync + 'static).

Cuándo SÍ crear un puerto:

  1. Cuando existen múltiples implementaciones reales en producción (por ejemplo, almacenamiento en disco local en desarrollo vs Amazon S3 en producción).
  2. Cuando el adaptador real involucra I/O costosa, no determinista o difícil de configurar en integración continua (por ejemplo, una pasarela de pagos con tarjeta de crédito Stripe).
  3. Cuando necesitas simular fallos catastróficos de red de forma reproducible para probar la resiliencia del sistema.

Cuándo NO crear un puerto: Si una función simplemente transforma datos o si tu aplicación tiene un único adaptador que no cambiará en los próximos años y cuyos tests de integración con base de datos real en memoria (o en contenedor efímero) corren en menos de 1 segundo, pasar los datos ya interpretados directamente como argumentos es infinitamente superior a inventar una jerarquía vacía de traits.


Ejemplo razonado#

El siguiente programa es Rust estándar ejecutable con rustc --test boundary.rs. Demuestra la separación completa entre regla pura de validación, un puerto de persistencia mediante trait, un adaptador simulado en memoria (fake) que permite inyectar fallos deterministas, y el servicio coordinador que traduce los errores sin filtrar infraestructura.

RUST
// ==========================================
// 1. PURE DOMAIN (Rules and Invariants)
// ==========================================

#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
    EmptyTitle,
}

#[derive(Debug, PartialEq, Eq)]
pub enum CreateTaskError {
    Validation(ValidationError),
    DuplicateTitle,
    StorageFailure(String),
}

// Pure function: referentially transparent
pub fn can_add(current: usize, limit: usize) -> bool {
    current < limit
}

// Pure function: input validation
pub fn validate_task_title(raw: &str) -> Result<String, ValidationError> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        Err(ValidationError::EmptyTitle)
    } else {
        Ok(trimmed.to_string())
    }
}

// ==========================================
// 2. PERSISTENCE PORT (Trait Contract)
// ==========================================

pub trait TaskRepository {
    fn exists_by_title(&self, title: &str) -> Result<bool, String>;
    fn save(&mut self, title: String) -> Result<(), String>;
}

// ==========================================
// 3. USE CASE / APPLICATION SERVICE
// ==========================================

pub fn create_task<R: TaskRepository>(
    repo: &mut R,
    raw_title: &str,
) -> Result<(), CreateTaskError> {
    // Step 1: Pure validation (without touching storage)
    let valid_title = validate_task_title(raw_title)
        .map_err(CreateTaskError::Validation)?;

    // Step 2: Query for duplicates through the port
    match repo.exists_by_title(&valid_title) {
        Ok(true) => Err(CreateTaskError::DuplicateTitle),
        Ok(false) => {
            // Step 3: Insert through the port
            repo.save(valid_title)
                .map_err(CreateTaskError::StorageFailure)
        }
        // Step 4: Translate infrastructure error
        Err(e) => Err(CreateTaskError::StorageFailure(e)),
    }
}

// ==========================================
// 4. FAKE ADAPTER (In-memory test double)
// ==========================================

#[derive(Default)]
pub struct InMemoryTaskRepository {
    pub tasks: Vec<String>,
    pub simulate_connection_failure: bool,
}

impl TaskRepository for InMemoryTaskRepository {
    fn exists_by_title(&self, title: &str) -> Result<bool, String> {
        if self.simulate_connection_failure {
            return Err("Connection to database timed out".to_string());
        }
        Ok(self.tasks.iter().any(|t| t == title))
    }

    fn save(&mut self, title: String) -> Result<(), String> {
        if self.simulate_connection_failure {
            return Err("Connection to database timed out".to_string());
        }
        self.tasks.push(title);
        Ok(())
    }
}

// ==========================================
// 5. AUTOMATED TESTS (Zero external dependencies)
// ==========================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pure_rule_can_add_enforces_capacity() {
        assert!(can_add(2, 3));
        assert!(!can_add(3, 3));
        assert!(!can_add(4, 3));
    }

    #[test]
    fn rejects_empty_title_before_hitting_persistence() {
        let mut repo = InMemoryTaskRepository {
            simulate_connection_failure: true, // Pure validation precedes storage access
            ..Default::default()
        };
        let result = create_task(&mut repo, "   ");
        assert_eq!(result, Err(CreateTaskError::Validation(ValidationError::EmptyTitle)));
        assert!(repo.tasks.is_empty());
    }

    #[test]
    fn creates_task_successfully_when_valid_and_unique() {
        let mut repo = InMemoryTaskRepository::default();
        let result = create_task(&mut repo, "Complete design module");
        assert_eq!(result, Ok(()));
        assert_eq!(repo.tasks, vec!["Complete design module"]);
    }

    #[test]
    fn prevents_duplicate_titles() {
        let mut repo = InMemoryTaskRepository {
            tasks: vec!["Complete design module".to_string()],
            ..Default::default()
        };
        let result = create_task(&mut repo, "Complete design module");
        assert_eq!(result, Err(CreateTaskError::DuplicateTitle));
        assert_eq!(repo.tasks.len(), 1);
    }

    #[test]
    fn preserves_storage_error_distinction_on_failure() {
        let mut repo = InMemoryTaskRepository {
            simulate_connection_failure: true,
            ..Default::default()
        };
        let result = create_task(&mut repo, "Urgent repair");
        assert_eq!(
            result,
            Err(CreateTaskError::StorageFailure("Connection to database timed out".to_string()))
        );
    }
}

Práctica independiente#

En este ejercicio clasificarás con rigor arquitectónico el ciclo de vida de una petición y construirás un caso de uso con límites claros.

1. Clasificación del ciclo de vida de una petición#

Imagina un endpoint HTTP POST /tasks que recibe un cuerpo JSON { "title": " Buy milk " }. Examina los siguientes cuatro pasos secuenciales y clasifícalos con precisión como Transporte, Regla de dominio o Efecto secundario:

  1. Deserializar los bytes entrantes de la petición HTTP a una estructura intermedia y validar que el formato JSON sea sintácticamente válido.
  2. Normalizar el texto (eliminar espacios exteriores con trim()) y verificar que la longitud esté dentro de los límites legales permitidos por la regla del negocio.
  3. Consultar la base de datos para verificar si ya existe una tarea activa con ese mismo título e insertar el nuevo registro.
  4. Construir la respuesta HTTP serializando un código de estado 201 Created o 409 Conflict con las cabeceras Content-Type: application/json.

2. El dilema de la desconexión: Disponibilidad vs Inexistencia#

Supón que la base de datos se encuentra temporalmente inaccesible por saturación de conexiones de red.

  • Si el adaptador de persistencia devuelve un error de timeout al consultar si el título existe, ¿qué ocurre si un programador confunde "la consulta falló" con "el título no existe"?
  • Predice la consecuencia concreta si el sistema procede a ciegas e intenta insertar el registro.
  • ¿Cómo garantiza la jerarquía de tipos de error de dominio (CreateTaskError::StorageFailure) que el cliente reciba un código HTTP 503 Service Unavailable o 500 Internal Server Error en vez de un falso 409 Conflict o un intento fallido de inserción?

3. Flujo TDD para la práctica#

  1. Fase RED: Escribe una prueba donde pases un título con solo espacios en blanco a create_task. Comprueba que falle reportando CreateTaskError::Validation(ValidationError::EmptyTitle).
  2. Fase GREEN: Haz que pase validando el título antes de cualquier llamada a persistencia.
  3. Fase REFACTOR: Comprueba que tu servicio use tipos genéricos <R: TaskRepository> (despacho estático) y que ninguna estructura ni función dentro del módulo de dominio importe módulos de frameworks web ni drivers de SQL.

Consulta la guía de soluciones en ../solutions/04-design.md tras completar tu intento independiente.


Preguntas de comprensión profunda y diagnóstico socrático#

Reflexiona sobre estas cinco preguntas de diseño. Cada una explora un límite del modelo mental de dependencias y arquitectura:

Pregunta 1: Dependencia de compilación vs Flujo de datos en tiempo de ejecución#

Examina esta aparente paradoja:

  • En tiempo de ejecución (runtime), cuando un usuario crea una tarea, el flujo de control nace en el socket TCP del servidor Actix Web, pasa por el dominio y termina escribiendo bytes en el socket de PostgreSQL.
  • Sin embargo, en el diagrama arquitectónico, la flecha de dependencia de código fuente del adaptador de PostgreSQL apunta hacia el puerto del dominio, y el dominio no contiene ninguna referencia a PostgreSQL.
  • Explica cómo el mecanismo de traits en Rust hace posible que el flujo de control viaje en una dirección mientras que la dependencia de compilación apunta en la dirección contraria. ¿Qué archivo se recompila si PostgreSQL se actualiza a una nueva versión mayor?

Pregunta 2: Fuga de abstracciones en tipos de error#

Un equipo de desarrollo define el siguiente trait de puerto para su sistema de pagos:

RUST
pub trait PaymentGateway {
    fn charge(&self, amount_cents: u32) -> Result<(), reqwest::Error>;
}
  • ¿Por qué retornar reqwest::Error destruye el límite arquitectónico del puerto?
  • Si en seis meses la pasarela de pagos se sustituye por una biblioteca gRPC o un adaptador en memoria para pruebas locales, ¿qué ocurrirá con todos los consumidores de este trait?
  • Diseña la enumeración de errores idiomática que el dominio debería declarar para este puerto.

Pregunta 3: La falacia de la función pura y la condición de carrera TOCTOU#

Un desarrollador argumenta en una revisión de código: "Nuestra función can_add(current_tasks, max_tasks) está 100% cubierta por pruebas unitarias puras que verifican que jamás se permitan más de 10 tareas. Por tanto, no necesitamos transacciones SERIALIZABLE ni bloqueos en PostgreSQL; la aplicación ya garantiza la regla."

  • Demuestra paso a paso el entrelazado de hilos (interleaving) que desmiente esta afirmación en un entorno de producción con múltiples réplicas del servicio.
  • ¿Qué responsabilidad corresponde a la función pura y qué responsabilidad le corresponde al adaptador de persistencia en la base de datos?

Pregunta 4: Despacho estático (impl Trait) vs Despacho dinámico (dyn Trait)#

En el servicio de aplicación podemos escribir:

  • Enfoque A: fn create_task<R: TaskRepository>(repo: &mut R, title: &str)
  • Enfoque B: fn create_task(repo: &mut dyn TaskRepository, title: &str)
  • Explica qué genera el compilador de Rust en el código máquina para el Enfoque A (monomorfización) y qué genera para el Enfoque B (puntero gordo con vtable).
  • ¿Qué ventajas de rendimiento (inlining) ofrece el Enfoque A y en qué escenario arquitectónico estarías obligado a elegir el Enfoque B?

Pregunta 5: El costo de la abstracción prematura#

Un ingeniero junior propone que para una pequeña aplicación de consola que procesa un archivo CSV de 20 líneas una vez al día, se creen 6 capas: CsvReaderPort, CsvReaderAdapter, TaskEntityFactory, TaskDomainService, OutputWriterPort y ConsoleWriterAdapter.

  • Aplica el principio de diseño con base en evidencia: ¿qué problema tangible está resolviendo esta arquitectura en este contexto?
  • ¿Cuál es el costo cognitivo y de mantenimiento que introduce esta estructura sobre el equipo?
  • ¿Cómo se escribiría una solución minimalista y cohesiva que mantenga el código fácilmente testeable sin inventar interfaces innecesarias?

Code smell#

Síntoma: Handler HTTP monolítico con I/O oculta y lógica de negocio mezclada#

RUST
// Deliberately problematic code:
async fn create_task_handler(
    req: HttpRequest,
    payload: web::Json<TaskPayload>,
    db_pool: web::Data<PgPool>,
) -> impl Responder {
    if payload.title.trim().is_empty() {
        return HttpResponse::BadRequest().json("Title cannot be empty");
    }
    let count: i64 = sqlx::query_scalar("SELECT count(*) FROM tasks")
        .fetch_one(db_pool.get_ref())
        .await
        .unwrap();
    if count >= 10 {
        return HttpResponse::Conflict().json("Limit reached");
    }
    sqlx::query("INSERT INTO tasks (title) VALUES ($1)")
        .bind(&payload.title)
        .execute(db_pool.get_ref())
        .await
        .unwrap();
    HttpResponse::Created().json("Task created")
}
  • Evidencia observable: Un único controlador HTTP contiene parsing JSON, validaciones de cadenas, consultas SQL crudas con el driver sqlx, toma de decisiones de capacidad y mapeo de respuestas HTTP.
  • Hipótesis del problema: La frontera arquitectónica no existe; la lógica de negocio está aprisionada dentro del framework web y atada al motor de persistencia.
  • Consecuencia concreta: Probar la regla de límite de 10 tareas requiere levantar una base de datos PostgreSQL real, sembrar datos, enviar peticiones HTTP reales sobre sockets TCP y lidiar con latencias de red. Si la base de datos se ralentiza, las pruebas unitarias se vuelven insoportablemente lentas y frágiles.
  • Tratamiento mínimo justificado:
  1. Extraer la regla pura can_add(current, limit) a un módulo de dominio libre de dependencias externas.
  2. Extraer la validación del título a una función de dominio independiente.
  3. Dejar en el controlador HTTP únicamente la responsabilidad de parsear la petición, invocar la regla y mapear el resultado a códigos HTTP (201, 400, 409, 500).
  • Cuándo NO refactorizar: Si se trata de un prototipo descartable (spike) de exploración tecnológica para validar si una biblioteca funciona, o un script administrativo de migración puntual de un solo uso donde no existen reglas de negocio complejas ni necesidad de mantenimiento a largo plazo.

Criterio de salida#

Puedes considerar superada esta lección cuando:

  • Puedes explicar la diferencia entre la dirección de dependencias en compilación y el flujo de control en runtime utilizando un diagrama de cajas.
  • Tu código de dominio compila y se prueba al 100% sin importar Actix Web, Axum, Tokio ni drivers de base de datos.
  • Has modelado un puerto de persistencia con un trait y verificado que un adaptador en memoria (fake) permite probar casos de éxito, duplicados y caídas de infraestructura.
  • Puedes justificar por qué una verificación pura en memoria can_add es vulnerable a condiciones de carrera TOCTOU en sistemas concurrentes y qué solución transaccional exige el almacenamiento.
  • Puedes enunciar al menos dos escenarios válidos donde introducir un trait o un repositorio genérico perjudica el diseño en lugar de mejorarlo.

Lecturas oficiales#


Siguiente paso#

Continúa con 15. Pruebas, fallos y depuración reproducible, donde dominarás la taxonomía de pruebas, la disciplina científica de depuración de defectos y la captura de comportamiento mediante pruebas de caracterización (Golden Master). Para consultar las soluciones de la práctica independiente, revisa la guía de respuestas de diseño.

Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)