Alguien en tu equipo acaba de importar pg en src/domain/invoice.ts. El PR compila. Los tests pasan. La revisión de código tiene trescientas líneas y nadie se da cuenta.

Tres meses después, quieres extraer la lógica de dominio a un package compartido. No puedes. Depende de tipos de Postgres, lógica de connection pooling y un wrapper de driver personalizado que solo existe en el monolito. El diagrama de la pizarra con círculos concéntricos ahora es una broma.

Este es el patrón de degradación estándar de la arquitectura limpia sin aplicación automatizada. Los arquitectos dibujan flechas apuntando hacia adentro. Los desarrolladores escriben imports apuntando hacia afuera. El compiler no le importan tus capas.

Qué significa realmente que el dominio no importe infraestructura

En la arquitectura por capas, las dependencias fluyen hacia adentro. La capa de dominio, en el centro, define entidades, reglas de negocio y casos de uso. No tiene conocimiento de HTTP, SQL, colas ni sistemas de archivos.

La infraestructura vive en el anillo exterior. Implementa las interfaces que el dominio define. La interfaz del repository vive en dominio. La implementación de Postgres vive en infraestructura. El dominio llama a save(invoice). Nunca llama a pool.query().

Cuando el código de dominio importa infraestructura directamente, pasan dos cosas. Primero, el dominio se vuelve imposible de testear sin tener la base de datos corriendo. Segundo, nunca podrás cambiar Postgres por DynamoDB sin reescribir la lógica de negocio. Todo el sentido de la separación en capas, la testabilidad y la capacidad de intercambio, se evapora.

Por qué la revisión de código no es suficiente

Cada equipo tiene un ingeniero senior que detecta malos imports en la revisión. Ese ingeniero también está de vacaciones, enfermo o revisando un PR de cincuenta archivos a las 5 PM de un viernes.

Los humanos son reconocedores de patrones con RAM limitada. Las reglas de dependencias automatizadas son validadores deterministas con paciencia infinita. El lugar correcto para hacer cumplir los límites arquitectónicos es el mismo donde haces cumplir los errores de sintaxis: el pipeline de build. Si compila pero viola el grafo de dependencias, debería fallar.

Cómo dependency-cruiser impone los límites de las capas

Para bases de código en TypeScript y JavaScript, dependency-cruiser convierte tu diagrama de arquitectura en un conjunto de reglas testeables. Analiza tu grafo de imports y hace fallar el build cuando aparece una arista prohibida.

Instálalo:

npm install --save-dev dependency-cruiser
npx depcruise --init

El script de init genera un config .dependency-cruiser.js. Añades una regla que prohíbe que el dominio toque la infraestructura:

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: "domain-cannot-import-infrastructure",
      comment:
        "Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        path: "^src/infrastructure",
      },
    },
    {
      name: "domain-cannot-import-node-modules",
      comment:
        "Domain code must not depend on external I/O libraries.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        dependencyTypes: ["npm"],
        // Allow only pure logic libraries like date-fns or lodash
        pathNot: "^(date-fns|lodash|ramda)",
      },
    },
  ],
  options: {
    doNotFollow: {
      path: "node_modules",
    },
    tsPreCompilationDeps: true,
    tsConfig: {
      fileName: "tsconfig.json",
    },
  },
};

Esta regla dice: cualquier archivo bajo src/domain que importe algo bajo src/infrastructure rompe el build. La segunda regla atrapa el import de pg o axios que llega vía node_modules.

Conéctalo a tu CI:

# .github/workflows/ci.yml
jobs:
  architecture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx depcruise src

Ahora el import de pg en src/domain/invoice.ts falla antes de que un humano siquiera abra el PR.

Cómo se ve el fallo

Cuando un desarrollador rompe la regla, obtiene una salida como esta:

error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
  Domain code must not depend on infrastructure. Move the import to an adapter or repository.

error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
  Domain code must not depend on external I/O libraries.

✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.

El mensaje de error les dice qué hacer. Mueve la llamada a la base de datos a un repository en la capa de infraestructura. Pasa el repository al caso de uso como una interfaz. El código de dominio se mantiene puro.

Alternativas para otros ecosistemas

No todo el mundo usa TypeScript. El principio es el mismo en todas partes. Solo cambian las herramientas.

Java: ArchUnit es el estándar de oro. Escribes un test, no un archivo de config:

@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..infrastructure..");

Esto se ejecuta como un test de JUnit, así que se integra con Maven y Gradle sin necesidad de nueva infraestructura de CI.

C#: NetArchTest ofrece el mismo estilo de API para .NET. Escribe un unit test, ejecútalo en CI.

Go: No hay un equivalente maduro. La mayoría de los equipos lo aplican con un simple shell script:

#!/bin/bash
# scripts/check-domain-imports.sh

if grep -r "infrastructure/" internal/domain/ ; then
  echo "Domain code imports infrastructure. Fix the dependency direction."
  exit 1
fi

Es tosco, pero funciona. Muchos equipos de Go también usan analizadores personalizados de go vet o el package go/ast para construir comprobaciones adecuadas.

Sistemas de build: Bazel, Gradle y Nx soportan gráficos de dependencias de modules de forma nativa. Si defines tu dominio e infraestructura como modules separados con declaraciones de dependencia explícitas, la herramienta de build impone el límite gratis. El truco es que primero debes adoptar el sistema de build.

Compromisos: falsos positivos, dolor de migration y fricción en el equipo

Las reglas automatizadas no son gratis. Deberías conocer los costes antes de activarlas.

Los falsos positivos ocurren cuando tienes utilidades compartidas legítimas que se sitúan entre capas. Un slugifier de strings o una clase base de error personalizada podrían vivir en src/shared. Si tanto dominio como infraestructura la importan, la regla está bien. Si la pones en src/infrastructure/utils y el dominio la importa, dependency-cruiser se quejará. La solución suele ser mover el código compartido, que a menudo es el movimiento correcto de todos modos.

El dolor de migration es real. Si tu base de código ya viola la regla en treinta lugares, no puedes activar el interruptor sin una refactorización heroica. El enfoque pragmático es empezar con warnings, arreglar las violaciones existentes durante un sprint y luego elevarlas a errores. Alternativamente, usa excepciones pathNot para archivos legacy conocidos y prohíbe las nuevas violaciones desde el día uno.

La fricción en el equipo es el coste oculto. Los desarrolladores que nunca han trabajado con aplicación estricta de capas se resistirán. Argumentarán que un import pequeño es inofensivo, que la regla es burocrática, que les ralentiza. Esta es una señal cultural, no técnica. Esos mismos desarrolladores también serán los que importen express en un modelo de dominio a las 11 PM para lanzar una feature. La regla existe porque los humanos bajo presión toman atajos.

Cómo implementar esto en una base de código existente

No necesitas una reescritura grande. Necesitas un límite y una forma de medirlo.

  1. Dibuja las capas. Decide qué directorios son dominio, aplicación e infraestructura. Escríbelo en ARCHITECTURE.md.

  2. Añade dependency-cruiser (o el equivalente de tu ecosistema) con una regla: el dominio no puede importar infraestructura.

  3. Ejecútalo. Cuenta las violaciones. Si el número es pequeño, arréglalas antes de mergear la config. Si es grande, añade las rutas legacy a excepciones pathNot.

  4. Haz que falle en CI. No es opcional. No es un warning. Un error que bloquea el merge.

  5. Cada vez que un desarrollador encuentre el error, ayúdale a mover el import. No solo le digas que el build está en rojo. Explica dónde debería vivir el código y por qué.

En dos semanas, el equipo dejará de intentar importar pg en archivos de dominio. El diagrama de arquitectura de la pizarra finalmente coincidirá con el código en el repository.

FAQ

¿Debería permitirse que la capa de aplicación importe infraestructura?

Normalmente, sí. La capa de aplicación orquesta los casos de uso. Puede conocer repositories y servicios externos, pero debería depender de interfaces, no de implementaciones concretas. Si quieres aplicación estricta, añade una segunda regla: application no puede importar implementaciones de infrastructure, solo sus interfaces.

¿Qué pasa con los domain events?

Los domain events son un agujero común. Un domain event es datos puros, así que pertenece a la capa de dominio. El bus de events de infraestructura que lo publica pertenece a infraestructura. La capa de aplicación suscribe los handlers de domain events al bus. El dominio en sí nunca sabe que el bus existe.

¿Puedo usar esto con Nx o Turborepo?

Sí. Nx tiene reglas de límites de modules integradas que funcionan a nivel de proyecto. Si tu dominio e infraestructura son librerías de Nx separadas, puedes aplicar la regla sin herramientas adicionales. Lo mismo aplica a Bazel y Gradle.

¿Y si necesito una utilidad de infraestructura?

No la necesitas. Mueve la utilidad a un package compartido common o kernel que no tenga dependencias de infraestructura. Si realmente necesita infraestructura, no es una utilidad. Es infraestructura.

Conclusión

La arquitectura limpia sin aplicación automatizada es un acuerdo de caballeros. Los acuerdos de caballeros no sobreviven a los deadlines de producción.

Dependency-cruiser, ArchUnit o incluso un shell script con grep convierten tu arquitectura de una sugerencia en una garantía. El dominio se mantiene puro. La infraestructura se mantiene intercambiable. Y la próxima vez que alguien intente importar pg en una regla de negocio, el build fallará antes de que el PR llegue siquiera a un revisor humano.

Empieza con una regla. Ponla en rojo en CI. Arregla la primera violación. El resto seguirá.