Ir al contenido
  1. Posts/

Todo CLI en Rust 1. Arquitectura hexagonal en un proyecto pequeño

·6 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 1: Este artículo

Muy buenas. En esta serie te cuento cómo estamos construyendo todo-cli-rs partiendo del Project #1 de CodeCrafters (Rust Projects). Si quieres ir viendo código en paralelo, aquí tienes el repo: github.com/rafafrdz/todo-cli-rs.

El reto parecía simple (hasta que dejó de serlo)
#

Sobre el papel era un CLI pequeño: recibir comandos, operar tareas y persistir estado local. El MVP inicial quedaba así:

  • add <title>
  • list [--status <all|todo|done>]
  • done <id>
  • todo <id>
  • delete <id>

Fácil, ¿no? El problema no era hacer que funcionase hoy. El problema era evitar que dentro de dos semanas fuera un main.rs gigante tocando parsing, JSON, salida por consola y reglas de negocio al mismo tiempo.

La decisión que cambió el proyecto
#

¿Por qué Arquitectura Hexagonal para un CLI?
#

Normalmente, las herramientas de línea de comandos se escriben como un único script: un main.rs que lee argumentos con clap, muta un archivo JSON en disco y hace un println! del resultado.

todo-cli-en-rust-1-arquitectura-hexagonal-en-un-proyecto-pequeno-img-28.png

Funciona rápido, pero mezcla tres cosas que deberían ser ortogonales:

  1. El mecanismo de entrada (parsing de argumentos CLI).
  2. Las reglas de negocio (qué es una tarea válida, qué transiciones de estado se permiten).
  3. El mecanismo de almacenamiento (escribir en el file system).

Si mañana queremos que este CLI pase a ser una interfaz interactiva en terminal (TUI) usando ratatui, o si queremos exponer las tareas por una API HTTP, tendríamos que desenredar toda la lógica.

Por eso, antes de escribir casos de uso, decidimos la estructura:

  • Arquitectura Hexagonal (Puertos y Adaptadores) para proteger el dominio e independizarlo de la infraestructura.
  • Screaming Architecture para que el árbol de carpetas grite “tasks” y no “utils”.

¿Es matar moscas a cañonazos para un MVP tan pequeño? Seguramente. ¿Compensa? Totalmente. El coste inicial de definir esta frontera nos permite tratar el “core” de la aplicación como una librería pura de Rust. Ese coste inicial evita refactors dolorosos cuando cambian requisitos (como pasar de persistencia JSON a SQLite).

Más detalle aquí: docs/architecture.md

Contratos primero: comandos de entrada
#

Definir bien las entradas no fue solo UX, fue diseño de sistema. Cuando acotas comandos y argumentos desde el principio, reduces ambigüedad en todo el flujo.

En esta primera iteración nos quedamos con el subconjunto anterior. Implementación de referencia: src/tasks/adapters/cli/cli_command.rs

Entendiendo la Arquitectura Hexagonal a fondo
#

Cuando hablamos de “Arquitectura Hexagonal”, mucha gente busca las seis caras del hexágono en su código o se imagina círculos concéntricos. La realidad es que el nombre original que le dio Alistair Cockburn es mucho más descriptivo: Arquitectura de Puertos y Adaptadores.

El hexágono es solo una metáfora visual para romper con la arquitectura tradicional en capas (arriba-abajo). Al usar una figura plana, se ilustra que un sistema puede tener múltiples puntos de entrada (CLI, HTTP, eventos) y múltiples puntos de salida (bases de datos, FS, APIs de terceros) conectándose todos a un núcleo central. No hay “arriba y abajo”, hay dentro y fuera.

Si queremos teorizar un poco más, el sistema se parece más a un grafo dirigido de dependencias. El valor real del modelo no está en dibujar una forma geométrica, sino en un concepto fundamental: el Principio de Inversión de Dependencias (DIP).

En una arquitectura clásica, el código de negocio llama directamente a la base de datos para guardar datos. Es decir, el negocio depende de la infraestructura. En la arquitectura hexagonal le damos la vuelta:

  1. El dominio (el centro) define qué necesita para funcionar a través de Puertos (en Rust, esto son puros traits).
  2. La infraestructura (el exterior) proporciona Adaptadores que implementan esos puertos.

Por eso, las tres ideas de fondo que rigen nuestro código son:

  • El dominio es el núcleo semántico. No sabe nada de JSON, de clap ni de la terminal. Debe mantenerse estable.
  • La infraestructura es un detalle de implementación reemplazable.
  • Los puertos dictan el contrato. Son la frontera que protege al dominio.

Ahora sí, aterrizándolo a algo operativo para este proyecto:

  • Las dependencias de compilación apuntan siempre hacia el núcleo.
  • Los casos de uso orquestan el flujo usando los puertos.
  • Los adaptadores (CLI, Persistencia JSON) viven en el borde e implementan esos puertos.

Con ese aterrizaje, una vista rápida de la estructura queda así:

                      +----------------------+
                      |         main         |
                      |   (entrypoint CLI)   |
                      +----------+-----------+
                                 |
                                 v
                    +------------+-------------+
                    | application/use_cases    |
                    | (orquesta el flujo)      |
                    +------+------------+------+
                           |            |
                           v            v
                +----------+--+    +---+----------------+
                |   ports     |    |      domain        |
                | (contratos) |    | (reglas puras)     |
                +------+-------+    +--------------------+
                       ^
                       |
        +--------------+---------------+
        |                              |
        v                              v
+-------+-------------+      +---------+---------------+
|   adapters/cli      |      | adapters/persistence    |
| (entrada/salida)    |      | (json, fs, sqlite...)   |
+---------------------+      +-------------------------+

Si lo quieres ver con dos lentes distintas, este doble esquema suele aclararlo mucho:

1) Flujo de ejecución (qué pasa al lanzar un comando)

Usuario
  |
  v
CLI adapter (clap / parser)
  |
  v
Use case (application)
  |
  v
Domain (entidades + reglas)
  |
  v
Port (trait)
  |
  v
Persistence adapter (json/fs/sqlite)

2) Flujo de dependencias (quién conoce a quién en código)

adapters  ------implementan------> ports
use_cases ------usan-------------> ports
use_cases ------usan-------------> domain
domain    ------(sin deps)------> nadie

La clave está en separar estas dos preguntas: cómo se ejecuta no siempre coincide con cómo se acopla el código.

Particionamiento: técnico vs dominio
#

Además de separar capas, hay que decidir cómo agrupar el proyecto. En términos prácticos, aquí tienes dos estrategias:

  • Partición por dominio: ideal cuando el negocio es amplio, con subdominios claros y equipos mixtos (producto, negocio, ingeniería). El lenguaje ubicuo manda. Definir una semántica que todos entiendan es imprescindible.
  • Partición técnica: útil cuando el alcance es pequeño, el equipo comparte contexto técnico y prima la velocidad de entrega.

Para este MVP elegimos partición técnica porque el dominio inicial (task) era acotado y no justificaba abrir múltiples contextos de negocio todavía.

Mapa rápido de módulos:

  • domain: reglas de negocio puras.
  • ports: contratos para desacoplar aplicación e infraestructura.
  • adapters/cli: entrada/salida por terminal.
  • adapters/persistence: repositorios concretos.
  • application/use_cases: orquestación de flujos.
  • main: punto de entrada.

Código base de módulos: src/tasks/mod.rs

Punto de inflexión real
#

La elección se validó cuando metimos salida dual (table/json) y persistencia en fichero. Ningún caso de uso tuvo que aprender de serde_json, rutas del sistema o println! con formato: todo quedó encapsulado en adaptadores.

Esa es la señal de que la arquitectura está haciendo su trabajo.

Conclusión
#

Empezar un proyecto desde cero es siempre una mezcla de ilusión y pragmatismo. La tentación de ir directamente a escribir código que compile y escupa un resultado por consola es enorme. Y para un script de usar y tirar, es exactamente lo que deberías hacer.

Pero cuando el objetivo es construir algo que va a evolucionar, sentar unas bases arquitectónicas sólidas deja de ser “sobreingeniería” para convertirse en supervivencia. En este primer paso no hemos escrito todavía la lógica para marcar una tarea como completada, pero hemos logrado algo mucho más importante: hemos trazado las fronteras.

Al elegir Arquitectura Hexagonal y dividir nuestro CLI en Dominio, Casos de Uso y Adaptadores, hemos blindado nuestras reglas de negocio. Hemos garantizado que el día que queramos cambiar la terminal por una interfaz gráfica, o el JSON por una base de datos real, el corazón de nuestra aplicación no sufrirá ni un rasguño. Solo tendremos que escribir un nuevo adaptador y conectarlo a un puerto.

El proyecto ya no es un “script de Rust”, es un núcleo de negocio protegido por una coraza de infraestructura.

En la siguiente entrega bajaremos al barro y entraremos directamente al núcleo que acabamos de aislar: diseñaremos un dominio inmutable, modelaremos las transiciones de estado de nuestras tareas y crearemos una taxonomía de errores fuertemente tipados por capa. ¡Nos vemos en el código!

Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 1: Este artículo