Organización del proyecto y límites de dependencia
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:
- Separá palabras con guiones: la clase
UserProfilevive enuser-profile.ts. - Sin sufijo
.component: el archivo se llama por lo que contiene, no por su rol técnico.user-profile.ts, nouser-profile.component.ts. El símbolo se llamaUserProfile, noUserProfileComponent. - Co-locá los tres archivos del componente con el mismo nombre y distinta extensión:
user-profile.ts,user-profile.html,user-profile.css. - Tests al lado del código que prueban, con extensión
.spec.ts:user-profile.spec.ts. No en un árboltest/paralelo; la cercanía reduce la fricción de mantenerlos. - Tipos y modelos junto a su feature. Si una interfaz solo la usa
checkout, vive encheckout/, no en una carpeta globalmodels/. Solo sube ashared/cuando la comparten dos o más features.
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.
core/: servicios singleton de aplicación, que se instancian una sola vez y viven toda la sesión: autenticación, cliente HTTP configurado, interceptores, guards globales, logging. Se proveen a nivel raíz (conprovidedIn: 'root'o enapp.config.ts).corees infraestructura: no conoce ninguna feature concreta.shared/: piezas reutilizables y sin estado de negocio: componentes de UI presentacionales (un botón, un modal, una tabla), directivas, pipes y utilidades puras.sharedno importa defeaturesni decore; es una hoja del grafo, consumible por cualquiera.features/: el negocio. Cada feature agrupa sus componentes, su estado y sus rutas. Una feature puede usarcoreyshared, pero no debería importar de otra feature sin un motivo explícito.
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:
coreno importa defeatures. 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 unInjectionToken) que vive encorey que la feature implementa —esto es inversión de dependencias, y lo desarrollamos en el capítulo de inyección de dependencias.sharedno importa defeaturesni decore. Si un componente “compartido” necesita el servicio de auth, no es compartido: es de una feature, o el dato debería entrar por uninput().- Una feature no importa de otra feature. Si
checkoutnecesita datos decatalog, esa lógica común probablemente pertenece acore(un servicio de dominio) o ashared(un modelo). Importar directamentecatalog/product-list.tsdesdecheckoutcrea 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:
- Es el API público de una librería publicable (una lib de Nx o un paquete npm): ahí querés un único punto de entrada estable y controlado. El costo de build queda del lado de quien compila la lib, una sola vez.
- La carpeta es pequeña y estable (dos o tres símbolos que casi nunca cambian).
Está contraindicado cuando:
- Es un
index.tsde conveniencia dentro de la app para ahorrar tipear rutas. - Reexporta muchos símbolos o algo con dependencias pesadas.
- Aparece en
core/shared, justo las carpetas más importadas.
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:
- apps: lo desplegable (tu app Angular, un SSR server). Las apps son sumideros: consumen libs, nadie las consume.
- libs: el código reutilizable, dividido por su rol. La convención de Nx usa tipos: feature (lógica y UI de un caso de uso), ui (presentacional puro), data-access (estado y llamadas a API) y util (helpers puros). Es la misma idea de
core/shared/features, pero cada pieza es una lib con frontera real.
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:
- Tenés varias apps que comparten código (una web y un panel de admin que usan los mismos data-access y UI).
- El equipo es grande y necesitás fronteras verificadas por herramienta, no por revisión manual.
- Querés builds y tests afectados: Nx solo recompila y testea los proyectos tocados por un cambio (
nx affected), lo que en CI de un repo grande es la diferencia entre minutos y segundos.
No aporta —y suma fricción— cuando:
- Es una sola app de un equipo chico. Ahí la estructura
core/shared/featuresconpathaliases y disciplina de revisión te da el 80% del beneficio sin la infraestructura. - No tenés código realmente compartido entre múltiples desplegables: un monorepo con una sola app es sobre todo overhead.
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
- La estructura es por feature, no por tipo de archivo (sin carpetas globales
components/,services/). - Existen las tres capas
core(singletons),shared(reutilizable sin estado) yfeatures(negocio). -
coreno importa defeatures;sharedno importa defeaturesni decore. - Ninguna feature importa directamente de otra feature; lo común vive en
coreoshared. - Las “feature modules” son carpetas standalone con su
*.routes.ts, sinNgModule. - Los archivos siguen las convenciones actuales: guiones, sin sufijo
.component, tres archivos co-locados con el mismo nombre. - Tests (
.spec.ts), tipos y estilos co-locados con el código que prueban o describen. - No hay barrel files de conveniencia dentro de la app; los deep imports son la norma y los barrels se reservan para el API público de librerías.
- Si es monorepo Nx: cada proyecto tiene tags de
type:*yscope:*, y@nx/enforce-module-boundariesestá configurado y falla ellintante un import prohibido. - La decisión de usar (o no) Nx responde a “¿tengo múltiples desplegables o necesito fronteras verificadas por herramienta?”.
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 →.