Como vimos en la entrega anterior de esta newsletter donde hablamos del UseCaseService, este servicio parece que es terriblemente fácil que se nos vaya de las manos. Echemos un vistazo:
export class UseCaseService { async execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { try { console.log('Logging use case:', useCase.constructor.name) console.log('Logging params:', params) const result = await useCase.handle(params) console.log('Logging result:', result) return result } catch (e) { alert(e.message) throw e } } }
Tenemos un problema. ¡Esta clase hace demasiadas cosas! Ejecuta el caso de uso, maneja el error y registra el resultado. No estamos siguiendo la S de SOLID.
“”Si para describir lo que hace una clase usas la conjunción
y, entonces está haciendo demasiadas cosas.
Primero, una vez identificadas las diferentes responsabilidades, podemos empezar a separarlas en diferentes clases para que sea más escalable.
#Separando responsabilidades
Para separar las responsabilidades de logging y manejo de errores, vamos a introducir el concepto de middleware:
export interface Middleware { intercept(params: unknown, next: UseCase): Promise<unknown> }
Un middleware es capaz de interceptar una petición, lo que básicamente significa hacer algo con ella. Nada más y nada menos. Lo definimos como una interfaz porque vamos a tener múltiples middlewares.
Como habrás visto en esta serie sobre arquitectura, saber cuándo usar una interfaz o una clase abstracta es bastante potente. Definitivamente vale la pena invertir tiempo en aprender cuándo usar cada una.
Veamos en detalle cómo se vería el middleware de logging:
export class LoggerMiddleware implements Middleware { async intercept(params: unknown, useCase: UseCase): Promise<unknown> { const useCaseName = useCase.constructor.name console.log('Logging use case:', useCaseName) console.log('Logging params:', params) const result = await useCase.handle(params) console.log('Logging result:', result) return result } }
No uses useCase.constructor.name en producción, ya que probablemente será ofuscado por el bundler, lo que significa que verás cosas como __a, __bc, etc., en la consola de las devtools.
Encontraremos algo mejor para loguear una vez que abordemos el contenedor de inyección de dependencias.
Hay una pieza de código interesante en el middleware. ¿Puedes verla? Sí, estamos llamando al caso de uso dentro del middleware. Espera, ¿qué? Bueno, ese es un aspecto clave de la Chain of ResponsibilitySe abre en una nueva pestaña. Cada Middleware (a veces llamado Link) llama al siguiente middleware.
Pero, César, no lo entiendo; parece que estamos llamando a useCase.handle(), que es el método que usamos para ejecutar los casos de uso. ¿Cómo puede eso llamar al siguiente middleware?
Bueno, la respuesta es el patrón de diseño decoratorSe abre en una nueva pestaña.
#El Patrón de Diseño Decorator
El patrón decorator nos permite añadir más funcionalidad a una clase dada. En nuestro caso, queremos algo que llame al siguiente middleware y que también tenga en su contexto el caso de uso que estamos ejecutando. Y así es como nace el UseCaseHandler:
export class UseCaseHandler implements UseCase { private constructor( readonly useCase: UseCase, private readonly middleware: Middleware, ) {} async handle(params: unknown): Promise<unknown> { return this.middleware.intercept(params, this.useCase) } static create({ middleware, useCase, }: { useCase: UseCase middleware: Middleware }) { return new UseCaseHandler(useCase, middleware) } }
Con esta clase estamos decorando el UseCase. Para hacerlo, implementamos la misma interfaz del elemento que queremos decorar. También añadimos un método de factoría estático para que se encargue de la creación por nosotros. Ahora, solo es cuestión de conectarlo todo en el UseCaseService.
#Conectándolo todo en el UseCaseService
export class UseCaseService { constructor( private readonly middlewares: Middleware[], ) {} execute<T extends UseCase>( useCase: UseCase, params?: UseCaseParams<T>, ): Promise<UseCaseReturn<T>> { let next = UseCaseHandler.create({ useCase, middleware: new EmptyMiddleware(), }) for (let i = this.middlewares.length - 1; i >= 0; i--) { next = UseCaseHandler.create({ useCase: next, middleware: this.middlewares[i] }) } return next.handle(params) as Promise<UseCaseReturn<T>> } }
UseCaseParams y UseCaseReturn son tipos de utilidad para obtener los parámetros y el retorno de un caso de uso como un tipo. Se ven así:
export type UseCaseParams<T extends UseCase> = T extends UseCase<infer P> ? P : unknown export type UseCaseReturn<T extends UseCase> = T extends UseCase<unknown, infer R> ? R : unknown
Ahora el UseCaseService recibe un array de middlewares listos para ser usados, cada uno ocupándose de su propia responsabilidad. Cuando ejecutamos un caso de uso, creamos una cadena de middlewares, donde cada uno apunta al siguiente. Por defecto, siempre es una buena práctica tener un EmptyMiddleware. ¿Qué hace?, te preguntarás. Bueno, ¡nada, por supuesto!
export class EmptyMiddleware implements Middleware { intercept(params: unknown, useCase: UseCase): Promise<unknown> { return useCase.handle(params) } }
Ahora, cuando creamos nuestro UseCaseService, podemos pasarle nuestro LoggerMiddleware:
const loggerMiddleware = new LoggerMiddleware() const useCaseService = new UseCaseService([loggerMiddleware]) useCaseService.execute(logindCmd)
Detengámonos un segundo y pensemos en lo que hemos hecho:
- Hemos creado una forma genérica de añadir aspectos transversales sin ensuciar el servicio del caso de uso.
- Hemos hecho que nuestra cadena sea configurable en tiempo de ejecución. Sí, podemos poner un
ify comprobar si estamos en producción para no añadir elloggerMiddleware. - Hemos hecho que todos nuestros casos de uso pasen por un pipeline de middlewares sin tener que duplicar esa lógica por todas partes.
Bastante ingenioso.
¡Y las posibilidades a partir de aquí son infinitas!
#Conclusión
Esto es mucho para digerir. Te sugiero que juegues con este código para que puedas interiorizar mejor lo que hemos hablado en esta entrega. ¿Cómo implementarías el manejo de errores en un middleware? Te invito a intentarlo.
P.D.: ¿Para qué usarías los middlewares? No dudes en responder a este correo, podrías aparecer en la próxima entrega, donde hablaremos de middlewares avanzados. ¡Mantente al tanto!
P.D 2: Recientemente he publicado mi review del año, te invito a que lees este artículo si te llama la atención ser Freelancer y el Nomadismo Digital.