En la entrega anterior de esta Newsletter hablamos de los Casos de Uso (Use Cases), que es un patrón que abstrae cómo ejecutas la lógica de negocio.
“”Los casos de uso se encargan de orquestar la lógica de negocio.
Aquí tienes un ejemplo de un Caso de Uso:
export class GetDestinationsQry implements UseCase<void, Destination[]> { constructor( private readonly destinationRepository: DestinationRepository, private readonly destinationOrderer: DestinationOrderer, ) {} async handle(): Promise<Destination[]> { return this.destinationOrderer.order( await this.destinationRepository.findAll() ) } }
El caso de uso no tiene lógica de negocio propia, sino que sabe a qué piezas de código necesita llamar para lograr el resultado final.
In este ejemplo, llama a un DestinationOrderer que parece ordenar los resultados y a un DestinationRepository, que parece obtener algunos datos... Esto nos lleva a la pregunta, ¿qué es un repositorio?
¿Qué es un repositorio?
¿Alguna vez has interactuado con datos? Quizás obteniendo datos de una base de datos, usando fetch para obtener datos de una API REST o tal vez incluso almacenando datos en memoria. ¡Bueno, eso es un Repositorio!
Un Repositorio representa una pieza de código que interactúa con datos.
En algunos lugares puede que lo hayas visto llamado Services. Sin embargo, para mí, service es demasiado genérico, ¡todo podría ser un servicio! Prefiero ser más específico sobre la intención detrás de las piezas de código que uso llamándolo repository.
Echemos un vistazo a DestinationRepository:
export interface DestinationRepository { findAll(): Promise<Destination[]> }
¡Espera, es una interfaz! ¡Sí, lo es! Nos ayuda a que nuestro código sea más robusto, dado que cuando tratamos con abstracciones en lugar de concreciones, nuestro código tiende a ser más flexible.
Así que esto significa que tiene que haber un lugar donde necesitemos implementar esta interfaz, ¿verdad?
Implementación del Repositorio
Normalmente defino las interfaces de los repositorios en la capa de dominio y su implementación en la capa de infraestructura.
No pasa nada si no has trabajado con capas antes, las explicaré con más detalle en una futura entrega.
Hago esta separación ya que pienso que crear destinos probablemente no va a cambiar, sin embargo, cómo creo los destinos es algo que parece depender de sistemas externos, lo que significa que es más frágil.
Es por eso que hago la definición (DestinationRepository) y la implementación (DestinationApiRepository) en diferentes capas.
Veamos el código:
export class DestinationApiRepository implements DestinationRepository { async findAll(): Promise<Destination[]> { const response = await fetch('/api/destinations') if (!response.ok) { throw new GetDestinationsError() } const data = (await response.json()) as Destination[] return data } }
Como puedes ver, aquí es donde realmente llamamos a la API Rest. Pero... ¿Cuál es el punto de todo esto? Bueno, hay varias ventajas.
Ventajas de usar el Patrón Repository
En primer lugar, las preocupaciones de orquestar la lógica de negocio y obtener datos son diferentes, por lo que con este enfoque seguimos la S de S.O.L.I.D.Se abre en una nueva pestaña
También podemos tener múltiples implementaciones de repositorios que podemos intercambiar según sea necesario. ¿Alguna vez has necesitado trabajar con datos de una API Rest que aún no estaba terminada? Bueno, puedes implementar un DestinationInMemoryRepository:
export class DestinationInMemoryRepository implements DestinationRepository { data = [DestinationMother.europe()] async findAll(): Promise<Destination[]> { return this.data } async create(createDestination: CreateDestination): Promise<void> { this.data.push({ ...createDestination, id: (Math.random() * 1000).toString() }) } }
“”¿Te preguntas qué es
DestinationMother? Es un patrón de diseño de Martin FowlerSe abre en una nueva pestaña donde manejas tus datos falsos usando Mothers, dando nombres significativos a los datos que podrías usar en los tests. Puedes leer más sobre el patrón de diseño Mother en este post del blog.
Ahora, en el contenedor de dependencias, es cuestión de intercambiar las instancias:
// Antes // export const destinationApiRepository = new DestinationApiRepository() // Después export const destinationInMemoryRepository = new DestinationInMemoryRepository() export const getDestinationsQry = new GetDestinationsQry(destinationInMemoryRepository, destinationOrderer)
También puedes intercambiar las dependencias durante el tiempo de ejecución.
export const destinationApiRepository = new DestinationApiRepository() export const destinationInMemoryRepository = new DestinationInMemoryRepository() export const getDestinationsQry = new GetDestinationsQry(process.env.NODE_ENV === 'development' ? destinationInMemoryRepository : destinationApiRepository, destinationOrderer)
Lo que hace que este sea un patrón muy potente para tratar escenarios en los que podrías necesitar cambiar dinámicamente tu fuente de datos. Por ejemplo, si estás usando una API de pago durante el desarrollo, no quieres hacer peticiones a dicha API, simplemente puedes añadir un if para usar el repositorio en memoria.
Otra ventaja es que puedes crear interfaces reutilizables para tener una forma unificada de llamar a tus repositorios:
export interface FindableAll<Result> { findAll(): Promise<Result[]> } export interface Creatable<Params, Result = void> { create(params: Params): Promise<Result> } export interface DestinationRepository extends FindableAll<Destination>, Creatable<CreateDestination> {}
Con esto, ¡puedes evitar tener diseminados en tu código create, upsert, save y muchos más nombres diferentes e inconsistentes para tratar con la creación de entidades!
Conclusión
El patrón repository es un patrón muy útil para abstraer la forma en que interactúas con los datos, haciendo que tu código sea más robusto a la vez que flexible. Proporciona ventajas como el intercambio de tus repositorios durante la compilación y el tiempo de ejecución, encapsula tu código y aporta consistencia.