Parte II · Angular

2. Angular: arquitectura, CLI y estructura del proyecto

Antes de escribir un solo componente conviene entender qué clase de herramienta es Angular, qué decisiones ha tomado por ti y cuáles te deja a ti. Este capítulo cubre el andamiaje: la historia que explica por qué el framework es como es, su arquitectura interna de compilación, el CLI completo mandato por mandato, la anatomía de un proyecto recién generado, el fichero angular.json, el arranque de la aplicación con bootstrapApplication y las decisiones de organización que determinarán si dentro de dos años el proyecto sigue siendo mantenible o se ha convertido en una madeja. No es el capítulo más vistoso del libro, pero es el que evita la mayoría de los problemas estructurales que aparecen en el mes seis.

CORE Angular Tiempo de lectura: ~120 min Prerrequisitos: capítulo 1 (TypeScript, Node y herramientas)

2.1 Qué vas a poder hacer al terminar

2.2 Historia y contexto: de AngularJS al Angular de hoy

Ningún framework se entiende sin su historia. Muchas decisiones de Angular que hoy parecen arbitrarias —la inyección de dependencias omnipresente, la obsesión por el tooling, el compilador propio— son respuestas concretas a problemas que el equipo sufrió en la primera versión. Merece la pena dedicarles unos minutos.

Cronología de Angular

2010 · AngularJS. Miško Hevery y Adam Abrons publican un proyecto que Google libera en octubre de 2010. Su propuesta era revolucionaria para la época: en lugar de manipular el DOM a mano con jQuery, se declaraban bindings en el propio HTML y el framework mantenía la sincronía. El eslogan «HTML enhanced for web apps» resumía la idea. Fue un éxito enorme: durante cinco años, AngularJS fue el framework de facto de la aplicación empresarial.

Los problemas de AngularJS. El modelo se apoyaba en tres piezas que envejecieron mal. El $scope era un objeto que se heredaba prototípicamente por el árbol de la interfaz: saber qué scope contenía una variable determinada requería seguir la cadena de prototipos a mano, y las escrituras en un scope hijo enmascaraban silenciosamente al padre. El digest cycle implementaba la sincronización mediante dirty checking: cada expresión enlazada registraba un $watch con el último valor conocido, y al llamar a $apply() el framework recorría todos los watchers comparando valor actual y anterior; como un watcher podía modificar algo observado por otro, el bucle se repetía hasta estabilizarse, con un tope de diez vueltas —de ahí el célebre error 10 $digest() iterations reached—. La consecuencia práctica era que el coste de cada interacción era proporcional al tamaño de la pantalla entera, no al del cambio: la regla no escrita era «no pases de 2.000 watchers por vista», y una tabla con miles de filas y varias columnas enlazadas hacía inutilizable la aplicación. A esto se sumaban una inyección de dependencias basada en cadenas de texto que se rompía al minificar (salvo que se usara la sintaxis de array), la ausencia de tipos y un rendimiento en móviles que en 2015 ya era inaceptable.

Septiembre de 2016 · Angular 2 y la reescritura. El equipo tomó la decisión más impopular y probablemente más acertada de su historia: reescribir el framework desde cero, en TypeScript, con un modelo de componentes en lugar de scopes, inyección de dependencias basada en tipos, un compilador de plantillas propio y detección de cambios unidireccional de arriba abajo. La polémica fue considerable: no había ruta de migración automática y miles de equipos se encontraron con una base de código escrita en un framework que ya no tendría futuro. Google publicó ngUpgrade para hacer convivir ambas versiones en la misma página, pero la migración real seguía siendo una reescritura. El coste reputacional fue alto y todavía hoy aparece en las discusiones sobre «cuál elijo».

2017 · Semver y ciclo semestral. Para no repetir el trauma, Angular adopta versionado semántico estricto y un calendario público: una versión mayor cada seis meses, dos menores por medio y correcciones semanales. Se saltó la versión 3 para alinear el número del framework con el del router, que ya iba por la 3. Cada versión mayor mantiene soporte activo seis meses y soporte a largo plazo (LTS) doce meses más. La promesa implícita es importante: actualizar de una mayor a la siguiente debe ser cuestión de un mandato, no de un proyecto.

Febrero de 2020 · Angular 9 e Ivy. Ivy sustituye al motor de renderizado anterior, ViewEngine. No fue un cambio cosmético: cambió el modelo de compilación de plantillas, redujo drásticamente el tamaño de las aplicaciones pequeñas, hizo posible el tree shaking real del framework, aceleró los tiempos de compilación y desbloqueó todo lo que vino después. Su propiedad clave, la localidad (locality), se explica en 2.5.

2022–2023 · La era standalone. En la v14 (junio de 2022) aparecen los componentes standalone en developer preview; en la v15 (noviembre de 2022) se declaran estables. La v16 y la v17 completan el modelo con las funciones provide*, el bootstrapApplication y las rutas loadComponent. Desde la v17 el CLI genera aplicaciones standalone por defecto y desde la v19 la propiedad standalone: true es implícita: quien quiera seguir en el mundo de los NgModule debe escribir standalone: false de forma explícita.

2023–2024 · Señales y el «renacimiento». La v16 (mayo de 2023) introduce signal(), computed() y effect() en developer preview; la v17 (noviembre de 2023) estabiliza el núcleo de la API y añade el nuevo control flow de plantillas (@if, @for, @switch) y @defer. En paralelo se cambia el sitio de documentación a angular.dev y se rehace la marca. La v17 estabiliza además el builder basado en esbuild con Vite como servidor de desarrollo, que multiplica por varias veces la velocidad de compilación.

2024 en adelante · Hacia el modo zoneless. Las señales se extienden a las entradas, salidas y consultas (input(), output(), viewChild()), aparecen linkedSignal() y resource(), y la detección de cambios sin Zone.js recorre el camino de experimental a estable. El objetivo declarado del equipo es que Zone.js deje de ser necesario y que la reactividad sea granular. Todo eso se trata en detalle en el capítulo 4.

La lección que hay que llevarse Angular es hoy un framework muy distinto del que muchos recuerdan de 2018, y radicalmente distinto de AngularJS. Si alguien te describe Angular hablando de $scope, de NgModule obligatorios o de bundles de dos megabytes, está describiendo un producto que ya no existe. Compara siempre contra la versión actual, no contra el recuerdo.

2.3 Definición y filosofía: framework completo y opinado

Angular es un framework de desarrollo de aplicaciones web, mantenido por Google, escrito en TypeScript, que proporciona en un único paquete versionado de forma conjunta el sistema de componentes, el enrutador, el cliente HTTP, los formularios, la inyección de dependencias, la internacionalización, el renderizado en servidor, las herramientas de compilación y el marco de pruebas. Esa frase larga contiene la diferencia esencial con una librería: React no es comparable a Angular; lo comparable es React más React Router más TanStack Query más React Hook Form más Vite más Vitest, cada uno con su ciclo de vida, su mantenedor y su criterio.

2.3.1 Qué significa «opinado» y por qué es una ventaja o un lastre

Un framework opinado (opinionated) tiene una respuesta preferente para cada pregunta de diseño: cómo se organiza un componente, cómo se obtiene una dependencia, cómo se navega, cómo se prueba. Esa opinión tiene un coste evidente —menos libertad— y una ventaja que solo se aprecia con el tiempo y con equipos grandes: la homogeneidad.

Analogía: el chalé y el bloque de viviendas

Construir con una librería es como levantar un chalé a medida: eliges cada material, cada distribución, cada proveedor. El resultado puede ser espectacular y estar exactamente adaptado a tus necesidades. También puede ser un desastre si el arquitecto no es bueno, y el fontanero que venga dentro de cinco años tendrá que descubrir por dónde pasan las tuberías.

Construir con un framework opinado es como levantar un bloque de viviendas con un sistema constructivo estándar: menos libertad en el detalle, pero cualquier operario sabe dónde está el cuadro eléctrico sin necesidad de planos. Cuando tienes cuarenta desarrolladores repartidos en seis equipos y una rotación del veinte por ciento anual, esa previsibilidad vale más que la elegancia de una solución a medida.

2.3.2 Qué problema resuelve realmente

2.3.3 Para quién NO es la mejor opción

La honestidad intelectual obliga a decir esto con claridad. Angular es una mala elección si:

El error de elección más caro No es elegir «mal» framework: es elegir uno por moda y abandonarlo a los seis meses. Cualquiera de los cuatro grandes puede sostener una aplicación excelente. Lo que ninguno sostiene es un equipo que cambia de criterio cada trimestre.

2.4 Comparación honesta con React, Vue y Svelte

Las comparaciones de frameworks suelen ser propaganda. Intentemos algo distinto: una tabla donde cada casilla describe un hecho verificable, y después unos criterios de elección explícitos. Ten presente que se compara Angular completo con el core de los demás; donde eso distorsiona, se indica.

CriterioAngularReactVueSvelte
Curva de aprendizajeAlta. TypeScript, DI, RxJS, señales, CLI y convenciones desde el primer díaMedia al inicio, alta al montar la arquitectura: hay que elegir y aprender diez libreríasBaja. Se puede ser productivo en un par de díasMuy baja. La sintaxis es casi HTML y JavaScript
Tamaño del bundle (hola mundo, comprimido)El mayor de los cuatro, pero muy mejorado con Ivy y tree shaking; escala bien porque el coste fijo se amortizaPequeño en el core; crece rápido al sumar router, estado y peticionesPequeño y con crecimiento moderadoEl más pequeño: compila a JavaScript imperativo y apenas hay runtime
Modelo de reactividadSeñales (grafo de dependencias) más RxJS, sobre detección de cambios con o sin Zone.jsRerenderizado de la función del componente y reconciliación de virtual DOM; memoización manualProxies reactivos (ref, reactive) con seguimiento automático de dependenciasRunes en la versión 5: reactividad granular resuelta en tiempo de compilación
EnrutadoOficial, incluido y muy completo: lazy loading, guards, resolvers, rutas hijas, estrategias de reutilizaciónDe terceros (React Router, TanStack Router) o del meta-framework (Next.js)Oficial (Vue Router), instalación aparteDel meta-framework (SvelteKit) o de terceros
Inyección de dependenciasJerárquica, con árbol de inyectores, tokens, ámbitos y sustitución. Es un pilar del diseñoNo existe como tal; se emula con context y con paso de propsprovide/inject, más sencillo y sin jerarquía de inyectores comparableContexto por componente, sin sistema de DI
FormulariosDos sistemas oficiales: reactivos y basados en plantilla, con validadores y estado tipadoDe terceros (React Hook Form, Formik) o a manoEnlace bidireccional con v-model; validación de terceros (VeeValidate)Enlace con bind:; validación a mano o de terceros
Renderizado en servidorOficial e integrado en el CLI (ng add @angular/ssr), con hidratación incremental en versiones recientesA través de meta-frameworks (Next.js, Remix); es su terreno más maduroNuxt, muy maduroSvelteKit, muy maduro
Grado de opiniónMuy alto: hay una forma canónica de hacer casi todoMuy bajo: el core solo resuelve la vistaMedio: sugiere convenciones sin imponerlasMedio-bajo en Svelte, alto en SvelteKit
Ecosistema empresarialExcelente: bibliotecas de componentes maduras, soporte LTS, presencia dominante en banca, seguros y administración públicaEl mayor ecosistema en volumen absoluto y en oferta de empleoMuy fuerte en Asia y en producto; menor presencia corporativa en EuropaCreciente pero mucho menor; menos oferta de perfiles
ActualizacionesCalendario público y migraciones automáticas de código con ng updateEstable, pero cada dependencia se actualiza por su cuentaOrdenadas; la migración de Vue 2 a 3 fue costosaLa migración de Svelte 4 a 5 introdujo cambios de modelo importantes

2.4.1 Criterios de elección, sin diplomacia

Sobre los benchmarks Los resultados que circulan por redes miden casi siempre la creación y actualización de una tabla de diez mil filas. Es un escenario legítimo, pero representa una fracción minúscula de lo que hace una aplicación de gestión real. En producción, el tiempo se va en peticiones de red, en re-renderizados innecesarios por un mal diseño de estado y en JavaScript de terceros —analítica, mapas, editores—, no en la capa de renderizado del framework. Desconfía de cualquier decisión de arquitectura basada exclusivamente en un benchmark sintético.

2.5 Arquitectura interna: cómo funciona Angular por dentro

Angular no interpreta tus plantillas en tiempo de ejecución: las compila. Entender ese proceso explica de golpe por qué existe el CLI, por qué los errores de plantilla aparecen al compilar y por qué el tamaño del bundle depende de qué usas y no de qué instalas.

2.5.1 Compilación AOT frente a JIT

Históricamente hubo dos modos de compilar las plantillas de Angular:

Desde Angular 9, AOT es el modo por defecto también en desarrollo. El modo JIT sigue existiendo para casos muy concretos (algunos escenarios de pruebas y de carga dinámica), pero para el trabajo diario puedes considerar que Angular es un compilador. Esta es una diferencia cualitativa con la mayoría de sus competidores: cuando escribes {{ usuario.nombree }} con una errata, Angular te lo dice antes de arrancar.

PIPELINE DE COMPILACIÓN DE ANGULAR

  ┌──────────────────────┐   ┌──────────────────────┐
  │  componente.ts       │   │  componente.html     │
  │  @Component({...})   │   │  plantilla Angular   │
  │  class Componente    │   │  {{ }}  @if  @for    │
  └──────────┬───────────┘   └──────────┬───────────┘
             │                          │
             └───────────┬──────────────┘
                         ▼
        ┌────────────────────────────────────┐
        │  1. COMPILADOR DE ANGULAR (ngtsc)  │
        │     Analiza los decoradores y      │
        │     traduce la plantilla a         │
        │     instrucciones de renderizado   │
        └────────────────┬───────────────────┘
                         ▼
        ┌────────────────────────────────────┐
        │  2. SALIDA: campos estáticos en la │
        │     propia clase                   │
        │     ɵcmp = defineComponent({       │
        │       template: function(rf, ctx){ │
        │         ... elementStart / text /  │
        │         property / advance ...     │
        │       }})                          │
        └────────────────┬───────────────────┘
                         ▼
        ┌────────────────────────────────────┐
        │  3. TypeScript → JavaScript        │
        │     comprobación de tipos incluida │
        │     en plantillas (strictTemplates)│
        └────────────────┬───────────────────┘
                         ▼
        ┌────────────────────────────────────┐
        │  4. EMPAQUETADO (esbuild/Rollup)   │
        │     tree shaking · minificación    │
        │     división en trozos (chunks)    │
        │     hash en los nombres de fichero │
        └────────────────┬───────────────────┘
                         ▼
        ┌────────────────────────────────────┐
        │  5. dist/  →  navegador            │
        │     main-A1B2C3.js, chunk-*.js     │
        └────────────────────────────────────┘

2.5.2 Qué es Ivy y qué es la «localidad»

Ivy es el nombre del motor de compilación y renderizado que sustituyó a ViewEngine en Angular 9. Su idea central es la localidad (locality): para compilar un componente basta con la información contenida en ese componente, sin necesidad de analizar globalmente toda la aplicación ni de generar metadatos intermedios (los antiguos ficheros .metadata.json de ViewEngine).

Las consecuencias de la localidad son enormes y explican casi todo lo demás:

2.5.3 De la plantilla a las instrucciones de renderizado

Este es el punto que más sorprende a quien viene de React: una plantilla de Angular no se convierte en un árbol de objetos que describe la interfaz. Se convierte en una función que, al ejecutarse, llama a instrucciones que crean y actualizan nodos del DOM. Veámoslo con un ejemplo mínimo.

saludo.component.ts · lo que escribes
@Component({
  selector: 'app-saludo',
  template: `
    <h1>Hola, {{ nombre() }}</h1>
    <button (click)="saludar()">Saludar</button>
  `,
})
export class SaludoComponent {
  readonly nombre = signal('Ana');
  saludar(): void { /* ... */ }
}
saludo.component.js · en lo que se convierte (simplificado y con nombres legibles)
// El compilador añade un campo estático a la propia clase.
// 'rf' son las "render flags": 1 = crear, 2 = actualizar.
SaludoComponent.ɵcmp = defineComponent({
  type: SaludoComponent,
  selectors: [['app-saludo']],
  decls: 4,        // número de nodos declarados
  vars: 1,         // número de expresiones enlazadas
  template: function SaludoComponent_Template(rf, ctx) {
    if (rf & 1) {                       // FASE DE CREACIÓN (una sola vez)
      elementStart(0, 'h1');
      text(1);                          // nodo de texto vacío, se rellena luego
      elementEnd();
      elementStart(2, 'button');
      listener('click', function () { return ctx.saludar(); });
      text(3, 'Saludar');
      elementEnd();
    }
    if (rf & 2) {                       // FASE DE ACTUALIZACIÓN (cada comprobación)
      advance(1);                       // sitúa el "puntero" en el nodo 1
      textInterpolate1('Hola, ', ctx.nombre(), '');
    }
  },
});
// Nota: los nombres reales van prefijados con ɵɵ (ɵɵelementStart, ɵɵadvance...).
// El prefijo indica API privada del framework: nunca la llames directamente.

Fíjate en tres cosas. Primera: hay dos fases separadas, creación y actualización, y la de actualización solo toca lo que puede cambiar. Segunda: cada nodo tiene un índice fijo conocido en tiempo de compilación, por lo que actualizar el texto es acceder a una posición de un array, no buscar en el DOM. Tercera, y decisiva: solo se importan las instrucciones que la plantilla necesita. Una plantilla sin @for no arrastra el código de repetición de listas.

Por qué esto importa en tu día a día Porque explica el tree shaking del framework: el tamaño de tu aplicación depende de qué características usas, no de qué tiene Angular. Y explica también por qué el CLI es prácticamente obligatorio: hay un paso de compilación de plantillas que ningún empaquetador genérico sabe hacer por sí solo.

2.5.4 Incremental DOM frente a Virtual DOM

La estrategia de Ivy se conoce como incremental DOM. La de React, virtual DOM. La diferencia se resume en qué se hace cuando hay que actualizar la pantalla.

AspectoVirtual DOM (React)Incremental DOM (Angular/Ivy)
Qué ocurre al actualizarSe ejecuta la función del componente, que devuelve un árbol de objetos nuevo que describe la interfaz; se compara con el árbol anterior (reconciliación) y se aplican las diferencias al DOM realSe ejecuta la parte de actualización de la función de plantilla, que comprueba expresión a expresión y escribe directamente en los nodos que han cambiado
MemoriaRequiere mantener en memoria una copia del árbol anterior para poder compararla, y asignar el árbol nuevo en cada renderNo hay árbol intermedio: la memoria adicional es proporcional al número de expresiones enlazadas, no al tamaño de la interfaz
Presión sobre el recolector de basuraAlta en interfaces grandes con actualizaciones frecuentes: cada render genera objetos efímerosMuy baja: la fase de actualización apenas asigna memoria
Tamaño del código de renderizadoEl algoritmo de reconciliación es genérico y va siempre completo en el bundleLas instrucciones son granulares y se importan solo las utilizadas: es tree-shakable
FlexibilidadMáxima: la vista es el resultado de una función de JavaScript, se puede componer con cualquier estructura de control del lenguajeMenor: la plantilla es un lenguaje aparte con su propio control flow, comprobado por el compilador
Coste típicoProporcional al tamaño del subárbol re-renderizado, salvo memoización manualProporcional al número de expresiones comprobadas, que con OnPush y señales se reduce a las vistas marcadas
Analogía: la carta y el post-it

El virtual DOM es como reescribir la carta entera cada vez que cambias una palabra, comparar la nueva con la anterior y luego pasar a limpio solo las líneas distintas. Funciona, es muy flexible, pero consumes un folio en cada iteración.

El incremental DOM es como tener la carta ya escrita con unos huecos numerados y pegar un post-it solo en el hueco cuyo contenido ha cambiado. Menos flexible —los huecos están decididos de antemano— y mucho más barato en papel.

2.5.5 El papel de Zone.js, a alto nivel

Nos falta una pieza: ¿quién decide cuándo se ejecuta la fase de actualización? Históricamente, Zone.js. Es una librería que parchea las APIs asíncronas del navegador —setTimeout, addEventListener, XMLHttpRequest, Promise— para saber cuándo empieza y cuándo termina cualquier tarea asíncrona. Cuando la cola de tareas de la zona de Angular se vacía, el framework deduce que «puede haber cambiado algo» y ejecuta un recorrido de detección de cambios.

La ventaja es la comodidad: escribes código normal y la vista se actualiza sola. El inconveniente es la imprecisión: Angular no sabe qué ha cambiado, solo que algo asíncrono ha ocurrido, así que comprueba de más. De ahí la dirección actual del framework, el modo zoneless, en el que las señales notifican con precisión qué vista hay que refrescar y Zone.js deja de ser necesario.

Aquí lo dejamos La detección de cambios, OnPush, las señales y el modo zoneless son el objeto del capítulo 4, que es el capítulo central de la parte de Angular. Por ahora basta con la idea: Zone.js es el disparador de la comprobación, no el mecanismo de renderizado.

2.6 Instalación y CLI completo

2.6.1 Requisitos previos

Angular necesita Node.js y un gestor de paquetes. Cada versión mayor de Angular declara qué versiones de Node, TypeScript y RxJS admite, y el CLI se niega a funcionar —con un mensaje explícito— si la versión de Node está fuera de rango.

Consulta siempre la tabla oficial de compatibilidad Los rangos concretos cambian en cada versión mayor y quedan obsoletos en cuestión de meses, así que este libro no los reproduce. La regla práctica es sencilla: usa una versión LTS par de Node (20, 22, 24…) razonablemente reciente y verifica el rango exacto en angular.dev/reference/versions. Si usas nvm, fija la versión del proyecto en un fichero .nvmrc para que todo el equipo y el servidor de integración continua usen la misma.
terminal · comprobación del entorno
# Versiones instaladas
node --version          # p. ej. v22.14.0
npm --version           # p. ej. 10.9.2

# Gestión de versiones de Node con nvm (recomendado)
nvm install 22
nvm use 22
node --version > .nvmrc   # deja constancia de la versión del proyecto

2.6.2 Instalación global frente a npx

terminalPROBLEMÁTICO
# CLI global fijo, instalado una vez hace dos años
npm install -g @angular/cli

# Consecuencia: trabajas en tres proyectos con
# Angular 17, 19 y 20, y tu 'ng' global es de la 16.
# Los generadores producen código de una versión
# y el proyecto espera otra. Los mensajes de error
# son confusos porque nadie mira la versión del CLI.
ng version   # dice 16.x mientras el proyecto es 20.x
terminalRECOMENDADO
# 1) Crear proyectos con npx: siempre la última versión
npx @angular/cli@latest new mi-app

# 2) Dentro del proyecto, usar SIEMPRE el CLI local
#    (el que está en node_modules y en package.json)
npx ng generate component tareas
npm run ng -- generate component tareas

# El CLI local está fijado en package.json, así que
# todo el equipo y la CI usan exactamente el mismo.
# Si además instalas uno global, mantenlo actualizado:
npm install -g @angular/cli@latest
Cómo sabe ng qué versión usar Cuando ejecutas ng dentro de un directorio que contiene un angular.json, el CLI global delega en el CLI local del proyecto si lo encuentra. Por eso a veces «funciona» pese a tener una global antigua. Aun así, la fuente de verdad debe ser siempre la dependencia del package.json: es lo único que la integración continua va a respetar.

2.6.3 ng new: crear el proyecto

terminal · creación con las opciones más habituales
npx @angular/cli@latest new gestor-tareas \
  --routing \                     # genera app.routes.ts y provideRouter
  --style=scss \                  # css | scss | sass | less
  --ssr=false \                   # renderizado en servidor: pregunta si se omite
  --package-manager=npm \         # npm | yarn | pnpm | cnpm | bun
  --prefix=app \                  # prefijo de los selectores: <app-tareas>
  --skip-tests=false \            # true omite los ficheros .spec.ts
  --skip-git=false                # true no inicializa el repositorio

# Opciones útiles adicionales:
#   --dry-run            muestra qué ficheros se crearían, sin escribir nada
#   --skip-install       no ejecuta npm install (útil en CI o sin red)
#   --minimal            sin pruebas ni configuración extra (prototipos)
#   --inline-template    plantilla dentro del .ts en lugar de fichero aparte
#   --inline-style       estilos dentro del .ts
#   --create-application=false   crea un espacio de trabajo vacío (monorepo)
#   --directory=carpeta  nombre de la carpeta si difiere del nombre del proyecto

# El catálogo completo de opciones de TU versión, siempre aquí:
ng new --help
Las opciones de ng new cambian entre versiones Algunas banderas nacen (por ejemplo, la creación directa de proyectos sin Zone.js apareció en versiones recientes), otras se vuelven el valor por defecto y dejan de tener sentido (--standalone, que hoy es implícito) y otras se retiran. Antes de copiar un mandato de un blog de hace tres años, ejecuta ng new --help con tu versión instalada: es la única fuente fiable.

2.6.4 ng generate: los schematics

Un schematic es un generador de código: una función que recibe unas opciones y produce o modifica ficheros del proyecto siguiendo las convenciones oficiales. No es un lujo cosmético: garantiza que todos los artefactos del proyecto se llamen, se sitúen y se registren igual, y evita las erratas de copiar y pegar.

SchematicAliasQué generaEjemplo real
componentcClase con @Component, plantilla, estilos y fichero de pruebasng g c features/tareas/lista-tareas
directivedClase con @Directive y su specng g d shared/directives/autofoco
pipepClase con @Pipe y su specng g p shared/pipes/tiempo-relativo
servicesClase con @Injectable({ providedIn: 'root' })ng g s core/api/tareas
guardgGuard funcional; pregunta el tipo (CanActivate, CanMatch…)ng g guard core/auth/sesion --functional
interceptorInterceptor funcional de HttpClientng g interceptor core/http/token
resolverResolver de datos de rutang g resolver features/tareas/detalle-tarea
classClase TypeScript simpleng g class core/modelos/tarea --type=model
interfaceInterfaz TypeScriptng g interface core/modelos/usuario
enumEnumerado TypeScriptng g enum core/modelos/estado-tarea
environmentsCarpeta environments/ y el fileReplacements en angular.jsonng g environments
configFicheros de configuración externos (karma, browserslist)ng g config karma
libraryLibrería publicable dentro del espacio de trabajong g library ui-kit
applicationAplicación adicional en el mismo espacio de trabajong g application panel-admin
modulemNgModule (solo si mantienes código heredado)ng g m legacy/informes --routing
web-workerWorker y la configuración de compilación asociadang g web-worker core/calculo-pesado
service-workerConfiguración de PWA con @angular/service-workerng add @angular/pwa
terminal · opciones transversales de ng generate
# --dry-run (-d): imprime qué ficheros se crearían SIN escribir nada.
# Úsalo siempre la primera vez que pruebes un schematic desconocido.
ng g c features/facturas/tabla-facturas --dry-run

# Opciones habituales de 'component'
ng g c features/facturas/tabla-facturas \
  --change-detection=OnPush \   # estrategia de detección de cambios
  --inline-style \              # estilos en el .ts
  --inline-template \           # plantilla en el .ts
  --flat \                      # no crea subcarpeta propia
  --skip-tests \                # sin fichero .spec.ts
  --export                      # lo exporta del NgModule (solo código heredado)

# --project: obligatorio en espacios de trabajo con varias aplicaciones
ng g c cabecera --project=panel-admin

# Fijar valores por defecto para TODO el equipo, en angular.json:
#   "schematics": { "@schematics/angular:component": {
#       "changeDetection": "OnPush", "style": "scss" } }
# Así nadie tiene que acordarse de escribir las banderas.

2.6.5 El resto de mandatos

MandatoPara qué sirveEjemplo real
ng serveCompila en memoria y levanta el servidor de desarrollo con recarga en calienteng serve --port 4300 --open --configuration=development
ng buildCompila a dist/. Por defecto usa la configuración de producciónng build --configuration=production --stats-json
ng testEjecuta las pruebas unitarias con el runner configuradong test --watch=false --code-coverage
ng lintAnaliza el código. Requiere instalarlo antes con ng add @angular-eslint/schematicsng lint --fix
ng updateActualiza paquetes y ejecuta migraciones de códigong update @angular/core@20 @angular/cli@20
ng addInstala un paquete y ejecuta su schematic de instalación, que configura el proyectong add @angular/material
ng deployPublica la aplicación. Solo existe si un paquete aporta el builder de despliegueng add angular-cli-ghpages && ng deploy
ng cacheGestiona la caché de compilación en disco (.angular/cache)ng cache info · ng cache clean
ng analyticsControla el envío anónimo de telemetría del CLIng analytics disable --global
ng versionMuestra las versiones de Angular, CLI, Node y paquetes. Primer mandato al pedir ayudang version
ng configLee o escribe valores de angular.json desde la línea de mandatosng config cli.packageManager pnpm
ng runEjecuta un target cualquiera del angular.json, incluidos los personalizadosng run mi-app:build:staging

2.6.6 ng update y las migraciones automáticas

Este es el argumento comercial más sólido de Angular y conviene entender cómo funciona por dentro. Cuando ejecutas ng update @angular/core@20, el CLI hace cuatro cosas:

terminal · actualización de una versión mayor, paso a paso
# 0) Punto de partida limpio y una rama para la actualización
git status                 # debe estar limpio
git switch -c chore/actualizar-angular-20

# 1) Consultar la guía oficial ANTES de tocar nada.
#    update.angular.dev genera la lista exacta de pasos
#    para tu par de versiones (origen -> destino).

# 2) Nunca saltes versiones mayores. De la 18 a la 20 se pasa
#    por la 19: cada mayor trae sus propias migraciones.
ng update @angular/core@19 @angular/cli@19
git add -A && git commit -m "chore: Angular 19"
ng update @angular/core@20 @angular/cli@20

# 3) Revisar QUÉ ha reescrito la herramienta en tu código
git diff --stat
git diff src/

# 4) Ejecutar migraciones opcionales que no se aplican solas
ng update @angular/core --migrate-only --name=nombre-de-la-migracion

# 5) Verificar
npm run test -- --watch=false
ng build --configuration=production
terminal · migraciones de código que se invocan a mano
# Migrar de NgModules a componentes standalone. Son tres pasos que
# se ejecutan en orden y por separado; se detallan en la sección 2.10.
ng generate @angular/core:standalone

# Otras migraciones útiles del paquete @angular/core
# (los nombres disponibles dependen de tu versión: consúltalos
#  en la guía de actualización antes de invocarlos)
ng generate @angular/core:control-flow      # *ngIf/*ngFor  ->  @if/@for
ng generate @angular/core:inject            # constructor   ->  inject()
ng generate @angular/core:signal-inputs     # @Input()      ->  input()
ng generate @angular/core:output-migration  # @Output()     ->  output()
Cómo saber qué migraciones existen en tu versión Ejecuta ng generate @angular/core: y pulsa el tabulador, o consulta node_modules/@angular/core/schematics/migrations.json. El catálogo cambia en cada versión mayor: unas migraciones se añaden y otras se retiran cuando la API antigua desaparece. No copies nombres de migración de un tutorial sin comprobar antes que existen en tu instalación.

2.7 Estructura del proyecto, archivo por archivo

Un proyecto recién creado con el CLI tiene unos veinte ficheros. Ninguno sobra y conviene saber qué hace cada uno antes de empezar a moverlos de sitio.

gestor-tareas/
│
├── angular.json            Configuración del ESPACIO DE TRABAJO: proyectos,
│                           builders, configuraciones, presupuestos, activos.
│                           Es el fichero más importante y el menos leído.
│
├── package.json            Dependencias y scripts de npm.
├── package-lock.json       Árbol exacto de versiones. VA AL REPOSITORIO.
│
├── tsconfig.json           Configuración base de TypeScript + angularCompilerOptions
├── tsconfig.app.json       Extiende la base: qué entra en la compilación de la app
├── tsconfig.spec.json      Extiende la base: qué entra en la compilación de tests
│
├── .editorconfig           Estilo de fichero (indentación, fin de línea)
├── .gitignore              Excluye node_modules/, dist/, .angular/
│
├── public/                 Activos estáticos copiados TAL CUAL a dist/
│   └── favicon.ico         (en versiones anteriores esta carpeta era src/assets/)
│
└── src/
    ├── main.ts             PUNTO DE ENTRADA. Arranca la aplicación.
    ├── index.html          Documento HTML base. Contiene <app-root></app-root>
    ├── styles.scss         Estilos globales (reset, tipografía, tema)
    │
    ├── environments/       (solo si ejecutas 'ng generate environments')
    │   ├── environment.ts             valores de PRODUCCIÓN
    │   └── environment.development.ts valores de DESARROLLO
    │
    └── app/
        ├── app.ts                  Componente raíz (clase)
        ├── app.html                Plantilla del componente raíz
        ├── app.scss                Estilos del componente raíz
        ├── app.spec.ts             Pruebas del componente raíz
        ├── app.config.ts           PROVEEDORES de la aplicación
        └── app.routes.ts           Tabla de rutas

NOTA SOBRE LOS NOMBRES: a partir de Angular v20 el CLI genera 'app.ts'
en lugar de 'app.component.ts' (desaparecen los sufijos .component,
.service, .directive...). Los proyectos anteriores conservan los sufijos
y ambos estilos son válidos. Comprueba qué genera TU versión con
'ng generate component prueba --dry-run'.

2.7.1 Qué hace exactamente cada fichero

FicheroResponsabilidadCuándo lo tocarás
angular.jsonDefine los proyectos del espacio de trabajo y, para cada uno, qué builder ejecuta cada tarea y con qué opcionesAl añadir estilos globales, activos, presupuestos, entornos o una configuración nueva
package.jsonDependencias, versiones y scripts de npm. Las de dependencies van al bundle; las de devDependencies, noAl instalar librerías o definir scripts del equipo
package-lock.jsonFija el árbol de dependencias completo, versión a versión, con sus hashes. Es lo que hace reproducible una instalaciónNunca a mano. Se confirma siempre en el repositorio
tsconfig.jsonOpciones del compilador de TypeScript y del compilador de Angular (angularCompilerOptions). Aquí viven strict, strictTemplates y los alias de rutasAl configurar alias de importación o endurecer el modo estricto
tsconfig.app.json
tsconfig.spec.json
Extienden al anterior y delimitan qué ficheros entran en cada compilación: el primero excluye los .spec.ts; el segundo añade los tipos del runner de pruebasRara vez: al declarar tipos globales o al cambiar de runner
src/main.tsPunto de entrada: llama a bootstrapApplication(App, appConfig)Casi nunca: la configuración vive en app.config.ts
src/index.htmlDocumento base. Contiene la etiqueta del componente raíz, el <base href> y las metaetiquetasAl añadir metaetiquetas, tipografías o el color del tema
src/styles.scssEstilos globales: reset, variables CSS, tipografía. No estilos de componentesAl definir el sistema de diseño
app.config.tsEl ApplicationConfig: la lista de proveedores de raíz de toda la aplicaciónConstantemente: cada vez que añades una capacidad global
app.routes.tsTabla de rutas de primer nivel, normalmente con carga diferidaAl añadir una sección nueva
public/Activos copiados sin procesar a la raíz de dist/: favicon, robots.txt, imágenes fijasAl añadir recursos estáticos que se sirven por URL
environments/Constantes que cambian según el entorno de compilaciónAl distinguir URLs de API entre desarrollo y producción
.angular/cache/Caché de compilación en disco. Se regenera solaNunca. Está en .gitignore y se borra con ng cache clean
El index.html y el <base href> Si despliegas la aplicación en un subdirectorio (por ejemplo https://empresa.com/gestor/), tienes que ajustar la etiqueta <base href="/gestor/"> o compilar con ng build --base-href=/gestor/. Es la causa número uno de «en local funciona y en el servidor la aplicación arranca en blanco con errores 404 de los ficheros JavaScript».

2.8 angular.json en detalle

Casi nadie lee este fichero hasta que algo falla. Es un error: angular.json describe cómo se construye tu producto. Vamos por partes.

2.8.1 Anatomía: proyectos, targets y builders

La jerarquía es siempre la misma: el espacio de trabajo contiene proyectos; cada proyecto tiene unos targets (también llamados architect targets: build, serve, test, lint); cada target nombra un builder —la función que ejecuta el trabajo— y le pasa unas options; y cada target puede definir varias configurations que sobrescriben esas opciones.

angular.json
│
└─ projects
   └─ gestor-tareas
      ├─ projectType: "application"
      ├─ prefix: "app"
      ├─ sourceRoot: "src"
      ├─ schematics        ← valores por defecto de 'ng generate'
      └─ architect
         ├─ build
         │  ├─ builder: "@angular/build:application"
         │  ├─ options          ← base común
         │  └─ configurations
         │     ├─ production    ← optimiza, hashea, reemplaza ficheros
         │     ├─ staging       ← la que tú añadas
         │     └─ development   ← sourceMaps, sin optimizar
         ├─ serve
         │  ├─ builder: "@angular/build:dev-server"
         │  └─ configurations   ← apuntan a una configuración de 'build'
         ├─ test
         └─ lint

'ng build'          usa la configuración por defecto (production)
'ng build -c staging'      usa options + configurations.staging
'ng run gestor-tareas:build:staging'   forma larga y explícita
El paquete del builder depende de la versión El builder moderno basado en esbuild se llama application, pero cambió de paquete: primero se publicó como @angular-devkit/build-angular:application y después se movió al paquete @angular/build, quedando como @angular/build:application. Los proyectos antiguos pueden seguir con el builder de webpack (:browser). Mira qué pone tu angular.json antes de copiar configuraciones de internet: las opciones no son idénticas entre builders.

2.8.2 Un fragmento real, comentado

angular.json · extracto del target build (los comentarios son didácticos: JSON no los admite)
{
  "projects": {
    "gestor-tareas": {
      "projectType": "application",
      "prefix": "app",
      "sourceRoot": "src",

      "schematics": {
        "@schematics/angular:component": {
          "changeDetection": "OnPush",
          "style": "scss"
        }
      },

      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "index": "src/index.html",
            "tsConfig": "tsconfig.app.json",
            "outputPath": "dist/gestor-tareas",
            "assets": [ { "glob": "**/*", "input": "public" } ],
            "styles": [ "src/styles.scss" ],
            "scripts": [],
            "stylePreprocessorOptions": { "includePaths": ["src/estilos"] }
          },
          "configurations": {
            "production": {
              "budgets": [
                { "type": "initial",
                  "maximumWarning": "500kB", "maximumError": "1MB" },
                { "type": "anyComponentStyle",
                  "maximumWarning": "4kB", "maximumError": "8kB" }
              ],
              "fileReplacements": [
                { "replace": "src/environments/environment.ts",
                  "with":    "src/environments/environment.production.ts" }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false
            },
            "development": {
              "optimization": false,
              "outputHashing": "none",
              "sourceMap": true,
              "extractLicenses": false
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production":  { "buildTarget": "gestor-tareas:build:production" },
            "development": { "buildTarget": "gestor-tareas:build:development" }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

2.8.3 Las opciones que de verdad importan

OpciónQué haceRecomendación
assetsFicheros copiados sin procesar al directorio de salidaSolo lo que se sirve por URL. Las imágenes de un componente deben importarse desde el código para que entren en el grafo del empaquetador
stylesHojas de estilo globales, en orden de inclusiónReset, variables y tema. Los estilos de componente van en el componente
scriptsJavaScript global inyectado antes de la aplicaciónEvítalo. Es un agujero por el que se escapan el tipado y el tree shaking. Instala la librería como dependencia
fileReplacementsSustituye un fichero por otro durante la compilaciónSolo para los environments. Sustituir servicios enteros produce compilaciones imposibles de razonar
optimizationMinificación, tree shaking, CSS crítico en línea, tipografías en línea. Admite true o un objeto detalladotrue en producción. Si algo se rompe solo al optimizar, desactiva las subopciones una a una para localizar la causa
sourceMapGenera mapas de fuentes. Admite objeto con scripts, styles, vendor y hiddenActivo en desarrollo. En producción, considera hidden: true y súbelos a tu servicio de monitorización sin publicarlos
outputHashingnone, media, bundles o all. Añade un hash del contenido al nombre del ficheroall en producción: es lo que permite cachear los estáticos para siempre e invalidarlos con cada despliegue
budgetsUmbrales de tamaño que producen aviso o errorObligatorios. Un presupuesto es la única defensa automática contra la degradación progresiva del bundle
defaultConfigurationConfiguración usada cuando no se pasa -cDéjala en production para build: es lo que evita desplegar una compilación de desarrollo por error

2.8.4 Presupuestos: cómo leer un fallo de budget

Los presupuestos son un contrato con tu yo del futuro. Sin ellos, el bundle crece un poco cada semana y nadie lo nota hasta que la aplicación tarda ocho segundos en arrancar en un móvil de gama media.

terminal · salida real de un presupuesto excedido
$ ng build

✔ Building...

Initial chunk files   | Names         |  Raw size | Estimated transfer size
main-7QZK4XJ2.js      | main          |   1.42 MB |               312.05 kB
styles-K3MPQ8LA.css   | styles        |  84.31 kB |                9.12 kB
polyfills-B6TL9N1E.js | polyfills     |  34.58 kB |               11.32 kB

▲ [WARNING] bundle initial exceeded maximum budget.
  Budget 500.00 kB was not met by 1.02 MB with a total of 1.52 MB.

✘ [ERROR] bundle initial exceeded maximum budget.
  Budget 1.00 MB was not met by 520.13 kB with a total of 1.52 MB.

Error: Bundle exceeded maximum budget.

Cómo se interpreta. El presupuesto de tipo initial mide todo lo que el navegador debe descargar antes de poder pintar la primera pantalla. Aquí hay 1,52 MB sin comprimir frente a un tope de 1 MB. Ojo con una confusión muy extendida: el presupuesto compara contra el raw size (sin comprimir), no contra el estimated transfer size (comprimido), que es lo que realmente viaja por la red. Un bundle de 1,4 MB sin comprimir suele quedarse en unos 300 kB por el cable.

Qué hacer, en este orden. Uno: ejecuta ng build --stats-json y analiza el resultado (ver 2.13) para averiguar qué ocupa. Dos: si es una librería pesada usada en una sola pantalla, muévela detrás de una ruta con carga diferida o de un bloque @defer. Tres: comprueba que no estás importando la librería entera cuando solo necesitas una función. Cuatro, y solo cuando hayas agotado lo anterior: sube el presupuesto documentando en el mensaje del commit por qué. Subir el presupuesto sin investigar es apagar la alarma de incendios porque molesta el ruido.

app.tsINCORRECTO
// Importa TODA la librería para usar una función.
// El empaquetador no siempre puede eliminar el resto,
// y en el caso de lodash (CommonJS) desde luego no puede.
import _ from 'lodash';
import * as moment from 'moment';

const nombres = _.uniq(this.usuarios.map((u) => u.nombre));
const fecha = moment().format('DD/MM/YYYY');

// Resultado: ~70 kB de lodash y ~230 kB de moment
// (con todas sus localizaciones) en el bundle inicial.
app.tsCORRECTO
// 1) La plataforma ya lo resuelve: no añadas dependencia.
const nombres = [...new Set(this.usuarios.map((u) => u.nombre))];
const fecha = new Intl.DateTimeFormat('es-ES').format(new Date());

// 2) Si necesitas una librería, elige una modular y
//    ESM, e importa solo lo que uses:
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
const legible = format(new Date(), 'dd/MM/yyyy', { locale: es });

// Resultado: unos pocos kB, y solo los que se usan.

2.9 Arranque de la aplicación

2.9.1 El modelo antiguo y el actual

main.tsANTIGUO (NgModule)
import { platformBrowserDynamic }
  from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch((err) => console.error(err));

// Problemas de este modelo:
//  · 'platformBrowserDynamic' implica compilador JIT
//    disponible en el arranque.
//  · Toda la configuración vive dentro de un NgModule
//    con arrays 'imports' y 'providers' que crecen sin
//    control y que el empaquetador no puede podar.
//  · Importar un módulo arrastra TODO su contenido,
//    se use o no.
main.tsACTUAL (standalone)
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));

// Ventajas:
//  · Sin compilador en tiempo de ejecución.
//  · La configuración es una lista PLANA de funciones
//    'provide*' que el empaquetador puede analizar:
//    lo que no se provee, no entra en el bundle.
//  · El fichero de arranque es trivial y estable;
//    la configuración vive aparte y se puede reutilizar
//    en las pruebas y en el renderizado en servidor.
FLUJO DE ARRANQUE DE UNA APLICACIÓN ANGULAR

  navegador descarga index.html
            │
            ▼
  carga main-HASH.js  (punto de entrada)
            │
            ▼
  ┌──────────────────────────────────────────────┐
  │ bootstrapApplication(App, appConfig)          │
  └───────────────────┬──────────────────────────┘
                      ▼
  ┌──────────────────────────────────────────────┐
  │ 1. Se crea la PLATAFORMA y el INYECTOR RAÍZ   │
  │    con todos los providers de appConfig       │
  │    (router, HttpClient, animaciones...)       │
  └───────────────────┬──────────────────────────┘
                      ▼
  ┌──────────────────────────────────────────────┐
  │ 2. INICIALIZADORES de la aplicación           │
  │    Angular ESPERA a que terminen (si          │
  │    devuelven una promesa u observable)        │
  │    antes de renderizar nada.                  │
  │    Ej.: cargar config remota, restaurar sesión│
  └───────────────────┬──────────────────────────┘
                      ▼
  ┌──────────────────────────────────────────────┐
  │ 3. Se instancia el COMPONENTE RAÍZ y se monta │
  │    en el elemento <app-root> del index.html   │
  └───────────────────┬──────────────────────────┘
                      ▼
  ┌──────────────────────────────────────────────┐
  │ 4. El ROUTER lee la URL actual, resuelve la   │
  │    ruta, ejecuta guards y resolvers y carga   │
  │    de forma diferida el chunk de la sección   │
  └───────────────────┬──────────────────────────┘
                      ▼
  ┌──────────────────────────────────────────────┐
  │ 5. PRIMERA DETECCIÓN DE CAMBIOS: se ejecuta   │
  │    la fase de creación de cada plantilla y    │
  │    aparecen los píxeles                       │
  └──────────────────────────────────────────────┘

2.9.2 Un app.config.ts de producción

src/app/app.config.ts
import {
  ApplicationConfig, ErrorHandler, inject, isDevMode,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';
import {
  provideRouter, withComponentInputBinding, withInMemoryScrolling,
  withRouterConfig, withViewTransitions,
} from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { rutas } from './app.routes';
import { interceptorToken } from './core/http/token.interceptor';
import { interceptorErrores } from './core/http/errores.interceptor';
import { ManejadorErroresGlobal } from './core/errores/manejador-errores';
import { ConfiguracionService } from './core/config/configuracion.service';

export const appConfig: ApplicationConfig = {
  providers: [
    // ── ENRUTADO ──────────────────────────────────────────────
    provideRouter(
      rutas,
      // Enlaza los parámetros de ruta directamente a los inputs
      // del componente: se acabó inyectar ActivatedRoute para todo.
      withComponentInputBinding(),
      // Restaura la posición de scroll al navegar atrás y salta
      // al ancla cuando la URL trae fragmento.
      withInMemoryScrolling({
        scrollPositionRestoration: 'enabled',
        anchorScrolling: 'enabled',
      }),
      // Transiciones de vista del navegador donde estén soportadas.
      withViewTransitions(),
      withRouterConfig({ onSameUrlNavigation: 'reload' }),
    ),

    // ── CLIENTE HTTP ──────────────────────────────────────────
    provideHttpClient(
      // Usa la API fetch en lugar de XMLHttpRequest: necesario
      // para el renderizado en servidor y para respuestas por streaming.
      withFetch(),
      // Los interceptores funcionales se ejecutan EN ORDEN.
      withInterceptors([interceptorToken, interceptorErrores]),
    ),

    // ── ANIMACIONES ───────────────────────────────────────────
    // La variante 'Async' carga el motor de animaciones de forma
    // diferida: no pesa en el bundle inicial si nada anima al arrancar.
    provideAnimationsAsync(),

    // ── ERRORES ───────────────────────────────────────────────
    // Captura errores no controlados de promesas y del propio
    // 'window' y los encamina hacia el ErrorHandler de Angular.
    provideBrowserGlobalErrorListeners(),
    { provide: ErrorHandler, useClass: ManejadorErroresGlobal },
  ],
};
Comprueba los nombres de los proveedores en tu versión Varias de las funciones anteriores han cambiado de nombre o han nacido en versiones concretas. Tres casos que conviene verificar antes de copiar: la captura global de errores del navegador (provideBrowserGlobalErrorListeners()) apareció en versiones recientes; la inicialización de la aplicación pasó del token APP_INITIALIZER a la función provideAppInitializer(); y el proveedor del modo sin zona se llamó primero provideExperimentalZonelessChangeDetection() y después provideZonelessChangeDetection() al estabilizarse. La comprobación es inmediata: escribe el nombre en el editor y mira si TypeScript ofrece la importación automática desde @angular/core; si no aparece, esa API no existe en tu versión.
src/app/core/errores/manejador-errores.ts · ErrorHandler propio
import { ErrorHandler, Injectable, NgZone, inject, isDevMode } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class ManejadorErroresGlobal implements ErrorHandler {
  private readonly zona = inject(NgZone);
  private readonly router = inject(Router);
  private readonly telemetria = inject(TelemetriaService);

  handleError(error: unknown): void {
    // 1) En desarrollo, que se vea en consola con su pila completa.
    if (isDevMode()) {
      console.error('[Error no controlado]', error);
    }

    // 2) Los errores HTTP ya se tratan en el interceptor: aquí solo
    //    llegan los que nadie ha capturado. Evita duplicar avisos.
    if (error instanceof HttpErrorResponse) {
      if (error.status === 401) {
        // El ErrorHandler se ejecuta FUERA de la zona de Angular:
        // hay que volver a entrar para que la navegación
        // dispare la detección de cambios.
        this.zona.run(() => this.router.navigate(['/entrar']));
      }
      return;
    }

    // 3) Los errores de carga de un chunk suelen significar que se ha
    //    desplegado una versión nueva y el fichero antiguo ya no existe.
    const mensaje = error instanceof Error ? error.message : String(error);
    if (/ChunkLoadError|dynamically imported module/i.test(mensaje)) {
      location.reload();
      return;
    }

    // 4) Todo lo demás va a la telemetría, SIN datos personales.
    this.telemetria.registrarExcepcion(error);
  }
}
src/app/app.config.ts · inicialización antes del primer render
// Cargar configuración remota ANTES de que se pinte nada.
// Angular espera a que la promesa se resuelva.
//
// API actual (v19 y posteriores):
provideAppInitializer(() => {
  const config = inject(ConfiguracionService);
  return config.cargar();   // devuelve Promise<void> u Observable
}),

// API anterior, equivalente, todavía presente en muchos proyectos:
// {
//   provide: APP_INITIALIZER,
//   useFactory: (config: ConfiguracionService) => () => config.cargar(),
//   deps: [ConfiguracionService],
//   multi: true,
// },

// AVISO DE RENDIMIENTO: todo lo que pongas aquí RETRASA el primer
// pintado. Reserva este mecanismo para lo imprescindible (la URL de
// la API, la marca blanca del cliente) y carga el resto en paralelo
// una vez arrancada la aplicación.

2.10 Standalone frente a NgModules

2.10.1 Qué era un NgModule y qué problemas tenía

Un NgModule era una clase decorada que agrupaba componentes, directivas y pipes, declaraba qué otros módulos necesitaba y qué exportaba al exterior. Cumplía tres funciones a la vez: ámbito de compilación (qué directivas puede usar una plantilla), agrupación para carga diferida y contenedor de proveedores. Mezclar tres responsabilidades en un mismo artefacto es exactamente lo que aconseja evitar el principio de responsabilidad única, y las consecuencias fueron las esperables:

tareas.module.ts + lista.component.tsANTIGUO
// FICHERO 1: el módulo
@NgModule({
  declarations: [ListaTareasComponent, FilaTareaComponent],
  imports: [CommonModule, ReactiveFormsModule, RouterModule],
  exports: [ListaTareasComponent],
  providers: [TareasService],
})
export class TareasModule {}

// FICHERO 2: el componente. Sus dependencias reales
// NO se ven aquí: están en el módulo de al lado.
@Component({
  selector: 'app-lista-tareas',
  templateUrl: './lista-tareas.component.html',
})
export class ListaTareasComponent {}

// Para leer este componente hay que abrir DOS ficheros
// y reconstruir mentalmente el ámbito de compilación.
lista-tareas.tsACTUAL
// UN SOLO FICHERO. Las dependencias son explícitas
// y locales: se leen en el propio componente.
@Component({
  selector: 'app-lista-tareas',
  imports: [ReactiveFormsModule, RouterLink, FilaTarea],
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './lista-tareas.html',
})
export class ListaTareas {
  private readonly tareas = inject(TareasService);
}

// Ventajas concretas:
//  · El empaquetador ve exactamente qué se usa.
//  · Un import sobrante lo marca el linter como
//    'unused', cosa imposible con NgModules.
//  · 'standalone: true' es implícito desde la v19.

2.10.2 Cómo se migra

No a mano. Angular incluye una migración oficial que hace el trabajo pesado en tres pasos, y la clave está en ejecutarlos en orden y confirmar en el control de versiones entre uno y otro para poder revisar cada diff por separado.

terminal · migración a standalone
# Paso 1 · Marcar componentes, directivas y pipes como standalone
#          y moverles sus dependencias desde el NgModule.
ng generate @angular/core:standalone
#   → elegir "Convert all components, directives and pipes to standalone"
git add -A && git commit -m "refactor: componentes standalone"

# Paso 2 · Eliminar los NgModules que han quedado sin contenido.
ng generate @angular/core:standalone
#   → elegir "Remove unnecessary NgModule classes"
git add -A && git commit -m "refactor: eliminar NgModules vacíos"

# Paso 3 · Cambiar el arranque a bootstrapApplication.
ng generate @angular/core:standalone
#   → elegir "Bootstrap the project using standalone APIs"
git add -A && git commit -m "refactor: arranque standalone"

# Después de cada paso, SIEMPRE:
npm run test -- --watch=false
ng build --configuration=production

2.10.3 Interoperabilidad entre ambos mundos

La migración puede ser gradual porque los dos modelos conviven de forma bidireccional. Merece la pena tener claras las dos direcciones:

SituaciónCómo se resuelve
Un componente standalone necesita algo declarado en un NgModule (por ejemplo, una librería antigua)Se añade el módulo entero al array imports del componente: imports: [MatButtonModule]
Un NgModule necesita usar un componente standalone en las plantillas de sus declaracionesSe añade el componente al array imports del módulo (no a declarations: un standalone no se declara en ningún sitio)
Hay proveedores repartidos en varios NgModule y quieres arrancar con bootstrapApplicationimportProvidersFrom(MiModulo) extrae los proveedores del módulo hacia el inyector raíz. Es un puente de transición: cuando la librería ofrezca una función provide*, cámbiala
Rutas con carga diferida de código antiguoloadChildren sigue funcionando con módulos; para componentes standalone se usa loadComponent, y para grupos de rutas, loadChildren devolviendo un array de rutas
Por qué standalone es hoy el estándar Porque elimina una capa de indirección sin perder ninguna capacidad: el ámbito de compilación pasa a ser local y explícito, la carga diferida se declara en la ruta y los proveedores se expresan con funciones que el empaquetador puede analizar. Es la misma idea de localidad que introdujo Ivy en el compilador, aplicada ahora al modelo de programación. Para código nuevo no hay ninguna razón defendible para escribir un NgModule.

2.11 Organización del código a escala

El CLI genera una carpeta app/ plana. Eso vale para el tutorial y deja de valer alrededor del componente número veinte. La pregunta que hay que responder es: ¿cuál es el criterio principal para agrupar ficheros? Hay dos respuestas posibles y solo una escala.

2.11.1 Agrupar por tipo frente a agrupar por funcionalidad

Por tipo (components/, services/…)Por funcionalidad (features/facturas/…)
Cómo se veTodos los componentes juntos, todos los servicios juntosTodo lo de facturación junto, todo lo de clientes junto
Añadir una funciónSe tocan cuatro carpetas lejanas entre síSe toca una carpeta; el diff se lee de un vistazo
Borrar una funciónHay que ir a cazar los restos por todo el proyectoSe borra la carpeta y punto
Carga diferidaDifícil: las dependencias cruzan carpetas sin controlNatural: cada funcionalidad es una unidad de carga
Propiedad del códigoImposible asignar carpetas a equiposUn equipo, una carpeta, un fichero CODEOWNERS
Escala hastaUnos veinte ficherosMiles
ESTRUCTURA RECOMENDADA A ESCALA

src/app/
│
├── core/                    Lo que existe UNA sola vez en la aplicación.
│   │                        Se usa en todas partes. No depende de features.
│   ├── auth/                sesión, guards de autenticación, permisos
│   ├── http/                interceptores (token, errores, reintentos)
│   ├── errores/             ErrorHandler global, tipos de error
│   ├── config/             configuración en tiempo de ejecución
│   └── modelos/             tipos e interfaces del dominio compartido
│
├── shared/                  Piezas REUTILIZABLES y SIN ESTADO de negocio.
│   │                        No depende de core ni de features.
│   ├── ui/                  botón, tarjeta, modal, tabla, paginador
│   ├── directivas/          autofoco, permiso, clic-fuera
│   ├── pipes/               tiempo-relativo, moneda-es, truncar
│   └── utilidades/          funciones puras, sin dependencias de Angular
│
├── features/                UNA CARPETA POR ÁREA FUNCIONAL.
│   ├── facturas/            Cada una se carga de forma diferida.
│   │   ├── facturas.routes.ts        rutas de la sección
│   │   ├── datos/                    servicios de acceso a la API
│   │   ├── modelos/                  tipos propios de esta feature
│   │   └── paginas/                  componentes con ruta
│   │       ├── lista-facturas/
│   │       └── detalle-factura/
│   ├── clientes/
│   └── informes/
│
├── layout/                  Cabecera, barra lateral, pie, contenedor
│
├── app.ts / app.html
├── app.config.ts
└── app.routes.ts            Solo rutas de primer nivel, todas diferidas

REGLA DE DEPENDENCIAS (la flecha significa "puede importar de"):

   features  ──▶  shared  ──▶  (nada del proyecto)
       │
       └──────▶  core    ──▶  shared

   features  ──✗──▶  otra feature      (prohibido: extrae a shared o core)
   shared    ──✗──▶  core o features   (prohibido: shared no sabe de negocio)
   core      ──✗──▶  features          (prohibido: crea ciclos)
La regla de oro de shared/ Si un componente de shared/ necesita importar un servicio de negocio, ya no es compartido: es de una funcionalidad concreta y está en el sitio equivocado. Un componente compartido recibe datos por sus entradas y emite eventos por sus salidas; no sabe qué es una factura. En cuanto relajas esta regla, shared/ se convierte en el nuevo SharedModule monstruo y arrastra media aplicación al bundle inicial.

2.11.2 Barrel files: la buena idea que envejece mal

Un barrel file es un index.ts que reexporta el contenido de una carpeta para poder importar desde un único sitio. Es cómodo y en aplicaciones grandes causa dos problemas serios.

shared/ui/index.tsINCORRECTO
// Un barril que reexporta TODO
export * from './boton/boton';
export * from './tabla/tabla';
export * from './editor-richtext/editor-richtext';  // arrastra 400 kB
export * from './grafico/grafico';                  // arrastra 180 kB

// El consumidor solo quiere el botón:
import { Boton } from '@app/shared/ui';

// PROBLEMA 1 · TREE SHAKING: el empaquetador debe analizar el
// módulo entero. Si el editor tiene efectos secundarios en el
// nivel superior, no puede podarlo y entran los 400 kB.
//
// PROBLEMA 2 · CICLOS: si un componente del barril importa a
// otro a través del propio barril, se crea una dependencia
// circular. Síntomas típicos y desconcertantes:
//   - "Cannot access 'X' before initialization" en el arranque
//   - NG0203 al inyectar dentro de un campo de clase
//   - un servicio que llega como 'undefined' sin motivo aparente
//
// PROBLEMA 3 · COMPILACIÓN: cualquier cambio en cualquier
// fichero del barril invalida a todos sus consumidores.
tsconfig.json + consumoCORRECTO
// Alias de rutas en lugar de barriles: rutas cortas
// SIN indirección y sin riesgo de ciclos.
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@core/*":     ["src/app/core/*"],
      "@shared/*":   ["src/app/shared/*"],
      "@features/*": ["src/app/features/*"]
    }
  }
}
// Consumo: se importa el fichero concreto.
//   import { Boton } from '@shared/ui/boton/boton';
//
// Ventajas:
//  · El empaquetador ve la dependencia exacta.
//  · Imposible crear un ciclo a través del barril.
//  · Al leer el import sabes DÓNDE está el fichero.
//
// Si aun así quieres barriles, limítalos al borde
// PÚBLICO de una librería (el public-api.ts de una
// librería publicable) y nunca dentro de una carpeta
// cuyos ficheros se importen entre sí.

2.11.3 Convenciones de nombres

2.11.4 Cuándo pasar a un monorepo con Nx

Nx es un sistema de construcción para monorepos que se integra con Angular y sustituye o envuelve al CLI. No es «Angular avanzado»: es una herramienta de escala que aporta cosas concretas.

Cuándo NO usar Nx Con una sola aplicación y un equipo de menos de diez personas, Nx añade complejidad conceptual y una capa más que mantener y actualizar sin resolver ningún problema que tengas. Las señales de que ha llegado el momento son concretas: tienes dos o más aplicaciones que comparten código, la integración continua tarda tanto que la gente empieza a saltársela, o las reglas de arquitectura se incumplen a diario y las revisiones de código se han convertido en una discusión sobre importaciones. Migrar a Nx más adelante es perfectamente viable (nx init); adoptarlo «por si acaso» rara vez compensa.

2.12 Configuración por entornos

2.12.1 Configuración en tiempo de compilación

El mecanismo clásico de Angular es el reemplazo de ficheros. Se genera con ng generate environments, que crea la carpeta y además escribe el fileReplacements correspondiente en angular.json.

src/environments/ · los tres ficheros
// ── environment.ts  (el que se importa SIEMPRE en el código) ──
// Contiene los valores de PRODUCCIÓN. Es el fichero por defecto.
export const environment = {
  produccion: true,
  apiUrl: 'https://api.empresa.com/v1',
  version: '2.4.0',
  registroNivel: 'error' as const,
};

// ── environment.development.ts ──
export const environment = {
  produccion: false,
  apiUrl: 'http://localhost:3000/api/v1',
  version: 'dev',
  registroNivel: 'debug' as const,
};

// ── Uso en el código: SIEMPRE se importa el fichero base ──
import { environment } from '../environments/environment';
private readonly base = environment.apiUrl;
// Durante la compilación de desarrollo, el builder sustituye
// físicamente el módulo por el de desarrollo. El código
// consumidor no se entera y no hay ningún 'if' en el bundle.
En el frontend NO hay secretos Todo lo que compilas se descarga en el navegador del usuario. Cualquiera puede abrir las herramientas de desarrollo, buscar en los ficheros JavaScript y leer literalmente cada cadena de texto que hayas puesto ahí. Minificar no es ofuscar y ofuscar no es cifrar: el navegador tiene que poder ejecutar el código, luego el código está a la vista. Una clave de API privada, una contraseña de base de datos o un secreto de firma en un fichero de environments es una filtración, no una configuración. Y no, ponerlo en una variable de entorno de la pipeline no cambia nada: acaba igualmente incrustada en el bundle.
environment.tsINCORRECTO
export const environment = {
  apiUrl: 'https://api.empresa.com',

  // TODO ESTO ES PÚBLICO. Está en el bundle.
  claveApiStripe: 'sk_live_51H8...',   // clave SECRETA
  secretoJwt: 'mi-secreto-de-firma',   // firma de tokens
  usuarioBd: 'admin',
  passwordBd: 'Passw0rd!',
  claveCifrado: 'AES256-clave-maestra',
};

// Consecuencia real: cualquiera abre las DevTools,
// busca "sk_live" en los ficheros descargados y tiene
// tu clave de cobros. Ha ocurrido muchas veces y sale
// carísimo.
environment.tsCORRECTO
export const environment = {
  produccion: true,

  // Solo datos que YA son públicos por naturaleza:
  apiUrl: 'https://api.empresa.com/v1',
  version: '2.4.0',

  // Clave PUBLICABLE de Stripe: está diseñada para
  // vivir en el cliente y no permite cobrar por sí sola.
  clavePublicaStripe: 'pk_live_51H8...',

  // Identificadores públicos de OAuth: el flujo con PKCE
  // no requiere secreto de cliente en aplicaciones SPA.
  clienteOauthId: 'gestor-tareas-web',
};

// Todo lo secreto vive en el BACKEND. El frontend pide
// al backend, y el backend habla con Stripe usando su
// clave secreta, que nunca sale del servidor.

2.12.2 Configuración en tiempo de ejecución

El reemplazo de ficheros tiene una limitación importante: obliga a compilar una vez por entorno. Si tienes desarrollo, integración, preproducción y producción, son cuatro artefactos distintos, y el que pruebas en preproducción no es exactamente el que despliegas en producción. Para muchos equipos eso es inaceptable, y para el modelo de contenedores («compila una vez, despliega en todas partes») directamente no funciona.

La alternativa es cargar la configuración al arrancar, desde un fichero JSON estático que se sustituye en cada despliegue.

src/app/core/config/configuracion.service.ts · configuración en tiempo de ejecución
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

export interface ConfiguracionApp {
  readonly apiUrl: string;
  readonly entorno: 'desarrollo' | 'integracion' | 'preproduccion' | 'produccion';
  readonly funcionesActivas: readonly string[];
}

@Injectable({ providedIn: 'root' })
export class ConfiguracionService {
  private readonly http = inject(HttpClient);
  private readonly _config = signal<ConfiguracionApp | null>(null);
  readonly config = this._config.asReadonly();

  /** Se invoca desde provideAppInitializer: Angular espera a la promesa. */
  async cargar(): Promise<void> {
    // El fichero vive en public/, así que se copia sin procesar a dist/.
    // En el despliegue se monta encima el config.json del entorno
    // (un volumen de Kubernetes, una capa de la imagen, un ConfigMap...).
    const config = await firstValueFrom(
      this.http.get<ConfiguracionApp>('/config.json'),
    );
    this._config.set(config);
  }

  /** Lectura segura: si esto lanza, el inicializador no se ejecutó. */
  get valor(): ConfiguracionApp {
    const c = this._config();
    if (!c) throw new Error('Configuración no cargada: falta el inicializador');
    return c;
  }
}
Tiempo de compilación (fileReplacements)Tiempo de ejecución (config.json)
ArtefactosUno por entornoUno solo para todos
Coste en el arranqueCeroUna petición HTTP antes del primer pintado
OptimizaciónEl valor es una constante: el compilador puede eliminar ramas muertasEl valor es dinámico: no hay eliminación de código muerto
Cambiar un valorRequiere recompilar y volver a desplegarBasta con cambiar el fichero y recargar
Encaja conProyectos con pocos entornos y despliegue directo desde CIContenedores, «compila una vez», marca blanca por cliente
Estrategia mixta, que es la que suele ganar Usa environments para lo que es estructuralmente distinto entre desarrollo y producción y no cambia nunca (el indicador produccion, el nivel de registro, si se activan las herramientas de depuración) y config.json para lo que varía por despliegue (la URL de la API, los interruptores de funcionalidad, el tema del cliente). Y recuerda: en ambos casos, nada secreto.

2.13 Rendimiento de la compilación y del arranque

2.13.1 El builder basado en esbuild y Vite

Durante años Angular compiló con webpack. Desde la v17 el builder por defecto para proyectos nuevos es el llamado application, que usa esbuild para el empaquetado y Vite como servidor de desarrollo. Las diferencias en la práctica:

Cómo saber qué builder usas y cómo migrar Mira el valor de architect.build.builder en tu angular.json: si pone :browser, sigues en webpack; si pone :application, ya estás en esbuild. Angular publica una migración automática para el cambio; el nombre exacto del schematic depende de la versión, así que consúltalo en la guía de actualización oficial en lugar de adivinarlo. Antes de migrar, revisa dos cosas: los plugins de webpack personalizados (si usabas @angular-builders/custom-webpack, no tienen equivalente directo) y los polyfills declarados a mano.

2.13.2 Analizar el bundle

terminal · de la sospecha al dato
# 1) Compilar generando el fichero de estadísticas
ng build --configuration=production --stats-json

# 2) IMPORTANTE: el formato de stats.json depende del builder.
#    · builder ':browser'     -> formato de webpack
#    · builder ':application'  -> "metafile" de esbuild
#    Las herramientas NO son intercambiables.

# Para el builder moderno (esbuild): esbuild-visualizer
npx esbuild-visualizer --metadata dist/gestor-tareas/stats.json \
  --filename analisis.html --open

# Alternativa sin instalar nada: subir el metafile al
# analizador oficial en esbuild.github.io/analyze/

# Para el builder antiguo (webpack): webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/gestor-tareas/stats.json

# 3) Vista rápida sin herramientas: los tamaños por chunk que el
#    propio 'ng build' imprime al terminar. Compáralos entre commits.

Qué buscar en el análisis. Primero, cualquier librería en el chunk inicial que solo se use en una pantalla concreta: es candidata a carga diferida. Segundo, dependencias duplicadas en varias versiones (dos copias de la misma librería suele significar un package-lock.json desordenado o dos rutas de importación distintas al mismo paquete). Tercero, locales e idiomas completos de librerías de fechas o de internacionalización. Cuarto, imágenes o fuentes incrustadas en base64 que serían más eficientes como ficheros aparte.

2.13.3 Qué hacer cuando el build tarda demasiado

2.13.4 El arranque en el navegador

2.14 Errores comunes y cómo solucionarlos

ErrorCausaSolución
Node.js version vX.Y.Z detected. The Angular CLI requires a minimum... La versión de Node está fuera del rango que admite tu versión de Angular, o es una versión impar (no LTS) Instala una versión LTS par admitida con nvm install 22 && nvm use 22, fíjala en un .nvmrc y en la imagen base de CI. Consulta el rango exacto en angular.dev/reference/versions
ng: command not found · 'ng' no se reconoce como un comando No hay CLI global, o el directorio global de npm no está en el PATH Usa el CLI local: npx ng .... Si prefieres el global, npm install -g @angular/cli y añade $(npm config get prefix)/bin al PATH
bundle initial exceeded maximum budget El bundle inicial supera el tope de budgets. Casi siempre por una librería pesada importada en el arranque Analiza con --stats-json, mueve lo pesado a carga diferida o a @defer, e importa solo lo que uses. Subir el umbral es el último recurso y hay que justificarlo
NG0203: inject() must be called from an injection context Se ha llamado a inject() fuera de un contexto de inyección: dentro de un método, en una función suelta, en un setTimeout o tras un await. También aparece por dependencias circulares entre ficheros (barriles) Llama a inject() solo en la inicialización de campos de clase, en el constructor o en factorías. Si necesitas inyectar más tarde, captura el Injector y usa runInInjectionContext. Si el código parece correcto, busca un ciclo de importación
Errores absurdos tras actualizar: tipos que no cuadran, plantillas que fallan sin haber cambiado Caché de compilación corrupta o desincronizada con las dependencias nuevas ng cache clean. Si persiste, borra .angular/, node_modules/ y dist/, y reinstala con npm ci (que respeta el lock) en lugar de npm install
The Angular Compiler requires TypeScript >=X.Y.Z and <A.B.C but X.Y.Z was found Mezcla de versiones: paquetes de Angular en versiones distintas entre sí, o TypeScript/RxJS fuera del rango que exige el core Todos los paquetes @angular/* deben compartir versión mayor y menor. Revisa con npm ls @angular/core y corrige con ng update, nunca editando versiones a mano en el package.json
Todo se rompe después de un npm install --force o --legacy-peer-deps Se han silenciado conflictos reales de peer dependencies: npm ha instalado un árbol que ninguna librería declara como compatible Nunca uses --force para «arreglar» una instalación. Lee el conflicto que npm reporta y resuélvelo actualizando el paquete que va retrasado. Si una librería no admite tu Angular, la decisión es esperar, sustituirla o contribuir a ella
La actualización de versión mayor falla a mitad y deja el repositorio en un estado extraño Se han saltado versiones intermedias, o había cambios sin confirmar, o una dependencia de terceros no admite la versión destino Vuelve al estado anterior con git reset --hard, actualiza de una mayor en una mayor confirmando entre pasos, y sigue la lista concreta que genera update.angular.dev para tu par de versiones
'app-tarjeta' is not a known element El componente standalone no está en el array imports de quien lo usa (o, en código heredado, el módulo no lo exporta) Añádelo a imports del componente consumidor. Si es un elemento web ajeno a Angular, añade CUSTOM_ELEMENTS_SCHEMA al componente
La aplicación arranca en blanco al desplegar en un subdirectorio, con 404 de los ficheros JavaScript El <base href> apunta a la raíz pero la aplicación se sirve desde una subcarpeta Compila con ng build --base-href=/subcarpeta/ y configura el servidor para que devuelva el index.html en cualquier ruta desconocida (reescritura de SPA)
Cannot access 'X' before initialization en el arranque Dependencia circular entre módulos, típicamente a través de un barrel file Importa el fichero concreto en lugar del barril. Añade la regla import/no-cycle al linter para que el ciclo se detecte en el pull request
JavaScript heap out of memory durante el build El proceso de Node agota su memoria: proyecto muy grande, mapas de fuentes activos o una biblioteca que genera código en exceso Amplía el límite con NODE_OPTIONS=--max-old-space-size=8192 como medida inmediata, y después investiga qué ha crecido tanto con el análisis del bundle

2.15 Buenas y malas prácticas

Haz esto

  • Fija la versión de Node en un .nvmrc y usa la misma imagen base en la integración continua que en tu máquina.
  • Usa siempre el CLI local del proyecto (npx ng) y confirma el package-lock.json en el repositorio.
  • Ejecuta ng generate en lugar de copiar y pegar ficheros: las convenciones se mantienen solas y no hay erratas en los selectores.
  • Configura los valores por defecto de los schematics en angular.json (changeDetection: OnPush, estilo) para que nadie tenga que acordarse de las banderas.
  • Actualiza de una versión mayor en una versión mayor, con el repositorio limpio, confirmando entre pasos y revisando el diff de cada migración.
  • Define presupuestos y trátalos como una prueba más: si fallan, se investiga la causa, no se sube el umbral.
  • Organiza por funcionalidad desde el primer día y respeta la regla de dependencias features → shared y features → core.
  • Usa alias de rutas (@core/*, @shared/*) e importa el fichero concreto en lugar de un barril.
  • Activa strict y strictTemplates desde el primer commit: retrofitarlos en una aplicación grande cuesta semanas.
  • Escribe la configuración global en app.config.ts con funciones provide*, deja main.ts reducido al arranque y ejecuta --dry-run la primera vez que uses un schematic que no conozcas.

Evita esto

  • Depender de un CLI global antiguo mientras el proyecto va varias versiones por delante.
  • Ejecutar npm install --force o --legacy-peer-deps para silenciar un conflicto en lugar de resolverlo.
  • Saltar versiones mayores al actualizar, o hacerlo con cambios sin confirmar.
  • Editar a mano las versiones de @angular/* en el package.json: acabarás con paquetes descoordinados entre sí.
  • Poner secretos en environments/. Todo el bundle es público, sin excepciones.
  • Crear un SharedModule —o un shared/index.ts— que lo exporte todo y acabe importado en todas partes.
  • Escribir NgModule para código nuevo cuando standalone es el estándar y la interoperabilidad está resuelta.
  • Meter librerías en scripts de angular.json para «evitar problemas de tipos»: pierdes tipado y tree shaking a la vez.
  • Cargar media aplicación en el inicializador: cada llamada ahí retrasa el primer pintado de todos los usuarios.
  • Adoptar Nx, micro-frontends o una librería de estado «por si acaso», o copiar configuraciones de angular.json de internet sin comprobar antes qué builder usa tu proyecto.

2.16 Preguntas frecuentes

¿Angular sigue siendo pesado y lento como se decía hace años?
Esa reputación viene de la era anterior a Ivy, cuando el motor ViewEngine generaba mucho código y el tree shaking del framework era pobre. Desde la v9 el panorama es otro: las instrucciones de renderizado se importan una a una, el builder basado en esbuild produce artefactos ajustados y las aplicaciones pequeñas caben en unas pocas decenas de kilobytes comprimidos. Sigue siendo cierto que el coste fijo de arranque es mayor que el de Svelte o Preact, pero también que ese coste se amortiza: en una aplicación de doscientas pantallas, el peso del framework es una fracción minúscula del total, y ahí lo que manda es la carga diferida.
¿Tengo que aprender RxJS para usar Angular hoy?
Menos que antes, pero sí. Con señales puedes cubrir el estado síncrono de componentes y servicios sin tocar un operador, y esa es hoy la vía recomendada. Ahora bien, HttpClient devuelve observables, el router expone observables y todo lo que tiene dimensión temporal —debounce, cancelación, reintentos con retroceso, sondeo— se resuelve mejor con RxJS. Lo razonable es empezar por seis o siete operadores (map, filter, switchMap, catchError, debounceTime, takeUntilDestroyed) y ampliar cuando aparezca la necesidad. El capítulo 4 lo trata en detalle.
Tengo una aplicación con NgModule que funciona. ¿Merece la pena migrar?
Sí, pero sin prisa y sin dramatismo. El modelo de NgModule sigue soportado y no hay una fecha de retirada anunciada, así que nada se va a romper mañana. Dicho eso, las APIs nuevas del framework se diseñan pensando en standalone, y las tres migraciones oficiales hacen el noventa por ciento del trabajo automáticamente. La estrategia sensata es ejecutar la migración cuando toque una actualización de versión mayor, revisar el diff con calma y prohibir por convención escribir NgModule nuevos a partir de ese momento.
¿Cuál es la diferencia entre ng add y npm install?
npm install descarga el paquete y lo apunta en package.json: nada más. ng add hace eso y además ejecuta el schematic de instalación que el paquete publica, que suele modificar angular.json, añadir estilos globales, registrar proveedores en app.config.ts o crear ficheros de configuración. Para cualquier paquete que ofrezca integración con el CLI —Angular Material, el service worker, el renderizado en servidor, ESLint— usa siempre ng add; te ahorra una configuración manual que es fácil dejar a medias.
¿Qué es exactamente un builder y puedo escribir uno?
Un builder es la función que ejecuta un target del angular.json: recibe las opciones declaradas y realiza el trabajo (compilar, servir, probar, desplegar). Angular publica los suyos, pero la interfaz es pública y sí puedes escribir el tuyo, empaquetarlo como paquete de npm y referenciarlo en architect. Es lo que hacen los paquetes de despliegue y los que permiten inyectar configuración de webpack personalizada. En la práctica, la mayoría de los equipos nunca necesitan escribir uno: casi todo lo que se quiere hacer cabe en un script de npm antes o después del build.
Mi ng build tarda cinco minutos. ¿Por dónde empiezo?
Por comprobar tres cosas en este orden. Uno: qué builder usas; si sigues en el de webpack (:browser), migrar al de esbuild (:application) suele ser la mayor ganancia individual disponible. Dos: si la caché está activa y si persiste entre ejecuciones de CI (ng cache info). Tres: los estilos, porque importar un fichero de variables grande en cada componente multiplica el trabajo de Sass por el número de componentes. Solo después de eso tiene sentido plantearse dividir el proyecto o adoptar Nx.
¿Puedo usar Vite, webpack o Rollup directamente, sin el CLI de Angular?
Técnicamente sí, existen plugins de comunidad, pero no lo recomiendo salvo que tengas una razón muy concreta. El compilador de Angular no es un simple transpilador de TypeScript: analiza decoradores, comprueba tipos dentro de las plantillas, genera las funciones de renderizado y coordina las migraciones. Salirte del CLI significa reimplementar y mantener esa integración por tu cuenta, perder ng update y quedarte fuera del camino que el equipo de Angular prueba en cada versión. El coste aparece siempre en la siguiente actualización mayor.
¿Por qué el bundle crece tanto al añadir una librería de componentes?
Normalmente por una de dos razones. La primera es importar el paquete completo en lugar de los puntos de entrada concretos: muchas librerías publican subrutas precisamente para que solo entre lo que usas. La segunda es que la librería esté publicada en CommonJS en lugar de módulos ES; en ese caso el empaquetador no puede analizar estáticamente qué se usa y no poda nada. El CLI avisa de las dependencias CommonJS durante la compilación: no ignores ese aviso, es exactamente el síntoma de este problema.
¿Qué significa el prefijo ɵ que aparece en algunos símbolos de Angular?
Es la letra griega theta y marca API privada del framework: código que Angular necesita exponer por razones técnicas —el compilador genera llamadas a esas funciones— pero que no forma parte del contrato público. Eso quiere decir que puede cambiar o desaparecer en cualquier versión, incluso en una menor, sin figurar como cambio incompatible. Verlo en las pilas de errores o en el código generado es completamente normal; escribirlo tú en tu código fuente es una garantía de que la próxima actualización te va a romper.
¿Debo confirmar el package-lock.json en el repositorio?
Siempre, sin excepción. Es el fichero que hace reproducible una instalación: fija cada dependencia transitiva a una versión y a un hash concretos. Sin él, dos desarrolladores que instalen el mismo día pueden obtener árboles distintos, y el clásico «en mi máquina funciona» se vuelve inevitable. En la integración continua usa npm ci en lugar de npm install: el primero instala exactamente lo que dice el lock y falla si el package.json se ha desincronizado, que es justo el comportamiento que quieres en una pipeline.
¿Cómo elijo entre ng serve y compilar y servir con un servidor estático?
ng serve es para desarrollar: compila en memoria, recarga en caliente y aplica la configuración de desarrollo, sin optimizar. Nunca lo uses para servir nada real, ni siquiera una demostración interna, porque no está pensado como servidor de producción ni endurecido para ello. Para verificar cómo se comporta la compilación real, ejecuta ng build y sirve el contenido de dist/ con cualquier servidor estático configurando la reescritura hacia index.html; es la única forma de detectar antes del despliegue los problemas de base href, de rutas y de optimización.

2.17 Ejercicios

Nivel 1 · básico

2.1 Crea un proyecto nuevo con ng new usando SCSS, enrutado y sin renderizado en servidor. Antes de ejecutarlo de verdad, hazlo con --dry-run y anota la lista completa de ficheros que se van a crear. Después ábrelos uno a uno y escribe en una frase para qué sirve cada uno.

2.2 Genera con el CLI un componente, un servicio, un guard, un pipe y una interfaz respetando la estructura por funcionalidad de 2.11. Ejecuta ng version y comprueba si tu versión genera nombres con sufijo (.component.ts) o sin él.

2.3 Abre angular.json y localiza el builder de build, la configuración de producción, los presupuestos y el defaultConfiguration. Añade una configuración nueva llamada staging que optimice como producción pero conserve los mapas de fuentes, y compílala con ng build -c staging.

2.4 Configura los schematics del proyecto en angular.json para que todos los componentes se generen con OnPush y sin fichero de pruebas. Verifica con ng g c prueba --dry-run que el cambio surte efecto.

Nivel 2 · intermedio

2.5 Provoca deliberadamente un fallo de presupuesto: instala una librería pesada, impórtala en el componente raíz y compila. Interpreta el mensaje de error distinguiendo raw size de estimated transfer size, y resuélvelo moviendo la librería detrás de una ruta con carga diferida. Documenta el tamaño del bundle inicial antes y después.

2.6 Configura alias de rutas (@core/*, @shared/*, @features/*) en tsconfig.json, reorganiza el proyecto según el árbol de 2.11 y sustituye todas las importaciones relativas con más de dos niveles de ../.

2.7 Escribe un app.config.ts completo con enrutado (incluyendo withComponentInputBinding), cliente HTTP con withFetch y un interceptor propio, animaciones diferidas y un ErrorHandler personalizado que distinga errores HTTP de errores de carga de chunk.

2.8 Implementa configuración en tiempo de ejecución: un config.json en public/, un servicio que lo cargue y un inicializador de aplicación que espere a la carga. Comprueba en la pestaña de red que la petición ocurre antes del primer pintado.

2.9 Ejecuta ng build --stats-json y analiza el resultado con la herramienta que corresponda a tu builder. Identifica las tres dependencias más pesadas del bundle inicial y propón una acción concreta para cada una.

Nivel 3 · avanzado

2.10 Toma un proyecto pequeño basado en NgModule (puedes generarlo con una versión antigua del CLI o escribirlo a mano) y migra a standalone con las tres migraciones oficiales, confirmando en git entre paso y paso. Escribe un informe con lo que reescribió cada migración y lo que tuviste que arreglar a mano.

2.11 Crea deliberadamente una dependencia circular a través de un barrel file hasta reproducir un NG0203 o un Cannot access 'X' before initialization. Después elimina el barril, añade la regla import/no-cycle al linter y verifica que el ciclo se detecta ahora en el análisis estático.

2.12 Monta la compilación del proyecto en una pipeline de integración continua con npm ci, caché persistente de .angular/cache y presupuestos como criterio de fallo. Mide el tiempo de compilación con y sin caché y documenta la diferencia.

2.13 Compara la salida de ng build con el builder de webpack (:browser) y con el de esbuild (:application) sobre el mismo proyecto: tiempo de compilación, número de chunks y tamaño total. Explica a qué se deben las diferencias que observes.

Soluciones comentadas (2.3 y 2.8)

2.3 · Una configuración staging. La clave es entender que options es la base común y que cada entrada de configurations la sobrescribe parcialmente. Hay que tocar dos targets: el de build, que define la configuración, y el de serve, que la referencia.

"build": {
  "configurations": {
    "staging": {
      "budgets": [
        { "type": "initial", "maximumWarning": "700kB", "maximumError": "1.5MB" }
      ],
      "fileReplacements": [
        { "replace": "src/environments/environment.ts",
          "with":    "src/environments/environment.staging.ts" }
      ],
      "optimization": true,
      "outputHashing": "all",
      "sourceMap": { "scripts": true, "styles": true, "hidden": false }
    }
  }
},
"serve": {
  "configurations": {
    "staging": { "buildTarget": "gestor-tareas:build:staging" }
  }
}

Compruébalo con ng build -c staging y con la forma larga ng run gestor-tareas:build:staging. Los mapas de fuentes visibles permiten depurar en preproducción con las pilas de llamadas reales; en producción conviene hidden: true, que los genera pero no los referencia desde el bundle, de modo que solo tu servicio de monitorización los usa.

2.8 · Configuración en tiempo de ejecución. Los tres puntos que suelen fallar son el orden de arranque, el tipado y qué ocurre si la petición falla.

// public/config.json  ← se copia sin procesar a dist/
// { "apiUrl": "https://api.empresa.com/v1", "entorno": "produccion",
//   "funcionesActivas": ["facturacion-v2"] }

// app.config.ts
providers: [
  provideHttpClient(withFetch()),
  // El inicializador se ejecuta ANTES de crear el componente raíz.
  // Angular espera a la promesa: nada se pinta hasta que resuelve.
  provideAppInitializer(() => inject(ConfiguracionService).cargar()),
],

// configuracion.service.ts (fragmento clave)
async cargar(): Promise<void> {
  try {
    const c = await firstValueFrom(
      this.http.get<ConfiguracionApp>('/config.json'),
    );
    this._config.set(c);
  } catch {
    // DECISIÓN DE DISEÑO: si la configuración no carga, la aplicación
    // no puede funcionar. Es preferible fallar de forma ruidosa y
    // explícita que arrancar con una apiUrl 'undefined' y producir
    // cien errores incomprensibles en cascada.
    throw new Error('No se pudo cargar /config.json');
  }
}

// Verificación: en la pestaña de red, /config.json debe aparecer
// ANTES de cualquier otra llamada a la API y antes del primer
// pintado. Si ves peticiones de negocio previas, algún servicio
// se está instanciando fuera del inicializador.

Nota de rendimiento: esta técnica añade una ida y vuelta a la red antes del primer pintado. Mantén el fichero pequeño, sírvelo desde el mismo dominio para evitar una resolución DNS adicional y cachéalo con una política corta que puedas invalidar en cada despliegue.

2.18 Resumen del capítulo

  • Angular se entiende por su historia. Los problemas de AngularJS —$scope, digest cycle y comprobación sucia proporcional al tamaño de la pantalla— explican la reescritura de 2016, y esta explica el ciclo semestral, las migraciones automáticas y la obsesión por el tooling.
  • Es un framework completo y opinado. Comparado honestamente, no compite con React sino con React más media docena de librerías. Su ventaja es la homogeneidad a escala; su desventaja, la ceremonia en proyectos pequeños.
  • Angular es un compilador. Con AOT por defecto, las plantillas se traducen a funciones de renderizado con dos fases, creación y actualización, y las instrucciones se importan una a una: por eso el bundle depende de qué usas, no de qué tiene el framework.
  • Ivy trajo la localidad: compilar un componente no requiere información global. De ahí salen la compilación incremental, el tree shaking real y las librerías precompiladas.
  • El incremental DOM no construye un árbol intermedio, a diferencia del virtual DOM: menos memoria, menos presión sobre el recolector de basura y código de renderizado podable, a cambio de menos flexibilidad en la plantilla.
  • El CLI es el centro de gravedad del proyecto: genera, compila, sirve, prueba y —lo más valioso— actualiza reescribiendo tu código con migraciones conscientes del árbol de sintaxis.
  • angular.json describe cómo se construye tu producto. Proyectos, targets, builders, configurations y presupuestos. Los presupuestos son la única defensa automática contra la degradación progresiva del bundle.
  • El arranque moderno es bootstrapApplication con un ApplicationConfig de funciones provide*: una lista plana, analizable por el empaquetador y reutilizable en pruebas y en renderizado en servidor.
  • Standalone es el estándar. Los NgModule mezclaban tres responsabilidades y añadían indirección; la migración es automática en tres pasos y la interoperabilidad entre ambos mundos es bidireccional.
  • Organiza por funcionalidad, no por tipo, con una regla de dependencias explícita, alias de rutas en lugar de barriles, y Nx solo cuando tengas varias aplicaciones o una integración continua insostenible.
  • En el frontend no hay secretos. Todo lo que compilas es público; los secretos viven en el backend, sin excepciones.

2.19 Recursos adicionales

Siguiente paso Ya sabes qué es Angular, cómo se compila, cómo se configura y cómo se organiza un proyecto para que sobreviva a varios años y a varios equipos. El capítulo 3 entra en la unidad básica de trabajo: el componente. Ciclo de vida, entradas y salidas basadas en señales, proyección de contenido, encapsulación de estilos y los patrones de composición que evitan que un componente de doscientas líneas se convierta en uno de dos mil.