Plantilla de proyecto Python para CLIs construidas con arquitectura hexagonal (ports & adapters), inspirada en el ejemplo de ArjanCodes pero adaptada a una interfaz de línea de comandos con Typer.
Gestiona dependencias con uv, aplica lint y format con ruff (reglas alineadas al Google Python Style Guide), verifica tipos y la conformidad estructural de los puertos con ty, y protege la arquitectura con tach (equivalente Python de ArchUnit).
El dominio se organiza en dos capas al estilo del scaffold de
Bancolombia:
una capa model/ donde cada modelo vive en su propio paquete junto a su
gateway (puerto), y una capa usecase/ separada que depende de los
modelos.
+-----------------------------------+
| entrypoint/ | CLI (Typer), composition
| | root, mapeo de errores
+----------------+-----------------+
| depende de
+----------------v-----------------+
| domain/usecase/ | funciones puras; retornan
| | resultados de dominio
+----------------+-----------------+
| depende de
+----------------v-----------------+
| domain/model/ | entidades + gateways
| | (puertos) por feature
+----------------+-----------------+
^ implementa (estructuralmente)
+----------------+-----------------+
| adapters/ | IO concreto (consola, fs)
+-----------------------------------+
Reglas de oro (verificadas por tach check en cada commit y en CI):
domain/model/no importa de ninguna otra capa.domain/usecase/depende solo dedomain/model/(y del kernel compartidodomain/errors.py), nunca al reves.domain/nunca importa deadapters/ni deentrypoint/.
Los use cases son puros: construyen y retornan un resultado de
dominio; la entrega por un puerto (p. ej. escribir a consola) ocurre en
el entrypoint/, no en el dominio.
python-cli-hexagonal/
├── README.md # este archivo
├── CLAUDE.md # reglas Google no expresables en ruff
├── pyproject.toml # uv + typer + ruff + pytest + tach
├── tach.toml # boundaries arquitectónicos
├── .pre-commit-config.yaml # ruff + tach pre-commit
├── .github/workflows/ci.yml # lint + format + tach + pytest
├── src/app/
│ ├── domain/ # NÚCLEO puro, sin dependencias externas
│ │ ├── errors.py # DomainError base (shared kernel)
│ │ ├── model/ # capa de modelos: entidad + gateway
│ │ │ └── greeting/
│ │ │ ├── models.py # @dataclass(frozen=True)
│ │ │ └── gateways.py # typing.Protocol → GreeterPort
│ │ └── usecase/ # capa de casos de uso (puros)
│ │ └── greet/
│ │ ├── use_case.py # greet(request) -> Greeting
│ │ └── errors.py # EmptyNameError(DomainError)
│ ├── adapters/ # implementaciones concretas
│ │ └── console_greeter.py
│ └── entrypoint/ # composition root + CLI Typer
│ ├── cli.py # typer.Typer() + mapeo DomainError
│ ├── wiring.py # Dependencies + build_dependencies()
│ ├── __main__.py # python -m app.entrypoint
│ └── commands/
│ └── greet.py # subcomando `greet`
└── tests/
├── conftest.py # FakeGreeter fixture
├── unit/test_use_cases.py
└── integration/test_cli.py
Requiere uv instalado.
uv syncEsto crea el .venv, instala dependencias de runtime y de desarrollo, e
instala el paquete app en modo editable.
Dos formas equivalentes:
uv run app greet --name Mundo
uv run python -m app.entrypoint greet --name MundoSalida esperada en verde negrita: Hola, Mundo!
Probar el manejo de errores de dominio:
uv run app greet --name " "
# Error: El nombre del destinatario no puede estar vacio.
# (en rojo a stderr, exit code 1)uv run pytestCoverage activo por defecto. Tests divididos en unit/ (use case puro y
entrega vía fake adapter) e integration/ (CLI end-to-end con
CliRunner).
uv run ruff check . # detecta problemas
uv run ruff check --fix . # corrige automáticamente
uv run ruff format . # formateaLas reglas de ruff están alineadas al Google Python Style Guide. Las
reglas del guide que ruff no puede expresar están en
CLAUDE.md y deben respetarse en code review.
uv run ty check # verifica tipos y conformidad de puertosty (de Astral, mismo ecosistema que uv y ruff) comprueba que cada
adapter satisface estructuralmente el Protocol de su gateway. Sin esto,
un cambio en la firma de un puerto pasaría lint y tests y solo fallaría
en runtime. La conformidad se ancla en wiring.py, donde el adapter se
devuelve tipado como el puerto.
uv run tach check # valida boundaries
uv run tach show # imprime grafo de dependencias permitidas
uv run tach mod # asistente para añadir nuevos módulostach.toml declara qué módulos pueden importar de qué módulos. Si
alguien añade from app.adapters.x import y dentro de
src/app/domain/, tach check falla y el commit/PR se bloquea.
uv run pre-commit installA partir de ahí, cada git commit corre ruff + tach.
- Crear el paquete de modelo en
src/app/domain/model/<feature>/:models.pycon los@dataclass(frozen=True)de la feature.gateways.pycon elProtocoldel puerto, junto al modelo, si el comando necesita un canal de IO nuevo.
- Crear el paquete de caso de uso en
src/app/domain/usecase/<feature>/:use_case.pycon una función pura que reciba la solicitud y retorne un resultado de dominio (no entrega IO).errors.pycon los errores específicos de la feature, que heredan deapp.domain.errors.DomainError.
- Implementar el adapter en
src/app/adapters/<nombre>.pyque cumpla estructuralmente elProtocoldel gateway. - Añadir el puerto como campo de
Dependenciesy construirlo enbuild_dependencies()dentro desrc/app/entrypoint/wiring.py(composition root único). - Crear el subcomando en
src/app/entrypoint/commands/<nombre>.pycon una funciónrun(ctx: typer.Context, ...)que: leactx.obj(lasDependencies), invoque el use case puro y luego entregue el resultado por el puerto. - Registrarlo en
src/app/entrypoint/cli.py:app.command(name="<nombre>")(<nombre>_cmd.run). - Tests: unitarios del use case puro (afirman sobre el valor retornado)
y del puerto con un fake en
tests/unit/; integración conCliRunnerentests/integration/. - No suele hacer falta tocar
tach.toml: las features nuevas viven dentro de las capas ya declaradas (app.domain.model,app.domain.usecase). Solo se añade atach.tomlsi introduces una capa nueva de primer nivel bajosrc/app/.
# 1. Renombrar el directorio
mv src/app src/<tu_nombre>
# 2. Sustituir el import path en todo el código
find . -type f \( -name '*.py' -o -name '*.toml' -o -name '*.yaml' \
-o -name '*.yml' -o -name '*.md' \) \
-not -path './.venv/*' -not -path './.git/*' \
-exec sed -i '' 's/\bapp\b/<tu_nombre>/g' {} +
# 3. Actualizar pyproject.toml:
# - [project] name
# - [project.scripts] <tu_nombre> = "<tu_nombre>.entrypoint.cli:main"
# - [tool.hatch.build.targets.wheel] packages = ["src/<tu_nombre>"]
# - [tool.ruff.lint.per-file-ignores] paths
# 4. Re-sync
uv sync
uv run <tu_nombre> greet --name Mundouv sync
uv run app greet --name Mundo # → "Hola, Mundo!" verde, exit 0
uv run app greet --name " " # → "Error: ..." rojo, exit 1
uv run pytest # 6 tests passing
uv run ruff check . # All checks passed!
uv run ruff format --check . # sin cambios pendientes
uv run ty check # All checks passed!
uv run tach check # ✓ All modules validated!
uv build # genera dist/app-0.1.0-*.whl