El patrón de Caso de Uso no es nada nuevo, ha existido desde antes de que yo naciera, sin embargo, la forma en que lo aplico en el Frontend es algo que no he visto (aún). El patrón de Caso de Uso te permite separar tu lógica de negocio de tal manera que puedes añadir diferentes piezas de código para cambiar completamente la forma en que tu código se ejecuta de una manera fácil y transversal.
“”El Patrón de Caso de Uso te permite orquestar las llamadas a tu lógica de negocio.
¿Qué es un Caso de Uso?
Un Caso de Uso representa cómo un usuario interactúa con el sistema. Todo lo que un usuario puede hacer, bueno, eso es un caso de uso. Al programar un caso de uso, la idea es encapsular un conjunto completo de acciones, para que los casos de uso puedan ser reutilizados. Los casos de uso orquestan, saben a quién llamar e integran las diferentes piezas para que devolvamos a la UI lo que necesita. Veamos algo de código para entenderlo mejor.
La Interfaz UseCase
Todo comienza con la interfaz UseCase:
export interface UseCase<In, Out> { handle(input?: In): Promise<Out> }
Un UseCase es una clase (también puede ser una función, pero prefiero las clases) que tiene un método llamado handle(). Este método recibe una entrada de tipo In y devuelve una salida de tipo Out.
A las funciones que se definen dentro de las clases las llamamos métodos.
Como habrás notado, la salida devuelta es siempre una Promesa. Esta es una elección de diseño consciente dado que la mayoría de las veces en el Frontend, nuestros casos de uso van a realizar indirectamente llamadas a la API, que resultan ser asíncronas.
Si necesitas ejecutar un caso de uso con mucha carga computacional, usar Promesas significa que también puedes evitar bloquear el hilo principal. ¿Hilos en JavaScript? Sí, échale un vistazo aquíSe abre en una nueva pestaña.
La implementación de UseCase
Ahora podemos implementar la interfaz UseCase.
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()) } }
Aquí estamos usando dos clases:
DestinationRepository: Realiza una solicitud a la API utilizando el Patrón Repository (del cual hablaré en otro boletín).DestinationOrderer: Ordena los destinos utilizando una clase de Dominio.
Ahora, probablemente te estés preguntando... ¿Por qué añadiría esta complejidad a mi código? Podría simplemente llamar a la API en la UI usando fetch y ordenar los Destinations en el componente que realiza la llamada.
Bueno, para empezar, como puedes ver en este código, no hay código de UI, lo que significa que en el futuro puedo cambiar de framework de UI o llamar a este Caso de Uso desde diferentes lugares sin necesidad de cambiar mucho código. Esto tiene que ver con cómo aplico las Capas de Arquitectura de Software y el Diseño Dirigido por el Dominio (DDD), que explicaré en otra entrega de este boletín.
Y no puedo obtener los beneficios de abstraer cómo ejecuto los casos de uso para manejar la funcionalidad transversal. Que es lo que explicaré a continuación.
El UseCaseService
El truco está en cómo ejecutar el UseCase. Uno pensaría que creas directamente la instancia y las ejecutas de la siguiente manera, por ejemplo, desde tu componente de ReactSe abre en una nueva pestaña:
import { type FC } from 'react' const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer) export const Comp: FC = () => { return <button onClick={() => getDestinationsQry.handle()}>Haz clic aquí</button> }
Sin embargo, estamos perdiendo una gran oportunidad. ¿Qué pasa si queremos soportar las siguientes características?:
- Manejo de errores
- Registro (Logging)
- Caché
- ¡Y muchas más!
La forma de hacerlo es creando el UseCaseService, que nos ayuda a abstraer cómo se ejecuta un caso de uso.
export class UseCaseService { execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { return useCase.handle(params) as Promise<Out> } }
No parece gran cosa, sin embargo, esto lo cambia todo. ¿Qué pasa si queremos añadir registros a todos los casos de uso? Bueno, en lugar de añadir console.log aquí o allá, podemos simplemente hacer esto:
export class UseCaseService { execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { console.log(useCase.constructor.name) console.log(params) return useCase.handle(params) as Promise<Out> } }
¡Así de fácil!
En producción, dado que los frameworks tienden a ofuscar los nombres de las clases para reducir el tamaño del código, suelo añadir un campo estático llamado ID con el nombre de la clase que utilizo para el registro y para la inyección de dependencias.
¡Hagamos ese cambio!
export class GetDestinationsQry implements UseCase<void, Destination[]> { static readonly ID = 'GetDestinationsQry' ... } export class UseCaseService { execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { console.log(useCase.ID) console.log(params) return useCase.handle(params) as Promise<Out> } }
Curiosamente, también podemos usar await en la ejecución del caso de uso para registrar el resultado:
export class UseCaseService { async execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { console.log(useCase.ID) console.log(params) const result = await useCase.handle(params) as Promise<Out> console.log(result) return result } }
Esto es bastante útil ya que podríamos querer ejecutar código después de ejecutar los casos de uso.
“”En otra entrega del boletín te mostraré cómo mejorar el UseCaseService para no mezclar diferentes lógicas en esta clase con Middlewares y el Patrón Chain of ResponsibilitySe abre en una nueva pestaña.
¿Qué pasa si quiero implementar un manejo de errores global?
export class UseCaseService { async execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { try { console.log(useCase.ID) console.log(params) const result = await useCase.handle(params) as Promise<Out> console.log(result) return result } catch (e) { alert(e) throw e } } }
¡Ahora tengo una forma de mostrar al usuario los errores de una manera genérica! Hay infinitas posibilidades.
Déjame mostrarte cómo usar el UseCaseService.
Ejecutando el UseCaseService
import { type FC } from 'react' const useCaseService = new UseCaseService() const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer) export const Comp: FC = () => { return <button onClick={() => useCaseService.execute(getDestinationsQry)}>Haz clic aquí</button> }
Normalmente, para evitar crear instancias por todas partes, tengo una clase contenedor que gestiona la instanciación de las clases y luego, simplemente importo las instancias o utilizo un Contenedor de Inyección de DependenciasSe abre en una nueva pestaña para recuperar la instancia.
Aquí tienes un ejemplo simplificado:
export const destinationApiRepository = new DestinationApiRepository() export const destinationOrderer = new DestinationOrderer() export const getDestinationsQry = new GetDestinationsQry(destinationApiRepository, destinationOrderer) export const useCaseService = new UseCaseService()
Llamando a UseCaseService desde la UI
Como probablemente también quiera llamar al caso de uso desde la UI y gestionar la carga (loading), creo un último envoltorio que se encarga de eso por mí. Déjame mostrarte un ejemplo en React:
export interface UseUseCaseOptions<In> {} /** * Estado devuelto por el hook useUseCase. */ export interface UseCaseState<In, Out> { /** Indica si el caso de uso se está ejecutando actualmente */ isLoading: boolean /** Función para ejecutar el caso de uso */ execute: (params?: In) => Promise<Out> } function useUseCase<In, Out>(useCase: UseCase<In, Out>, options: UseUseCaseOptions<In> = {}): UseCaseState<In, Out> { const [isLoading, setIsLoading] = useState(false) const execute = useCallback( async (params?: In): Promise<Out> => { setIsLoading(true) try { const result = await useCaseService.execute(useCase, params) return result } finally { setIsLoading(false) } }, [useCase], ) return { isLoading, execute, } }
Ahora, en el componente puedes llamar al hook así:
import { type FC } from 'react' import { getDestinationsQry } from './container' import { useUseCase } from './use-use-case' export const Comp: FC = () => { const { execute, isLoading } = useUseCase(getDestinationsQry) return <button disabled={isLoading} onClick={() => execute()}>Haz clic aquí</button> }
Conclusión
Los Casos de Uso proporcionan 3 beneficios principales en la Arquitectura Frontend:
- Proporcionan un punto de extensión para añadir funcionalidad transversal.
- Te hacen pensar en cómo el usuario interactúa con tu aplicación abstrayendo sus acciones en código.
- Te permiten reutilizar fácilmente casos de uso o componer casos de uso más grandes llamando a otros más pequeños.
Con eso, ¡espero que hayas disfrutado de la primera entrega de este boletín! Si te resulta útil, compártelo con otros, ¡me alegrarás el día!
No dudes en responder a este correo si has encontrado alguna errata o si el correo no se visualiza correctamente, me aseguraré de corregirlo para la versión web y tomar nota para el próximo boletín. ¡Muchas gracias por leer hasta aquí!
P.D.: ¿Quieres aprender más sobre casos de uso? Lee otra publicación sobre casos de usoSe abre en una nueva pestaña en mi web.