
Estructura de carpetas en Angular 22: features lazy y fronteras que el lint garantiza
En julio escribí un panorama de arquitectura de carpetas para React, Angular y Node.js. La parte de Angular envejeció mal. Recomendaba checkout.component.ts y auth.guard.ts, carpetas components/ y services/ dentro de cada feature y una carpeta data-access/ global. Parte de eso Angular ya lo había cambiado cuando el texto salió; otra parte nunca la había probado.
Esta vez no confié en la memoria. Generé un proyecto con Angular CLI 22.2.0, la versión más reciente hoy (salió el 23 de septiembre), y pasé cada afirmación por ng build, ng test o ng lint. Lo que sigue es la salida de esos comandos o texto de la documentación oficial. Cuando algo es opinión mía, lo digo.
Qué genera el CLI 22.2, y por qué cambiaron los nombres
ng new con rutas y sin SSR crea esto dentro de src/:
src/
├─ index.html
├─ main.ts
├─ styles.css
└─ app/
├─ app.ts
├─ app.html
├─ app.css
├─ app.spec.ts
├─ app.config.ts
└─ app.routes.ts
Ningún app.component.ts, ningún AppModule. El componente raíz es la clase App, sin standalone: true en el decorador, porque standalone es el valor por defecto desde Angular 19 (el changelog de la 19.0.0 dice: "Angular directives, components and pipes are now standalone by default"). Los NgModules no se acabaron: siguen documentados, en la sección "Extended Ecosystem" de angular.dev. Solo dejaron de ser el camino por defecto.
Después ejecuté ng generate para cada tipo de artefacto. El resultado:
ng generate | Archivo creado | Símbolo exportado |
|---|---|---|
component product-list | product-list.ts (+ .html, .css, .spec.ts) | ProductList |
service product-api | product-api.ts | ProductApi |
directive autofocus | autofocus.ts | Autofocus |
pipe currency-brl | currency-brl-pipe.ts | CurrencyBrlPipe |
guard auth | auth-guard.ts | authGuard |
interceptor auth-token | auth-token-interceptor.ts | authTokenInterceptor |
resolver product | product-resolver.ts | productResolver |
Hay un patrón. Componente, directiva y servicio perdieron el tipo en el nombre del archivo y en la clase. Pipe, guard, interceptor y resolver mantuvieron el tipo, pero separado por guion, no por punto. Eso sigue la guía de estilo actual: "Separate words within a file name with hyphens" y "When the file contains a TypeScript class, the file name should reflect that class name". El propio ng new --help describe las dos convenciones: la guía "2025" (por defecto) usa app.ts; la "2016" pone el tipo en el nombre, app.component.ts. La opción --file-name-style-guide=2016 existe en ng new desde el CLI 21, para quien quiera el formato antiguo en un proyecto nuevo.
Si tu CLI todavía genera .component.ts
No es un bug. Cuando la convención cambió, en la v20, ng update empezó a ejecutar una migración que escribe valores por defecto en angular.json para que el proyecto existente siga generando los nombres antiguos. Según la descripción del commit, son estos:
{
"@schematics/angular:component": { "type": "component" },
"@schematics/angular:directive": { "type": "directive" },
"@schematics/angular:service": { "type": "service" },
"@schematics/angular:guard": { "typeSeparator": "." },
"@schematics/angular:interceptor": { "typeSeparator": "." },
"@schematics/angular:module": { "typeSeparator": "." },
"@schematics/angular:pipe": { "typeSeparator": "." },
"@schematics/angular:resolver": { "typeSeparator": "." }
}
La lista explica el patrón de la tabla: type devuelve el sufijo a los tres primeros, y typeSeparator devuelve el punto a los otros cinco. Si tu proyecto viene de actualizaciones sucesivas, busca la clave schematics en angular.json antes de culpar al CLI. Renombrar una base entera es otra decisión, y yo no la tomaría solo por estética: la guía pone la consistencia dentro de cada archivo por encima de cualquiera de sus reglas.
El @Service() que genera el CLI 22
El product-api.ts generado no usa @Injectable:
import { Service } from '@angular/core';
@Service()
export class ProductApi {}
@Service llegó en Angular 22.0.0, en junio, según el changelog. La documentación de servicios lo define como "a modern, ergonomic shorthand for the traditional @Injectable({ providedIn: 'root' }) syntax": singleton, disponible en toda la aplicación y tree-shakable. La misma página trae la diferencia que importa en la práctica: @Service no admite inyección por constructor, solo inject(). Para alcance de ruta o de componente existe @Service({ autoProvided: false }), que desactiva el registro automático y exige un providers explícito. Ese detalle vuelve más adelante, porque es el que decide dónde vive el estado de una feature.
El árbol que uso
Este es el proyecto final, con dos features (catálogo y checkout), todo verificado con build, tests y lint:
src/app/
├─ app.ts app.html app.css app.config.ts app.routes.ts
├─ core/
│ ├─ auth/
│ │ ├─ auth-session.ts # @Service(): quién tiene sesión iniciada
│ │ └─ auth-guard.ts # canMatch de la ruta de checkout
│ └─ http/
│ └─ auth-token-interceptor.ts
├─ shared/
│ ├─ products/
│ │ └─ product.ts # tipo usado por las dos features
│ └─ ui/
│ └─ currency-brl-pipe.ts
└─ features/
├─ catalog/
│ ├─ catalog.routes.ts # export default de las rutas de la feature
│ ├─ catalog-store.ts # estado con alcance de ruta
│ ├─ product-api.ts
│ ├─ product-list/ # .ts, .html, .css y .spec.ts juntos
│ └─ product-detail/
└─ checkout/
├─ checkout.routes.ts
├─ checkout-store.ts
└─ cart/
Tres reglas de la guía de estilo sostienen esta forma. "Organize your project into subdirectories based on the features of your application", y de forma explícita: "avoid creating directories like components, directives, and services". Los archivos de un componente van juntos, y el test va al lado del código ("Unit tests should live in the same directory as the code-under-test"). Y nada de utils.ts: la guía pide "Avoid overly generic file names like helpers.ts, utils.ts, or common.ts".
core/, shared/ y features/ no están en la guía. Son convención mía, y la razón es concreta: cada una de esas carpetas se convierte en un tipo de elemento en la regla de lint que aparece más adelante. Una carpeta a la que ninguna regla apunta es decoración. core/ guarda lo que toda la aplicación necesita desde la primera carga. shared/ guarda lo que no sabe que existe ninguna feature. features/ agrupa las partes que se cargan bajo demanda.
El nombre catalog.routes.ts, con punto, imita el app.routes.ts que genera el propio CLI.
Lazy por feature: la carpeta se convierte en un chunk
Las rutas de la aplicación solo apuntan a las features:
// app.routes.ts
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'catalog' },
{ path: 'catalog', loadChildren: () => import('@features/catalog/catalog.routes') },
{ path: 'checkout', canMatch: [authGuard], loadChildren: () => import('@features/checkout/checkout.routes') },
];
Y cada feature exporta sus propias rutas como default, lo que evita el .then(). La documentación de estrategias de carga dice: "If the lazily loaded file uses a default export, you can return the import() promise directly".
// features/catalog/catalog.routes.ts
export default [
{
path: '',
providers: [CatalogStore],
children: [
{ path: '', loadComponent: () => import('./product-list/product-list').then((m) => m.ProductList) },
{ path: ':id', loadComponent: () => import('./product-detail/product-detail').then((m) => m.ProductDetail) },
],
},
] satisfies Routes;
El ng build de producción muestra el resultado:
Initial chunk files | Names | Raw size | Estimated transfer size
main-SGQLTLX3.js | main | 240.39 kB | 64.96 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-CsoRDU0D.js | product-list | 796 bytes | 796 bytes
chunk-CUGTxQp-.js | product-detail | 732 bytes | 732 bytes
chunk-BavZ1Hy7.js | catalog-routes | 634 bytes | 634 bytes
chunk-iqYSogQM.js | checkout-routes | 524 bytes | 524 bytes
chunk-Dny5jqu0.js | cart | 498 bytes | 498 bytes
chunk-B-WAw2NO.js | - | 302 bytes | 302 bytes
chunk-DX5tK5oh.js | - | 274 bytes | 274 bytes
Buscando cada clase en los archivos generados se ve adónde fue cada cosa. CatalogStore está en el chunk catalog-routes, junto a la ruta que lo provee. ProductApi, un @Service() de alcance raíz, está en un chunk lazy compartido (el de 302 bytes), no en main. La documentación promete que un servicio raíz que nadie usa queda fuera del bundle; el build muestra el paso siguiente: un servicio raíz usado solo por features lazy viaja con ellas. "Provisto en la raíz" no quiere decir "en el bundle inicial". El pipe de moneda, usado por tres componentes lazy, quedó en el otro chunk compartido. Y AuthSession está en main, porque el guard que lo usa se ejecuta en app.routes.ts, antes de que cargue cualquier feature.
canMatch no descarga la feature; canActivate sí
La ruta de checkout usa canMatch, no canActivate, y la diferencia se puede medir. En un test con RouterTestingHarness, conté cuántas veces se ejecuta loadChildren cuando el guard niega el acceso:
let loads = 0;
const loadFeature = () => {
loads++;
return [{ path: '', component: Home }];
};
it('canMatch: el loader nunca se ejecuta', async () => {
expect(await visitWith([{ path: 'feature', canMatch: [() => false], loadChildren: loadFeature }])).toBe(0);
});
it('canActivate: el loader se ejecuta igual', async () => {
expect(await visitWith([{ path: 'feature', canActivate: [() => false], loadChildren: loadFeature }])).toBe(1);
});
Los dos pasan. Con canActivate, el código de la feature se carga aunque el guard niegue el acceso. Un detalle que reveló el test: sin una ruta ** de respaldo, el canMatch rechazado termina en NG04002: Cannot match any routes. La documentación de guards explica el motivo: cuando canMatch devuelve false, "Angular tries other matching routes instead of completely blocking navigation". Si ninguna otra ruta coincide, la navegación falla. Por eso el authGuard de este proyecto devuelve un UrlTree a /catalog en lugar de false.
El barrel que deshace el lazy loading
El post de julio, en la parte de React, defendía un index.ts en la raíz de cada feature como su "API pública". Antes de llevar la idea a Angular, la probé aquí. Creé features/catalog/index.ts reexportando CatalogStore, ProductList y ProductDetail, e hice que App importara solo el store a través de él:
// features/catalog/index.ts
export { CatalogStore } from './catalog-store';
export { ProductList } from './product-list/product-list';
export { ProductDetail } from './product-detail/product-detail';
// app.ts
import { CatalogStore } from '@features/catalog';
El build cambió así:
Import en App | product-list (lazy) | product-detail (lazy) | Dónde está el código de los componentes |
|---|---|---|---|
'@features/catalog' (barrel) | 122 bytes | 122 bytes | en el bundle inicial |
'@features/catalog/catalog-store' | 774 bytes | 738 bytes | en los chunks lazy |
Con el barrel, el código de los dos componentes pasó al bundle inicial, y los chunks lazy quedaron como cáscaras de 122 bytes. Importando el archivo directamente, siguieron siendo lazy. En este proyecto de juguete son unos cientos de bytes; en una feature real, es la feature entera yendo a la primera carga sin que nadie lo note, porque la ruta sigue con loadComponent y el código parece correcto. No investigué por qué el bundler no descartó los componentes no usados. Para decidir, el resultado basta: en una feature lazy, no pongo barrel en la raíz.
Dónde vive el estado
La documentación de providers enumera tres lugares para registrar una dependencia: en el arranque de la aplicación, en un componente o directiva, y en una ruta. Además está el registro automático en la raíz, que es lo que hace @Service() sin ninguna configuración.
Alcance raíz (@Service()). Una instancia para toda la aplicación. Ahí viven AuthSession y ProductApi. No necesita providers, y el código va al chunk de quien lo usa, como mostró el build.
Alcance de ruta (@Service({ autoProvided: false }) más providers en la ruta). Ahí vive CatalogStore:
@Service({ autoProvided: false })
export class CatalogStore {
private readonly api = inject(ProductApi);
readonly products = signal<readonly Product[]>(this.api.list());
}
La documentación garantiza que los servicios provistos en la ruta "are available to all components and directives within that route, as well as to its guards and resolvers". autoProvided: false es lo que impide usarlo fuera de ella. Un test con las dos variantes muestra la diferencia: fuera de cualquier ruta, un @Service() normal se resuelve en silencio desde el injector raíz, con una instancia que no es la de la feature; @Service({ autoProvided: false }) falla con NG0201: No provider found. Prefiero el error.
Alcance de componente (providers en el @Component). La instancia nace y muere con el componente: "when the component gets destroyed, the provided service is also destroyed as well". Sirve para estado de formulario o de modal, no de feature.
El injector de la ruta no muere cuando sales de ella
Este comportamiento me sorprendió. Escribí un test que entra en una ruta con provider propio, sale a otra y vuelve, contando cuántas instancias se crearon y se destruyeron:
let created = 0;
let destroyed = 0;
@Service({ autoProvided: false })
class FeatureStore {
constructor() {
created++;
inject(DestroyRef).onDestroy(() => destroyed++);
}
}
async function enterLeaveAndReturn(...features) {
TestBed.configureTestingModule({ providers: [provideRouter(routes, ...features)] });
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/feature');
await harness.navigateByUrl('/elsewhere');
const destroyedAfterLeaving = destroyed;
await harness.navigateByUrl('/feature');
return { destroyedAfterLeaving, created };
}
it('sobreviven a la ruta por defecto', async () => {
expect(await enterLeaveAndReturn()).toEqual({ destroyedAfterLeaving: 0, created: 1 });
});
it('se destruyen al salir con withAutoCleanupInjectors (y se recrean al volver)', async () => {
expect(await enterLeaveAndReturn(withAutoCleanupInjectors())).toEqual({ destroyedAfterLeaving: 1, created: 2 });
});
Los dos pasan. Por defecto, el store de la ruta sigue vivo después de que el usuario sale de ella, con el estado que tenía. Quien vuelve encuentra la misma instancia. Eso puede ser justo lo que quieres (un filtro del catálogo que se conserva) o una fuga silenciosa (un store pesado que nunca se libera).
withAutoCleanupInjectors() se estabilizó en Angular 22.2.0, lanzado el 23 de septiembre; hasta entonces existía como withExperimentalAutoCleanupInjectors(), que ahora está marcado como deprecated. La referencia de la API describe el comportamiento: el router destruye los EnvironmentInjector de rutas que ya no están activas ni guardadas por la RouteReuseStrategy. La condición es que RouteReuseStrategy.shouldDestroyInjector devuelva true. Lo comprobé en el código de @angular/router 22.2.0: la estrategia por defecto devuelve true, así que con la estrategia por defecto basta con activar la feature. Con una estrategia personalizada hay que implementar ese método, y también retrieveStoredRouteHandles si guarda rutas para reutilizarlas.
El costo aparece en el segundo test: created: 2. Con la limpieza activada, volver a la ruta crea otro store, y el estado anterior se pierde. Lo decido por feature. El estado costoso que el usuario espera reencontrar vive en la raíz, a propósito, y no en la ruta por accidente.
Fronteras: la carpeta no impide nada, el lint sí
Las carpetas no significan nada para el compilador. Para demostrarlo, hice que CheckoutStore importara CatalogStore y ProductApi del catálogo, uno por el alias y otro por ruta relativa. ng build terminó con normalidad. La frontera entre features solo existe si alguna herramienta la verifica.
Usé eslint-plugin-boundaries 7.2.0 sobre el ESLint que configura angular-eslint. Cada carpeta se convierte en un tipo de elemento, y las políticas dicen quién puede importar a quién:
{
files: ['src/**/*.ts'],
plugins: { boundaries },
settings: {
'import/resolver': { typescript: { alwaysTryTypes: true } },
'boundaries/elements': [
{ type: 'feature', pattern: 'src/app/features/*', capture: ['feature'] },
{ type: 'core', pattern: 'src/app/core' },
{ type: 'shared', pattern: 'src/app/shared' },
],
'boundaries/files': [{ category: 'shell', pattern: 'src/app/*.ts' }],
},
rules: {
'boundaries/dependencies': ['error', {
default: 'disallow',
message: '{{from.element.types}} cannot import {{to.element.types}}',
policies: [
{ from: { file: { categories: 'shell' } }, allow: { to: { file: { categories: 'shell' } } } },
{ from: { file: { categories: 'shell' } }, allow: { to: { element: { types: { anyOf: ['feature', 'core', 'shared'] } } } } },
{ from: { element: { type: 'core' } }, allow: { to: { element: { type: 'shared' } } } },
{ from: { element: { type: 'feature' } }, allow: { to: { element: { types: { anyOf: ['core', 'shared'] } } } } },
{
from: { element: { type: 'feature' } },
disallow: { to: { element: { type: 'feature' } } },
message: 'feature "{{from.element.captured.feature}}" cannot import feature "{{to.element.captured.feature}}"; move what both need to shared/',
},
// Al final a propósito: gana la última política que coincide, y esta cubre archivos del mismo elemento.
{ allow: { dependency: { relationship: { to: 'internal' } } } },
],
}],
},
}
El orden de las políticas no es estético. La documentación del plugin dice que "the final result is determined by the last matching policy". La regla que prohíbe que una feature importe otra también coincidiría con imports dentro de la misma feature; la política de dependencias internas va después para ganar en ese caso. capture: ['feature'] es lo que permite que el mensaje diga qué feature importó cuál. Y no hay política para @angular/* ni para otros paquetes de npm porque no hace falta: según la guía de migración, "by default, boundaries/dependencies only checks dependencies whose target module origin is local". Yo había escrito una política que permitía paquetes externos; la quité, y el lint siguió pasando.
Con los imports prohibidos en su lugar, ng lint respondió:
src\app\core\auth\core-leak.ts
1:28 error core cannot import feature boundaries/dependencies
src\app\features\checkout\checkout-store.ts
3:30 error feature "checkout" cannot import feature "catalog"; move what both need to shared/ boundaries/dependencies
4:28 error feature "checkout" cannot import feature "catalog"; move what both need to shared/ boundaries/dependencies
src\app\shared\ui\leak.ts
1:31 error shared cannot import feature boundaries/dependencies
✖ 4 problems (4 errors, 0 warnings)
El alias @features/catalog/... y la ruta relativa ../catalog/... se detectaron igual. Los imports dinámicos también cuentan: cuando quité la política que permite al shell, el lint marcó las líneas de import() de app.routes.ts.
Dos notas de la versión 7, para quien copie configuración de ejemplos antiguos. La opción mode de los elementos pasó a deprecated: el lint avisa y pide usar boundaries/files en lugar de mode: 'file', que es lo que hace la configuración de arriba con los archivos de la raíz de app/. Y, según la guía de migración de la v6 a la v7, la opción rules de la regla se renombró a policies, y los presets ahora solo activan boundaries/dependencies; la antigua boundaries/element-types ya estaba deprecated.
Cuando el checkout necesita el producto
El error pide mover a shared/ lo que necesitan las dos features, y la pregunta es qué mover. En el proyecto, el checkout solo necesitaba la forma de un producto, así que eso fue lo que subió: la interfaz Product, en shared/products/product.ts. CatalogStore y ProductApi se quedaron en el catálogo. Mi regla es subir lo más pequeño que resuelva el problema. Si lo que las dos features necesitan es un store entero, desconfío de la división de las features antes de mover el store.
Path aliases sin baseUrl
Los alias @core, @shared y @features están en tsconfig.json sin baseUrl:
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"]
}
No es preferencia. El proyecto generado usa TypeScript 6.0, y cuando agregué "baseUrl": "./", tsc lo rechazó:
error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
Las notas de TypeScript 6.0 explican que baseUrl también funcionaba como raíz de búsqueda de módulos, lo que hacía que imports que nunca funcionarían en runtime parecieran válidos, y piden poner el prefijo directamente en cada entrada de paths. Del lado del lint, eslint-import-resolver-typescript "automatically detects custom path mappings defined in your tsconfig", según la documentación del plugin, así que el lint y el compilador ven las mismas rutas.
Qué cambió desde el post de julio
- Nombres:
checkout.component.tsyauth.guard.tspasaron a sercheckout.tsyauth-guard.tsen los proyectos nuevos desde la v20. En los existentes, la migración mantuvo el formato antiguo a través deangular.json. - Carpetas por tipo dentro de la feature:
components/yservices/son justo lo que la guía pide evitar. - Un
data-access/global: el acceso a datos va dentro de la feature que lo usa; solo sube ashared/lo que necesitan dos features. - Barrel en la raíz de la feature (defendido en la parte de React): en una feature lazy de Angular, como se midió arriba, lleva al bundle inicial código que debería ser lazy.
- Fronteras "privadas": el post decía que los componentes de una feature eran privados. Sin lint, eso es solo una intención.
Por qué importa
Una estructura de carpetas es una afirmación sobre dependencias: lo que está en features/checkout no depende de features/catalog, y lo que está en shared/ no depende de ninguna feature. Nadie verifica esa afirmación por defecto. El compilador acepta cualquier import, el build sigue en verde, y la estructura se va deshaciendo un import a la vez hasta que las carpetas son solo nombres.
Lo que mantiene la afirmación verdadera son tres verificaciones. La salida de ng build muestra un chunk por feature, y un barrel o un import equivocado aparece ahí como un chunk encogido. El lint de fronteras, ejecutándose en el CI, rompe el pull request que cruza features. Y los nombres de archivo siguen lo que genera el CLI, para que el próximo ng generate no cree un segundo patrón en medio del primero. Lo demás, core o shared, features/ o no, es convención. Elige la tuya, pero activa las tres verificaciones.
Referencias
- Angular — Style Guide
- Angular — Creating and using services (
@Service) - Angular — Defining dependency providers
- Angular — Route loading strategies
- Angular — Route guards
- Angular — API
withAutoCleanupInjectors - Angular — API
RouteReuseStrategy - Angular —
ng new - Angular — CHANGELOG (19.0.0, 22.0.0, 22.2.0)
- Angular CLI — migración "keep previous style guide generation behavior" (v20)
- TypeScript 6.0 — release notes (
baseUrldeprecated) - eslint-plugin-boundaries — Policies
- eslint-plugin-boundaries — TypeScript support
- eslint-plugin-boundaries — migración de v6 a v7
Comentarios
Cargando comentarios...