Dominio
Aquí hablamos del dominio de toda la vida: los conceptos del negocio y las reglas que los gobiernan. En una tienda, un carrito contiene productos y no acepta cantidades en cero. Eso sigue siendo cierto tanto si la petición llega por HTTP como si llega por otro medio, y no depende de la base de datos que usemos.
DFE conserva la separación entre negocio e infraestructura que también encontramos en DDD, Clean Architecture y la arquitectura hexagonal. No propone un significado nuevo para el dominio. Por eso mantuve el nombre domain/: describe lo que espero encontrar ahí.
En Rust, esas reglas pueden expresarse con tipos, funciones y métodos. No hace falta añadir todas las piezas de DDD para empezar; cada una debe resolver una necesidad del proyecto. Lo que sí conviene conservar es que el dominio pueda aplicar sus reglas sin consultar una base de datos ni llamar a otro servicio.
El carrito, por ejemplo, contiene estos datos:
pub struct Cart {
id: CartId,
customer_id: CustomerId,
items: Vec<CartItem>,
}
pub struct CartItem {
product_id: ProductId,
quantity: u32,
}El carrito no conoce el ORM, Redis, HTTP ni Kafka. La regla de una cantidad en cero vive en el tipo:
impl Cart {
pub fn add_product(
&mut self,
product_id: ProductId,
quantity: u32,
) -> Result<(), CartError> {
if quantity == 0 {
return Err(CartError::InvalidQuantity);
}
// el resto de las reglas del carrito
Ok(())
}
}Consultar si el producto existe o cuánto stock queda requiere acceder a la base de datos. El flow obtiene esa información mediante fx/db; una consulta que solo lee no modifica por sí misma el estado del negocio. Decidir si la cantidad solicitada supera el stock disponible sigue siendo una regla de negocio: necesitar un dato externo no la convierte en una regla técnica.
El flow usa los datos obtenidos para resolver la operación. Si una regla se expresa en el dominio, puede recibir el stock como argumento sin saber de qué tabla salió. De esta forma, consultar la información y decidir qué hacer con ella siguen siendo cosas distintas.
El dominio se prueba sin base de datos. Para comprobar una regla de stock basta con entregarle una cantidad disponible y una cantidad solicitada; no necesitamos ejecutar un SELECT. El flow se prueba con la base real para comprobar las consultas y el resultado. La prueba completa incluye API, que guarda ese resultado.
Cuando una regla merece un tipo
En el carrito simple usamos un u32 y comprobamos la cantidad al agregar el producto. Cuando una cantidad aparece en varias operaciones y repetimos las mismas validaciones, prefiero darle un tipo propio. Quantity expresa que no estamos trabajando con cualquier número, sino con una cantidad que cumple las reglas del dominio. Es el value object conocido de otras formas de diseñar; DFE no le da un significado distinto. Se reconoce por su valor, no por una identidad propia.
En el ejemplo intermedio, la cantidad debe estar entre 1 y 99. El constructor comprueba esa condición y el campo privado impide crear una cantidad desde otro módulo sin pasar por la validación:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Quantity(u32);
#[derive(Debug)]
pub enum QuantityError {
Invalid,
}
impl Quantity {
pub fn try_new(raw: u32) -> Result<Self, QuantityError> {
if raw == 0 || raw > 99 {
return Err(QuantityError::Invalid);
}
Ok(Self(raw))
}
pub fn as_inner(&self) -> u32 {
self.0
}
}Una vez creado, el valor puede usarse sin repetir la comprobación del rango. as_inner permite leer el número, pero no modificarlo por fuera del tipo. Cualquier otra forma de construirlo, incluida la deserialización desde JSON, debe respetar la misma validación; derivar Deserialize sin comprobarla permitiría saltarse el constructor.
No hace falta crear un tipo por cada campo. Conviene hacerlo cuando expresa una regla o evita confundir valores distintos, no solo para envolver un String o un número. El ejemplo de Quantity está en examples/cart/intermediate/src/domain/quantity.rs.