Clean Architecture aplicado a un proyecto TS

Clean Architecture aplicado a un proyecto TS

La ingeniería de software es un arte en constante evolución, y aunque ya ha pasado más de medio siglo desde sus primeros pasos, seguimos aprendiendo y refinando nuestras técnicas. En este post, exploraremos los principios fundamentales de la arquitectura limpia, adaptados para TypeScript, basados en las enseñanzas del icónico libro Clean Code de Robert C. Martin.

Este no es un conjunto de reglas estrictas, ni un manual de estilo. En su lugar, es una guía para ayudarte a escribir código más legible, reutilizable y refactorizable en TypeScript. No todos los principios que exploraremos aquí deben ser seguidos al pie de la letra, y es probable que algunos generen debate. Son directrices, formuladas a lo largo de años de experiencia colectiva, que sirven como base sólida para evaluar la calidad del código que tú y tu equipo producen.

El viaje hacia una arquitectura de software madura, tan antiguo como la propia arquitectura en sí, aún está en sus primeras fases. Las reglas duras y rápidas tal vez lleguen algún día, pero por ahora, estas pautas actúan como una brújula para guiar nuestro camino.

Un último punto a tener en cuenta: dominar estos principios no te convertirá de inmediato en un mejor desarrollador. La práctica y la experiencia son esenciales, y cometer errores es parte del proceso. Recuerda que todo código comienza como un primer borrador, algo moldeable que va tomando forma con el tiempo. La clave está en refinarlo con la ayuda de tus compañeros de equipo. No te castigues por un primer intento imperfecto; mejor castiga el código cuando sea necesario.

Clean Architecture en el Frontend?

En el mundo del desarrollo frontend, a menudo escuchamos que los frontends existen únicamente para "pintar" las respuestas de las APIs. Sin embargo, esa perspectiva limitada no tiene en cuenta la creciente complejidad de las aplicaciones modernas. El frontend no es solo una capa para consumir datos; se ha convertido en un elemento vital que afecta directamente la escalabilidad, mantenibilidad y evolución de una aplicación a lo largo del tiempo. Aquí es donde entra Clean Architecture, un enfoque que, lejos de ser exclusivo del backend, puede transformar la forma en que estructuramos nuestras aplicaciones frontend.

¿Por qué aplicar Clean Architecture en el frontend?

Los frameworks frontend populares, como React, Angular y Vue, suelen integrar todo en su propio ecosistema. Esto puede hacer que el código se vuelva monolítico y difícil de mantener a medida que el proyecto crece. La premisa fundamental de Clean Architecture es separar las preocupaciones, creando capas independientes que permitan un desarrollo más ágil y menos dependiente de los detalles del framework o de la UI. Aunque algunos argumentan que los frontends simplemente gestionan la interacción con las APIs, un enfoque limpio y desacoplado ayuda a resolver problemas que van más allá de la simple integración de datos. Clean Architecture en el frontend promueve:

  • Independencia de Frameworks: Desacoplar la lógica de negocio de los detalles del framework utilizado. De este modo, si el equipo decide cambiar de framework en el futuro, las partes críticas de la aplicación pueden mantenerse intactas.
  • Escalabilidad y Mantenibilidad: Al estructurar el código en capas claramente definidas (como entidades, servicios y componentes), facilita la escalabilidad a medida que el proyecto crece y evita que las futuras modificaciones se conviertan en una pesadilla.
  • Testabilidad: Al separar la lógica de la UI y las API, se facilita la creación de pruebas unitarias y de integración, lo que mejora la calidad del código y la capacidad de detectar errores.
  • Flexibilidad: Permite integrar diferentes servicios y soluciones sin acoplarse a un único enfoque o solución tecnológica, lo que abre la puerta a futuras optimizaciones o cambios en los requisitos del proyecto sin reescribirlo desde cero.

Este artículo no pretende decir que debamos abandonar los frameworks o construir aplicaciones frontend desde cero, sino más bien ofrecer una estructura clara y flexible que permita a los desarrolladores tomar decisiones informadas sobre dónde usar los frameworks y cómo gestionar la evolución del código sin comprometer la calidad. Incluso en un entorno donde los frontends "solo consumen APIs", adoptar los principios de Clean Architecture en el frontend puede resultar en una experiencia de desarrollo más eficiente y menos propensa a problemas a medida que el proyecto crece.

Un resumen de en qué consiste Clean Architecture

La Clean Architecture aplicada al desarrollo frontend tiene como objetivo crear aplicaciones más escalables, mantenibles y testables. A menudo, los desarrolladores frontend se enfocan únicamente en consumir APIs, pero esta visión es demasiado simplista para proyectos complejos. Clean Architecture en frontend permite desacoplar las diferentes partes de la aplicación y ofrecer una estructura clara que facilita su evolución con el tiempo.

¿Cómo lograr el desacoplamiento en aplicaciones frontend?

Aunque las ideas innovadoras de los frameworks modernos (como React, Vue o Angular) son fundamentales, todas ellas giran en torno a un concepto común: las páginas web. HTML, CSS, imágenes, scripts y otros recursos externos son la base de cualquier aplicación frontend. Por lo tanto, al aplicar Clean Architecture, nos centramos en abstraer y separar las responsabilidades dentro de la aplicación, permitiendo que la UI, la lógica de negocio y la interacción con las APIs se manejen de manera independiente.

Existen cuatro abstracciones principales en una arquitectura limpia para frontend:

1. Entities (Entidades)

Las entidades representan los datos relevantes utilizados para renderizar o actualizar la aplicación. Su función principal es adaptar los datos (como respuestas JSON de APIs) y transformarlos en un formato adecuado para la aplicación. Este enfoque centraliza la gestión de datos y evita la repetición de lógica (como la formateación de precios) en varios componentes.

Ejemplo de entidad:

export const Product = ({
    id = -1,
    title = 'No title',
    description = 'No description',
    rating = -1,
    stock = -1,
    brand = 'No Brand defined',
    category = 'No category',
    image = 'No url for image',
    ...rest
}) => ({
    id,
    title,
    description,
    rating,
    stock,
    brand,
    category,
    price: { ...ProductPrice(rest) }
})

export const ProductPrice = ({
    price = -1,
    discountPercentage = 'No discount percentage'
}) => ({
    raw: price,
    discount: discountPercentage,
    formatted: price.toLocaleString('pt-br', { style: 'currency', currency: 'BRL' })
})

2. Services (Servicios)

Los servicios gestionan la comunicación externa, como realizar solicitudes HTTP para obtener o enviar datos (GET, POST, PUT, DELETE). Estos servicios deben ser sin estado y devolver promesas de entidades. Además, centralizan las llamadas a las APIs, lo que facilita la gestión de errores y cambios en el comportamiento de las peticiones.

Ejemplo de servicio:

import http from './http'
import { User } from '/entities/user'

export const getPersonList = async () => {
  const [users, photos, posts] = await Promise.all([
    http.get('/users'),
    http.get('/photos'),
    http.get('/posts')
  ])
  
  return users.map((user) => {
    const photo = photos.find((photo) => photo.id === user.id)
    const post = posts.find((post) => post.id === user.id)
    return User({ ...user, photo, post })
  })
}

3. Store (Almacenamiento)

El Store es responsable de gestionar el estado de la aplicación y la persistencia de los datos del lado del cliente. Es un lugar centralizado para almacenar y gestionar el estado, facilitando la comunicación entre los componentes. Además, los stores pueden usar servicios para realizar solicitudes de datos y almacenar los resultados.

4. Components (Componentes)

Los componentes son responsables de la presentación de la interfaz de usuario, la interacción del usuario y las actualizaciones de la vista. A pesar de que cada framework tiene sus propios componentes, el principio de Clean Architecture es mantener estos componentes desacoplados de la lógica de negocio y la gestión de los datos.

Los componentes deberían ser lo más simples posible, con un enfoque en renderizar y gestionar la interacción del usuario sin preocuparse por la lógica de negocio o las llamadas a las APIs.

Estructura de carpetas en un proyecto Vue con Clean Architecture

La estructura de carpetas es una parte clave de la implementación de Clean Architecture. Permite organizar el código de manera clara y modular. En un proyecto Vue, una estructura recomendada podría ser la siguiente:

src/
├── pages/
   └── home/
       ├── services/        # Servicios específicos para la página (por ejemplo, solicitudes HTTP)
       ├── entities/        # Entidades (modelos de datos)
       ├── use-cases/       # Casos de uso o lógica de negocio específica
       └── components/      # Componentes de la interfaz de usuario
└── shared/
    ├── utils/              # Utilidades compartidas
    ├── services/           # Servicios reutilizables
    ├── entities/           # Entidades comunes
    ├── use-cases/          # Casos de uso comunes
    └── components/         # Componentes compartidos

Cómo vamos a "encajar" todas estas piezas: la inyección de dependencias

La inyección de dependencias (DI) es un patrón de diseño que ayuda a desacoplar los componentes de una aplicación, permitiendo que las dependencias (como servicios o clases) se proporcionen a los objetos en lugar de ser creadas directamente por ellos. En lugar de que un objeto cree o gestione sus dependencias internamente, estas son "inyectadas" desde el exterior, facilitando la reutilización, la escalabilidad y la capacidad de realizar pruebas unitarias.

En términos más simples, la inyección de dependencias permite que los componentes de la aplicación no tengan que saber cómo crear sus dependencias, solo cómo usarlas. Esto se logra mediante un contenedor que gestiona la creación y la provisión de las dependencias necesarias a lo largo de la aplicación.

¿Cómo encaja la inyección de dependencias en una Clean Architecture para un frontend?

En una Clean Architecture, los componentes y las capas de la aplicación están desacoplados, lo que significa que cada capa solo interactúa con la capa que está directamente por encima o por debajo de ella. Las dependencias entre estas capas deben ser gestionadas de manera eficiente para evitar el acoplamiento fuerte, lo que puede dificultar la escalabilidad y el mantenimiento del código a largo plazo.

La inyección de dependencias se integra perfectamente en este enfoque, ya que permite que las dependencias se gestionen centralizadamente, facilitando la distribución de responsabilidades y asegurando que cada componente reciba solo lo que necesita sin tener que preocuparse por cómo obtenerlo.

Uso de un contenedor de dependencias: biblioteca Inversify

Inversify es una biblioteca de inyección de dependencias (DI) para aplicaciones JavaScript y TypeScript. Es una herramienta que facilita la gestión de las dependencias de una aplicación y mejora la modularidad, flexibilidad y testabilidad del código. En aplicaciones grandes y complejas, donde diferentes módulos o componentes dependen unos de otros, utilizar un contenedor de dependencias como Inversify puede hacer que el código sea mucho más mantenible y escalable.

¿Qué es un contenedor de dependencias?

Un contenedor de dependencias es un patrón de diseño que ayuda a gestionar la creación y la inyección de objetos o dependencias de manera centralizada. En lugar de crear instancias de objetos directamente en cada clase o componente que las necesita, un contenedor de dependencias proporciona las instancias necesarias cuando se requieren. Este patrón ayuda a desacoplar los componentes de la aplicación, lo que mejora la organización del código y facilita el mantenimiento y las pruebas.

¿Cómo funciona un contenedor de dependencias?

En términos sencillos, un contenedor de dependencias actúa como un almacén donde registramos las clases, servicios o repositorios y sus respectivas dependencias. Luego, cuando se necesiten estas dependencias, el contenedor se encarga de inyectarlas automáticamente en las clases que las soliciten.

En lugar de tener que gestionar las dependencias manualmente en cada clase o componente, el contenedor de dependencias se encarga de ello, proporcionando las instancias necesarias según sea requerido. Esto facilita el control sobre el ciclo de vida de los objetos y su inyección en las clases que lo necesitan.

¿Por qué necesitamos un contenedor de dependencias?

  • Desacoplamiento de componentes: el principal beneficio de usar un contenedor de dependencias es el desacoplamiento entre los componentes de la aplicación. Sin un contenedor, las clases tendrían que crear sus propias dependencias o gestionarlas explícitamente, lo que haría que su código estuviera fuertemente acoplado a esas dependencias. Con un contenedor, las clases solo se encargan de utilizar las dependencias que se les proporcionan, sin tener que saber cómo crearlas.
    Por ejemplo, si una clase necesita acceder a una API a través de un servicio, el servicio será inyectado en la clase en lugar de ser creado por la misma. Esto facilita los cambios y la evolución del código, porque las clases no necesitan modificar directamente la forma en que se obtienen las dependencias.
  • Facilidad para realizar pruebas unitarias: La inyección de dependencias facilita la pruebas unitarias. En lugar de crear dependencias dentro de las clases y objetos, podemos inyectar mocks o stubs de las dependencias durante las pruebas. Esto hace que las pruebas sean más aisladas y precisas, ya que podemos controlar las dependencias sin necesidad de interactuar con servicios reales o bases de datos.
  • Gestión del ciclo de vida de las dependencias: Un contenedor de dependencias se encarga de gestionar el ciclo de vida de las instancias, es decir, se asegura de que se cree una instancia solo cuando sea necesario y se reutilice cuando corresponda. Además, el contenedor puede configurar el ciclo de vida de los objetos, como si deben ser instancias únicas (Singleton) o si deben crearse por cada solicitud (transitorios). Esto permite un control más preciso sobre cómo se gestionan los recursos en la aplicación.
  • Modularidad y escalabilidad: Al utilizar un contenedor de dependencias, podemos construir aplicaciones modulares donde las dependencias pueden ser gestionadas y modificadas de manera centralizada. Esto facilita la escalabilidad de la aplicación, ya que los componentes pueden ser añadidos o modificados sin tener que realizar cambios extensivos en el código existente. Además, si necesitamos cambiar la implementación de una dependencia (como cambiar un servicio de API por otro), podemos hacerlo sin afectar el resto de la aplicación, ya que el contenedor se encargará de inyectar la nueva instancia.
  • Reutilización de componentes: Gracias al desacoplamiento y la inyección de dependencias, los componentes y servicios son más reutilizables. Una vez que definimos una clase o servicio, podemos inyectarlo en cualquier parte de la aplicación sin preocuparnos de su implementación interna. Esto aumenta la coherencia y reutilización del código.

¿Cómo se usa Inversify en la práctica?

En Inversify, el contenedor de dependencias se crea utilizando la clase Container. Una vez que el contenedor está creado, se registran las dependencias que queremos gestionar. Por ejemplo, podemos registrar un servicio de la siguiente manera:

import { Container } from "inversify";
import { IUserRepository } from "./interfaces";
import { UserRepository } from "./repositories";

// Creamos el contenedor
const container = new Container();

// Registramos la dependencia en el contenedor
container.bind<IUserRepository>("IUserRepository").to(UserRepository);

// Luego, podemos inyectar esta dependencia en cualquier clase que la necesite

En este ejemplo, el contenedor se encarga de crear una instancia de UserRepository cuando sea necesario y de inyectarla en cualquier clase que la solicite.

Ejemplo de inyección de dependencias simplificado

En este ejemplo sencillo, vamos a crear una pequeña aplicación donde tenemos un servicio que necesita acceder a un repositorio para obtener información. Utilizaremos un contenedor de dependencias para gestionar la creación y la inyección de las dependencias.

  1. Definir la interfaz del repositorio: Aquí definimos la interfaz que nuestro servicio va a utilizar.
// Definición de la interfaz del repositorio
interface IUserRepository {
  getUserById(id: number): string;
}
  1. Implementación del repositorio: Creamos la implementación concreta de la interfaz IUserRepository.
// Implementación concreta del repositorio
class UserRepository implements IUserRepository {
  getUserById(id: number): string {
    return `User with ID: ${id}`;
  }
}
  1. Servicio que utiliza el repositorio: Ahora creamos un servicio que necesita la dependencia del repositorio para funcionar.
// Servicio que depende del repositorio
class UserService {
  private userRepository: IUserRepository;

  // El repositorio es inyectado a través del constructor
  constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }

  public getUser(id: number): string {
    return this.userRepository.getUserById(id);
  }
}
  1. Configurar el contenedor de dependencias: Usamos un contenedor para gestionar la creación e inyección de las dependencias.
import { Container } from "inversify";

// Creamos un contenedor de dependencias
const container = new Container();

// Registramos las dependencias en el contenedor
container.bind<IUserRepository>("IUserRepository").to(UserRepository);
container.bind<UserService>("UserService").to(UserService);

// Obtener una instancia de UserService con la dependencia inyectada
const userService = container.get<UserService>("UserService");
console.log(userService.getUser(1)); // Salida: "User with ID: 1"
  1. Resultado: Ahora, al ejecutar el código, veremos que el servicio UserService recibe su dependencia UserRepository a través de la inyección, sin tener que crearla internamente. El contenedor gestiona todas las instancias de las dependencias.

Carpeta shared

En la carpeta shared de un proyecto frontend que sigue los principios de Clean Architecture, se incluyen aquellos módulos y servicios que son reutilizables y que no están estrictamente ligados a una funcionalidad o página específica, sino que pueden ser utilizados por diferentes partes del sistema. Esto ayuda a mantener un código limpio, modular y escalable. Aquí, agrupamos diversas herramientas que facilitan la comunicación con servicios externos, la gestión de errores y la configuración de utilidades comunes.

1. Instancia de Axios

La instancia de Axios es una configuración centralizada para manejar las solicitudes HTTP. Al mantenerla en el directorio shared, podemos reutilizarla en todas las páginas o componentes que necesiten realizar peticiones, sin tener que configurar Axios repetidamente en cada lugar.

Ejemplo de la configuración de Axios:

import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
import router from '@/router';

const axiosInstance: AxiosInstance = axios.create({
  // Configuración común, como baseURL o headers
});

axiosInstance.interceptors.response.use(
    (response: AxiosResponse) => {
        return response;
    },
    (error: AxiosError) => {
        if (error.response?.status === 401) {
            router.push({ name: 'Login' });
        }
        return Promise.reject(error);
    }
);

export default axiosInstance;

En este ejemplo, creamos una instancia de Axios y configuramos un interceptor de respuesta que, si el servidor responde con un error 401 (no autorizado), redirige al usuario a la página de login. Este archivo se puede importar en cualquier parte de la aplicación para realizar solicitudes HTTP sin tener que reconfigurar Axios cada vez.

2. Servicio HTTP

En shared también podemos tener un servicio HTTP centralizado que utiliza la instancia de Axios para realizar las solicitudes, manejar errores y devolver datos de manera estandarizada hacia nuestro backend.

Este servicio puede incluir lógica adicional, como el manejo de errores específicos y la validación de respuestas, evitando la duplicación de código y centralizando el comportamiento de las peticiones.

Ejemplo de como suelo implementar un servicio HTTP:

import axios, {
  AxiosResponse,
  AxiosRequestConfig,
  AxiosError,
} from 'axios';
import axiosInstance from './axiosInstance';
import { err, Result } from 'neverthrow';
import { HttpError } from './HttpError';
import { inject } from 'inversify-props';

type IHttpRequest = {
  url: string;
  config?: AxiosRequestConfig;
  data?: any;
}

type Parser<T, M> = {
  parseTo: (data: T) => Result<M, Error>
}

export class HttpService {
  private axiosService: typeof axiosInstance;

  constructor(@inject() axiosInstance: typeof axiosInstance) {
    this.axiosService = axiosInstance;
  }

  public async get<T, M>({ url, config }: IHttpRequest, parser: Parser<T, M>): Promise<Result<M, HttpError>> {
    try {
      const response = await this.axiosService.get<T>(url, config);
      return parser.parseTo(response.data);
    } catch (error: any) {
      return err(new HttpError(error.response?.status || 500, error.message));
    }
  }
  
  // Métodos POST, PUT, DELETE se pueden añadir de manera similar
}

Este servicio encapsula las solicitudes HTTP dentro de un único módulo, lo que permite que el manejo de errores y la transformación de respuestas estén centralizados. Además, mejora la reutilización del código y la coherencia en las interacciones con la API.

3. Gestión de Errores HTTP

Es importante tener un manejo estandarizado de los errores HTTP en toda la aplicación. Los errores pueden ser causados por problemas del cliente (códigos 4xx) o problemas del servidor (códigos 5xx), y se deben manejar de forma consistente.

Ejemplo de clase de error HTTP:

import { HttpStatusCode } from './HttpStatusCode';

export class HttpError extends Error {
  public status: number;

  constructor(status: number, message: string) {
    super(message);
    this.status = status;
    this.name = HttpStatusCode[status] || 'Unknown Error';
  }

  public isClientError(): boolean {
    return this.status >= 400 && this.status <= 499;
  }

  public isServerError(): boolean {
    return this.status >= 500 && this.status <= 599;
  }

  public static fromStatus(status: number, message: string): HttpError {
    return new this(status, message);
  }
}

Esta clase maneja el estado HTTP y permite diferenciar entre errores del cliente y del servidor, lo que facilita la gestión de errores en diferentes partes de la aplicación.

4. Exportación Unificada

En lugar de importar cada uno de estos módulos por separado en diversas partes de la aplicación, puedes unificarlos en un solo archivo dentro de la carpeta shared para facilitar su importación.

// src/shared/http/index.ts
export * from './HttpStatusCode';
export * from './HttpError';
export * from './HttpService';
export * from './axiosInstance';

Esto simplifica la gestión de dependencias y facilita la reutilización del código en todo el proyecto. Ahora, cuando necesites hacer uso de estas funcionalidades, solo necesitarás importar desde un solo archivo:

import { HttpService, HttpError } from '@/shared/http';

Implementación completa de un caso de uso, paso a paso

A continuación, vamos a detallar el flujo de un caso de uso en TypeScript aplicado a un sistema de gestión de matrículas a cursos de una plataforma de formación, siguiendo los principios de la Arquitectura Limpia. Usaremos como ejemplo el caso de uso GetExternalRegistrationsByYearAndStudentType, que permite obtener las matrículas externas para un año y tipo de estudiante específicos. Este ejemplo está dividido en distintas capas según los principios de la Arquitectura Limpia: Aplicación, Dominio, Infraestructura y Presentación.

1. Interfaz del Caso de Uso (Capa de Aplicación)

El caso de uso comienza con la definición de su interfaz. Esta interfaz establece el contrato que debe seguir cualquier implementación de este caso de uso.

Archivo: GetExternalRegistrationsByYearAndStudentType.ts

// Interfaz del caso de uso
export interface GetExternalRegistrationsByYearAndStudentType {
  execute(year: number, studentType: string): Promise<ExternalRegistration[] | Error>;
}

Explicación:

  • La interfaz GetExternalRegistrationsByYearAndStudentType define un método execute() que debe recibir dos parámetros:
    • year: el año para el cual se quieren obtener las matrículas.
    • studentType: el tipo de estudiante (por ejemplo, "nuevo", "recurrente").
  • El método debe devolver una promesa que resuelva con un arreglo de matrículas externas o con un error en caso de que algo falle.

2. Implementación del Caso de Uso (Capa de Aplicación)

La implementación concreta del caso de uso es donde se escribe la lógica que coordina las interacciones entre los distintos componentes del sistema.

Archivo: GetExternalRegistrationsByYearAndStudentTypeImpl.ts

import { injectable, inject } from "inversify";
import { ExternalRegistrationRepository } from "../domain/ExternalRegistrationRepository";
import { GetExternalRegistrationsByYearAndStudentType } from "./GetExternalRegistrationsByYearAndStudentType";

// Implementación concreta del caso de uso
@injectable()
export class GetExternalRegistrationsByYearAndStudentTypeImpl implements GetExternalRegistrationsByYearAndStudentType {
  private repository: ExternalRegistrationRepository;

  // Inyección de dependencias a través del constructor
  constructor(
    @inject("ExternalRegistrationRepository") repository: ExternalRegistrationRepository
  ) {
    this.repository = repository;
  }

  // Método execute que delega al repositorio
  public async execute(year: number, studentType: string): Promise<ExternalRegistration[] | Error> {
    try {
      const registrations = await this.repository.getRegistrationsByYearAndStudentType(year, studentType);
      return registrations;
    } catch (error) {
      return new Error("Error fetching external registrations");
    }
  }
}

Explicación:

  • La clase GetExternalRegistrationsByYearAndStudentTypeImpl implementa la interfaz GetExternalRegistrationsByYearAndStudentType.
  • A través de la inyección de dependencias (mediante @injectable() y @inject()), se recibe el repositorio ExternalRegistrationRepository, que es el encargado de interactuar con la base de datos o la API.
  • El método execute() simplemente delega la responsabilidad al repositorio llamando a su método getRegistrationsByYearAndStudentType(), que realiza la lógica específica de acceso a los datos.

3. Interfaz del Repositorio (Capa de Dominio)

El siguiente paso es la definición de la interfaz del repositorio, que establece los métodos que deben ser implementados por cualquier repositorio que maneje matrículas externas.

Archivo: ExternalRegistrationRepository.ts

export interface ExternalRegistrationRepository {
  getRegistrationsByYearAndStudentType(year: number, studentType: string): Promise<ExternalRegistration[]>;
}

Explicación:

  • Esta interfaz define el método getRegistrationsByYearAndStudentType(), que debe recibir un año y un tipo de estudiante, y devolver una promesa con las matrículas externas correspondientes.

4. Implementación del Repositorio (Capa de Infraestructura)

La implementación del repositorio es donde se realiza la interacción real con los datos. En este caso, el repositorio se comunica con una API externa para obtener los datos de las matrículas.

Archivo: ExternalRegistrationRepositoryImpl.ts


import { injectable, inject } from "inversify-props";
import { ExternalRegistrationRepository } from "../domain/ExternalRegistrationRepository";
import { ExternalRegistration } from "../domain/ExternalRegistration";
import { HttpService, HttpError } from "@/shared/http";
import { Result } from "neverthrow";

const externalRegistrationParser = {
  parseTo: (data: any): Result<ExternalRegistration[], Error> => {
    if (!Array.isArray(data)) {
      return Result.err(new Error("Expected an array"));
    }

    try {
      const registrations: ExternalRegistration[] = data.map((item) => ({
        id: item.id,
        year: item.year,
        studentType: item.studentType,
      }));

      return Result.ok(registrations);
    } catch (e) {
      return Result.err(new Error("Failed to parse external registrations"));
    }
  }
};

@injectable()
export class ExternalRegistrationRepositoryImpl implements ExternalRegistrationRepository {
  constructor(
    @inject() private readonly httpService: HttpService
  ) {}

  public async getRegistrationsByYearAndStudentType(
    year: number,
    studentType: string
  ): Promise<ExternalRegistration[]> {
    const result = await this.httpService.get<any, ExternalRegistration[]>(
      {
        url: `/external-registrations?year=${year}&studentType=${studentType}`
      },
      externalRegistrationParser
    );

    if (result.isOk()) {
      return result.value;
    } else {
      throw result.error;
    }
  }
}

Explicación:

  • La clase ExternalRegistrationRepositoryImpl implementa la interfaz ExternalRegistrationRepository y es responsable de obtener las matrículas externas mediante una solicitud HTTP utilizando Axios.
  • El método getRegistrationsByYearAndStudentType() hace la solicitud al backend, pasando el año y el tipo de estudiante como parámetros, y devuelve los datos en formato JSON.

5. Configuración de la Inyección de Dependencias

Para que el sistema funcione correctamente, debemos configurar la inyección de dependencias. Esto se realiza mediante un contenedor que maneja las instancias de las clases.

Archivo: di.ts

import { Container } from "inversify";
import { ExternalRegistrationRepository } from "./domain/ExternalRegistrationRepository";
import { ExternalRegistrationRepositoryImpl } from "./infrastructure/ExternalRegistrationRepositoryImpl";
import { GetExternalRegistrationsByYearAndStudentType } from "./application/GetExternalRegistrationsByYearAndStudentType";
import { GetExternalRegistrationsByYearAndStudentTypeImpl } from "./application/GetExternalRegistrationsByYearAndStudentTypeImpl";

// Configuración del contenedor DI
const container = new Container();
container.bind<ExternalRegistrationRepository>("ExternalRegistrationRepository").to(ExternalRegistrationRepositoryImpl).inSingletonScope();
container.bind<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType").to(GetExternalRegistrationsByYearAndStudentTypeImpl).inSingletonScope();

export { container };

Explicación:

  • En este archivo, usamos el contenedor Container de Inversify para registrar las dependencias.
  • Registramos ExternalRegistrationRepositoryImpl como la implementación de ExternalRegistrationRepository.
  • Registramos GetExternalRegistrationsByYearAndStudentTypeImpl como la implementación del caso de uso GetExternalRegistrationsByYearAndStudentType.

6. Uso en la Interfaz de Usuario (Capa de Presentación)

Finalmente, en la interfaz de usuario, que puede ser un componente de Vue.js, se utiliza el caso de uso para ejecutar la lógica cuando el usuario interactúa con la interfaz.

Archivo: CoursesCompletedInYear.vue

<template>
  <div>
    <button @click="getYearlyReport(2021, 'new')">Obtener listado</button>
    <ul>
      <li v-for="registration in registrations" :key="registration.id">{{ registration.courseName }}</li>
    </ul>
  </div>
</template>

<script lang="ts">
import { component } from 'vue-property-decorator';
import { container } from "@/di";
import { GetExternalRegistrationsByYearAndStudentType } from "@/application/GetExternalRegistrationsByYearAndStudentType";

@component
export default class CoursesCompletedInYear extends Vue {
  private registrations: any[] = [];

  // Método que ejecuta el caso de uso
  private async getYearlyReport(year: number, studentType: string) {
    const getExternalRegistrations = container.get<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType");
    const result = await getExternalRegistrations.execute(year, studentType);

    if (result instanceof Error) {
      console.error("Error:", result.message);
    } else {
      this.registrations = result;
    }
  }
}
</script>

Explicación:

  • El componente Vue.js CoursesCompletedInYear.vue contiene un botón que, cuando se hace clic, obtiene el listado de matrículas externas para un año y tipo de estudiante específicos.
  • El método getYearlyReport() obtiene el caso de uso GetExternalRegistrationsByYearAndStudentType del contenedor y lo ejecuta con los parámetros proporcionados.
  • La respuesta (éxito o error) se maneja y se muestra en la interfaz.

Flujo Completo de la Operación

  1. Vista (CoursesCompletedInYear.vue): El usuario selecciona un año y un tipo de estudiante y hace clic en "Obtener listado".
  2. Controlador (getYearlyReport): Obtiene el caso de uso desde el contenedor DI y lo ejecuta con los parámetros proporcionados.
  3. Caso de Uso (GetExternalRegistrationsByYearAndStudentTypeImpl): El caso de uso recibe los parámetros y delega la solicitud al repositorio.
  4. Repositorio (ExternalRegistrationRepositoryImpl): El repositorio formatea la URL, realiza la petición HTTP al backend y convierte la respuesta en un formato adecuado.
  5. Vista (CoursesCompletedInYear.vue): La vista recibe los datos transformados y actualiza la interfaz de usuario.

Este patrón sigue una clara separación de responsabilidades entre capas: Dominio, Casos de Uso, Infraestructura y Presentación, lo que facilita la escalabilidad, mantenimiento y pruebas del sistema.

Como escribir tests de todo esto

Uno de los principales objetivos de la Arquitectura Limpia es facilitar la realización de pruebas, ya que promueve un diseño desacoplado y modular de los componentes del sistema. Esto permite que cada componente, como los casos de uso, los repositorios o los servicios, pueda ser probado de manera aislada, sin dependencias complejas.

En este post, os voy a mostrar cómo se pueden escribir pruebas unitarias para los diferentes componentes de un sistema basado en Arquitectura Limpia. Aunque lo ideal sería seguir una metodología como Test-Driven Development (TDD), en este caso no lo hemos hecho para no desviarnos del objetivo principal del post, que es explicar cómo estructurar las pruebas en una arquitectura limpia.

A continuación, vamos a ver cómo podríamos escribir pruebas unitarias para los siguientes componentes: Caso de Uso, Repositorio, Contenedor de Dependencias y Vista, siguiendo los principios de la Arquitectura Limpia.

1. Pruebas del Caso de Uso (GetExternalRegistrationsByYearAndStudentTypeImpl)

El caso de uso es una pieza clave en la arquitectura y se debe probar de forma aislada, asegurándonos de que su lógica funciona correctamente. En este caso, el propósito del test es comprobar que el caso de uso delega correctamente la responsabilidad al repositorio y maneja los resultados correctamente.

Test del Caso de Uso

Archivo: GetExternalRegistrationsByYearAndStudentTypeImpl.test.ts

import { GetExternalRegistrationsByYearAndStudentTypeImpl } from '@/application/GetExternalRegistrationsByYearAndStudentTypeImpl';
import { ExternalRegistrationRepository } from '@/domain/ExternalRegistrationRepository';
import { mock } from 'jest-mock-extended';

describe('GetExternalRegistrationsByYearAndStudentTypeImpl', () => {
  let getExternalRegistrations: GetExternalRegistrationsByYearAndStudentTypeImpl;
  let externalRegistrationRepository: ExternalRegistrationRepository;

  beforeEach(() => {
    // Creamos un mock del repositorio
    externalRegistrationRepository = mock<ExternalRegistrationRepository>();
    // Inyectamos el mock en el caso de uso
    getExternalRegistrations = new GetExternalRegistrationsByYearAndStudentTypeImpl(externalRegistrationRepository);
  });

  it('should return registrations when valid data is provided', async () => {
    // Definimos el comportamiento esperado del repositorio
    const expectedRegistrations = [
      { id: 1, courseName: 'Course 1' },
      { id: 2, courseName: 'Course 2' }
    ];
    externalRegistrationRepository.getRegistrationsByYearAndStudentType.mockResolvedValue(expectedRegistrations);

    // Ejecutamos el caso de uso
    const result = await getExternalRegistrations.execute(2021, 'new');

    // Verificamos que el resultado sea el esperado
    expect(result).toEqual(expectedRegistrations);
    expect(externalRegistrationRepository.getRegistrationsByYearAndStudentType).toHaveBeenCalledWith(2021, 'new');
  });

  it('should return an error when the repository throws', async () => {
    externalRegistrationRepository.getRegistrationsByYearAndStudentType.mockRejectedValue(new Error('Repository Error'));

    // Ejecutamos el caso de uso
    const result = await getExternalRegistrations.execute(2021, 'new');

    // Verificamos que se haya manejado el error
    expect(result).toBeInstanceOf(Error);
    expect((result as Error).message).toBe('Error fetching external registrations');
  });
});

Explicación del Test:

  • beforeEach: Creamos una nueva instancia del caso de uso e inyectamos un mock del repositorio para que no dependa de ninguna implementación real.
  • mockResolvedValue: Simulamos una respuesta exitosa del repositorio.
  • mockRejectedValue: Simulamos un error para comprobar que se maneja correctamente.
  • expect: Verificamos que el caso de uso devuelve el resultado esperado y que el repositorio recibe los parámetros correctos.

2. Pruebas del Repositorio (ExternalRegistrationRepositoryImpl)

El repositorio es responsable de interactuar con los datos (en este caso, realizando una solicitud HTTP). A continuación, crearemos pruebas para verificar que el repositorio maneja correctamente las solicitudes y respuestas de la API.

Test del Repositorio

Archivo: ExternalRegistrationRepositoryImpl.test.ts

import { ExternalRegistrationRepositoryImpl } from '@/infrastructure/ExternalRegistrationRepositoryImpl';
import axios from 'axios';
import { mock } from 'jest-mock-extended';

// Hacemos mock de axios para interceptar las llamadas HTTP
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('ExternalRegistrationRepositoryImpl', () => {
  let repository: ExternalRegistrationRepositoryImpl;

  beforeEach(() => {
    repository = new ExternalRegistrationRepositoryImpl();
  });

  it('should fetch registrations by year and student type', async () => {
    // Definimos una respuesta simulada
    const mockResponse = { data: [{ id: 1, courseName: 'Course 1' }] };
    mockedAxios.get.mockResolvedValue(mockResponse);

    const result = await repository.getRegistrationsByYearAndStudentType(2021, 'new');

    // Verificamos que la respuesta es la esperada
    expect(result).toEqual([{ id: 1, courseName: 'Course 1' }]);
    expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/external-registrations?year=2021&studentType=new');
  });

  it('should throw an error when API call fails', async () => {
    mockedAxios.get.mockRejectedValue(new Error('API Error'));

    try {
      await repository.getRegistrationsByYearAndStudentType(2021, 'new');
    } catch (error) {
      // Verificamos que se lanzó un error
      expect(error).toBeInstanceOf(Error);
      expect(error.message).toBe('Error fetching data from API');
    }
  });
});

Explicación del Test:

  • Usamos jest.mock para hacer un mock de la librería Axios y controlar sus respuestas.
  • Verificamos que la URL construida sea la correcta y que el repositorio devuelva los datos correctamente o maneje los errores adecuadamente.

3. Pruebas del Contenedor de Dependencias (DI)

El contenedor de dependencias es responsable de gestionar la inyección de las dependencias. Aunque no se suele probar directamente, podemos escribir pruebas para verificar que las dependencias están correctamente registradas e inyectadas.

Test del Contenedor de Dependencias

Archivo: di.test.ts

import { container } from '@/di';
import { GetExternalRegistrationsByYearAndStudentType } from '@/application/GetExternalRegistrationsByYearAndStudentType';
import { ExternalRegistrationRepositoryImpl } from '@/infrastructure/ExternalRegistrationRepositoryImpl';

describe('Dependency Injection Container', () => {
  it('should resolve the GetExternalRegistrationsByYearAndStudentType case of use with the correct repository', () => {
    // Recuperamos el caso de uso desde el contenedor
    const getExternalRegistrations = container.get<GetExternalRegistrationsByYearAndStudentType>("GetExternalRegistrationsByYearAndStudentType");

    // Verificamos que el caso de uso tenga la dependencia correcta inyectada
    expect(getExternalRegistrations).toBeInstanceOf(GetExternalRegistrationsByYearAndStudentTypeImpl);
  });

  it('should resolve the repository correctly', () => {
    const repository = container.get<ExternalRegistrationRepositoryImpl>("ExternalRegistrationRepository");
    expect(repository).toBeInstanceOf(ExternalRegistrationRepositoryImpl);
  });
});

Explicación del Test:

  • En este test verificamos que el contenedor DI resuelve las dependencias correctamente y que los objetos inyectados son los esperados.

Resumen

  • Caso de Uso: Probamos que el caso de uso delega correctamente la responsabilidad al repositorio y maneja los resultados correctamente.
  • Repositorio: Probamos que el repositorio maneja correctamente las solicitudes HTTP y maneja los errores de manera adecuada.
  • Contenedor de Dependencias: Probamos que el contenedor DI inyecta las dependencias correctamente.
  • Vista (Componente Vue): Probamos la interacción del usuario con el componente y verificamos que el flujo de datos desde el caso de uso hasta la vista funcione correctamente.

Estas pruebas aseguran que cada componente de nuestro sistema se comporte de la manera esperada, manteniendo el código modular, mantenible y fácil de testear.