Ir al contenido

Arquitecturas

Dos preguntas, mezcladas a propósito

Las cinco apps responden dos preguntas distintas, así que no son cinco puntos de una misma escala. Los nombres de las carpetas responden la primera; los import, la segunda. Organizar por área de negocio puede convivir con cualquier dirección de dependencias.

Las dos preguntas, por app
AppQué corta la parte superior de src/Hacia dónde apuntan las dependencias
LayeredRol técnico: http/, services/, repositories/Hacia el almacenamiento: los servicios importan repositorios concretos.
Feature modulesÁrea de negocio: modules/projects/, modules/tasks/Hacia el almacenamiento dentro de cada grupo de código alrededor de un área de negocio, con sus rutas, reglas y SQL; el resto del código usa solo su punto de entrada público.; entre módulos, tasks → projects.
HexagonalAdentro y afuera: core/, adapters/Hacia el en Hexagonal, el código de la aplicación que no conoce ninguna tecnología: reglas, modelo y puertos, en core/.: los código que traduce entre un puerto y una tecnología concreta, como Express o node:sqlite. implementan interfaz que la aplicación declara en sus propios términos, para lo que ofrece (las operaciones) o para lo que necesita de afuera (almacenamiento, atomicidad). que declara el núcleo.
Cleanen Clean, cada uno de los cuatro niveles de src/; un anillo importa solo de sí mismo y de los anillos interiores.: entities/, use-cases/, interface-adapters/, frameworks/Hacia adentro, anillo por anillo, hasta las objeto cuyos métodos protegen sus propias invariantes, como Project.archive en Clean..
Use-case-firsten Use-case-first, una carpeta por operación en la parte superior de src/, con su ruta, sus reglas y su SQL, sin imports entre acciones.: create-project/, archive-project/, …Hacia el almacenamiento: cada acción ejecuta su propio SQL.

Las tres reglas

  1. Un proyecto se archiva solo si ninguna de sus tareas está sin terminar (todo o in-progress).
  2. Un proyecto archivado no acepta tareas nuevas.
  3. Una tarea avanza de a un paso (todo → in-progress → done); una tarea en done no avanza.

Términos

Capa (layer)
grupo de código con un solo rol técnico, como recibir HTTP, aplicar reglas o guardar datos, que solo depende de las capas de abajo.
Módulo (module)
grupo de código alrededor de un área de negocio, con sus rutas, reglas y SQL; el resto del código usa solo su punto de entrada público.
Puerto (port)
interfaz que la aplicación declara en sus propios términos, para lo que ofrece (las operaciones) o para lo que necesita de afuera (almacenamiento, atomicidad).
Adaptador (adapter)
código que traduce entre un puerto y una tecnología concreta, como Express o node:sqlite.
Caso de uso (use case)
una cosa que un usuario puede hacer con el sistema; acá, cada una de las seis operaciones del contrato.
Entidad (entity)
objeto cuyos métodos protegen sus propias invariantes, como Project.archive en Clean.
Regla de dependencia (dependency rule)
en Clean, las dependencias del código fuente apuntan hacia adentro, hacia las reglas de negocio, nunca hacia frameworks o drivers.
Acción (action)
en Use-case-first, una carpeta por operación en la parte superior de src/, con su ruta, sus reglas y su SQL, sin imports entre acciones.
Núcleo (core)
en Hexagonal, el código de la aplicación que no conoce ninguna tecnología: reglas, modelo y puertos, en core/.
Anillo (ring)
en Clean, cada uno de los cuatro niveles de src/; un anillo importa solo de sí mismo y de los anillos interiores.

Layered

grupo de código con un solo rol técnico, como recibir HTTP, aplicar reglas o guardar datos, que solo depende de las capas de abajo. técnicas en una sola dirección. Cada request baja del controlador hasta SQLite.

Cómo está organizada
src/ se divide por rol técnico. Una capa solo depende de las de abajo, y main.ts conecta las piezas. Los servicios usan repositorios concretos, sin interfaces: hay una sola implementación de almacenamiento.
Dónde viven las reglas
Regla 1 (archivar): ProjectService.archive. Regla 2 (crear tarea): TaskService.create. Regla 3 (avanzar): TaskService.advance.
Recorrido de archivar un proyecto
  1. http/app.ts enruta la solicitud al controlador.
  2. http/project-controller.ts valida el id con requireId.
  3. services/project-service.ts abre la transacción de repositories/database.ts.
  4. repositories/project-repository.ts busca el proyecto; el servicio decide 404 o 409.
  5. repositories/task-repository.ts cuenta las tareas sin terminar; si hay alguna, 409.
  6. updateStatus guarda el cambio; http/errors.ts traduce cualquier error.
Qué facilita
Encontrar un tipo de código: todo el SQL está en repositories/. Leer una regla sin HTTP ni SQL: ProjectService.archive es un método corto.
Qué cuesta
En esta app, un cambio de funcionalidad se reparte entre las tres capas. El modelo compartido las acopla: un campo nuevo toca models.ts, el esquema, el SQL y HTTP.
Por dónde empezar a leer
  1. src/http/app.ts (el único lugar que lista las 6 operaciones)
  2. src/services/project-service.ts (regla 1 y transacción en un solo método)
  3. src/repositories/database.ts (conexión, esquema y helper de transacción)
Se diferencia de Feature modules en
que corta src/ por rol técnico, no por área de negocio: la regla de una tarea y su SQL quedan en carpetas distintas.

Feature modules

Código agrupado por área de negocio: projects y tasks, cada una con sus rutas, reglas y SQL.

Cómo está organizada
Cada grupo de código alrededor de un área de negocio, con sus rutas, reglas y SQL; el resto del código usa solo su punto de entrada público. está dividido adentro por rol: *-routes.ts, lógica y *-store.ts. Desde afuera solo se importa su index.ts. tasks puede importar projects; al revés, no.
Dónde viven las reglas
Regla 1 (archivar): archive en modules/projects/projects.ts. Regla 2 (crear tarea) y regla 3 (avanzar): create y advance en modules/tasks/tasks.ts.
Recorrido de archivar un proyecto
  1. modules/projects/project-routes.ts: requireId rechaza los ids que no son UUID (400).
  2. modules/projects/projects.ts: archive abre la transacción (shared/database.ts).
  3. modules/projects/project-store.ts: findProjectById lee; archive decide 404 o 409.
  4. main.ts: la función inyectada hasUnfinishedTasks consulta al módulo tasks.
  5. modules/tasks/task-store.ts: cuenta las tareas pendientes dentro de la misma transacción.
  6. modules/projects/projects.ts: archive decide la regla 1 (409) o guarda.
Qué facilita
Cada tabla vive en un solo módulo, con sus reglas y sus rutas. Los tests de reglas están junto al módulo que protegen.
Qué cuesta
Por la regla 1, projects recibe una función de tasks, conectada en main.ts. Los dos módulos dependen de shared/, que según su README podría volverse una capa oculta. La conexión Database forma parte de la API pública de cada módulo.
Por dónde empezar a leer
  1. src/main.ts (crea los dos módulos y los conecta)
  2. src/modules/projects/projects.ts (la regla 1 y por qué no importa tasks)
  3. src/modules/tasks/tasks.ts (el único import entre módulos; reglas 2 y 3)
Se diferencia de Layered en
que src/ se corta primero por área de negocio, no por rol técnico.

Hexagonal

Un núcleo sin tecnología, y alrededor todo lo que habla con HTTP o SQLite.

Cómo está organizada
core/ guarda las reglas y los interfaz que la aplicación declara en sus propios términos, para lo que ofrece (las operaciones) o para lo que necesita de afuera (almacenamiento, atomicidad). que declara. adapters/ implementa esos puertos con Express, node:sqlite y una versión en memoria.
Dónde viven las reglas
Reglas 1, 2 y 3: ProjectBoard.archiveProject, createTask y advanceTask, en core/project-board.ts. HTTP solo valida la forma del pedido.
Recorrido de archivar un proyecto
  1. adapters/http/app.ts: valida el id y llama a archiveProject.
  2. core/project-board.ts: pone los chequeos y la escritura dentro del puerto Atomic.
  3. adapters/sqlite/atomic.ts: abre BEGIN IMMEDIATE antes de leer.
  4. core/project-board.ts: rechaza si el proyecto no existe o ya está archivado.
  5. adapters/sqlite/task-store.ts: cuenta las tareas sin terminar; el núcleo decide la regla 1.
  6. adapters/sqlite/project-store.ts: guarda; SqliteAtomic confirma con COMMIT.
Qué facilita
Las reglas se prueban sin base de datos, sobre el adaptador en memoria. Una sola llamada en main.ts elige el almacenamiento.
Qué cuesta
Tres puertos y dos adaptadores de almacenamiento para seis operaciones. El adaptador en memoria existe para los tests y tiene que imitar a SQLite.
Por dónde empezar a leer
  1. src/core/project-board.ts (las operaciones y las reglas)
  2. src/core/ports.ts (lo que el núcleo le pide al almacenamiento)
  3. src/main.ts (dónde se conectan el núcleo y los adaptadores)
Se diferencia de Clean en
el foco: la frontera entre adentro y afuera, y el adaptador reemplazable. Clean pone las reglas en entidades.

Clean

Cuatro anillos: las reglas en el centro y todas las dependencias apuntando hacia adentro.

Cómo está organizada
Las entidades protegen sus invariantes. Cada operación es una clase de una cosa que un usuario puede hacer con el sistema; acá, cada una de las seis operaciones del contrato.. La regla de dependencia prohíbe que un anillo importe de uno exterior, y el lint la hace cumplir.
Dónde viven las reglas
Regla 1 (archivar) y regla 2 (crear tarea): Project.archive y Project.acceptTask en entities/project.ts. Regla 3 (avanzar): Task.advance en entities/task.ts.
Recorrido de archivar un proyecto
  1. frameworks/http/app.ts: route() arma un pedido plano, sin Express, para el controlador.
  2. interface-adapters/project-controller.ts: valida el id y llama al caso de uso.
  3. use-cases/archive-project.ts: ArchiveProject.execute abre la transacción con TransactionRunner.
  4. frameworks/sqlite/: los repositorios devuelven el proyecto y todas sus tareas como entidades.
  5. entities/project.ts: Project.archive rechaza si ya está archivado o si quedan tareas sin terminar.
  6. El caso de uso guarda con update; interface-adapters/error-translation.ts traduce los rechazos a 404 o 409.
Qué facilita
Las reglas 1 a 3 se prueban con entidades solas, sin base de datos. Los controladores traducen HTTP sin Express: reciben y devuelven datos planos.
Qué cuesta
Más archivos para flujos chicos: main.ts arma 12 objetos antes de arrancar. La regla 1 carga todas las tareas como entidades, donde alcanzaría un COUNT.
Por dónde empezar a leer
  1. src/entities/project.ts (reglas 1 y 2 como métodos de la entidad)
  2. src/use-cases/archive-project.ts (una operación completa dentro de una transacción)
  3. src/use-cases/persistence.ts (los contratos que implementa frameworks/sqlite/)
Se diferencia de Hexagonal en
el énfasis: acá las entidades guardan las invariantes y cada operación es una clase. Hexagonal junta las reglas en ProjectBoard y muestra adaptadores intercambiables.
Código
apps/clean

Use-case-first

Una carpeta por cada en Use-case-first, una carpeta por operación en la parte superior de src/, con su ruta, sus reglas y su SQL, sin imports entre acciones. del usuario; cada archivo tiene su ruta, sus reglas y su SQL.

Cómo está organizada
Ninguna acción importa a otra; solo comparten shared/ (conexión, errores y HTTP). Es una interpretación de Screaming Architecture, en la que el árbol muestra qué hace el sistema; no es una arquitectura formal.
Dónde viven las reglas
Regla 1 (archivar): archiveProject. Regla 2 (crear tarea): createTask. Regla 3 (avanzar): advanceTask. Cada una está en el archivo de su acción.
Recorrido de archivar un proyecto
  1. La ruta de archive-project/archive-project.ts valida el id con requireId (shared/http.ts).
  2. archiveProject abre la transacción de shared/database.ts.
  3. findProject: si no existe el proyecto, 404; si ya está archivado, 409.
  4. countUnfinishedTasks consulta tasks directamente; si hay pendientes, 409.
  5. updateProjectStatus guarda archived, y la transacción hace COMMIT.
Qué facilita
Leer una acción completa, de la ruta al SQL, en un solo archivo. Sumar una acción: una carpeta nueva, más su import y su registro en main.ts.
Qué cuesta
Duplicación deliberada: la regla de entrada y "cargar el proyecto o 404" se repiten. Un cambio de columna se busca por texto en cada acción. Que un proyecto archivado tenga solo tareas done depende de tres acciones a la vez.
Por dónde empezar a leer
  1. src/main.ts (registra las seis acciones)
  2. src/archive-project/archive-project.ts (una acción entera, de arriba abajo)
  3. src/shared/database.ts (el esquema y la transacción compartidos)
Se diferencia de Feature modules en
que agrupa por verbo, no por sustantivo: archive-project consulta tasks sin pasar por otro módulo.

Las cinco, lado a lado

Las dos primeras filas miden el mismo request: archivar un proyecto (probalo en el playground). La regla 1 impide archivar un proyecto con tareas sin terminar.

Comparación por app
MedidaLayeredFeature modulesHexagonalCleanUse-case-first
Archivos que se ejecutan al archivar (éxito / con error)7 / 99 / 106 / 811 / 143 / 4
Cómo cuenta la regla 1 las tareas sin terminarCOUNT … status <> 'done'COUNT … status <> 'done'COUNT … status IN (?, ?)Carga todas las tareas; decide la objeto cuyos métodos protegen sus propias invariantes, como Project.archive en Clean.COUNT … IN ('todo', 'in-progress')
Archivos que importan node:sqlite3, en repositories/3: shared/database.ts y 2 stores5, en adapters/sqlite/4, en frameworks/sqlite/1: shared/database.ts; las 6 acciones escriben su SQL
Archivos que importan express4, en http/4: app.ts, 2 archivos de rutas, shared/http.ts2, en adapters/http/2, en frameworks/http/7: las 6 acciones y shared/http.ts
Qué controla el lintDirección de las grupo de código con un solo rol técnico, como recibir HTTP, aplicar reglas o guardar datos, que solo depende de las capas de abajo.; node:sqlite solo en repositories/Puntos de entrada públicos; projects nunca importa tasksEl núcleo importa solo archivos propios y no usa globales de Node; adaptadores independientesCada anillo importa solo de sí mismo y de anillos interiores; sin globales de Node adentroLas acciones no se importan entre sí; node:sqlite solo en shared/database.ts
Dónde corren los tests de reglas (archivos / tests)SQLite real, sin sustituto (2 / 5)SQLite; projects solo, con un hasUnfinishedTasks falso (3 / 7)código que traduce entre un puerto y una tecnología concreta, como Express o node:sqlite. en memoria; regla 1 en ambos adaptadores (3 / 19)Solo entidades, sin almacenamiento (3 / 9)SQLite, datos de prueba en SQL plano (5 / 7)
Archivos / líneas, sin tests ni helpers de test14 / 60914 / 68815 / 78725 / 100111 / 636

Cómo leer estos números

  • Muestran dónde está el código y cuánto recorre un request. No miden calidad.
  • La primera fila cuenta los archivos cuyo código se ejecuta mientras se atiende el request. No incluye el registro de rutas al arrancar ni los imports solo de tipos.
  • El tamaño favorece a lo chico. Con 6 operaciones y 3 reglas, las apps con menos estructura parecen más baratas; muchas reglas que crucen operaciones cambiarían el balance.
  • Los tests no se comparan por cantidad. Los 19 de Hexagonal incluyen tests que corren una vez por adaptador. Compará cómo se preparan, no los totales.
  • El lint revisa imports, y en Hexagonal y Clean también globales. Nunca revisa dónde está escrita la lógica.
  • Los conteos salen del código en el commit 93f7d9e y cambian cuando cambian las apps.