En GFT en Brasil, construíamos dashboards en tiempo real para firmas de inversión sobre un codebase que comenzó con un módulo compartido. Todo lo que no era un componente de feature iba a SharedModule. En poco tiempo, SharedModule importaba y exportaba docenas de cosas, y nadie podía decirte qué pertenecía ahí versus en una feature. Desenmarañarlo nos costó tiempo real de sprint. En Wonderlabs, fuimos en la otra dirección y pusimos demasiado en módulos de feature, incluyendo cosas que varias features necesitaban. Terminamos con componentes casi duplicados. La estructura que uso ahora es el resultado de esos dos fracasos. Una nota de alcance antes de los detalles: la plataforma de solicitud de préstamos en la que trabajé en BMO estaba dividida en micro-frontends, que es un problema diferente con sus propias reglas. Este post trata sobre estructurar una sola aplicación Angular, como los productos independientes de GFT y Wonderlabs. Los nombres de carpetas y features en los ejemplos son ilustrativos, no están tomados del codebase de ningún empleador.
El modelo de tres capas
Core, Shared, Features. Estas tres capas tienen reglas diferentes y propósitos diferentes. Acertar con las reglas es lo que hace que la estructura escale.
- Core: servicios singleton, interceptores HTTP, guards de autenticación, estado a nivel de aplicación, shell de layout. Se instancia una vez. Nunca se importa por Shared, y nunca lo vuelven a proveer las Features.
- Shared: componentes presentacionales, pipes, directivas y utilidades sin conocimiento del dominio de negocio. Reutilizable en cualquier proyecto. Sin servicios con estado.
- Features: cortes verticales de funcionalidad de negocio. Cada feature posee sus componentes, servicios, estado, modelos y rutas. Las features no se importan entre sí directamente.
src/app/
core/
auth/
auth.service.ts
auth.guard.ts
auth.interceptor.ts
http/
api.service.ts
error.interceptor.ts
loading.interceptor.ts
layout/
shell/
header/
sidebar/
store/ # Global NgRx state (app-level only)
app.state.ts
app.effects.ts
shared/
components/
button/
data-table/
modal/
form-field/
pipes/
currency-format.pipe.ts
date-locale.pipe.ts
directives/
tooltip.directive.ts
features/
applications/
lending/
profile/Estructura de carpeta de feature
Cada feature es un corte vertical autocontenido. Un desarrollador que nunca ha visto el codebase debería poder abrir la carpeta de applications y entender todo sobre la feature de applications. La estructura es consistente en todas las features.
features/
applications/
components/
application-list/
application-list.component.ts
application-list.component.html
application-list.component.scss
application-list.component.spec.ts
application-detail/
application-form/
services/
applications.service.ts
applications-api.service.ts
store/
applications.store.ts # NgRx Signal Store
models/
application.model.ts
application-status.enum.ts
applications.routes.ts
index.ts # Public API for cross-feature importsEl index.ts en la raíz de la feature es un límite de API pública deliberado. Solo exporta lo que otras features o core necesitan legítimamente. Todo lo demás es interno a la feature. Cuando necesitas refactorizar los internos, sabes el radio de impacto.
Archivos barrel: úsalos en los límites, en ningún otro lugar
Los archivos barrel en cada nivel de carpeta suenan ordenados en teoría. En la práctica, crean cadenas de dependencias circulares que TypeScript resuelve de formas no obvias, y ralentizan la compilación en proyectos grandes porque el compilador de TypeScript tiene que resolver más archivos. Mi regla: un barrel en la raíz de la feature para la API pública, y nada más.
// features/applications/index.ts
// Only export what genuinely crosses the feature boundary
export { ApplicationSummaryComponent } from './components/application-summary/application-summary.component';
export { Application } from './models/application.model';
export { ApplicationStatus } from './models/application-status.enum';
export { ApplicationsService } from './services/applications.service';
// Consuming from another feature: use the barrel
import { Application } from '@features/applications';
// Do NOT do this from outside the feature:
import { ApplicationFormComponent } from '@features/applications/components/application-form/application-form.component';Alias de rutas
Configura los alias de rutas de tsconfig.json desde el día uno. Los imports relativos que suben más de dos niveles en el árbol de directorios son una señal de que la estructura necesita arreglarse, pero hasta que arregles la estructura, los imports relativos hacen que la refactorización sea dolorosa. Los alias también son autodocumentados: @core/auth se lee mejor que ../../../core/auth.
{
"compilerOptions": {
"paths": {
"@core/*": ["src/app/core/*"],
"@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"],
"@env/*": ["src/environments/*"]
}
}
}Hacer cumplir los límites con ESLint
La estructura sin enforcement es decoración. Los desarrolladores bajo presión de plazos importarán directamente de los internos de otra feature si la ruta funciona. Si usas un workspace Nx, la regla @nx/enforce-module-boundaries de ESLint es la mejor opción: depende del project graph de Nx para resolver los límites, por lo que no se puede usar fuera de un workspace Nx. Para proyectos Angular sin Nx, la alternativa que uso es eslint-plugin-boundaries, que expresa las mismas reglas a partir de patrones de archivo y cumple la misma función.
Los límites que hago cumplir: las features no pueden importar directamente de otras features (deben pasar por index.ts), shared no puede importar de features o core, core no puede importar de componentes shared. Las violaciones fallan el lint en CI antes de que el PR sea revisado. El cableado no tiene nada de especial: las reglas de límites se ponen en error en lugar de warn, y el pipeline corre ng lint como un paso propio antes del build. Un límite violado hace que ESLint salga con código distinto de cero, el paso se pone en rojo, y el pull request queda bloqueado por la protección de rama. Sin reporter propio, sin plugin extra, y sin nada que funcione solo en un proveedor de CI.
Qué se rompe sin esta estructura
- Un mega módulo shared que crece hasta que nadie sabe qué contiene: divide shared en ui-components, utilities y models
- Componentes nombrados por su rol visual en lugar de su dominio: TransactionCard, no CardWithBadge
- Servicios proveídos en features que se instancian múltiples veces porque no están proveídos en root
- Configuración de rutas dispersa por app.module o app.config en lugar de colocada junto a la feature
- Archivos de test en una carpeta de tests separada en la raiz en lugar de colocados junto al archivo que prueban
Qué haría diferente empezando hoy
Con los componentes standalone como predeterminado (para proyectos nuevos desde Angular 17, y en el decorador mismo desde Angular 19), la capa de NgModule desapareció. El modelo de tres capas se mantiene, pero la capa shared es ahora solo componentes standalone, pipes y directivas, no un módulo. Las rutas de features son solo archivos de rutas, no NgModules. La estructura es la misma, el boilerplate es menos.
También configuraría las reglas de límites de ESLint desde el día uno, antes de que haya código que las viole. Agregar enforcement de límites retrospectivamente a un codebase que nunca lo tuvo significa corregir cientos de violaciones de imports antes de que la regla pueda ponerse en error. Configúralo el día uno y no cuesta nada.
La prueba de una estructura es si un desarrollador que nunca ha visto el codebase puede navegar al lugar correcto en menos de dos minutos. Si no puede, la estructura no está pagando su costo.
El modelo de tres capas con límites reforzados, alias de rutas y tests colocados junto al código ha resistido en cada proyecto donde lo he aplicado. No es emocionante. Simplemente es consistente, y la consistencia es lo que hace que los codebases grandes de Angular sean mantenibles a medida que el equipo y la cantidad de features crecen.
Referencias
- Estructura de archivos de proyecto Angular. https://angular.dev/reference/configs/file-structure
- Alias de rutas en TypeScript. https://www.typescriptlang.org/tsconfig#paths
- angular-eslint. https://github.com/angular-eslint/angular-eslint
- Nx enforce-module-boundaries. https://nx.dev/nx-api/eslint-plugin/documents/enforce-module-boundaries