Estructura
Una estructura de carpetas no resuelve por sí sola los problemas de un sistema, pero puede hacer que esos problemas sean más fáciles de encontrar. La prueba está en si una persona que entendió el ejemplo pequeño reconoce el mapa cuando el proyecto crece.
No empiezo creando cuatro carpetas vacías. Primero veo qué operaciones necesita el proyecto y qué hace cada una. Un endpoint que consulta un dato no necesita la misma estructura que un checkout con reglas, escritura y avisos a otros sistemas.
Los nombres domain/, flows/, fx/ y api/ conservan su responsabilidad cuando aparecen, pero no son cuatro pasos obligatorios. Podemos tener una operación directa junto a otra que necesita un flow, dentro del mismo servicio.
Cuando basta con API
Un endpoint de salud que solo responde que el proceso está activo puede resolverse en el handler. No consulta datos ni aplica una regla de negocio. No necesita un flow que devuelva lo mismo ni una función de fx/ sin trabajo que hacer.
src/
└── api/
└── http.rs
Eso no significa poner SQL, reglas y clientes externos dentro del handler cuando aparezcan. Si la operación necesita consultar la base de datos, esa consulta tiene su lugar en fx/db. Si necesita una decisión de negocio, la operación merece un flow.
API llama directamente a fx
Ver el contenido de un carrito, sin aplicar una regla adicional, puede ser una consulta directa. API recibe el identificador, llama a fx/db y prepara la respuesta.
api → fx/db → api responde
Lo mismo ocurre con una escritura técnica que no requiere una decisión de negocio, por ejemplo actualizar una marca de última sincronización. API llama a la función de escritura y responde o propaga el error. No creamos un flow que solo reenvíe la llamada.
En ambos casos puede bastar esta estructura:
src/
├── fx/
│ └── db.rs
└── api/
└── http.rs
Es un ejemplo de organización, no una exención de permisos o validaciones de entrada. Si «ver el carrito» requiere decidir qué información puede ver una persona según una regla del negocio, deja de ser la consulta sencilla de este caso.
Una lectura también puede necesitar un flow
Supongamos que queremos mostrar las promociones que se pueden aplicar al carrito. No basta con devolver filas: hay que considerar su contenido y las condiciones de cada promoción. El flow consulta los datos necesarios y obtiene el resultado aplicando esas reglas; API lo convierte en una respuesta. No hay nada que guardar.
api → flow → consultas de fx/db
│
└── aplica reglas del dominio
← resultado
api responde, sin escritura
Tampoco todas las operaciones necesitan una base de datos. Si API ya recibe todos los datos necesarios para calcular un total, el flow puede aplicar las reglas del dominio y devolver el cálculo sin llamar a fx/.
api → flow → domain
← resultado
api responde
Usar un flow no obliga a añadir persistencia. Y tener una regla sencilla en un método del dominio no obliga a repartirla entre varios flows: el flow debe expresar la operación, no duplicar esa regla.
Una operación que prepara cambios
Agregar un producto sí necesita comprobar existencia, stock y cantidad. El flow obtiene los datos, aplica las reglas y devuelve el carrito modificado en memoria. No ejecuta la escritura.
api abre la transacción
├── flow consulta mediante fx/db y aplica reglas
│ └── devuelve el carrito, sin persistirlo
├── api guarda mediante fx/db
└── api hace commit y responde
API mantiene las consultas y la escritura en la misma transacción cuando la regla lo requiere, con las restricciones y el aislamiento adecuados. Si no hay flow porque la tarea es puramente técnica, API sigue siendo quien ejecuta la escritura. La página de API explica esa responsabilidad y la página de Flows desarrolla el acceso directo a las consultas.
Cómo crece la estructura
Estas estructuras muestran posibles divisiones del mismo proyecto. No son plantillas que haya que completar ni etapas definidas por un número de entidades o desarrolladores. Divido un archivo cuando sus responsabilidades ya cuestan de encontrar, no porque el proyecto haya alcanzado cierto tamaño.
Los árboles omiten parte de los archivos de declaración de módulos para concentrarse en la organización. En Rust, cart.rs puede declarar submódulos en cart/; también puede usarse cart/mod.rs. No hace falta cambiar de convención para aplicar DFE.
Chico: varias operaciones en un módulo
Una tienda con carrito y catálogo puede mantener las reglas en unos pocos archivos y reunir las operaciones del carrito en flows/cart.rs. No necesita un archivo por función desde el primer día.
src/
├── domain/
│ ├── cart.rs
│ └── product.rs
├── flows/
│ └── cart.rs
├── fx/
│ └── db.rs
└── api/
└── http.rs
En api/http.rs pueden convivir un GET que llama directamente a fx/db y un POST que llama al flow y después guarda su resultado. La presencia de flows/cart.rs no obliga a pasar todas las peticiones por él.
Intermedio: separar lo que ya cuesta encontrar
Cuando el carrito tiene varias operaciones y aparecen promociones, separo los flows por operación. En el dominio agrupo el carrito y sus tipos; en fx/db, las consultas y escrituras por el dato que manejan. Si se añade gRPC, tiene su entrada en API, pero no una copia de las reglas.
src/
├── domain/
│ ├── cart/
│ │ ├── cart.rs
│ │ └── quantity.rs
│ ├── product.rs
│ └── promotion.rs
├── flows/
│ └── cart/
│ ├── add_product.rs
│ ├── remove_product.rs
│ ├── apply_coupon.rs
│ ├── eligible_promotions.rs
│ └── mod.rs
├── fx/
│ └── db/
│ ├── cart.rs
│ ├── product.rs
│ ├── promotion.rs
│ └── mod.rs
└── api/
├── http/
│ ├── cart.rs
│ └── product.rs
└── grpc/
└── cart/
├── service.rs
└── adapters.rs
adapters.rs aparece si hay conversiones entre el contrato gRPC y los tipos que recibe la operación. No es un lugar para repetir la regla ni un archivo obligatorio por cada mensaje.
Complejo: agrupar por área de negocio
Cuando comercio y catálogo tienen suficientes operaciones, sus nombres ayudan a agrupar el código dentro de cada responsabilidad. No cambia quién consulta, decide o guarda; cambia la profundidad necesaria para encontrarlo.
src/
├── domain/
│ ├── commerce/
│ │ ├── cart/
│ │ └── order/
│ ├── catalog/
│ │ ├── product/
│ │ └── category/
│ └── shared/
│ └── money.rs
├── flows/
│ ├── commerce/
│ │ ├── cart/
│ │ └── order/
│ │ ├── commands/
│ │ │ ├── checkout.rs
│ │ │ └── cancel.rs
│ │ └── queries/
│ │ ├── get_detail.rs
│ │ └── eligible_returns.rs
│ └── catalog/
│ └── product/
├── fx/
│ ├── db/
│ │ ├── commerce/
│ │ │ ├── carts.rs
│ │ │ └── orders.rs
│ │ └── catalog/
│ │ └── products.rs
│ ├── payments/
│ ├── cache/
│ ├── events/
│ └── notifications/
│ └── email.rs
└── api/
├── http/
│ ├── commerce/
│ └── catalog/
├── grpc/
│ └── services/
│ ├── commerce/
│ └── catalog/
└── actions/
└── commerce/
└── order/
└── notify_customer.rs
commands/ y queries/ sirven para ordenar una lista de operaciones que ya resulta larga. Un command prepara un cambio, pero sigue sin persistirlo: API guarda su resultado. Una query calcula o selecciona información con reglas, sin escritura posterior. Esos nombres no obligan a separar bases de datos ni a introducir mensajería.
shared/ tampoco es una carpeta para todo lo que no sabemos dónde poner. Un Money puede compartirse si tiene el mismo significado y las mismas reglas para quienes lo usan. Si dos áreas necesitan reglas distintas, unificar sus tipos solo por el nombre puede terminar acoplándolas.
Cuándo aparece api/actions/
En el checkout, el flow devuelve el pedido y API lo guarda. Después del commit puede haber que cobrar, avisar a otro servicio o enviar una confirmación. Las funciones que llaman a esos sistemas viven en fx/; el código que coordina esas acciones sigue perteneciendo a API.
Mientras ese código se lea bien en el handler, lo dejo ahí. Cuando ocupa demasiado o debe compartirse entre HTTP y gRPC, puede moverse a api/actions/. No es una quinta capa por la que deba pasar toda petición, ni depende de alcanzar el tamaño «complejo».
Mover la coordinación no cambia sus responsabilidades: los errores de esas acciones se registran y se gestionan; no se ocultan para devolver éxito ni se trasladan al flow. Los problemas de entrega y reintentos se explican aparte en Integración con otros patrones.
El mismo carrito, con más reglas
Los tres tamaños de examples/cart/ muestran esa evolución:
- Agregar productos. El dominio rechaza cantidades inválidas y el flow consulta existencia y stock. API guarda el carrito devuelto. Una operación cabe en un archivo de flow.
- Aplicar un cupón. La cantidad adquiere su propio tipo y aparece otra operación.
apply_couponconsulta el cupón; el dominio decide si puede aplicarse al carrito. Tiene su propio flow, no una condición más dentro deadd_product. El GET sigue consultando directamentefx/db: tener más reglas en el proyecto no obliga a usar un flow en todas partes. - Preparar el pedido. Checkout consulta el carrito y el dominio calcula el total con su descuento. El resultado ya es un pedido, no un carrito modificado. API lo guarda y, después del commit, coordina la publicación y el aviso. Esa secuencia puede extraerse a
api/actions/si hay que compartirla con otra entrada.
El crecimiento se ve en nuevas reglas, operaciones y resultados. No hace falta añadir una capa por cada necesidad, ni mover las escrituras al flow cuando la operación se vuelve más larga.
Qué conservar al dividir archivos
No hace falta crear la misma profundidad en las cuatro carpetas. Catálogo puede seguir teniendo un archivo de consultas mientras comercio ya tiene varias operaciones. Si un módulo reúne cosas que cambian por motivos distintos, conviene separarlo; si una carpeta solo añade un paso para encontrar una función, quizá todavía no hace falta.
El árbol de decisión ayuda a elegir el camino de cada operación. Las carpetas crecen después, cuando el código necesita esa división.