One of the ideas of the UseCase layer is to be an interaction layer. It's where different parts of the application work together to achieve a defined result. This means we need a way to make these guys available for the UseCase before it executes its main flow. Besides that, we want our UseCases enough decoupled, so we can test them in isolation. In other words, we need to manage dependencies.
We have at least two ways of doing this: at compile time or at runtime.
Compile time
I leave the javascript interpreted vs compiled debate to who knows better.
Solving the dependencies problem at compile time looks like this:
import { productRepository } from 'repositories/product'
const getProductsList = async () => {
try {
const products = await productRepository.getList()
return useCaseSuccess(products)
} catch (e) {
console.log(e)
return useCaseError(e)
}
}
This UseCase depends on someone that knows how to get the products list. This is usually a repository. So, here we are importing the repository and using it inside our UseCase. Simple, right?
Our React Component would look like this:
import { getProductsList } from 'useCases'
function MyComponent = () => {
const [loading, setLoading] = React.useState(false)
const [productsList, setProductsList] = React.useState([])
const fetchProducts = async () => {
setLoading(true)
const { success, error } = await getProductsList()
setLoading(false)
if (error) {
// handle error somehow
return // interrupt
}
success && setProductsList(success)
}
}
It works and we have the previously mentioned advantages of UseCases: the presentation layer knows very little of what is going on and we have control over states such as loading, error and so on.
The problem with this solution is that this UseCase is not fully testable. We can't write tests for this UseCase without using the productRepository
implementation from "repositories/product"
file. This is also called implicit dependency. Our tests will always call the actual implementation of this dependency. So, we can't test this UseCase in isolation.
And more. For the same reason, our React component is also not fully testable.
This leads us to the next solution.
Runtime
A simple way to solve this problem at runtime is using a closure:
interface IProductRepository {
getList(): Promise<Products[]>
}
const makeGetProductsList =
(productRepository: IProductRepository) =>
async (): UseCaseReturnType<Products[]> => {
try {
const products = await productRepository.getList()
return useCaseSuccess(products)
} catch (e) {
console.log(e)
return useCaseError(e)
}
}
We are defining a function that returns an async function. The first function (the outer one) is responsible to receive the dependencies and make them available in the inner scope. The second one (the inner function) is our UseCase.
This new way of writing a UseCase means that we must set up our function before it can be used. In React this can be achieved by passing this function as a prop, like this:
import { MyComponent } from 'components'
import { makeGetProductsUseCase } from 'usecases'
import { productsRepository } from 'repositories'
const projectRoot = () => (
<MyComponent
getProductsUseCase={makeGetProductsUseCase(productsRepository)}
/>
)
I like to use this at the entry point of the project (screens or pages). Because we are composing things (putting the pieces together), this layer is usually called the composition root.
For the React world, this can happen on the router for a React app, the pages folder for a Next.js app or the Navigators for a React Native app. In terms of rendering, passing the closure as a prop is safe because props themselves do not trigger new renderings. So, we are passing static data (in this case, a function) to our component.
Our Component code will then look like this:
interface MyComponentProps {
getProductsList(): UseCaseReturnType<ProductsList[]>
}
function MyComponent = ({ getProductsList }) => {
const [loading, setLoading] = React.useState(false)
const [productsList, setProductsList] = React.useState([])
const fetchProducts = async () => {
setLoading(true)
const { success, error } = await getProductsList()
setLoading(false)
if (error) {
// handle error somehow
return // interrupt
}
success && setProductsList(success)
}
}
You can argue this component has no difference from the previous one. However, if you ask your LSP "what is this getProductsList
function?", all you will get is an interface saying this: "it is an async function that returns success and error". And that is all. No more information can be get about the UseCase from the Component itself.
This is what "depend on abstractions, not implementations" means. Our component knows 0% of what happens behind the scenes to get a list of products. All it knows is a signature.
In this new way, If you make some mistake when passing the dependencies, the compiler will warn you in the composition root. It's normal to mismatch signatures when we are developing and the compiler is still our friend.
In this runtime way, we are injecting the dependency on the UseCase (the repository) and we are injecting dependency on the React Component (the UseCase). This means we decoupled these 3 pieces of our software, making it orders of magnitude more maintainable, testable and scalable.
Let me know your thoughts.
See you!