Organización del proyecto y límites de dependencia

Por: Artiko
angularbuenas-practicasarquitecturamonoreponxbarrel-filedependencias

Organización del proyecto y límites de dependencia

La estructura de carpetas no es cosmética. Es la primera línea de defensa contra el acoplamiento: si cualquier archivo puede importar a cualquier otro, tarde o temprano vas a tener un grafo de dependencias con ciclos, un main.js que no se puede tree-shakear y un cambio en una feature que rompe otras tres. Este capítulo trata sobre dónde poner las cosas y, sobre todo, sobre qué puede importar a qué.

El problema de calidad que atacamos es doble: mantenibilidad (poder cambiar una parte sin auditar todo el sistema) y tiempo de build / tamaño de bundle (que la herramienta pueda eliminar código muerto y compilar en incremental). Ambos dependen de que las dependencias fluyan en una sola dirección y de que los límites entre módulos sean explícitos y verificables.

Estructura por features, no por tipo de archivo

El error más común heredado del Angular clásico es organizar por tipo técnico: una carpeta components/, otra services/, otra models/, otra pipes/. Parece ordenado, pero produce un acoplamiento transversal: para tocar la feature “checkout” tenés que saltar entre cuatro carpetas, y nada te impide que components/cart.ts importe services/admin-report.ts.

graph TD
  A["src/app/"] --> B["components/ — todos los componentes de toda la app"]
  A --> C["services/ — todos los servicios"]
  A --> D["models/ — todas las interfaces"]
  A --> E["pipes/ — todos los pipes"]

El problema no es estético: la cohesión es baja (archivos de un mismo feature dispersos) y el acoplamiento es alto (todo es alcanzable desde todo). Cuando borrás una feature, tenés que ir carpeta por carpeta cazando sus restos.

La guía de estilo oficial de Angular es explícita: organizá por área funcional, no por tipo de código. Textualmente recomienda evitar crear subdirectorios como components, directives o services. Cada feature es una carpeta autocontenida con todo lo que necesita adentro:

graph TD
  A["src/app/"] --> CORE["core/ — singletons de app, se cargan una vez"]
  A --> SHARED["shared/ — reutilizable, sin estado, sin dependencias de features"]
  A --> FEAT["features/"]
  CORE --> CAUTH["auth/"]
  CORE --> CHTTP["http/"]
  SHARED --> SUI["ui/"]
  SHARED --> SUTILS["utils/"]
  FEAT --> CHECKOUT["checkout/"]
  FEAT --> CATALOG["catalog/"]
  CHECKOUT --> CART["cart/ — cart.ts, cart.html, cart.css, cart.spec.ts"]
  CHECKOUT --> PAY["payment/"]
  CHECKOUT --> CROUTES["checkout.routes.ts"]
  CHECKOUT --> CSTORE["checkout-store.ts — estado propio de la feature"]
  CATALOG --> PLIST["product-list/"]
  CATALOG --> CATROUTES["catalog.routes.ts"]

Fijate en dos cosas. Primero, la co-location: el componente, su template, sus estilos, su test y hasta su store viven juntos. Borrar checkout/ borra la feature completa sin dejar huérfanos. Segundo, ya no hay NgModules. En Angular 22 una “feature module” no es una clase @NgModule; es simplemente una carpeta con componentes standalone y un archivo de rutas (checkout.routes.ts) que se carga con loadChildren. El límite del módulo lo define la carpeta y el punto de entrada de rutas, no un decorador.

Nombres y colocación (convenciones actuales)

La guía de estilo de Angular 22 cambió las convenciones de nombres respecto de versiones viejas:

La regla de colocación es simple: un archivo vive lo más cerca posible del único lugar que lo usa. Solo lo promovés a una carpeta compartida cuando aparece un segundo consumidor real, no “por si acaso”.

Los tres pisos: core, shared y features

Esta división da a cada archivo un lugar según su rol, y con eso habilita reglas de dependencia claras.

flowchart TD
  F1[features/checkout]
  F2[features/catalog]
  S[shared]
  C[core]

  F1 --> S
  F1 --> C
  F2 --> S
  F2 --> C
  C --> S

  F1 -. prohibido .-> F2
  S -. prohibido .-> F1
  C -. prohibido .-> F1

  classDef ok stroke:#16a34a,stroke-width:2px;
  classDef no stroke:#dc2626,stroke-width:2px,stroke-dasharray:4 4;
  class F1,F2,S,C ok;

La regla de las dependencias hacia adentro

Todas estas restricciones son una sola idea, la misma de la arquitectura hexagonal/limpia: las dependencias apuntan hacia adentro, hacia lo estable. Lo volátil (una feature, que cambia con cada sprint) depende de lo estable (utilidades compartidas, contratos de core). Nunca al revés.

De ahí salen las reglas concretas:

  1. core no importa de features. Si tu servicio de autenticación necesita saber algo de la feature “perfil”, invertiste la dependencia: lo estable pasó a depender de lo volátil. La solución es un contrato (una interfaz o un InjectionToken) que vive en core y que la feature implementa —esto es inversión de dependencias, y lo desarrollamos en el capítulo de inyección de dependencias.
  2. shared no importa de features ni de core. Si un componente “compartido” necesita el servicio de auth, no es compartido: es de una feature, o el dato debería entrar por un input().
  3. Una feature no importa de otra feature. Si checkout necesita datos de catalog, esa lógica común probablemente pertenece a core (un servicio de dominio) o a shared (un modelo). Importar directamente catalog/product-list.ts desde checkout crea un acoplamiento que impide cargar, testear o borrar cualquiera de las dos por separado, y suele terminar en ciclos de dependencia.

El acoplamiento entre features es especialmente costoso porque rompe el lazy loading: si checkout importa un símbolo de catalog, el bundler no puede separarlos en chunks independientes, y cargás código de catálogo aunque el usuario solo entre al checkout.

Antipatrón concreto y su refactor

// ANTIPATRÓN: checkout importa directamente de otra feature
// features/checkout/cart.ts
import { ProductService } from '../catalog/product-list/product-service';

@Component({
  selector: 'app-cart',
  template: `...`,
})
export class Cart {
  private products = inject(ProductService); // acoplamiento cruzado entre features
}

Esto crea una arista checkout → catalog que no debería existir. ProductService es lógica de dominio: su lugar es core (o una lib de datos, si estás en Nx). El refactor mueve el contrato hacia adentro:

// MEJOR: el servicio de dominio vive en core, ambas features dependen de él
// core/catalog/product-service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  getById(id: string) {
    return this.http.get<Product>(`/api/products/${id}`);
  }
}

// features/checkout/cart.ts
import { ProductService } from '../../core/catalog/product-service';

export class Cart {
  private products = inject(ProductService); // ahora depende de core (estable), no de otra feature
}

Ahora el grafo es checkout → core y catalog → core: sin aristas entre features, sin ciclos, y cada feature sigue siendo lazy-loadable y borrable por separado.

El antipatrón del barrel file

Un barrel file es un index.ts que reexporta el contenido público de una carpeta:

// shared/ui/index.ts  ← el barrel
export * from './button';
export * from './modal';
export * from './table';
export * from './data-grid'; // arrastra ag-grid, 400 KB

La promesa es cómoda: import { Button } from '@app/shared/ui' en lugar de rutas largas. El costo, en un proyecto grande, aparece en tres frentes.

1. Impacto en tree-shaking y tamaño de bundle

Cuando importás { Button } desde el barrel, el módulo shared/ui/index.ts se evalúa entero: el bundler tiene que procesar todas las ramas del export * para resolver qué reexporta cada una. Si data-grid importa una librería pesada en el nivel superior de su módulo (o tiene efectos secundarios de import), esa dependencia puede terminar en tu chunk aunque solo hayas pedido el botón. El tree-shaking moderno mitiga parte de esto, pero se rompe apenas hay un efecto secundario, un export default reexportado o un import con side effects. El resultado medible: un LCP peor por descargar y parsear código que nunca se usa.

// Import directo: el grafo que ve el bundler es exactamente lo que usás
import { Button } from '@app/shared/ui/button';   // solo button y sus deps reales

// Import por barrel: el grafo incluye TODO lo reexportado por index.ts
import { Button } from '@app/shared/ui';          // button + modal + table + data-grid...

2. Ciclos de dependencia

Los barrels son fábricas de dependencias circulares. Si button.ts necesita un helper de shared/ui/utils.ts, y por costumbre lo importa desde el barrel (import { fmt } from '../'), creaste el ciclo index.ts → button.ts → index.ts. Angular y el bundler lo toleran a veces, pero producen bugs sutiles: símbolos que son undefined en tiempo de inicialización porque el módulo aún no terminó de evaluarse. Diagnosticarlos consume horas.

3. Tiempos de build e invalidación de caché

Cada barrel es un punto de fan-out: un archivo del que dependen muchos consumidores y que a su vez depende de muchos módulos. Cuando tocás cualquier archivo reexportado por el barrel, el hash del barrel cambia, y con él se invalida la caché de compilación (y de HMR) de todos los que importaron desde ese barrel, no solo de los que usan el símbolo que cambiaste. En un shared/index.ts que reexporta 60 símbolos consumidos por media app, un cambio trivial dispara una recompilación desproporcionada. El efecto se nota sobre todo en el ciclo de desarrollo (dev server lento) y en builds incrementales de CI.

flowchart LR
  subgraph Con barrel
    A1[button.ts] --> BX[index.ts]
    A2[modal.ts] --> BX
    A3[data-grid.ts] --> BX
    BX --> U1[checkout]
    BX --> U2[catalog]
    BX --> U3[perfil]
  end

Un cambio en data-grid.ts invalida index.ts, y index.ts invalida a checkout, catalog y perfil, aunque ninguno use el data-grid.

Cuándo un barrel está bien

El barrel no es un error en sí mismo; el problema es el fan-out amplio en el interior de la app. Un barrel está justificado cuando:

Está contraindicado cuando:

La regla práctica: importá desde el archivo concreto (deep imports) dentro de la app y reservá los barrels para el borde público de librerías. Herramientas como path aliases en tsconfig te dan rutas legibles sin necesidad de un barrel.

Monorepo con Nx: límites verificados por el linter

Todo lo anterior son acuerdos. Nada impide técnicamente que alguien escriba el import prohibido; dependés de la revisión de código para atraparlo. En un proyecto que crece, eso no escala. Nx convierte esos acuerdos en reglas que el lint verifica y que rompen el build si se violan.

Nx modela el repositorio como un grafo de proyectos, divididos en dos clases:

Tags y enforce-module-boundaries

A cada proyecto le ponés tags en su project.json:

// libs/checkout/feature/project.json
{
  "name": "checkout-feature",
  "tags": ["scope:checkout", "type:feature"]
}

Y en la config de ESLint declarás las restricciones de dependencia con la regla @nx/enforce-module-boundaries:

// eslint.config.js (raíz del workspace)
'@nx/enforce-module-boundaries': [
  'error',
  {
    depConstraints: [
      // Un feature solo puede depender de ui, data-access y util
      {
        sourceTag: 'type:feature',
        onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'],
      },
      // La UI es presentacional: solo puede depender de otra ui o de util
      {
        sourceTag: 'type:ui',
        onlyDependOnLibsWithTags: ['type:ui', 'type:util'],
      },
      // util es una hoja: no depende de nadie con tipo
      {
        sourceTag: 'type:util',
        onlyDependOnLibsWithTags: ['type:util'],
      },
      // Aislamiento por dominio: checkout no puede importar de catalog
      {
        sourceTag: 'scope:checkout',
        onlyDependOnLibsWithTags: ['scope:checkout', 'scope:shared'],
      },
      {
        sourceTag: 'scope:catalog',
        onlyDependOnLibsWithTags: ['scope:catalog', 'scope:shared'],
      },
    ],
  },
],

Con esto, dos ejes de reglas quedan garantizados por el linter. El eje de tipo (type:*) codifica la regla de dependencias hacia adentro: feature puede depender de ui/data-access/util, pero util no puede depender de un feature. El eje de scope (scope:*) codifica el aislamiento entre dominios: checkout y catalog solo se tocan a través de scope:shared. Si alguien escribe el import cruzado, nx lint falla con un error que nombra la regla violada. El acuerdo dejó de depender de la buena memoria del equipo.

Detalle importante: en Nx, un proyecto sin tags no puede depender de ningún otro proyecto. El default es cerrado, lo que te obliga a declarar la intención de cada arista.

flowchart TD
  App[app: shell]
  CF[checkout/feature]
  CU[checkout/ui]
  CD[checkout/data-access]
  U[shared/util]

  App --> CF
  CF --> CU
  CF --> CD
  CU --> U
  CD --> U

  CU -. bloqueado por lint .-> CF
  U  -. bloqueado por lint .-> CD

  classDef leaf stroke:#2563eb,stroke-width:2px;
  class U leaf;

Cuándo un monorepo aporta y cuándo no

Nx no es gratis: agrega una capa de configuración, un grafo que mantener y una curva de aprendizaje. Aporta cuando:

No aporta —y suma fricción— cuando:

La decisión no es “monorepo sí o no”, sino cuánta frontera necesitás verificar automáticamente. Empezá con carpetas y path aliases; migrá a libs de Nx cuando el costo de un import prohibido que se cuela en revisión empiece a doler.

Checklist

Con la estructura y los límites en su lugar, el siguiente paso es que cada componente respete internamente el principio de responsabilidad única y las demás reglas de diseño. Seguimos con Componentes con SOLID →.