Errores

Agregar un producto puede fallar porque la cantidad es cero, porque no encontramos el carrito o porque la base de datos no responde. No son tres formas de decir lo mismo. En un caso se incumple una regla, en otro no podemos completar la operación y en el tercero falla el acceso a los datos. Si convertimos todo en «no se pudo agregar el producto», perdemos información para responder y para encontrar el problema.

El dominio explica qué regla no se cumple

Cart puede rechazar una cantidad en cero sin saber que existe una base de datos. Su error pertenece al dominio porque describe una regla del carrito. Lo mismo ocurre si un Quantity rechaza un número fuera de rango: los value objects son un caso, no el único motivo para tener errores de dominio.

#[derive(Debug)]
pub enum CartError {
    InvalidQuantity,
}

El dominio no devuelve un estado HTTP, un tonic::Status ni un sqlx::Error. Si mañana llamamos a Cart::add_product desde una tarea en segundo plano, la cantidad seguirá siendo inválida y el error seguirá teniendo sentido.

El flow explica por qué no pudo resolver la operación

El flow necesita más información que el carrito: consulta si existe, obtiene el producto y comprueba el stock. Puede fallar antes de llegar a una regla de Cart, o recibir un error al aplicarla. Por eso su error no es simplemente otro nombre para CartError.

En una versión del ejemplo que conserva los errores originales, los tipos quedarían así. Cada uno vive en su módulo; se muestran juntos para ver la relación:

#[derive(Debug)]
pub enum AddProductError {
    CartNotFound,
    ProductNotFound,
    InsufficientStock,
    Domain(CartError),
    Db(sqlx::Error),
}

#[derive(Debug)]
pub enum ApiError {
    BadRequest,
    AddProduct(AddProductError),
    Database(sqlx::Error),
}

CartNotFound aparece cuando una consulta devuelve None: no falló la base de datos, pero falta algo que la operación necesita. Db conserva un fallo de la consulta. Domain conserva el rechazo del carrito sin convertirlo en un mensaje genérico.

El flow puede hacer esa conversión al aplicar la regla:

cart.add_product(product.id, quantity)
    .map_err(AddProductError::Domain)?;

No hay que repetir todas las variantes de CartError dentro de AddProductError. En este caso basta con envolverlo. Si necesitamos distinguir un caso de otra manera porque tiene un significado propio en la operación, podemos convertirlo explícitamente. Lo importante es no perder el motivo solo para que todos los errores tengan el mismo tipo.

El ejemplo simple del carrito convierte ese rechazo en AddProductError::InvalidQuantity, porque solo muestra una regla. Cuando hay varias reglas y queremos conservar sus errores, la variante Domain(CartError) evita copiar la misma lista en dos lugares.

fx conserva el fallo técnico

Una función de fx/db puede devolver Result<Option<Cart>, sqlx::Error>. Ok(None) significa que no encontró el dato; Err significa que no pudo completar la consulta. El flow decide que la ausencia es CartNotFound, pero el error de SQLx sigue siendo la causa técnica de un fallo de acceso a datos.

No hace falta crear un error propio por cada función de fx. Para una consulta sencilla puede bastar con el error del driver. Cuando una integración necesita distinguir situaciones útiles, como un servicio no disponible o una respuesta inválida, un error de esa integración permite nombrarlas sin repartir los detalles del SDK por todo el proyecto.

Conviene conservar la causa técnica, no reducirla enseguida a un String. En Rust podemos implementar std::error::Error y exponerla mediante source(), o usar thiserror para generar esas implementaciones. Así podemos registrar el detalle sin obligar al dominio a conocer SQLx o el SDK del proveedor.

API decide cómo responder

API puede recibir un error del flow, pero también fallar por su cuenta: el JSON puede estar mal formado, la escritura puede fallar o el commit puede no completarse. Por eso ApiError::Database no tiene que pasar por AddProductError: el flow no ejecutó esa escritura.

La distinción queda visible al llamar a la operación y guardar su resultado:

let cart = flows::cart::add_product(
    &mut tx,
    request.cart_id,
    request.product_id,
    request.quantity,
)
.await
.map_err(ApiError::AddProduct)?;

fx::db::update_cart(&mut tx, &cart)
    .await
    .map_err(ApiError::Database)?;

tx.commit().await.map_err(ApiError::Database)?;

Si el flow falla, API no ejecuta la escritura. Si falla la escritura, no intenta confirmar. Si el commit devuelve un error, no responde como si la operación hubiera terminado bien; ante una pérdida de conexión puede ser necesario comprobar el resultado antes de reintentar.

Solo el adaptador de transporte decide qué respuesta enviar. El mismo error del flow puede traducirse a HTTP o a gRPC sin cambiar el dominio ni la operación. Esta tabla muestra una posible elección, no códigos que DFE imponga:

Situación HTTP gRPC
Cantidad inválida 422 INVALID_ARGUMENT
Carrito o producto no encontrado 404 NOT_FOUND
Stock insuficiente 409 FAILED_PRECONDITION
Fallo inesperado de consulta, escritura o commit 500 INTERNAL

El detalle de un fallo técnico se registra con el contexto de la operación; no hace falta devolver al cliente la consulta SQL ni el mensaje interno del proveedor. Para un rechazo conocido, API puede responder con un código y un mensaje que permitan corregir la petición.

No se trata de crear cuatro versiones de cada error. CartError explica la regla que falló; AddProductError, lo que impidió resolver la operación; fx, la causa técnica; API decide cómo comunicarlo. Creamos un tipo o una conversión cuando aporta esa información, no solo porque haya otra carpeta.