Parte VIII · Ampliaciones

30. Monorepos con Nx

TaskFlow no es una sola aplicación: es un frontend Angular, una API NestJS, entidades MikroORM, DTOs compartidos, componentes de interfaz y, con el tiempo, más servicios. Cuando cada pieza vive en su propio repositorio, el coste de coordinar cambios compartidos se come el tiempo de ingeniería. Este capítulo explica qué problema resuelve un monorepo, cómo organizarlo con Nx (apps, libs, tags, boundaries, affected y caché) y cómo aplicar esa estructura al dominio TaskFlow sin caer en microfrontends prematuros ni en acoplamientos circulares.

AVANZADO Tiempo de lectura: ~85 min Prerrequisitos: capítulos 2, 9 y 21

30.1 Qué vas a poder hacer al terminar

  • Explicar con precisión qué es un monorepo, en qué se diferencia de un polyrepo y qué problemas de coordinación, versionado y CI resuelve (y cuáles no).
  • Crear un workspace Nx y distinguir apps de libs, workspaces package-based e integrated, y el papel de project.json, generators y executors.
  • Diseñar una estructura de carpetas para TaskFlow (Angular + Nest + MikroORM) con librerías de tipos, DTOs, data-access y UI, y justificar cada frontera.
  • Configurar tags y module boundaries para que una importación ilegal falle en lint, no en producción.
  • Usar el grafo de dependencias, comandos affected y la caché local/remota para no reconstruir lo que no ha cambiado.
  • Decidir cuándo Module Federation aporta valor y cuándo es complejidad gratuita.
  • Escribir un workflow de GitHub Actions con nx affected, paralelismo y caché.
  • Comparar Nx con Turborepo, pnpm workspaces y Yarn de forma honesta, sin marketing.
  • Reconocer los errores típicos (ciclos, app→app, libs «god», caché incorrecta) y corregirlos.
Dominio de referencia: TaskFlow

A lo largo del capítulo usamos TaskFlow: gestión de tareas con proyectos, asignaciones, estados y comentarios. El frontend es Angular; la API, NestJS; la persistencia, MikroORM sobre PostgreSQL. El monorepo debe permitir cambiar un DTO una sola vez y que tipen a la vez el cliente HTTP del frontend y los controladores del backend. Si no consigues eso, el monorepo no te está aportando su valor principal.

30.2 Qué es un monorepo y qué problema resuelve

Un monorepo (repositorio único) es un repositorio de control de versiones que contiene varios proyectos —aplicaciones, librerías, herramientas— que se desarrollan juntos, con un historial compartido y, normalmente, un pipeline único que entiende las dependencias entre ellos. Un polyrepo (o multirepo) es la estrategia contraria: un repositorio por aplicación o por equipo.

La definición importa porque mucha gente llama «monorepo» a meter dos carpetas en el mismo Git sin herramientas. Eso es un monorepo en bruto: funciona hasta que alguien cambia un tipo compartido y rompe tres aplicaciones sin enterarse. Un monorepo serio añade límites explícitos, grafo de dependencias, ejecución selectiva (affected) y, idealmente, caché reproducible.

30.2.1 El problema que el polyrepo no escala bien

Imagina TaskFlow partido en tres repos: taskflow-api, taskflow-web y taskflow-shared. Quieres añadir el campo prioridad a una tarea. El flujo real acaba siendo:

  1. Cambias el DTO en taskflow-shared, subes versión (semver), publicas el paquete.
  2. Actualizas la API para consumir la nueva versión, despliegas.
  3. Actualizas el frontend, que a menudo se queda una o dos versiones atrás «porque el PR es otro».
  4. Durante días conviven tres versiones del mismo contrato. Los bugs de desajuste aparecen en producción, no en CI.

Eso no es un fallo de disciplina: es el coste estructural de versionar contratos internos como si fueran APIs públicas. El monorepo elimina ese baile: un solo commit puede tocar DTO, controlador Nest y servicio Angular. El CI valida el conjunto. El atomic commit es la propiedad más valiosa.

Analogía: el edificio y los solares

Un polyrepo es tres solares con tres arquitectos, tres permisos de obra y tres camiones de hormigón distintos. Si el plano del ascensor cambia, hay que coordinar tres obras. Un monorepo es un único solar con varias plantas: el plano del núcleo vertical es compartido; cambiarlo afecta a todas las plantas a la vez, y el aparejador (Nx) te dice qué plantas hay que revisar. No es «más simple» en abstracto: es más barato coordinar cambios transversales.

30.2.2 Historia breve

Cronología esencial

Años 2000–2010. Google populariza (y mitifica) el monorepo a escala: un árbol enorme, herramientas internas (Piper, Blaze) y una cultura de «todo se ve». Facebook sigue un camino similar. Fuera de esas empresas, el consejo habitual sigue siendo «un repo por servicio».

2015–2017. Babel, Angular, React y otros proyectos open source consolidan monorepos con Lerna + Yarn workspaces. Aparece el patrón «paquetes versionados dentro de un solo Git».

2017 · Nx. Nrwl (fundadores del equipo original de Angular) publica Nx: primero orientado a Angular, después agnóstico. Aporta generators, executors, grafo, affected y caché. El mensaje no es «mete todo en un repo», sino «haz el repo inteligible para la máquina».

2019–2022. Turborepo (luego Vercel) y mejoras fuertes de pnpm workspaces democratizan la orquestación ligera. Bazel sigue siendo la referencia de hermeticidad en empresas muy grandes.

2023–2025. Nx distingue claramente workspaces integrated (plugins, project graph rico) y package-based (más cercanos a workspaces clásicos). La caché remota y Nx Cloud se vuelven pieza central del discurso de CI.

La lección histórica es simple: el monorepo no es una moda estética. Es una respuesta a un coste de coordinación que crece con el número de límites artificiales entre código que, en realidad, cambia junto.

30.2.3 Cuándo sí y cuándo no

SituaciónMonorepo suele ayudarPolyrepo suele ser mejor
Frontend y API del mismo producto (TaskFlow)Sí: contratos compartidos, un CISolo si equipos y ciclos de release son radicalmente distintos
Librería open source consumida por tercerosMonorepo interno + publicación selectivaRepo propio si la comunidad y el versionado son el producto
Equipos sin confianza ni ownership claroNo: el monorepo amplifica el ruidoSí: límites de acceso y ownership más claros
Código que no comparte tipos ni calendarioNo aporta; añade fricción
Necesidad de secretos/ACL por equipo muy estrictosPosible pero costosoMás natural
El monorepo no sustituye la arquitectura

Meter código acoplado en un solo repo no lo desacopla. Sin límites de importación, tags y ownership, acabas con un big ball of mud versionado. Nx brilla cuando codificas las reglas que ya deberías tener en la cabeza.

  POLYREPO                         MONOREPO (TaskFlow)

  ┌────────────┐                   ┌──────────────────────────────┐
  │ web.git    │──npm pkg──┐       │  apps/web   apps/api         │
  └────────────┘           │       │  libs/shared/dto             │
  ┌────────────┐           ▼       │  libs/shared/types           │
  │ shared.git │──────► registry   │  libs/data-access/...        │
  └────────────┘           ▲       │                              │
  ┌────────────┐           │       │  un commit = cambio atómico  │
  │ api.git    │──npm pkg──┘       │  CI conoce el grafo          │
  └────────────┘                   └──────────────────────────────┘

30.3 Nx: instalación, workspace, apps vs libs, tags y boundaries

Nx es un sistema de build para monorepos JavaScript/TypeScript (y más allá). No sustituye a npm/pnpm/yarn como instalador de paquetes: se apoya en ellos. Su valor está en conocer el grafo de proyectos, generar estructura con plugins, ejecutar tareas (build, test, lint, serve) de forma inteligente y cachear resultados.

30.3.1 Instalación y creación del workspace

La forma recomendada hoy es crear el workspace con el CLI oficial. Elige el package manager que ya uses en el equipo; en este libro preferimos pnpm por su eficiencia de disco y sus workspaces nativos, pero npm y yarn son perfectamente válidos.

terminal
# Crear un workspace integrado orientado a aplicaciones
npx create-nx-workspace@latest taskflow --preset=apps

# Alternativa: workspace vacío para ir añadiendo plugins
# npx create-nx-workspace@latest taskflow --preset=ts

cd taskflow
# Añadir soporte Angular y Nest (plugins oficiales)
pnpm add -D @nx/angular @nx/nest @nx/js @nx/node

Tras la creación tendrás, como mínimo, un nx.json (configuración global: caché, named inputs, target defaults), un package.json raíz y una estructura de proyectos. La CLI nx (o pnpm exec nx) es el punto de entrada de casi todo.

comandos esenciales
npx nx graph                 # abre el grafo interactivo
npx nx show projects         # lista proyectos
npx nx show project api      # detalle de un proyecto
npx nx run api:build         # ejecuta el target build de api
npx nx run-many -t test      # tests de todos los proyectos
npx nx affected -t lint,test,build

30.3.2 Apps frente a libs

En la jerga de Nx (y de Angular desde hace años):

La regla de oro: las apps orquestan; las libs encapsulan. Si una app importa otra app, has roto el modelo. Si una lib «hace de casi-app» (arranca servidor, lee variables de entorno de producción), también.

  ┌─────────────────────────────────────────────────────────┐
  │                        APPS                             │
  │   apps/web (Angular)          apps/api (NestJS)         │
  └──────────────┬──────────────────────────┬───────────────┘
                 │ importan                 │
                 ▼                          ▼
  ┌──────────────────────┐     ┌────────────────────────────┐
  │ libs/web/*           │     │ libs/api/*                 │
  │  ui, feature-*,      │     │  feature-*, data-access-*, │
  │  data-access-*       │     │  util-*                    │
  └──────────┬───────────┘     └─────────────┬──────────────┘
             │                               │
             └──────────────┬────────────────┘
                            ▼
               ┌─────────────────────────┐
               │ libs/shared/*           │
               │  types, dto, util       │
               │  (sin Angular ni Nest)  │
               └─────────────────────────┘

30.3.3 Tags y module boundaries

Los tags son etiquetas declarativas en cada proyecto (scope:web, scope:api, scope:shared, type:feature, type:ui, type:data-access, type:util). Por sí solos no hacen nada. Cobran sentido con la regla de ESLint @nx/enforce-module-boundaries, que define qué tag puede depender de qué otro.

libs/shared/dto/project.json (extracto)
{
  "name": "shared-dto",
  "tags": ["scope:shared", "type:util"],
  "targets": {
    "lint": { "executor": "@nx/eslint:lint" }
  }
}
eslint.config.mjs (regla de boundaries, idea)
// Fragmento conceptual de la regla @nx/enforce-module-boundaries
{
  files: ['**/*.ts'],
  rules: {
    '@nx/enforce-module-boundaries': [
      'error',
      {
        allow: [],
        depConstraints: [
          {
            sourceTag: 'scope:web',
            onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'],
          },
          {
            sourceTag: 'scope:api',
            onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'],
          },
          {
            sourceTag: 'scope:shared',
            onlyDependOnLibsWithTags: ['scope:shared'],
          },
          {
            sourceTag: 'type:feature',
            onlyDependOnLibsWithTags: [
              'type:feature',
              'type:ui',
              'type:data-access',
              'type:util',
            ],
          },
          {
            sourceTag: 'type:ui',
            onlyDependOnLibsWithTags: ['type:ui', 'type:util'],
          },
          {
            sourceTag: 'type:data-access',
            onlyDependOnLibsWithTags: ['type:data-access', 'type:util'],
          },
        ],
      },
    ],
  },
}
apps/web/.../tasks.service.tsINCORRECTO
// El frontend importa código Nest / MikroORM del backend
import { Task } from '@taskflow/api/data-access-tasks';
import { EntityManager } from '@mikro-orm/core';

export class TasksBrowserService {
  // Esto acopla el bundle del navegador al ORM del servidor
}
apps/web/.../tasks.service.tsCORRECTO
// Solo DTOs/tipos compartidos + cliente HTTP propio
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class TasksApi {
  private readonly http = inject(HttpClient);

  create(dto: CreateTaskDto): Observable<TaskDto> {
    return this.http.post<TaskDto>('/api/tasks', dto);
  }
}
shared no conoce frameworks

libs/shared/* debe poder compilarse sin Angular ni Nest en el classpath de tipos. Si empiezas a importar @angular/core o @nestjs/common en shared, has filtrado un framework hacia el otro lado. Los decoradores de validación (class-validator) son un caso límite aceptable si ambos lados los usan; las entidades MikroORM, no.

30.4 Generators y executors; project.json / package-based vs integrated

Nx separa dos conceptos que en otros sistemas se mezclan:

Esta separación es poderosa: puedes cambiar el executor de build (esbuild, webpack, vite, swc) sin cambiar cómo generas librerías, y puedes escribir generators propios que respeten las convenciones de TaskFlow.

30.4.1 Generators en la práctica

terminal · scaffolding TaskFlow
# Aplicación Angular
npx nx g @nx/angular:application web --directory=apps/web --routing --style=scss

# Aplicación NestJS
npx nx g @nx/nest:application api --directory=apps/api

# Librería de DTOs compartidos (JS/TS puro)
npx nx g @nx/js:library shared-dto --directory=libs/shared/dto --importPath=@taskflow/shared/dto

# Librería de UI Angular
npx nx g @nx/angular:library web-ui --directory=libs/web/ui --importPath=@taskflow/web/ui

# Librería data-access Nest (módulo Nest importable)
npx nx g @nx/nest:library api-data-access-tasks \
  --directory=libs/api/data-access-tasks \
  --importPath=@taskflow/api/data-access-tasks

El flag importPath define el alias TypeScript (paths en tsconfig.base.json). Usa siempre un scope de organización (@taskflow/...) para que las importaciones lean como paquetes reales, no como rutas relativas de diecisiete niveles.

tsconfig.base.json (extracto)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@taskflow/shared/dto": ["libs/shared/dto/src/index.ts"],
      "@taskflow/shared/types": ["libs/shared/types/src/index.ts"],
      "@taskflow/web/ui": ["libs/web/ui/src/index.ts"],
      "@taskflow/web/data-access-tasks": ["libs/web/data-access-tasks/src/index.ts"],
      "@taskflow/api/data-access-tasks": ["libs/api/data-access-tasks/src/index.ts"]
    }
  }
}
Exporta por el index público

Cada lib debe exportar solo lo estable desde src/index.ts. Importar un fichero interno con ruta profunda (@taskflow/web/ui/src/lib/button/button.component) rompe el encapsulamiento y hace frágiles los refactors. La regla de boundaries de Nx puede prohibir deep imports.

30.4.2 Executors y project.json

En un workspace integrated, cada proyecto declara sus targets en project.json (o en el package.json con inferencia, según versión y plugins). Un target tipico:

apps/api/project.json (extracto)
{
  "name": "api",
  "tags": ["scope:api", "type:app"],
  "targets": {
    "build": {
      "executor": "@nx/js:tsc",
      "outputs": ["{options.outputPath}"],
      "options": {
        "outputPath": "dist/apps/api",
        "main": "apps/api/src/main.ts",
        "tsConfig": "apps/api/tsconfig.app.json"
      }
    },
    "serve": {
      "executor": "@nx/js:node",
      "options": {
        "buildTarget": "api:build",
        "watch": true
      }
    },
    "test": {
      "executor": "@nx/jest:jest",
      "options": {
        "jestConfig": "apps/api/jest.config.ts"
      }
    }
  }
}

Los outputs son críticos para la caché: Nx necesita saber qué carpetas/ficheros produce un target para reutilizarlos. Si omites outputs, la caché no restaura artefactos aunque el cálculo de hash diga «hit».

30.4.3 Package-based frente a integrated

AspectoIntegratedPackage-based
Modelo mentalProyectos Nx con plugins y project graph ricoPaquetes npm/pnpm clásicos + Nx como orquestador
Configuraciónproject.json, plugins @nx/*package.json por paquete, scripts estándar
GeneratorsMuy ricos (Angular, Nest, React…)Más ligeros; tú montas más a mano
Ideal paraTaskFlow full-stack con Angular+NestMonorepos de librerías publicables, equipos ya en workspaces
CurvaMás conceptos Nx al principioMás familiar si vienes de pnpm workspaces

Para este libro recomendamos integrated en TaskFlow: los plugins de Angular y Nest ahorran semanas de configuración webpack/vite/Jest y alinean targets. Si tu monorepo es sobre todo paquetes publicados a npm sin apps Angular, package-based puede ser más honesto.

nx.json (namedInputs y targetDefaults, extracto)
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.[jt]s"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    },
    "lint": {
      "cache": true
    }
  }
}

dependsOn: ["^build"] significa: antes de construir este proyecto, construye las dependencias del grafo. Es lo que evita que web compile contra una lib desactualizada en disco.

30.4.4 Generators propios: codificar el estándar del equipo

Cuando el equipo supera las tres personas, el problema deja de ser «saber usar Nx» y pasa a ser «hacer siempre lo mismo». Si cada feature Angular se crea a mano, acabas con cuatro estilos de carpeta, tags a medias y un index.ts que exporta el mundo. Un generator propio (o un wrapper documentado sobre los generators oficiales) es la forma de convertir la convención en código.

Un generator mínimo para TaskFlow debería: crear la lib bajo libs/web/feature-*, asignar tags scope:web y type:feature, generar un fichero de rutas exportado, un componente contenedor y un stub de test, y registrar el importPath en tsconfig.base.json. No necesita ser sofisticado el primer día: necesita ser la única forma aceptable de crear features. El coste de mantener el generator se amortiza en el primer mes.

Generators ≠ magia

Un generator malo multiplica basura. Revisa lo que genera igual que revisas un PR humano. Versiona el generator en tools/generators o como plugin local del workspace, y añade un test que ejecute el schematic sobre un árbol virtual y compruebe tags y paths.

En entrevistas y en auditorías de arquitectura, una señal de madurez del monorepo es precisamente esa: no solo hay carpetas bonitas, hay un camino automatizado que hace difícil hacerlo mal. Nx brilla aquí porque el mismo ecosistema que ejecuta builds también ejecuta scaffolding; no dependes de un script bash frágil copiado en un wiki.

30.5 Grafo de dependencias, affected commands, caché local y remota

El project graph es el mapa de quién importa a quién. Nx lo calcula analizando package.json, imports TypeScript y configuración de proyectos. Sin grafo fiable, ni affected ni la caché merecen confianza.

terminal · inspeccionar el grafo
npx nx graph
npx nx graph --file=graph.json   # exportar para CI o auditorías
npx nx show project web --web    # detalle de un proyecto
  Cambio en libs/shared/dto
           │
           ▼
     ┌─────────────┐
     │ shared-dto  │  ← affected directamente
     └──────┬──────┘
            │ lo consumen
     ┌──────┴───────┐
     ▼              ▼
 ┌─────────┐   ┌─────────┐
 │ web     │   │ api     │  ← affected transitivamente
 │ (build, │   │ (build, │
 │  test)  │   │  test)  │
 └─────────┘   └─────────┘
     libs/web/ui NO affected si no depende de dto

30.5.1 Comandos affected

nx affected compara el grafo con un rango de commits (por defecto, contra la base de la rama) y ejecuta targets solo en los proyectos tocados o que dependen de lo tocado.

terminal · affected en local y CI
# Contra main (local)
npx nx affected -t lint,test,build --base=main --head=HEAD

# En GitHub Actions suele usarse la base del PR
npx nx affected -t lint,test,build \
  --base=origin/main \
  --head=HEAD \
  --parallel=3
Base incorrecta = falsa seguridad

Si --base apunta a un commit equivocado (por ejemplo, el mismo HEAD), affected puede reportar «nada que hacer» y saltarse tests. En CI, fija la base al branch de destino del PR o al SHA del último commit verde de la rama principal. Documenta el criterio en el README del repo.

30.5.2 Caché local y remota

Cuando un target es cacheable, Nx calcula un hash a partir de: fuentes del proyecto (según inputs), fuentes de dependencias, runtime (versión de Node, variables listadas), flags del comando. Si el hash ya existe, restaura stdout/stderr y outputs sin reejecutar.

project.jsonINCORRECTO
{  "targets": {
    "build": {
      "executor": "@nx/js:tsc",
      "options": {
        "outputPath": "dist/apps/api",
        "main": "apps/api/src/main.ts",
        "tsConfig": "apps/api/tsconfig.app.json"
      }
    }
  }
}
project.jsonCORRECTO
{  "targets": {
    "build": {
      "executor": "@nx/js:tsc",
      "outputs": ["{options.outputPath}"],
      "options": {
        "outputPath": "dist/apps/api",
        "main": "apps/api/src/main.ts",
        "tsConfig": "apps/api/tsconfig.app.json"
      }
    }
  }
}

Sin outputs, un cache hit puede «tener éxito» y dejarte sin artefactos en dist/. El síntoma clásico: el job de Docker no encuentra la carpeta compilada aunque Nx diga que el build estaba cacheado.

nx.json · variables que afectan a la caché
{
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["production", "^production", { "env": "NODE_ENV" }]
    }
  }
}

Incluye en inputs solo lo que realmente cambia el resultado. Meter {workspaceRoot}/**/* invalida la caché ante cualquier README tocado. Por eso existen namedInputs de producción que excluyen specs.

30.5.3 Determinismo: la caché solo es segura si el target lo es

La caché de Nx asume que, con los mismos inputs, obtienes el mismo resultado. Si tus tests leen la hora del sistema, escriben en una carpeta fuera de outputs, dependen del orden de un readdir o fallan uno de cada veinte, la caché se convierte en una máquina de falsos verdes o de misterios. Antes de activar remote cache en serio, haz una pasada de higiene:

Un truco de diagnóstico: ejecuta dos veces el mismo target con caché fría y caliente y compara no solo el exit code, sino el artefacto (hash de dist/). Si el hash del output cambia sin cambiar inputs, tienes no-determinismo. Encontrarlo pronto es más barato que perseguir un flaky en producción atribuido «a Nx».

En equipos que vienen de polyrepo, la primera reacción ante un cache hit sospechoso es desactivar la caché globalmente. Resiste esa tentación: desactívala solo en el target enfermo ("cache": false), corrige la causa, y vuelve a activarla. La caché es un multiplicador; el determinismo es el prerequisito.

30.6 Librerías compartidas: tipos, DTOs, UI, data-access

El diseño de libs es donde se gana o se pierde el monorepo. Una taxonomía clara evita el antipatrón «libs/shared/misc con 200 exports».

30.6.1 Taxonomía recomendada para TaskFlow

TipoContienePuede depender deEjemplo
type:util / typesTipos TS, enums, guards de tipo, constantesOtras util compartidasTaskStatus, Priority
type:util / dtoClases/interfaces de contrato HTTP + validacióntypesCreateTaskDto, TaskDto
type:data-access (api)Entidades MikroORM, repositorios, módulos Nestdto, types, util apiTask entity, TasksService
type:data-access (web)Servicios HTTP Angular, stores/signals de servidordto, types, ui (evitar)TasksApi
type:uiComponentes presentacionalesutil, types (no data-access)TaskStatusBadge
type:featurePáginas/casos de uso que componen ui + data-accessui, data-access, utilfeature-task-list
libs/shared/types/src/lib/task-status.ts
export const TASK_STATUSES = ['todo', 'doing', 'done', 'blocked'] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];

export const PRIORITIES = ['low', 'medium', 'high'] as const;
export type Priority = (typeof PRIORITIES)[number];
libs/shared/dto/src/lib/create-task.dto.ts
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { Priority, PRIORITIES, TaskStatus, TASK_STATUSES } from '@taskflow/shared/types';

export class CreateTaskDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(200)
  title!: string;

  @IsOptional()
  @IsString()
  @MaxLength(4000)
  description?: string;

  @IsEnum(TASK_STATUSES)
  status: TaskStatus = 'todo';

  @IsEnum(PRIORITIES)
  priority: Priority = 'medium';
}

export class TaskDto {
  id!: string;
  title!: string;
  description?: string;
  status!: TaskStatus;
  priority!: Priority;
  projectId!: string;
  createdAt!: string;
  updatedAt!: string;
}
libs/api/data-access-tasks/src/lib/task.entity.ts
import { Entity, PrimaryKey, Property, Enum } from '@mikro-orm/core';
import { Priority, TaskStatus } from '@taskflow/shared/types';
import { randomUUID } from 'crypto';

@Entity({ tableName: 'tasks' })
export class Task {
  @PrimaryKey({ type: 'uuid' })
  id: string = randomUUID();

  @Property({ length: 200 })
  title!: string;

  @Property({ type: 'text', nullable: true })
  description?: string;

  @Enum({ items: () => ['todo', 'doing', 'done', 'blocked'] })
  status: TaskStatus = 'todo';

  @Enum({ items: () => ['low', 'medium', 'high'] })
  priority: Priority = 'medium';

  @Property({ fieldName: 'project_id' })
  projectId!: string;

  @Property({ onCreate: () => new Date() })
  createdAt: Date = new Date();

  @Property({ onCreate: () => new Date(), onUpdate: () => new Date() })
  updatedAt: Date = new Date();
}

Observa la separación: la entidad conoce MikroORM; el DTO conoce class-validator; ambos comparten solo los tipos primitivos del dominio. El mapeo entidad↔DTO vive en el servicio de aplicación Nest, no en shared.

libs/api/data-access-tasks/src/lib/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { Task } from './task.entity';

@Injectable()
export class TasksService {
  constructor(private readonly em: EntityManager) {}

  async create(dto: CreateTaskDto, projectId: string): Promise<TaskDto> {
    const task = this.em.create(Task, { ...dto, projectId });
    await this.em.persistAndFlush(task);
    return this.toDto(task);
  }

  private toDto(task: Task): TaskDto {
    return {
      id: task.id,
      title: task.title,
      description: task.description,
      status: task.status,
      priority: task.priority,
      projectId: task.projectId,
      createdAt: task.createdAt.toISOString(),
      updatedAt: task.updatedAt.toISOString(),
    };
  }
}

30.6.2 Reglas de importación (no circular, no app→app)

ciclo.tsINCORRECTO
// libs/web/feature-tasks importa data-access
// y data-access importa un helper de feature-tasks
import { taskListColumns } from '@taskflow/web/feature-tasks';
import { TasksApi } from '@taskflow/web/data-access-tasks';
// Ciclo: feature → data-access → feature
columnas en util/uiCORRECTO
// Extraer columnas a libs/web/ui o libs/web/util-tasks
import { taskListColumns } from '@taskflow/web/ui';
import { TasksApi } from '@taskflow/web/data-access-tasks';
// feature-tasks importa ambos; nadie importa feature desde abajo
Barrels circulares

Un index.ts que reexporta demasiado facilita ciclos invisibles. Prefiere barrels estrechos por lib y, si hace falta, subpaths (@taskflow/shared/dto/tasks) en lugar de un único megabarrel.

30.7 Angular + Nest en el mismo workspace: estructura TaskFlow

Esta es la estructura que recomendamos como punto de partida. No es dogma: es un mapa que escala hasta varios dominios sin obligarte a microfrontends.

árbol de carpetas TaskFlow
taskflow/
├── apps/
│   ├── web/                 # Angular (shell + rutas)
│   ├── web-e2e/             # Playwright/Cypress
│   ├── api/                 # NestJS (main, AppModule)
│   └── api-e2e/
├── libs/
│   ├── shared/
│   │   ├── types/           # enums y tipos de dominio
│   │   ├── dto/             # contratos HTTP + class-validator
│   │   └── util/            # helpers puros (fechas, ids…)
│   ├── web/
│   │   ├── ui/              # design system ligero
│   │   ├── util-*/          # pipes, guards Angular reutilizables
│   │   ├── data-access-*/   # HttpClient + estado remoto
│   │   └── feature-*/       # rutas smart (listado, detalle…)
│   └── api/
│       ├── util-*/          # filtros, pipes Nest transversales
│       ├── data-access-*/   # entidades MikroORM + servicios
│       └── feature-*/       # módulos de dominio (TasksModule…)
├── tools/                   # scripts, generators propios
├── nx.json
├── tsconfig.base.json
└── package.json

30.7.1 Apps delgadas

La app Nest debería limitarse a arranque, configuración y composición de módulos. La lógica vive en libs.

apps/api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { TasksFeatureModule } from '@taskflow/api/feature-tasks';
import { ProjectsFeatureModule } from '@taskflow/api/feature-projects';
import mikroOrmConfig from './mikro-orm.config';

@Module({
  imports: [
    MikroOrmModule.forRoot(mikroOrmConfig),
    TasksFeatureModule,
    ProjectsFeatureModule,
  ],
})
export class AppModule {}
apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';

export const appRoutes: Routes = [
  {
    path: 'tasks',
    loadChildren: () =>
      import('@taskflow/web/feature-task-list').then((m) => m.TASK_LIST_ROUTES),
  },
  {
    path: 'projects',
    loadChildren: () =>
      import('@taskflow/web/feature-projects').then((m) => m.PROJECT_ROUTES),
  },
  { path: '', pathMatch: 'full', redirectTo: 'tasks' },
];

El lazy loading por feature lib permite que el grafo de Nx y el code-splitting de Angular cuenten la misma historia: una feature es una unidad de ownership, de test y de carga diferida.

30.7.2 Desarrollo local: un comando, dos procesos

En local conviene levantar API y web con un target compuesto, y un proxy en el dev-server de Angular hacia Nest para evitar CORS durante el desarrollo.

apps/web/proxy.conf.json
{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false,
    "changeOrigin": true
  }
}
package.json (scripts de comodidad)
{
  "scripts": {
    "web": "nx serve web",
    "api": "nx serve api",
    "dev": "nx run-many -t serve -p api,web --parallel=2",
    "affected": "nx affected -t lint,test,build"
  }
}
Analogía: cocina y sala

La API es la cocina; el frontend, la sala. Comparten la carta (DTOs) escrita una sola vez. No compartes los fogones (MikroORM) con los camareros (componentes Angular). El monorepo es el edificio; los tags son las puertas cortafuegos.

30.7.3 Ownership, CODEOWNERS y ritmo de PRs

El monorepo concentra el tráfico de pull requests. Sin reglas, libs/shared/dto se convierte en el pasillo donde todo el mundo deja mudanzas. Define ownership explícito:

.github/CODEOWNERS (ejemplo)
/apps/web/                       @taskflow/frontend
/apps/api/                       @taskflow/backend
/libs/web/                       @taskflow/frontend
/libs/api/                       @taskflow/backend
/libs/shared/                    @taskflow/platform
/nx.json                         @taskflow/platform
/tsconfig.base.json              @taskflow/platform
/.github/workflows/              @taskflow/platform

El equipo platform (aunque sean dos personas a media jornada) cuida tooling, boundaries y shared. Un cambio de DTO exige revisión de platform + del lado consumidor. Parece fricción; en realidad es el sustituto del versionado semver entre repos. Si shared cambia sin revisión, el monorepo solo acelera la propagación de errores.

Sobre el ritmo: prefiere PRs pequeños que toquen una vertical (feature + dto + test) frente a PRs «limpieza general» de veinte libs. Affected y los revisores humanos agradecen lo mismo. Si necesitas un refactor transversal (renombrar un enum), sepáralo de features nuevas y comunícalo: es el equivalente monorepo a un major bump.

30.7.4 Estrategia de tests en el monorepo

No todos los tests deben correr en todos los PRs. Una estratificación sana para TaskFlow:

El error clásico es trasladar al monorepo la costumbre polyrepo de «la CI de mi repo corre mis e2e siempre». Multiplicado por N apps, el pipeline muere. Affected no es opcional a escala: es la única forma de mantener la promesa de feedback en minutos.

acoplamiento por pathINCORRECTO
// En Angular: importar un servicio Nest por path relativo
import { TasksService } from
  '../../../api/src/app/tasks/tasks.service';
contrato compartidoCORRECTO
import { TaskDto } from '@taskflow/shared/dto';
// Cada lado implementa su adaptador:
// Nest: TasksService + controlador
// Angular: TasksApi (HttpClient)

30.8 Module Federation / microfrontends con Nx

Nx ofrece soporte de primera clase para Module Federation (webpack y, en evolución, variantes con esbuild/rsbuild según versión del plugin). Permite que varias aplicaciones Angular se carguen en runtime como remotos de un shell (host).

30.8.1 Cuándo sí

30.8.2 Cuándo no

Microfrontend no es sinónimo de monorepo

Puedes tener monorepo sin microfrontends (lo habitual en TaskFlow) y microfrontends sin monorepo (varios repos publicando remotos). Mezclar ambos sin necesidad es acumular las dos complejidades.

idea de host/remoto (conceptual)
// En el shell (host): cargar un remoto en runtime
export const appRoutes: Routes = [
  {
    path: 'admin',
    loadChildren: () =>
      import('admin/Routes').then((m) => m.remoteRoutes),
  },
];

// El nombre 'admin/Routes' lo resuelve Module Federation,
// no el bundler como un path TypeScript normal.
  SIN MF (recomendado TaskFlow inicial)     CON MF (equipos/deploy independientes)

  ┌────────────────────┐                    ┌──────────┐
  │ apps/web (único)   │                    │  host    │
  │  feature-tasks     │                    └────┬─────┘
  │  feature-projects  │                         │ carga runtime
  │  libs/web/*        │                    ┌────┴─────┬──────────┐
  └────────────────────┘                    ▼          ▼          ▼
                                       remoto      remoto     remoto
                                       tasks       projects   admin

Si más adelante TaskFlow crece a un módulo de administración desplegado por otro equipo, entonces evalúa MF. Hasta entonces, libs + lazy routes.

Un criterio de decisión práctico: escribe en una frase quién despliega qué y con qué frecuencia. Si la respuesta es «el mismo equipo, el mismo pipeline, la misma versión de Angular», Module Federation no te está comprando independencia real; solo te vende la ilusión. Si la respuesta es «el equipo de Admin despliega los jueves sin coordinar con Core, y aceptan versionar el contrato del shell», entonces el coste de MF (shared dependencies, debugging cross-app, contratos de semver en runtime) puede merecer la pena. Documenta ese contrato igual que documentarías una API HTTP: qué exporta el remoto, qué versiones del shell soporta y cómo se hace rollback si el remoto rompe el host.

En la práctica, muchos equipos llegan a MF demasiado pronto porque han leído que «microfrontends escalan organizaciones». La organización escala primero con libs, ownership y CI affected. MF es el último escalón, no el primero.

30.9 CI: affected, parallel, caching en GitHub Actions

El capítulo 21 cubrió pipelines genéricos. Aquí el matiz es Nx: el CI debe dejar de construir el mundo entero en cada push.

.github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  main:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Derivar base de affected
        id: base
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            echo "base=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
          else
            echo "base=HEAD~1" >> "$GITHUB_OUTPUT"
          fi

      - name: Lint, test y build afectados
        run: |
          pnpm exec nx affected -t lint,test,build \
            --base=${{ steps.base.outputs.base }} \
            --head=HEAD \
            --parallel=3 \
            --configuration=ci

Puntos críticos del workflow:

ci.yml · caché de Nx en Actions (idea)
- name: Restaurar caché Nx
  uses: actions/cache@v4
  with:
    path: .nx/cache
    key: nx-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
    restore-keys: |
      nx-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nx-${{ runner.os }}-

# Alternativa más potente: Nx Cloud (remote cache distribuida)
# NX_CLOUD_ACCESS_TOKEN en secretos del repositorio
Nx Cloud frente a cache de Actions

La caché de actions/cache es por job/runner y tiene límites de evicción. Nx Cloud (u otro remote cache) comparte resultados entre PRs y desarrolladores con mayor hit rate. Para un equipo pequeño, actions/cache sobre .nx/cache ya aporta mucho; mide antes de pagar.

job opcional · e2e solo si web affected
# Lista proyectos affected y decide
pnpm exec nx show projects --affected --base=origin/main --head=HEAD | tee affected.txt
if grep -qE '^(web|web-e2e)$' affected.txt; then
  pnpm exec nx run web-e2e:e2e
fi
ci.ymlINCORRECTO
- uses: actions/checkout@v4
  # fetch-depth por defecto = 1
- run: npx nx affected -t test --base=main
  # sin origin/main fetch → base incorrecta
ci.ymlCORRECTO
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: git fetch origin main:main
- run: pnpm exec nx affected -t test --base=main --head=HEAD

30.10 Versionado y publicación de libs

En TaskFlow, la mayoría de las libs no se publican a un registro npm: se consumen por path aliases dentro del workspace. Publicar solo tiene sentido cuando:

Si publicas, trata esas libs como producto: semver, changelog, y no rompas consumidores con cambios silenciosos en el barrel.

estrategia de release (conceptual)
# Independent versioning con cambios detectados por Conventional Commits
# (herramientas habituales: Nx Release, changesets, semantic-release)

pnpm exec nx release plan          # o el flujo de tu herramienta
pnpm exec nx release               # bump + changelog + publish

# Solo paquetes marcados como publishable
# package.json de la lib: "publishConfig": { "access": "restricted" }
libs/shared/dto/package.json (si es publicable)
{
  "name": "@taskflow/shared-dto",
  "version": "0.0.1",
  "type": "commonjs",
  "main": "./index.js",
  "types": "./index.d.ts",
  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.pkg.github.com"
  }
}
No versionar lo que no sale del repo

Versionar libs internas «por si acaso» añade ceremonias (changelogs, bumps) sin consumidores externos. Dentro del monorepo, el versionado efectivo es el commit SHA. Reserva semver para lo que cruza la frontera del Git.

Para releases de las apps (web, api), el artefacto es la imagen Docker o el bundle estático (capítulo 21), no un paquete npm. El monorepo puede etiquetar el repo entero (taskflow-web@1.4.0) o usar un solo calendario de producto: elige una convención y no mezcles las dos sin documentarlo.

30.11 Alternativas: Turborepo, pnpm workspaces, Yarn — comparación honesta

Nx no es la única forma de operar un monorepo. Elegir herramienta es elegir qué problemas quieres que resuelva el framework y cuáles asumes tú.

HerramientaFortalezaDebilidadEncaja con TaskFlow si…
NxPlugins Angular/Nest, generators, boundaries, affected, caché, grafoMás conceptos; acoplamiento a su modelo de proyectosQuieres full-stack tipado con reglas de arquitectura
TurborepoOrquestación y caché remota muy simples; poco «framework»No genera apps Angular/Nest ni enforce boundaries por sí soloYa tienes workspaces y solo quieres pipelines rápidos
pnpm workspacesInstalación excelente, enlazado de paquetes local, filtrosNo calcula affected semántico ni generators de frameworkMonorepo pequeño y disciplina manual alta
Yarn workspacesMaduro, buenos workspaces; Yarn Berry con PnPPnP complica a veces nativos/Nest; sin grafo de tasks ricoEquipo ya estandarizado en Yarn
Lerna (hoy a menudo + Nx)Histórico en publicación multi-paqueteSolo ya no compite en build orchestration modernaLegado; migrar en lugar de adoptar
BazelHermeticidad y escala extremaCurva y coste operativo altosEmpresa con equipo de build dedicado
pnpm-workspace.yaml (sin Nx)
packages:
  - 'apps/*'
  - 'libs/*'
  - 'libs/shared/*'
turbo.json (idea equivalente a pipelines)
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {}
  }
}

Con Turborepo + pnpm consigues caché y orden de tasks. Lo que no consigues «de serie» es: generar un módulo Nest alineado, validar que scope:web no importa entidades ORM, ni un grafo visual del mismo nivel. Puedes añadir ESLint boundaries a mano, pero entonces estás reconstruyendo parte de Nx.

Recomendación pragmática para este libro

TaskFlow Angular + Nest + MikroORM → Nx integrated. Si mañana solo mantienes paquetes TS publicados a npm sin apps, reevalúa package-based o Turborepo. No migres por moda: migra cuando el dolor (CI lento, imports ilegales, scaffolding inconsistente) esté medido.

Una trampa frecuente es adoptar Nx y seguir trabajando como si fuera un polyrepo: cada equipo solo mira «su» app, nadie mantiene shared, y el grafo se pudre con dependencias fantasma. La herramienta no impone la cultura; como mucho la hace visible. Programa una revisión trimestral del grafo (nx graph), del número de ciclos detectados, del tiempo medio de CI y del porcentaje de cache hits. Esas cuatro métricas te dicen si el monorepo está sano mejor que cualquier debate abstracto sobre «si Nx merece la pena».

filtros pnpm (útil también dentro de Nx)
pnpm --filter @taskflow/api test
pnpm --filter "./libs/shared/**" lint
pnpm --filter "...@taskflow/web" build   # web + dependencias

30.12 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
CI dice «No projects were affected» y no testea nadafetch-depth: 1 o --base mal elegidoCheckout completo; base = branch destino del PR
Cache hit pero dist/ vacíoFalta outputs en el targetDeclarar outputs; limpiar caché y reconstruir
ESLint no pilla import app→appSin tags o sin enforce-module-boundariesTags en todos los proyectos + regla en error
Ciclo de dependencias en el grafoLibs que se importan mutuamente vía barrelsExtraer tipos/util comunes; barrels estrechos
Bundle de Angular incluye MikroORMImport transitivo desde shared/apiSeparar entidad de DTO; boundaries scope:web
Paths TS fallan en CI pero no en IDEtsconfig.base.json distinto o lib sin buildUn solo base; dependsOn: ["^build"]
nx serve lento al tocar una libRebuild excesivo / dependency excessLibs más pequeñas; build incremental; revisar inputs
Dos versiones de RxJS en el grafoDependencias duplicadas en package.json de libsDependencias de framework solo en raíz; peerDeps
Generator crea lib fuera de convenciónFlags distintos por personaGenerator propio o documentación + schematic compartido
Publicar lib rompe consumidores internosVersionar libs que solo usa el monorepoNo publicar; consumo por path
e2e siempre en rojo tras affectedEntorno (API) no levantado cuando solo web cambiaJob e2e con compose; o mock/contract tests
«God lib» shared de 10k líneasSin taxonomía type/scopePartir por dominio y por tipo (dto, ui, data-access)
libs/shared/src/index.tsINCORRECTO
export * from './api/task.entity';
export * from './web/task-card.component';
export * from './dto/create-task.dto';
// Un solo barrel mezcla ORM, Angular y DTOs
libs separadasCORRECTO
// @taskflow/shared/dto
export * from './lib/create-task.dto';
// @taskflow/api/data-access-tasks
export * from './lib/task.entity';
// @taskflow/web/ui
export * from './lib/task-card.component';
dependencias en cada libINCORRECTO
{  "name": "@taskflow/web/ui",
  "dependencies": {
    "@angular/core": "19.0.0",
    "rxjs": "7.8.1"
  }
}
peerDependencies + rootCORRECTO
{  "name": "@taskflow/web/ui",
  "peerDependencies": {
    "@angular/core": "^19.0.0",
    "rxjs": "^7.8.0"
  }
}

30.13 Buenas y malas prácticas

Haz esto

  • Apps delgadas que solo componen; lógica en libs.
  • Tags scope + type en todos los proyectos desde el día uno.
  • Boundaries en error, no en warning: el warning se ignora.
  • DTOs y tipos en shared sin frameworks de UI ni ORM.
  • affected en CI con historial Git completo.
  • Declarar outputs en todo target cacheable.
  • Un importPath por lib estable (@taskflow/...).
  • Generators documentados (o custom) para no divergir.
  • Medir el CI: tiempo, cache hit rate, proyectos affected medios.
  • Lazy routes por feature lib alineadas con ownership.

Evita esto

  • Importar app desde app o desde deep paths internos.
  • Una lib «shared/misc» que crece sin frontera.
  • Entidades MikroORM en el frontend «porque TypeScript deja».
  • Desactivar boundaries para salir del paso en un PR.
  • Module Federation sin equipos ni deploys independientes.
  • Publicar todas las libs al registro interno sin consumidores.
  • CI que siempre hace run-many sobre 40 proyectos.
  • Duplicar Angular/Nest en dependencies de cada lib.
  • Commits gigantes que tocan 15 libs sin necesidad: afecta a half CI.
  • Documentación oral de la estructura: el siguiente fichaje no la adivina.
Analogía: el metro y los billetes

El grafo de Nx es el plano del metro: te dice qué líneas conectan. Affected es «solo revisamos estaciones aguas abajo del tramo en obras». La caché es el abono: si el trayecto no cambió, no pagas de nuevo. Los tags son zonas tarifarias: sin ellas, cualquiera se cuela en cualquier andén.

30.14 Preguntas frecuentes

¿Monorepo implica que todo el mundo puede tocar todo el código?
No. El acceso de lectura suele ser amplio (esa es parte de la ventaja: ver contratos y ejemplos), pero el ownership debe seguir siendo claro: CODEOWNERS por carpeta, tags por scope, y revisores obligatorios en libs compartidas. Un monorepo sin ownership degenera en commits transversales sin dueño. Nx no sustituye acuerdos de equipo; los hace ejecutables vía boundaries y revisiones.
¿Puedo empezar TaskFlow en polyrepo y migrar después a Nx?
Sí, pero el coste de migración crece con el tiempo: paths, CI, versiones de shared y hábitos de PR. Si ya sabes que frontend y API comparten DTOs y calendario, empieza en monorepo aunque sea pequeño. Migrar dos apps maduras implica rehacer aliases, tests e2e y Dockerfiles. La migración típica es: crear workspace Nx, mover api y web como apps, extraer shared, activar boundaries, y solo entonces apagar los repos viejos.
¿Qué diferencia hay entre nx run-many y nx affected?
run-many ejecuta un target en todos los proyectos (o en una lista -p) sin mirar Git. Es útil en nightly builds, releases o cuando quieres validar el repo entero. affected calcula el subgrafo tocado por un rango de commits y ejecuta solo ahí. En PRs diarios quieres affected; en main tras un merge grande, a veces conviene un run-many de verificación completa semanal.
¿Dónde pongo la configuración de MikroORM: app o lib?
La configuración de conexión (host, credenciales, pool) pertenece a la app o al entorno de ejecución: cambia por entorno y no debe importarse desde el frontend. Las entidades y repositorios viven en libs data-access-*. El MikroOrmModule.forRoot se declara en AppModule (o en una lib api/util-orm importada solo por apps api), listando las entidades exportadas por las libs de dominio. Nunca exportes el config con secretos desde shared.
¿Los DTOs con class-validator ensucian el frontend?
Añaden una dependencia de validación que el navegador puede no usar en runtime si solo importas los tipos. En la práctica, muchas veces importas la clase y el bundler arrastra decoradores. Mitigaciones: separar TaskDto (interface/tipo) de CreateTaskDto (clase con validadores) en ficheros distintos y que el web importe solo tipos (import type); o generar tipos desde OpenAPI. Para TaskFlow, import type + boundaries suele bastar si eres disciplinado.
¿Nx sustituye a Jest, Playwright o ESLint?
No. Nx orquesta executors que delegan en esas herramientas. Sigues configurando Jest/Vitest, Playwright y ESLint; Nx aporta grafo, caché, affected y generators que dejan la config en el sitio convencional. Si un test es flaky, el problema es el test, no Nx. Si la caché reutiliza un resultado malo porque tu test no es determinista, el problema es la determinismo: evita tiempo real no mockeado y orden dependiente.
¿Cómo evito que un cambio en shared tumbe CI media hora?
Es el trade-off del monorepo: shared está en la base del grafo. Mitiga con: libs shared pequeñas y estables (menos churn); tests unitarios rápidos en la base y e2e solo cuando apps affected; caché remota para no repetir builds idénticos; y revisar si ese cambio en shared realmente debe ser transversal o puede vivir en un adaptador de un solo lado. Si shared cambia cada día, quizá estás metiendo lógica de feature donde solo deberían vivir contratos.
¿Package-based es «Nx light»?
Es Nx centrado en paquetes con package.json como fuente de verdad, más parecido a pnpm/turbo. Sigue teniendo grafo, caché y affected, pero menos magia de plugins de aplicación. Para TaskFlow full-stack, integrated te ahorra más. Para un monorepo de diez librerías publicables sin Angular, package-based es más honesto y menos ceremonioso.
¿Puedo mezclar React y Angular en el mismo workspace Nx?
Técnicamente sí: Nx es agnóstico y tiene plugins para ambos. Organizativamente, solo tiene sentido con boundaries feroces y ownership claro. Compartirías tipos/DTOs; no compartirías componentes UI. En TaskFlow no lo recomendamos: doblas el coste de tooling, diseño y contratación. Si hay un legado React, encapsúlalo como app remota o projéctalo a migración, no a un festín de imports cruzados.
¿Qué hago con secretos y .env en el monorepo?
Igual que en polyrepo (capítulo 21): ningún secreto en Git. Un .env.example en la raíz o por app; valores reales en el gestor de secretos CI y en el entorno de despliegue. Cuidado con la caché: si un build embebe variables, decláralas en inputs o el hash será incorrecto. Mejor: builds sin secretos y configuración en runtime para la API.
¿Module Federation y lazy loading de Angular son lo mismo?
No. Lazy loading parte un mismo build en chunks cargados al navegar. Module Federation carga builds independientes publicados por separado, con shared scope de dependencias en runtime. El primero es complejidad de bundler; el segundo es complejidad de arquitectura de despliegue. TaskFlow casi siempre necesita el primero y casi nunca el segundo al inicio.
¿Cómo debugueo un fallo de enforce-module-boundaries?
Lee el mensaje: suele decir source project, target project y tags. Abre nx graph y localiza la arista. Pregunta: ¿falta un tag? ¿la dependencia es legítima y hay que relajar una constraint? ¿o el código debería moverse a otra lib? Relajar la regla «solo esta vez» es deuda. Mover el símbolo a scope:shared o a una lib de tipo permitido es la salida limpia.
¿Vale la pena Nx Cloud en un equipo de tres personas?
Mide primero el hit rate con caché local + actions/cache. Si el CI tarda menos de cinco minutos y los developers no se bloquean, quizá no. Si cada PR reconstruye Angular y Nest desde cero en runners fríos, el remote cache suele pagarse solo en tiempo. Tres personas que empujan a menudo generan más reconstrucciones redundantes de lo que parece.
¿Cómo conviven OpenAPI/Swagger y DTOs compartidos?
Dos escuelas: (1) DTOs first: las clases shared alimentan validación Nest y tipos Angular; Swagger se genera desde decoradores Nest. (2) Contrato first: OpenAPI es la fuente y se generan clientes/tipos. En monorepo, (1) brilla porque evitas publicar el contrato fuera. Si aparecen consumidores externos (móvil, partners), (2) escala mejor. No mantengas ambas fuentes a mano: eliges una o generas una desde la otra.
¿Qué tamaño máximo debe tener una lib?
No hay un número mágico. Señales de partición: el nombre ya no describe una responsabilidad; los tests tardan demasiado; equipos distintos pelean el mismo PR; el grafo muestra que solo una fracción de la lib se usa por cada consumidor. Prefiere muchas libs pequeñas con API pública clara a pocas libs enormes. El coste de «demasiadas libs» se mitiga con generators y estructura de carpetas predecible.

30.15 Ejercicios

Nivel 1 · básico

30.1 Crea un workspace Nx con preset apps, añade plugins Angular y Nest, y genera apps/web y apps/api. Captura la salida de nx show projects y explica qué tags tienen por defecto (si alguno).

30.2 Genera libs/shared/types y libs/shared/dto con importPath @taskflow/shared/types y @taskflow/shared/dto. Define TaskStatus y CreateTaskDto. Importa el DTO desde un controlador Nest y desde un servicio Angular.

30.3 Activa @nx/enforce-module-boundaries con constraints scope:web / scope:api / scope:shared. Provoca a propósito un import ilegal y pega el error de ESLint en tu informe.

30.4 Dibuja a mano (o con nx graph --file) el grafo tras añadir una lib web/ui usada solo por web. Marca qué nodos se afectarían al cambiar shared/dto.

Nivel 2 · intermedio

30.5 Implementa TasksService en una lib Nest api/data-access-tasks con entidad MikroORM y mapeo a TaskDto. La app api solo importa el feature module.

30.6 Configura proxy.conf.json y un script pnpm dev que levante api y web en paralelo. Demuestra una petición POST /api/tasks desde el frontend tipada con el DTO compartido.

30.7 Escribe un workflow de GitHub Actions con fetch-depth: 0, pnpm, y nx affected -t lint,test,build. Abre dos PRs: uno que solo toque README y otro que toque un DTO; compara qué proyectos ejecuta cada uno.

30.8 Rompe deliberadamente la caché omitiendo outputs en el build de api, observa el síntoma, corrígelo y documenta el antes/después del contenido de dist/ tras un cache hit.

Nivel 3 · avanzado

30.9 Diseña e implementa la taxonomía completa de tags (scope + type) para al menos seis libs. Añade constraints para que type:ui no pueda depender de type:data-access. Incluye un test de lint que falle si se viola.

30.10 Extrae un generator propio (schematic Nx) que cree una feature Angular con la estructura TaskFlow (routes, component, data-access stub) y tags correctos. Úsalo para crear feature-projects.

30.11 Compara en un documento de una página Nx affected + cache frente a pnpm -r / Turborepo en el mismo repo (puedes simular scripts equivalentes). Mide tiempos en frío y en caliente tras un cambio en shared/types.

30.12 Propón (sin implementarlo en producción) un diseño Module Federation para un futuro admin remoto. Lista riesgos, contratos de versión y por qué hoy no lo activarías en TaskFlow. Alternativa: implementa solo el shell host en local como spike de 2 horas.

Solución comentada · 30.3 · Boundaries que fallan en rojo

El objetivo no es «tener la regla», sino demostrar que el CI/local rechaza un import ilegal. Tras etiquetar proyectos, una constraint mínima:

depConstraints: [
  {
    sourceTag: 'scope:web',
    onlyDependOnLibsWithTags: ['scope:web', 'scope:shared'],
  },
  {
    sourceTag: 'scope:api',
    onlyDependOnLibsWithTags: ['scope:api', 'scope:shared'],
  },
  {
    sourceTag: 'scope:shared',
    onlyDependOnLibsWithTags: ['scope:shared'],
  },
]

Desde un fichero de apps/web importa algo de @taskflow/api/data-access-tasks. Al ejecutar nx run web:lint debes ver un error de @nx/enforce-module-boundaries citando tags. Si no aparece: (1) la lib api no tiene tag scope:api; (2) el fichero no entra en el lint de web; (3) la regla está en warn. Corrige hasta que sea error reproducible. Luego elimina el import ilegal: el ejercicio deja el repo verde.

Solución comentada · 30.7 · Affected en GitHub Actions

Lo que más falla en la práctica es el historial Git. El esqueleto válido:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: affected
  run: |
    BASE="origin/${{ github.base_ref }}"
    if [ "${{ github.event_name }}" = "push" ]; then BASE="HEAD~1"; fi
    pnpm exec nx affected -t lint,test,build --base="$BASE" --head=HEAD --parallel=3

PR solo README: affected debería listar cero proyectos de build (o solo tooling si el README está en inputs globales; ajústa namedInputs si el README invalida todo). PR que cambia un DTO: deben aparecer shared-dto, api, web y cualquier lib intermedia. Si el PR del DTO no afecta a web, tu grafo no tiene la dependencia de import: revisa que el frontend importe el DTO de verdad, no una interface duplicada.

Solución comentada · 30.5 · data-access Nest desacoplado

Estructura objetivo:

// libs/api/data-access-tasks/src/lib/tasks.module.ts
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';

@Module({
  imports: [MikroOrmModule.forFeature([Task])],
  providers: [TasksService],
  exports: [TasksService, MikroOrmModule],
})
export class TasksDataAccessModule {}

// libs/api/feature-tasks/src/lib/tasks.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateTaskDto, TaskDto } from '@taskflow/shared/dto';
import { TasksService } from '@taskflow/api/data-access-tasks';

@Controller('tasks')
export class TasksController {
  constructor(private readonly tasks: TasksService) {}

  @Post()
  create(@Body() dto: CreateTaskDto): Promise<TaskDto> {
    return this.tasks.create(dto, dto.projectId ?? 'default');
  }
}

La app solo importa TasksFeatureModule. Si alguien intenta importar TasksController desde Angular, boundaries debe impedirlo. El mapeo entidad→DTO permanece en el servicio: shared no conoce MikroORM. Añade un test de módulo Nest que inserte una tarea en SQLite en memoria o en Postgres de CI (capítulo 13) para cerrar el círculo.

30.16 Resumen del capítulo

  • Un monorepo serio no es «varias carpetas en Git»: es grafo, límites, ejecución selectiva y caché. Su premio mayor es el cambio atómico de contratos compartidos.
  • Polyrepo versiona contratos internos como APIs públicas y paga coordinación; tiene sentido con ownership, secretos o ciclos de release radicalmente distintos.
  • Nx aporta generators, executors, project graph, tags/boundaries, affected y caché. Se apoya en pnpm/npm/yarn, no los sustituye.
  • Apps vs libs: las apps se despliegan; las libs encapsulan. Prohibido app→app y ciclos.
  • Taxonomía TaskFlow: shared/{types,dto,util}, web/{ui,data-access,feature}, api/{data-access,feature}. Shared sin Angular ni MikroORM.
  • Tags + enforce-module-boundaries convierten la arquitectura en error de lint reproducible.
  • Affected + outputs + inputs son el truco del CI rápido; fetch-depth: 0 es obligatorio.
  • Module Federation solo con equipos y deploys independientes; si no, lazy routes + libs.
  • Publica libs solo con consumidores fuera del repo; dentro, el versionado es el commit.
  • Turborepo/pnpm orquestan bien; Nx gana cuando quieres plugins Angular/Nest y boundaries de verdad. Elige por dolor medido, no por moda.
  • Contratos compartidos (DTO, tipos de tarea/proyecto) viven en libs sin UI ni ORM: un solo cambio de campo actualiza clientes y servidores en el mismo PR.
  • CI verde rápido no es vanidad: es lo que permite exigir affected en cada PR sin que el equipo desactive la calidad «porque tarda media hora».
Señal de un monorepo sano en TaskFlow

Un desarrollador puede añadir un campo a CrearTareaDto, regenerar o actualizar el cliente HTTP, ajustar la entidad MikroORM y el formulario Angular en un único PR, con lint de boundaries en verde y CI que solo construye web, api y las libs tocadas. Si para eso hace falta coordinar tres repositorios y un calendario de versiones, el monorepo (o la disciplina de contratos) aún no está haciendo su trabajo.

Evita dos antipatrones tempranos. El primero es la carpeta shared-everything donde acaba el código que nadie sabe dónde poner: en seis meses es un grafo circular con olor a aplicación disfrazada de librería. El segundo es copiar DTOs «por si acaso» en web y api «para no acoplar»: has recreado el polyrepo dentro del monorepo. La regla práctica: si el cambio debe ser atómico, es una lib compartida; si el ciclo de vida es independiente de verdad, es otro deployable (app) con contrato versionado.

Cuando el equipo crezca, documenta el mapa de tags en el README del workspace y añade un diagrama del project graph generado por nx graph en la wiki interna. Las reglas de boundary que no se entienden se desactivan; las que se enseñan en el onboarding se respetan. Nx no sustituye arquitectura: la hace verificable.

En entrevistas, explica el trade-off en una frase: «pagamos complejidad de tooling para ganar atomicidad de cambios y CI selectivo». Luego da un ejemplo con TaskFlow (shared DTO + affected). Si solo dices «usamos Nx porque está de moda», pierdes la pregunta. Si puedes dibujar el grafo apps/libs y decir qué tag impide que web-ui importe MikroORM, demuestras criterio de arquitectura frontend/backend en monorepo.

Relaciona este capítulo con Docker/CI (21), integración full-stack (18) y migraciones (31): un monorepo bien cacheado hace barato el tren de actualizaciones porque el coste de verificar el impacto está acotado. Un polyrepo sin versionado interno cuidadoso hace cada major update un proyecto de sincronización humana.

30.17 Recursos adicionales

Siguiente paso

Con monorepo y CI affected, el cuello de botella vuelve al diseño de módulos y al despliegue. Revisa el capítulo 21 para empaquetar api y web en imágenes multi-stage desde el mismo repo, y el capítulo 9 para mantener los módulos Nest tan delimitados como tus libs Nx.