Clean Architecture para aplicaciones Angular

· 9 min de lectura
Clean Architecture  para aplicaciones Angular

Arquitectura Frontend
Mucha gente parece rodar sus cabezas directamente hacia el lado del backend de las cosas una vez que escuchan Arquitectura. Un concepto comúnmente mal entendido es que el frontend no necesita arquitectura en absoluto o incluso si lo hace, no tiene mucho impacto. Vamos a discutir que todo esto es falso. 😱

Por qué es necesaria la arquitectura del frontend:


Piensa en cualquier edificio que no tenga arquitectura, todo se deja en manos de los constructores para que decidan qué hacer y cómo montar todo el edificio. Cuando la decisión se deja en manos de los constructores, puede que uno de ellos tenga experiencia en muros de piedra y opte por hacer un lado del edificio con el muro de piedra, mientras que el segundo constructor puede estar versado en la carpintería y decide hacer todo el muro con madera.

Ahora todo el edificio será un desastre debido a la inconsistencia, y sobre todo podría no ser capaz de sostener la estructura prevista para el edificio y finalmente se derrumbaría.

Lo mismo ocurre con las aplicaciones web. La totalidad de una aplicación depende de la filosofía de diseño del sistema. El arquitecto del sistema se asegura de que el producto final no sólo se consiga, sino también de que el sistema sea capaz de adaptarse a cualquier modificación que pueda producirse.

En cuanto a la arquitectura del frontend, se establece un estándar de proyecto en el que el código es comprobable, se garantiza la coherencia del código y se prevé un producto final.

Una de las arquitecturas más elegantes, adaptables, escalables y estructuradas que existen es la Arquitectura Limpia (clean architecture)

Clean Architecture:

La Arquitectura Limpia también está relacionada con el Diseño Dirigido al Dominio, toma varias características del DDD. En general la Arquitectura Limpia es una combinación de las siguientes arquitecturas, donde se resume la esencia de la arquitectura en una sola dando una arquitectura robusta para retener aplicaciones a gran escala.

La idea principal de la arquitectura limpia es mantener la lógica del negocio y la lógica de la aplicación como entidades separadas, de modo que los cambios en una no afecten necesariamente a la otra. Usando esta arquitectura hacemos que el dominio o las entidades sean completamente independientes de la capa de presentación, por lo que ningún cambio en la interfaz de usuario puede afectar al dominio o a cualquier entidad. Usando esta arquitectura como base tenemos toda la libertad de tener un sistema que es lo suficientemente flexible como para hacer frente a los cambios genéricos, los cambios de la UI, los cambios de la interfaz, e incluso los cambios de tecnología donde el núcleo de la aplicación sigue siendo el mismo y es completamente independiente de las capas de presentación, las bases de datos y la infraestructura.

El flujo de la dependencia con respecto a la Arquitectura Limpia es siempre desde fuera hacia dentro, es decir, desde la capa de presentación hacia la capa de dominio/negocio.

Discutiremos en detalle la implementación desde la capa de dominio hasta la capa de presentación y discutiremos la estructuración de carpetas para toda la aplicación.

En este ejemplo, vamos a ver una simple aplicación de usuario con el inicio de sesión de usuario, registro y obtener el perfil de usuario.

Estructura de carpetas:


Tras la creación de la aplicación, configuramos la estructura de carpetas de forma que podamos separar cada una de las capas por separado.

src/
├─ dominio/
├─ datos/
├─ presentación/

Dominio:


Esta carpeta puede contener todos los modelos de negocio, casos de uso, interactores y abstracciones del repositorio para la aplicación. Es una carpeta de relevancia pura para la lógica de negocio que SÓLO cambiará si se produce un cambio en los requisitos de negocio.

Datos:


Esta carpeta puede contener todos los procesadores de la aplicación, las implementaciones del repositorio, los modelos de fuentes de datos y los mapeadores.

Presentación:


Esta carpeta puede contener toda la interfaz de usuario de la aplicación. Todos los archivos de estilo y los componentes que se construyen para formar toda la experiencia de la UI. Todos los componentes utilizarán toda la lógica de los datos y el núcleo en esta capa.

Ten en cuenta que la implementación de la capa de presentación no está en el ámbito de este proyecto ni se incluye en el código.

Implementación:


Vamos a empezar con el código:

Configuración de los archivos base:
En la carpeta src, creamos una nueva carpeta para los archivos base.

Estos archivos base serán los encargados de configurar una plantilla para el caso de uso y el mapeador que utilizaremos más adelante.

Crea una nueva carpeta dentro de src llamada base y luego crea una nueva carpeta utils seguida de un nuevo archivo llamado mapper.ts dentro de src/base/utils.

Así que el directorio sería ahora:

src/
├─ base/
│  ├─ utils/
│  │  ├─ mapper.ts

En el archivo mapper.ts crea una clase que creará el mapeo To y From

export abstract class Mapper<I, O> {
    abstract mapFrom(param: I): O;
    abstract mapTo(param: O): I;
}

src/base/utils/mapper.ts

Crea una carpeta llamada domain dentro de src y luego crear una carpeta llamada base en src/domain y luego crear un archivo titulado use-case.ts. Todos nuestros casos de uso tendrán una dependencia de este archivo base usecase.ts.

import { Observable } from 'rxjs';
export interface UseCase<S, T> {
    execute(params: S): Observable<T>;
}

src/dominio/base/caso de uso.ts

Ahora crea una carpeta models dentro de la carpeta src/domain y añade el modelo para el usuario que servirá como la lógica de negocio principal para la existencia del usuario.

La carpeta models contendrá todos los modelos para la lógica de negocio. Crea un archivo user.model.ts dentro de la carpeta domain/models recién creada. src/domain/models/user.model.ts

En el archivo user model agrega todos los atributos del usuario desde la perspectiva general. Esto no tiene que depender de ninguna API y de los datos que puedas estar gestionando para las peticiones http, sino únicamente de lo que el negocio demande del objeto usuario.

Para nuestro ejemplo iremos con lo siguiente:

export interface UserModel {
    id: string;
    fullName: string;
    username: string;
    email?: string;
    phoneNum: string;
    createdAt?: Date;
    profilePicture: string;
    activationStatus: boolean;
}

src/dominio/modelos/usuario.modelo.ts

A continuación, vamos a crear otra carpeta para manejar los repositorios por lo que la llamaremos repositorios y crearemos un archivo llamado user.repository.ts.

En este repositorio, crearemos una clase abstracta que definirá todas las acciones que se realicen con el modelo de usuario. Implementaremos el inicio de sesión, el registro y la activación del usuario para la cuenta.

import { Observable } from 'rxjs';
import { UserModel } from '../models/user.model';
export abstract class UserRepository {
    abstract login(params: {username: string, password: string}): Observable<UserModel>;
    abstract register(params: {phoneNum: string, password: string}): Observable<UserModel>;
    abstract getUserProfile(): Observable<UserModel>;
}

src/dominio/repositorios/usuario.repositorio.ts

Por último añadiremos los casos de uso de nuestra aplicación. Cada uno de los casos de uso tendrá un archivo separado para que sea fácil de gestionar y mantener todas las acciones independientes para que, más tarde, si hay algún cambio necesario otros casos de uso no se vean afectados.

Creamos una carpeta con el nombre de casos de uso dentro de la carpeta del dominio y creamos archivos para cada uno de los casos de uso de la siguiente manera:

import { Observable } from 'rxjs';
import { UseCase } from '../base/use-case';
import { UserModel } from '../models/user.model';
import { UserRepository } from '../repositories/user.repository';
export class UserLoginUseCase implements UseCase<{ username: string; password: string }, UserModel> {
    constructor(private userRepository: UserRepository) { }
    execute(
       params: { username: string, password: string },
    ): Observable<UserModel> {
        return this.userRepository.login(params);
    }
}

src/dominio/usecases/user-login.usecase.ts

Observa que el nombre del archivo incluye el sufijo usecase para que quede claro que el archivo respectivo es usecase.

import { Observable } from 'rxjs';
import { UseCase } from '../base/use-case';
import { UserModel } from '../models/user.model';
import { UserRepository } from '../repositories/user.repository';
export class UserRegisterUseCase implements UseCase<{ phoneNum: string; password: string }, UserModel> {
    constructor(private userRepository: UserRepository) { }
    execute(
        params: { phoneNum: string; password: string },
    ): Observable<UserModel> {
        return this.userRepository.register(params);
    }
}

src/dominio/usecases/user-register.usecase.ts

import { Observable } from 'rxjs';
import { UseCase } from '../base/use-case';
import { UserModel } from '../models/user.model';
import { UserRepository } from '../repositories/user.repository';
export class GetUserProfileUseCase implements UseCase<void, UserModel> {
    constructor(private userRepository: UserRepository) { }
    execute(): Observable<UserModel> {
        return this.userRepository.getUserProfile();
    }
}

src/dominio/usecases/get-user-profile.usecase.ts

Con esto, ya hemos terminado en la carpeta del dominio.

Así es como debería verse la estructura hasta ahora:

src/
├─ base/
│  ├─ mapper.ts
├─ domain/
│  ├─ base/
│  |  ├─ use-case.ts
│  ├─ models/
│  │  ├─ user.model.ts
│  ├─ repositories/
│  │  ├─ user.repository.ts
│  ├─ usecases/
│  │  ├─ user-login.usecase.ts
│  │  ├─ user-register.usecase.ts
│  │  ├─ get-user-profile.usecase.ts

Ahora hacia la carpeta de datos.


Crearemos una carpeta de repositorios dentro de la carpeta de datos. En esta carpeta, crearemos otra carpeta para el usuario que nos conectará con la API para los casos de uso que necesitemos realizar.

Primero, vamos a crear una entidad para el Usuario. La entidad del usuario será según la respuesta que reciba de la base de datos a través de la API. Podría ser la misma que su modelo, pero en algunos casos, puede diferir del modelo.

Creando el archivo de entidad en src/data/respositories/user/entities

export interface UserEntity {
    id: string;
    name: string;
    userName: string;
    phoneNumber: string;
    userPicture: string;
    activationStatus: boolean;
}

src/data/repositories/user/entities/user-entity.ts

Ahora vamos a crear un mapper para la implementación. El mapeo mapeará las propiedades de UserModel a UserEntity y viceversa.

import { Mapper } from 'src/base/mapper';
import { UserModel } from 'src/domain/models/user.model';
import { UserEntity } from '../entities/user-entity';
export class UserImplementationRepositoryMapper extends Mapper<UserEntity, UserModel> {
    mapFrom(param: UserEntity): UserModel {
        return {
            id: param.id,
            fullName: param.name,
            username: param.userName,
            phoneNum: param.phoneNumber,
            profilePicture: param.userPicture,
            activationStatus: param.activationStatus
        };
    }
    mapTo(param: UserModel): UserEntity {
        return {
            id: param.id,
            name: param.fullName,
            userName: param.username,
            phoneNumber: param.phoneNum,
            userPicture: param.profilePicture,
            activationStatus: param.activationStatus
        }
    }
}

src/data/repositorios/user/mappers/user-repository.mapper.ts

Con todo esto configurado vamos a pasar al repositorio de implementación donde ejecutamos los casos de uso reales. Este repositorio de implementación de usuario ejecutará todos los casos de uso indicados para el usuario.

Creando el user-implementation.repository.ts

import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { UserEntity } from './entities/user-entity';
import { UserImplementationRepositoryMapper } from './mappers/user-repository.mapper';
import { UserRepository } from 'src/domain/repositories/user.repository';
import { UserModel } from 'src/domain/models/user.model';
@Injectable({
    providedIn: 'root',
})
export class UserImplementationRepository extends UserRepository {
    userMapper = new UserImplementationRepositoryMapper();
    constructor(private http: HttpClient) {
        super();
    }
    login(params: {username: string, password: string}): Observable<UserModel> {
        return this.http
            .post<UserEntity>('https://example.com/login', {params})
            .pipe(map(this.userMapper.mapFrom));
    }
    register(params: {phoneNum: string, password: string}): Observable<UserModel> {
       return this.http
            .post<UserEntity>('https://example.com/register', {params})
            .pipe(map(this.userMapper.mapFrom));
    }
    getUserProfile(): Observable<UserModel>{
        return this.http.get<UserEntity>('https://example.com/user').pipe(
            map(this.userMapper.mapFrom));
    }
}

src/data/repositories/user/user-implementation.repository.ts

Con esto, toda la configuración de la implementación es ahora el momento de proporcionar todos los casos de uso al proveedor del módulo de datos.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { UserRepository } from 'src/domain/repositories/user.repository';
import { UserLoginUseCase } from 'src/domain/usecases/user-login.usecase';
import { UserRegisterUseCase } from 'src/domain/usecases/user-register.usecase';
import { GetUserProfileUseCase } from 'src/domain/usecases/get-user-profile.usecase';
import { UserImplementationRepository } from './repositories/user/user-implementation.repository';
const userLoginUseCaseFactory = 
(userRepo: UserRepository) => new UserLoginUseCase(userRepo);
export const userLoginUseCaseProvider = {
    provide: UserLoginUseCase,
    useFactory: userLoginUseCaseFactory,
    deps: [UserRepository],
};
const userRegisterUseCaseFactory = 
(userRepo: UserRepository) => new UserRegisterUseCase(userRepo);
export const userRegisterUseCaseProvider = {
    provide: UserRegisterUseCase,
    useFactory: userRegisterUseCaseFactory,
    deps: [UserRepository],
};
const getUserProfileUseCaseFactory = 
(userRepo: UserRepository) => new GetUserProfileUseCase(userRepo);
export const getUserProfileUseCaseProvider = {
    provide: GetUserProfileUseCase,
    useFactory: getUserProfileUseCaseFactory,
    deps: [UserRepository],
};
@NgModule({
    providers: [
        userLoginUseCaseProvider,
        userRegisterUseCaseProvider,
        getUserProfileUseCaseProvider,
        { provide: UserRepository, useClass: UserImplementationRepository },
    ],
    imports: [
        CommonModule,
        HttpClientModule,
    ],
})
export class DataModule { }

src/data/data.module.ts

Con todo esto hecho ahora sólo queda ejecutar el usecase que ejecutará el repositorio de implementación.

Para ello en la carpeta de presentación crea tus componentes separados y en el constructor importa los usecases según el componente y utiliza la función execute y luego suscríbete o llámalo como llamarías a una función normal de la API.

La estructura final de la carpeta sería muy similar a esta:

c/
├─ base/
│  ├─ mapper.ts
├─ domain/
│  ├─ base/
│  |  ├─ use-case.ts
│  ├─ models/
│  │  ├─ user.model.ts
│  ├─ repositories/
│  │  ├─ user.repository.ts
│  ├─ usecases/
│  │  ├─ user-login.usecase.ts
│  │  ├─ user-register.usecase.ts
│  │  ├─ get-user-profile.usecase.ts
├─ data/
│  ├─ respositories/
│  │  ├─ user/
│  │  │  ├─ entities/
│  │  │  │  ├─ user-entity.ts
│  │  │  ├─ mappers/
│  │  │  │  ├─ user-repository.mapper.ts
│  │  │  ├─ user-implementation.repository.ts
│  ├─ data.module.ts
├─ presentation/
│  ├─ your_structure_for_components

Hemos podido aprender que la idea de esta arquitectura es permitir que la parte central, que consiste en la lógica de negocio completa y las entidades de la aplicación, sea lo suficientemente adaptable y flexible como para hacer frente a los cambios de tecnología e interfaces. Además, el núcleo de la aplicación sigue siendo el mismo e independiente de las capas de presentación, las infraestructuras y las bases de datos.

En estas tecnologías tan rápidas, los frameworks de JavaScript, el framework web, la base de datos y las partes externas se vuelven absolutas o se actualizan, utilizando esta arquitectura limpia se pueden reemplazar estos elementos con un mínimo esfuerzo. Esta arquitectura ahorra mucho tiempo porque el núcleo de la lógica de negocio de la aplicación y la entidad son independientes del marco, las presentaciones, las bases de datos y las partes externas. Por lo tanto, la arquitectura es sostenible y se acopla libremente a la lógica del negocio y la entidad principal con la capa de presentación o el marco.

Repositorio

Fuente