Volver a la página principal
lunes 16 septiembre 2024
20

Arquitectura simple y limpia para tus proyectos en Angular

Conceptualmente, esta arquitectura podría aplicarse a cualquier framework frontend. Dado que tengo más conocimientos de Angular que de otros frameworks frontend, escribiré ejemplos en Angular cuando sea necesario.

Una arquitectura simple y limpia para tus proyectos en Angular

Conceptualmente, esta arquitectura podría aplicarse a cualquier framework frontend. Dado que tengo más conocimientos de Angular que de otros frameworks frontend, escribiré ejemplos en Angular cuando sea necesario.

Si deseas ir directamente a la arquitectura, haz clic aquí.

Para una arquitectura más avanzada con abstracción por capas, haz clic aquí.

Antes de entrar en los aspectos técnicos, preguntémonos:

¿Por qué enfocarse en la arquitectura frontend?

Aunque la implementación de una arquitectura backend es de sentido común, puede ser más difícil lograr una arquitectura limpia en el lado frontend.

Por varias razones.

Frameworks de Javascript

Históricamente, aparecían nuevos frameworks de Javascript cada día. Dominar un framework lleva tiempo y esfuerzo. Se necesita una experiencia sólida para conocer todas las capacidades de un framework y saber qué hacer ante un desafío programático.

Actualmente, los frameworks frontend parecen estar dominados por React, Angular y Vue desde hace algunos años. Esto trae estabilidad. Algunos patrones y conceptos arquitectónicos se comparten entre ellos.

Conceptos de frontend

El frontend utiliza muchos conceptos que necesitas conocer para codificar tu producto. Conceptos que no encuentras cuando trabajas en el backend.

Algunos de los desafíos y conceptos que un desarrollador frontend enfrenta todos los días son:

  • Gestión de estado para mejorar la fuente única de verdad;
  • Código asíncrono para no bloquear tu UI;
  • Combinar modularidad de UX y modularidad de código.

Arquitectura Backend vs Frontend

Este motivo está muy influenciado por mi propia percepción de la arquitectura TI y lo que veo en algunos proyectos a mi alrededor.

Se invierte menos esfuerzo en las arquitecturas frontend mientras que se invierte más en las arquitecturas backend.

Algunos dirían que la lógica de negocio y la seguridad son temas sensibles en el backend. Esas dos cosas deben ser sólidas como una roca, mientras que el frontend solo está ahí para "mostrar cosas del backend".

Es cierto que el frontend no es nada sin un backend fuerte. Pero lo contrario también es válido. Si tienes un backend fuerte, no querrás un frontend débil lleno de errores y difícil de mantener para comunicarse con tu backend.

Refactorizar el código frontend es mucho más difícil

Aviso: Principalmente uso IntelliJ IDEA.

Los IDE modernos tienen fuertes capacidades de refactorización para proyectos Java. Cambiar nombres de archivos y mover clases de un paquete a otro es fácil.

No puedo decir lo mismo para proyectos de Angular. Las importaciones permanecen sin cambios, NgModule no se actualiza, etc. Siempre tengo la sensación de que faltan algunos "enlaces inteligentes" entre archivos.

Al destacar la separación de responsabilidades con una buena arquitectura, el número de esas refactorizaciones disminuirá y será más fácil de procesar.

Principios básicos

Puede que estés o no de acuerdo con todas estas razones, pero seguramente coincides en que la arquitectura frontend merece tiempo y esfuerzo para estar bien organizada.

El objetivo principal de una arquitectura es reducir el acoplamiento entre los elementos que componen el software. Separación de responsabilidades, modularidad, abstracción, etc., son las principales ideas para lograrlo.

Desafortunadamente, los frameworks frontend sufren de dos problemas específicos que deben abordarse para alcanzar este objetivo:

  • La lógica de negocio tiende a dividirse entre múltiples componentes;
  • Existe el riesgo de que los datos estén contenidos en múltiples lugares, lo que aumenta la posibilidad de utilizar datos desactualizados o no sincronizados.

Dado esos objetivos y problemas, la siguiente arquitectura prioriza:

  • Componentes sin estado;
  • Fuente única de verdad;
  • Separación de responsabilidades.

La arquitectura SCA

Necesitamos un nombre llamativo. Llamemos a esta arquitectura la arquitectura SCA: la arquitectura Angular Simple y Limpia.

Vamos a profundizar en el gráfico:

La arquitectura se compone de 4 grandes partes:

  • Componentes;
  • Dominio;
  • Store (almacenamiento);
  • Infraestructura (en la mayoría de los casos, los clientes).

Dependiendo de tu proyecto y casos de uso, también puedes utilizar el almacenamiento local o el almacenamiento de sesión. Estos pertenecen a la infraestructura.

El store también podría clasificarse como una capa de infraestructura. Pero dado que merece especial atención, mantengámoslo como una parte separada.

Dominio

Este es el centro de nuestra aplicación. Está compuesto por servicios. Puede ser un solo servicio simple o múltiples servicios separados. La capa de dominio debe representar los casos de uso, por lo que necesitas organizarlos de la manera más lógica posible.

El dominio expone métodos que son acciones disponibles para los componentes. Esas acciones ejecutan lógica de negocio, usan la infraestructura y/o modifican el estado del store.

El dominio también expone selectores que son observables que seleccionan datos del store. Se pueden aplicar operadores de RxJs a esta selección, pero el resultado final siempre debe basarse únicamente en el valor del store. Se emiten cada vez que se modifica la selección. Estos selectores expuestos tienen un significado en el negocio.

Ejemplo de selector:

userAge$ = this.store.select(state => state.userDetails.birthdayDate)
    .pipe(
        map(birthdayDate => getAge(birthdayDate))
    );

El dominio debe ser lo más independiente posible del framework utilizado. Deberíamos poder migrar la lógica del dominio desde el proyecto angular a cualquier otro proyecto TypeScript con muy pocos esfuerzos. Por supuesto, siempre estarán los decoradores @Injectable y @NgModule, pero entiendes la idea.

Componentes

Los componentes están ahí por dos razones:

  • Mostrar información de manera atractiva;
  • Captar las entradas del usuario.

Las entradas del usuario se interpretan y desencadenan una acción dedicada en la capa de dominio. Si es necesario, se pueden enviar datos adicionales como argumento de esta acción.

La información mostrada proviene de los selectores expuestos. Los observables se utilizan en la plantilla y no se debe aplicar lógica de negocio sobre ellos.

Estas dos responsabilidades hacen que los componentes no tengan estado, no se ejecuta lógica alguna sobre la información manejada por el componente.

Clientes e infraestructura

Las capas de infraestructura son dependencias necesarias para que la capa de dominio realice sus acciones. Pueden ser clientes web, comunicaciones con el almacenamiento local, o cualquier otra dependencia que pueda abstraerse de la capa de dominio.

La capa de infraestructura contiene servicios con código muy simple relacionado solo con el uso de la dependencia. Opcionalmente, puede contener mapeadores si es necesario.

Ejemplo de servicio de cliente:

@Injectable()
export class ItemClient {

  constructor(private httpClient: HttpClient){}

  postItem(item: Item): Observable<Item> {
    return this.httpClient.post<Item>(API_PATH, item);
  }
}

Store

El store se compone de:

  • El estado de la aplicación;
  • Algunos métodos reductor que pueden modificar el estado;
  • Algunos selectores, que son observables que emiten las modificaciones del estado.

Solo debe contener lógica relacionada con el store. No debe haber lógica de negocio ni llamadas a otra capa de infraestructura.

El store puede implementarse usando cualquier tecnología. Puedes usar una librería como NgRx o NGXS. Otra solución es crear tu propio store utilizando un BehaviorSubject.

Enfoquémonos en el store

Volvamos al store y cómo implementarlo. Al elegir el store que utilizarás, debes seleccionar uno que respete tu arquitectura. Debes asegurarte de que este store esté oculto detrás de una abstracción para no contaminar tu capa de dominio con código relacionado con el store.

NgRx podría ser tu elección, pero tal vez no sea la más adecuada.

El problema con NgRx

Aquí está la gestión del estado propuesta por NgRx:

NgRx viene con un concepto que rompe nuestras reglas arquitectónicas: los efectos. Al usar el concepto de efectos, ejecutas lógica de negocio en la capa del store y contactas directamente la capa de clientes. Terminas con lógica ejecutándose en dos lugares: la capa de dominio y la capa del store.

NgRx es genial, pero viene con su propia arquitectura, que no es compatible con nuestra arquitectura SCA. A menos que elimines el uso de efectos.

Otra solución podría ser crear un store personalizado simple.

Store personalizado

Estado, acciones, selectores y reductores, esos cuatro conceptos del store se pueden construir alrededor de un BehaviorSubject. Este tipo de observable es una gran ayuda para trabajar con datos.

Aquí tienes un ejemplo de un store personalizado:

import {BehaviorSubject, distinctUntilChanged, map, Observable} from "rxjs";

export class Store<T> {

  private state$: BehaviorSubject<T>;

  constructor(initialState: T) {
    this.state$ = new BehaviorSubject<T>(initialState);
  }

  /**
   * Este método proporciona un observable que representa una parte del estado.
   * @param selectFn define un método que selecciona una subparte U del estado T
   */
  public select<U>(selectFn: (state: T) => U): Observable<U> {
    return this.state$.asObservable().pipe(map(selectFn), distinctUntilChanged());
  }

  /**
   * Este método se utiliza para actualizar el estado.
   * @param reduceFn define un método que transforma el estado en otro estado
   */
  public update(reduceFn: (state: T) => T): void {
    this.state$.next(reduceFn(this.state$.getValue()));
  }
}

El método select será utilizado por los selectores. El método update reducirá el estado de acuerdo con la acción.

Pero no es suficiente, este store necesita ser instanciado y utilizado por un servicio:

@Injectable()
export class ItemStore {

  store = new Store<Item[]>([]);

  items$ = this.store.select(items => items);

  item$ = (itemId: number) =>
    this.store.select(items => items.find(item => item.id === itemId) as Item);

  initialize(initialItems: Item[]): void {
    this.store.update(_ => initialItems);
  }

  remove(id: number): void {
    this.store.update(state => state.filter(item => item.id !== id));
  }

  add(item: Item): void {
    this.store.update(state => [...state, item])
  }
}

El servicio expone selectores y acciones que tienen un significado para la capa de dominio. Entonces, está listo para ser utilizado por la capa de dominio.

Próximo paso

Si observamos el gráfico de la arquitectura SCA, notamos que las dependencias llevan desde los componentes hasta las capas de infraestructura. Esto significa que es posible inyectar un cliente en un componente.

Por supuesto, esto está mal, pero hay una solución para evitarlo: la inversión de dependencias.

¡Feliz codificación! 😊😊

Etiquetas:
angular
Compartir:
Creado por:
Author photo

Jorge García

Fullstack developer