# Arquitectura y buenas prácticas para una aplicación Angular

Última modificación  

Español | English

En este artículo encontrarás el planteamiento de una posible evolución de los conceptos de arquitectura de una aplicación Angular que podemos extraer de la guía de estilo de referencia oficial de Angular (opens new window). Para ello he definido una serie de pautas y buenas prácticas a la hora de planificar y estructurar nuestra aplicación con el objectivo de hacerla escalable.

La escalabilidad en una aplicación Angular implica soportar el aumento del tamaño de los datos cargados en la misma, lo que aumenta la complejidad y el tamaño del proyecto y generalmente es seguido de tiempos de carga más largos.

# Estructura de la aplicación

Seguiremos una estructura de proyecto orientada a módulos. Con dicho enfoque, oficialmente recomendado, los módulos son claramente visibles en el árbol de carpetas como directorios separados y cada módulo contiene todos los archivos que están relacionados con el módulo en cuestión.

app/
|- core/
   |- core.module.ts
   |- services/
      |- auth.service.ts
   |- core-routing.module.ts
   |- core.module.ts
   |- index.ts
|- feature1/
   |- components/
      |- component1/
         |- ...
         |- component1.component.ts
      |- component2/
         |- ...
      |- shared/
         |- ...
   |- models/
      |- ...
   |- services/
      |- ...
   |- feature1-routing.module.ts
   |- feature1.module.ts
   |- index.ts
|- feature2/
   |- ...
|- feature3/
   |- ...
|- shared/
   |- components/
      |- component1/
         |- ...
         |- component1.component.ts
      |- component2/
         |- ...
   |- models/
   |- pipes/
      |- pipe1.pipe.ts
   |- index.ts
   |- shared.module.ts
|- app-component.ts
|- app.module.ts

Como podemos apreciar, existen tres módulos principales en el proyecto:

  • AppModule: es el módulo principal de la aplicación, responsable de su arranque y de la combinación de otros módulos.
  • CoreModule: incluye las funcionalidades básicas de la aplicación, en su mayoría servicios globales, que se utilizarán en toda la aplicación a nivel global. No debe ser importado por los módulos de funcionalidades de la aplicación.
  • SharedModule: es normalmente un conjunto de componentes o servicios que se reutilizarán en otros módulos de la aplicación, pero que no son aplicados globalmente en la aplicación. Puede ser importado por los módulos de funcionalidades.

El tercero de estos módulos se engloba en los denominados módulos de funcionalidades de la aplicación (opens new window). Dichos módulos estarán aislados entre si y se ubicarán en directorios específicos bajo el directorio raíz de la aplicación.

Los módulos de funcionalidades se clasifican en seis tipos (opens new window) con el objetivo de separar las responsabilidades en:

  • Dominio: ofrece una experiencia de usuario dedicada a un dominio de aplicación en particular, como editar un cliente o realizar un pedido
  • Enrutador: es el módulo de dominio que actúa como componente principal de la funcionalidad y cuyo objetivo es el de encaminar la navegación del usuario por la funcionalidad. Por definición, todos los módulos cargados de forma diferida (lazy loading) son módulos de funcionalidades enrutados.
  • Enrutamiento: proporciona la configuración de enrutamiento para otro módulo y separa las preocupaciones de enrutamiento de su módulo complementario.
  • Servicio: proporciona servicios de utilidad tales como acceso a datos y mensajería.
  • Complemento: hace que los componentes, directivas y demás artilugios estén disponibles para los módulos externos. Muchas bibliotecas de componentes de UI de terceros son módulos de complementos (widgets).
  • Compartido: permite reutilizar piezas de la aplicación como directivas, transformadores (pipes) y componentes. Es al módulo que conmunmente llamamos SharedModule.

Esta estructura permite la separación de responsabilidades de una manera clara, además de ser el punto de partida para la implementación de la carga diferida de los contenidos de la aplicación, paso fundamental para la preparación de una arquitectura escalable.

# AppModule

  • Este módulo ocupa la raíz de la carpeta de la aplicación y deberá contener exclusivamente lo más elemental.
  • Su función es simplemente arrancar la aplicación Angular y proporcionar la salida de la ruta raíz (router-outlet). Este enfoque también deja abierta la posibilidad de ejecutar múltiples aplicaciones Angular independientes a través de la misma URL base.
  • Importará los módulos CoreModule y SharedModule.

# CoreModule

  • Su objetivo es recopilar todos los servicios que tienen que tener una única instancia en la aplicación (servicios singleton) en un sólo módulo. Es el caso por ejemplo del servicio de autenticación o el servicio de usuario.
  • El CoreModule será importado únicamente en el módulo AppModule con el objetivo de reducir la complejidad de dicho módulo y enfatizar su papel como simple orquestador de la aplicación.

¿Cómo asegurarnos de que CoreModule sólo sea importado desde el AppModule? Controlándolo a través de su constructor.

  constructor (@Optional() @SkipSelf() parentModule?: CoreModule) {
    if (parentModule) {
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only.');
    }
  }

core.module.ts

  • Desde Angular 6, la forma preferida de crear un servicio singleton es indicando en el propio servicio, que debe ser proporcionado en la raíz de la aplicación. Para ello, se debe configurar la propiedad providedIn como root en el decorador @Injectable del servicio. De este modo, no es necesario indicar explícitamente en la propiedad providers del decorador NgModule de CoreModule nuestros servicios singleton.
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
}

auth.service.ts

CONSEJO · Podemos potenciar la reutilización de los servicios y funcionalidades de CoreModule en otras aplicaciones creando un nuevo directorio core en la raíz de la aplicación y moviendo cada funcionalidad de CoreModule a un nuevo módulo.

# SharedModule

  • Todos los componentes, servicios y pipelines compartidos deben ser implementados en este módulo.
  • No importará ni inyectará servicios u otras características del CoreModule u otras características en sus constructores.
  • Sí podrá definir servicios que no deban ser persistentes.
  • Sus componentes deberán recibir todos los datos a través de atributos en el modelo del componente que los utiliza. Todo esto se resume en el hecho de que SharedModule no tiene ninguna dependencia del resto de nuestra aplicación.
  • Es el módulo en el por ejemplo deberemos importar y reexportar los componentes de Angular Material y los módulos CommonModule, FormsModule y FlexLayoutModule.

CONSEJO · Podemos potenciar la reutilización de componentes de interfaz de SharedModule en otras aplicaciones creando un nuevo directorio ui en el raíz de la aplicación y moviendo cada grupo de componentes de SharedModule a un nuevo módulo.

# Feature modules

  • Crearemos múltiples módulos de funcionalidades para cada característica independiente de nuestra aplicación.
  • Los módulos de funcionalidades sólo deben importar servicios de CoreModule o SharedModule, así pues, si el módulo de funcionalidades feature1 necesita importar un servicio del módulo feature2, será necesario trasladar ese servicio al CoreModule.
  • Podrán contener sus propios artefactos (servicios, interfaces o modelos entre otros) siempre y cuando sean exclusivos para el propio módulo.
  • Permitirá asegurarnos de que los cambios en una característica no pueden afectar o interrumpir el resto de nuestra aplicación.

# Enrutamiento

El enrutador de Angular (Router (opens new window)) permite la navegación de una vista a la siguiente. En nuestro caso, no vamos a añadir rutas al componente raíz (AppComponent), sino que cuando la aplicación arranque, el CoreRoutingModule (declarado en app/core/core-routing.module.ts) se activará y cargará los componentes necesarios.

CoreModule maneja el enrutamiento raíz de la aplicación, por lo tanto en teoría, deberíamos ser capaces de importar un nuevo Core2Module en AppModule que podría representar una nueva versión de la aplicación y la implementación de esta aplicación no tendría ningún impacto en la aplicación que se ejecuta a través de CoreModule.

En CoreRotingModule se configurarán las rutas de entrada a los módulos de funcionalidades, dentro de los cuales se utilizarán las rutas declaradas en el propio módulo para navegar y mostrar el contenido dentro de la URL del propio módulo.

Los módulos de funcionalidades son módulos con enrutamiento propio y se crean para separar y organizar las diferentes áreas de la aplicación. Es por ello que se cargan solo una vez, ya sea desde el enrutamiento raíz o mediante la carga diferida.

Cuando el usuario navegue a un área protegida de la aplicación, el AuthGuardService del CoreModule comprobará las condiciones de canActivate y sólo cargará el módulo de manera diferida si el usuario está autenticado.

const routes: Routes = [
  {
    path: 'feature1',
    loadChildren: () => import('./feature1/feature1.module').then(m => m.Feature1Module),
    canActivate: [AuthGuardService]
  },
  {
    path: 'feature2',
    loadChildren: () => import('./feature2/feature2.module').then(m => m.Feature2Module),
    pathMatch: 'full'
  },
 ...

core-routing.module.ts

# Carga diferida

Para evitar posibles problemas de rendimiento en la carga de la aplicación, se hará uso del patrón carga diferida (Lazy-Loading (opens new window)), capacidad incorporada en Angular y que permite aplazar la carga de una parte particular de la aplicación hasta que sea realmente necesaria.

Basta con definir correctamente las rutas de los módulos, para que apunte a un archivo de módulo que será cargado sólo cuando sea realmente necesario.

const routes: Routes = [
  {
    ...
    loadChildren: () => import('./feature1/feature1.module').then(m => m.Feature1Module),
    ...
  },
 ...

core-routing.module.ts

Gracias a la estructura de módulos definida, nuestros módulos de funcionalidades podrán cargarse de forma diferida una vez que se haya inicializado la aplicación, lo que reducirá enormemente el tiempo de arranque de la aplicación. Además de ello, cuando la aplicación crezca y se añadan más módulos, el paquete de núcleo de la aplicación y por lo tanto su tiempo de inicio seguirán siendo los mismos.

# Flujo de datos y tipos de componentes

En Angular, el flujo de datos preferido es unidirecional donde los datos fluyen de arriba hacia abajo, mucho más fácil de mantener y seguir que el enlace bidireccional. Los datos pasan del componente padre principal al componente hijo secundario y del componente hijo a la plantilla.

La separación de responsabilidades de los componentes en niveles facilita su reutilización, mantenimiento y validación (pruebas unitarias).

Cuando sea necesario estructurar los componentes por niveles, debemos seguir las siguientes directrices.

  • Componentes de nivel N
    • Definen las partes de la aplicación que contienen parte de lógica, la comunicación con los servicios y causan efectos secundarios (como llamadas a servicios o actualizaciones de estado). Es el que inyecta el servicio y lo usa para obtener los datos.
    • Son los contenedores de los componentes a los que se transferirán los datos a través de sus atributos.
    • Desde el punto de vista del enrutamiento, podríamos considerarlos como los componentes de entrada de las rutas del propio módulo por lo que cada componente estaría asociado a una ruta del módulo.
    • Se deberá procurar que el número de componentes de este tipo sea el menor posible.
  • Componentes de nivel N+1
    • Son componentes con muy poca o ninguna lógica.
    • Todos los datos de entrada que necesitan se pasan a través de sus parámetros @Input.
    • Si el componente desea comunicarse hacia fuera, debe emitir un evento a través del atributo @Output.
    • Cuantos más componentes tengamos de este tipo más sencillo será el flujo de datos y más fácil será trabajar con él.
    • La estrategia de detección de cambios para estos componentes puede ajustarse a onPush (opens new window), que activará el proceso de detección de cambios para el componente sólo cuando se hayan modificado las propiedades de entrada. Es un método fácil de implementar y muy eficiente para optimizar aplicaciones Angular.

Si conseguimos encontrar el equilibrio entre un número adecuado de componentes y el principio de responsabilidad única, más sencillo será el flujo de datos y más fácil será trabajar con él.

# Administración de estado

Por regla general, el estado de la aplicación es compartido de manera transversal por toda su arquitectura y su información afecta a múltiples componentes e incluso pantallas a la vez. Es por ello que las operaciones sobre el estado suelen ser complejas en una aplicación Angular, donde además se pueden llegar a realizar con frecuencia.

Una de las maneras de abordar estos problemas es aprovechar el flujo de datos unidireccional a nivel de toda la aplicación. La comunidad Angular ha adoptado ampliamente el patrón de arquitectura Redux (opens new window), creado originalmente para aplicaciones React.

La idea detrás de Redux es que todo el estado de la aplicación se almacena en un único store (opens new window), el objeto que representa el estado actual de la aplicación. Un store es inmutable, no puede ser modificado, cada vez que un estado necesita ser cambiado, un nuevo objeto tiene que ser creado.

Un único punto de referencia para todo el estado de la aplicación simplifica el problema de la sincronización entre las diferentes partes de la aplicación. No tienes que buscar una información determinada en diferentes módulos o componentes, todo está disponible en el store.

Si quieres disponer de una solución de almacenamiento centralizada, sencilla y poco costosa, también te recomiendo que le eches un vistazo a este otro artículo que he creado sobre Gestionar el estado de una aplicación Angular usando RxJs BehaviorSubject para servicios de datos observables.

# Aliases para la aplicación y entorno

Usar un alias para las carpetas y entornos de nuestra aplicación nos permitirá realizar importaciones de una manera más limpia y consistente a lo largo de la evolución de nuestra aplicación.

El uso de un alias nos permitiría simplificar el modo de realizar nuestras importaciones:

import { AuthService } from '../../../.../core/services/auth.service';

[vs]

import { AuthService } from '@app/core';

IMPORTANTE: La siguiente técnica debe usarse con extrema precaución. Me he encontrado con errores de ejecución en alguna aplicación compleja por abusar de ella, debido al orden de las exportaciones de los artefactos en los ficheros index.ts (que veremos más adelante), así como por la inclusión en los mismos de ficheros que provocaban dependencias circulares (como algunos módulos).

En primer lugar debemos configurar las propiedades baseUrl y paths en nuestro archivo tsconfig.json de la siguiente manera (verás que estamos creado un alias para toda la aplicación y otro para los environments):

{
  ...
  "compilerOptions": {
    ...
    "baseUrl": "src",
    "paths": {
      "@app/*": ["app/*"],
      "@env/*": ["environments/*"]
    }
  }
}

tsconfig.json

A continuación, debemos agregar un archivo index.ts por cada paquete (se llamará como la carpeta física que lo contiene) que queramos importar y dentro del cual realizaremos la exportación de todas las entidades públicas que queramos incluir en dicho paquete.

export * from './core.module';
export * from './services/auth-guard.service';
export * from './services/auth.service';

app/core/index.ts

Este fichero se podría simplificar más aún si extrapolamos la creación de ficheros index.ts en el resto de carpetas de nuestros artefactos.

export * from './auth-guard.service';
export * from './auth.service';

app/core/services/index.ts

export * from './core.module';
export * from './services';

app/core/index.ts

En caso de que usando VSCode, este no reconozca nuestros alias al usarlos en los import, deberemos reiniciar nuestro servidor TypeScript. Para ello en VSCode pulsamos Cmd/Ctrl + Shift + P, escribimos Typescript: Restart TS Server y pulsamos Enter.

# Optimización mediante el análisis de paquetes

Una estrategia adicional a las ya mencionadas para incrementar la optimización de nuestra aplicación Angular consiste en realizar el análisis de paquetes npm con webpack.

# Sass

Soy partidario de establecer Sass (opens new window) como preprocesador de estilos CSS a utilizar. Además de las ventajas propias de Sass, éste nos permite integrar de una manera más efectiva la biblioteca oficial de componentes de Angular Material así como sus amplias capacidades de personalización.

Para ello, debemos indicarlo en la creación del proyecto:

ng new scss-project --style=scss

O establecerlo en los estilos predeterminados de un proyecto existente:

ng config schematics.@schematics/angular:component.styleext scss

@schematics/angular es el esquema predeterminado para Angular CLI Será necesario además renombrar la extensión de todas las hojas de estilo de css a scss.

# Compilación manual para producción

Dadas las limitaciones de compilación para producción ofrecidas de manera predeterminada por Angular CLI en el archivo package.json, debemos hacer una pequeña personalización en dicho archivo para poder disponer de la posibilidad de compilar la aplicación con opciones específicas para su integración en producción.

{
  ...
  "scripts": {
    ...
    "build:prod": "ng build --prod --build-optimizer",
    ...
  }
  ...
}

package.json

ng build (opens new window)

# Commits y changelog

Para tener una rápida visión general de cuáles son las nuevas características y correcciones de errores del proyecto, debemos hacer uso del archivo CHANGELOG.md.

Como agregar los cambios manualmente a dicho archivo sería una tarea tediosa, lo mejor es automatizar dicho proceso. En nuestro caso vamos a optar por la herramienta standard-version (opens new window).

Esta herramienta genera y actualiza automáticamente el archivo CHANGELOG.md en base a la especificación Conventional Commits (opens new window) (basado a su vez en la convención de Angular (opens new window)), que indica cómo estandarizar los commits de todas las modificaciones de nuestra aplicación y que determina correctamente la nueva versión de nuestro proyecto.

Conventional Commits define el tipo (obligatorio), el ámbito (opcional), seguido por el mensaje de confirmación. También es posible añadir cuerpo y pie de página (opcionales), ambos separados por una línea en blanco.

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

format

feat: allow provided config object to extend other configs

BREAKING CHANGE: `extends` key in config file is now used for extending other config files

example

refactor!: drop support for Node 6

example

docs: correct spelling of CHANGELOG

example

feat(lang): add Spanish language

example

fix: correct minor typos in code

see the issue for details

on typos fixed.

Reviewed-by: Z
Refs #133

example

Resumen de tipos

  • build: Cambio que afecta a la compilación del sistema o a dependencias externas
  • ci: Cambios en la configuración CI
  • docs: Cambios sólo en la documentación
  • feat: Una nueva funcionalidad
  • fix: La solución de un error
  • perf: Un cambio de código que mejora el rendimiento
  • refactor: Un cambio de código que no corrige un error ni añade una característica
  • style: Cambios que no afectan el significado del código (espacios en blanco, formato, un punto y coma que falta, etc)
  • test: Añadir pruebas que faltan o corregir pruebas existentes

# Angular Material

Angular Material (opens new window) es una librería de componentes web basada en Material Design y creada por el propio equipo de Angular. Aquí te explico ¿Por qué usar Angular y Material Design?

# Sidenav

El componente sidenav se usa para añadir contenido a los laterales de una aplicación a pantalla completa.

La estructura básica de uso del componente es la siguiente:

<mat-sidenav-container>
  <mat-sidenav>Sidenav content</mat-sidenav>
  <mat-sidenav-content>Main content</mat-sidenav-content>
</mat-sidenav-container>

# Detección del evento scroll

En el caso de que queramos detectar el evento scroll sobre el contenido de mat-sidenav-content, no debemos agregar dicho nodo a la plantilla ya que éste se generará automáticamente con la directiva cdkScrollable ya añadida a él. Si haces uso de mat-sidenav-content en tu plantilla, el objeto scrollable será undefined.

Es necesario además que se use el evento AfterViewInit en lugar de OnInit para evitar que el objeto scrollable sea undefined.

import { Component, ViewChild, AfterViewInit, NgZone } from '@angular/core';
import { MatSidenavContainer } from '@angular/material';

export class SidenavComponent implements AfterViewInit {

  @ViewChild(MatSidenavContainer) sidenavContainer: MatSidenavContainer;
  scrolledContent: boolean = false;

  constructor(private ngZone: NgZone) {}

  ngAfterViewInit() {
    this.sidenavContainer.scrollable.elementScrolled().subscribe(() =>
      {
        this.onSidenavContainerScroll();
      });
  }

  private onSidenavContainerScroll() {
    const scrollTop = this.sidenavContainer.scrollable.getElementRef().nativeElement.scrollTop || 0;
    if (scrollTop > 10 && this.scrolledContent === false) {
      this.ngZone.run( () => { this.scrolledContent = true; });
    } else if (scrollTop <= 10 && this.scrolledContent === true) {
      this.ngZone.run( () => { this.scrolledContent = false; });
    }
  }

}

sidevav.component.ts

NgZone (opens new window) nos permite ejecutar nuestro trabajo en la zona de Angular. En el ejemplo de sidevav.component.ts lo usamos para que un componente hijo sea notificado de que la variable scrolledContent ha cambiado.