In the two previous issues of this Newsletter, we talked about the Use Case pattern and the Repository Pattern and in this newsletter. In this issue we'll delve deeper in what makes the UseCaseService such a great tool.
When we follow the use case pattern we are actually making use of the Command Design PatternOpen in a new tab, since all use cases are devised to be executed using the same method: handle().
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() ) } }
Here is another use case:
export class CreateDestinationCmd implements UseCase<CreateDestination, void> { constructor(private readonly destinationRepository: DestinationRepository) {} async handle(createDestination: CreateDestination): Promise<void> { return this.destinationRepository.create(createDestination) } }
A use case must implement the handle() method to be considered a Use Case.
Did you know you can implement interfaces in JavaScript? The approach is, in my opinion, less robust, but it's still doable: the answer? With tests. You can create a generic test that checks if an instance of a class has a certain method
export function createUseCaseTest(useCase) { describe('UseCase', () => { it('should have method handle()', async () => { expect(useCase.handle).not.be.undefined }) }) } const destinationRepository = new DestinationInMemoryRepository() const getDestinationsQry = new GetDestinationsQry( destinationRepository, new DestinationOrderer() ) createUseCaseTest(getDestinationsQry)
However, not using TypeScript would mean we would lose compile time checks, type safe autocompletion and safe type based refactors.
Why?
So, why would we want to execute use cases in the same way? Well, because then we can add logic before, after or during the execution of use cases in a simple, yet powerful way.
Let me introduce you the UseCaseService:
export class UseCaseService { execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { return useCase.handle(params) } }
It's meant to be used like this:
export const destinationInMemoryRepository = new DestinationInMemoryRepository() export const createDestinationCmd = new CreateDestinationCmd(destinationInMemoryRepository) export const useCaseService = new UseCaseService() const destinations = await useCaseService.execute(createDestinationCmd, newDestination)
In following issues of the Newsletter we are going to improve this Dependency Injection Container.
Ok, so what? All of this code just to add another abstraction? Well, what if we needed to implement logging for every use case? Easy.
export class UseCaseService { execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { console.log('Logging use case:', useCase.constructor.name) console.log('Logging params:', params) return useCase.handle(params) } }
Now every use case has logging capabilities. Want also to log the result? We can await the useCase.handle(params):
export class UseCaseService { async execute<In, Out>(useCase: UseCase<In, Out>, params?: In): Promise<Out> { 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 } }
What if we need a global error handling system to display the user a friendly error message?
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 } } }
We will improve the UseCaseService so we use the Chain of Responsibility PatternOpen in a new tab to improve how we handle cross-cutting concerns in a way that is maintainable.
With this approach, you don't need to keep adding try catch all over your code, you can handle them in just one place. If you still need to handle a particular error, you can still do so, but by default, the system is prepared to handle them in a generic way.
Conclusion
The UseCaseService is a wonderful place to handle transversal core features. Over the years I've used it to solve really complex problems in a simple way. Here are some of the use cases of the UseCaseService that I've applied:
- Caching
- Logging
- Offline capabilities
- Confirmation message
- Success messages
- Retries
- Timers
- Roles and permissions handling
- Loading strategies
- Many more
How would you use the UseCaseService?