Flows

En muchos proyectos una operación termina repartida entre un handler, un servicio, un repositorio y varios adaptadores. Cada pieza puede estar bien escrita y, aun así, cuesta reconstruir qué ocurre cuando alguien agrega un producto.

Un flow es la respuesta de DFE a ese problema: una operación concreta, con un comienzo y un objetivo reconocibles. Agregar un producto no necesita ser un objeto con execute(): en DFE es una función.

¿Por qué llamarla flow y no usecase? Usecase también puede describir una operación y no es un nombre incorrecto. Elegí flow porque quiero que al abrir el archivo se vea el recorrido concreto: qué datos obtiene, qué reglas aplica y qué resultado devuelve. No es una capa para reenviar llamadas ni una clase que haya que crear por cada operación.

flows/
└── cart/
    ├── add_product.rs
    ├── remove_product.rs
    └── checkout.rs

add_product obtiene el carrito, obtiene el producto, comprueba el stock y aplica las reglas del dominio. Devuelve el carrito modificado en memoria, sin guardarlo en la base de datos. Recibe la transacción que abrió api/ para hacer las consultas; después, la API usa esa misma transacción para guardar el resultado y hacer commit.

pub async fn add_product(
    tx: &mut Transaction<'_, Postgres>,
    cart_id: CartId,
    product_id: ProductId,
    quantity: u32,
) -> Result<Cart, AddProductError> {
    let mut cart = db::get_cart(tx, cart_id)
        .await?
        .ok_or(AddProductError::CartNotFound)?;

    let product = db::get_product(&mut **tx, product_id)
        .await?
        .ok_or(AddProductError::ProductNotFound)?;

    if product.stock < quantity {
        return Err(AddProductError::InsufficientStock);
    }

    cart.add_product(product.id(), quantity)?;
    Ok(cart)
}

El acoplamiento es una decisión

En el ejemplo, add_product llama directamente a db::get_cart y db::get_product, y recibe una transacción de PostgreSQL. Si vienes de Clean Architecture o de la arquitectura hexagonal, probablemente esperarías una interfaz entre la operación y el acceso a datos. Aquí no la hay. El flow conoce esas funciones de consulta y acepta esa dependencia.

Podría crear un trait de repositorio y pasarle una implementación al flow. Pero en este carrito todavía no necesito intercambiar el acceso a datos ni aislar la operación de PostgreSQL. Estaría añadiendo piezas para que el código siguiera una estructura que elegí antes de necesitarlas. Prefiero que agregar un producto siga siendo una función donde puedo leer qué obtiene, qué comprueba y qué devuelve.

Separar las reglas del negocio de la infraestructura sigue teniendo sentido: Cart no conoce SQL ni recibe la transacción. Eso no me obliga a implementar la operación con la misma estructura de clases, interfaces e inyección que usaría en Java o C#. En Rust puedo conservar esa separación del dominio y resolver la operación con funciones, tipos y llamadas directas. Esa es una de las decisiones por las que nació DFE.

Además, escribir el trait no resuelve por sí solo cómo conectar las piezas en Rust. Todavía debo decidir quién posee el repositorio, cómo lo toma prestado el flow y cómo se usa la transacción durante las llamadas asíncronas. Puedo hacerlo con genéricos sin dyn ni Arc; también puedo usar despacho dinámico cuando lo necesite. Pero poder construir esa solución no es motivo suficiente para añadirla a cada operación.

dyn permite despacho dinámico y Arc permite compartir la propiedad de un valor; no son requisitos de la inyección ni herramientas que DFE prohíba. Tampoco se pierde la comprobación de tipos por usar dyn. Si necesito una interfaz asíncrona con despacho dinámico, tendré que adaptar los métodos async fn, por ejemplo devolviendo futuros mediante Box, con los requisitos de lifetimes y Send que correspondan. Ese trabajo se justifica cuando necesito la interfaz, no solo porque haya decidido que toda operación debe pasar por una.

Qué comprueba Rust y qué debemos probar

El compilador me ayuda a mantener las llamadas directas. Si cambio el tipo de un argumento o el resultado de get_cart de una forma incompatible con sus usos, cargo check señala dónde hay que corregir el código, antes de ejecutarlo. Ownership, borrowing y lifetimes comprueban además cómo se usan las referencias: por ejemplo, que un préstamo de la transacción no siga en uso después de que esta se consuma al hacer commit.

Eso no significa que el compilador compruebe todo. Una consulta puede devolver datos equivocados sin cambiar su tipo, y una regla puede estar mal aunque compile. Los tests del flow usan una base de datos real para comprobar las consultas y el resultado calculado; las reglas de Cart siguen probándose solas. Las pruebas de la operación completa incluyen la escritura desde API. Los lifetimes ayudan con la validez de las referencias, no deciden si una transacción debería confirmar o revertir.

También hay un costo: si cambia la forma de acceder a los datos y ya no encaja con estas funciones, habrá que adaptar los flows que las usan. El compilador ayuda a encontrar incompatibilidades, pero no sustituye el aislamiento que daría una interfaz. Si el proyecto necesita ese aislamiento o varias implementaciones reales, un trait puede ser una buena decisión.

En DFE empiezo con la operación que necesita el proyecto, no con las piezas que tendría que añadir para parecerse a otra arquitectura. Si más adelante hace falta otra separación, la incorporo por lo que resuelve.

Las consecuencias externas

El flow puede consultar mediante fx/db, pero no ejecuta INSERT, UPDATE ni DELETE. Consultar el stock no modifica por sí mismo el estado del negocio. Cambiar el carrito en memoria prepara el resultado; guardarlo en la base de datos es responsabilidad de api/. El flow no es puro en el sentido funcional, porque sus consultas dependen de información externa.

La API recibe ese resultado, lo guarda mediante fx/db y confirma la transacción. Después coordina las consecuencias externas, como invalidar una caché, enviar un correo, llamar a una pasarela de pago o publicar un evento. Si el aviso falla, el carrito ya quedó escrito. La página de API muestra la escritura y la página de effects explica la distinción entre consultar y producir cambios.

El código de los tres tamaños está en examples/cart/. Cuando la estructura crezca, debería seguir siendo fácil encontrar la operación completa y las reglas del dominio que utiliza.

Referencias de Rust