In the previous issue of this newsletter we talked about Advanced Middlewares. We introduced the Chain of Responsibility design patternOpen in a new tab to handle multiple middlewares. We also introduced the concept of Use Cases, Repositories, Transformers and other software pieces. What do they all have in common?. They are all instanceable.
As the project grows, we are finding ourselves with more and more classes, and to use classes, we need to create instances of them. We might find ourselves handling the instances in different places like this:
import { type FC } from 'react' const useCaseService = new UseCaseService() const httpClient = new HttpClient() const destinationRepository = new DestinationRepository(httpClient) const destinationOrderer = new DestinationOrderer() const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer) export const Comp: FC = () => { return <button onClick={() => useCaseService.execute(getDestinationsQry)}>Click here</button> }
This pattern is not really scalable, are we going to keep creating and creating the instances over and over again?
“”Whenever I find myself repeating a lot of the same code, I look for ways to abstract it in reusable pieces, and that is something AI has problems getting right.
Perhaps we might export from the same class declaration the instance:
import { destinationRepository } from './destination-repository' import { destinationOrderer } from './destination-orderer' export class GetDestinationsQry implements UseCase<void, Destination[]> { ... } export const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer)
Do you find it strange that we are receiving in the constructor the dependencies of the class? That's called the Dependency Inversion principle, and it's related to Dependency Injection. I invite you to keep on reading to find out more about how these two principles are related.
This might "fix" the problem of duplicating instances; however, we have several limitations:
- The instances are hard-coded, so we lose flexibility when changing the instances.
- When importing the class, then the instance is created, so we lose control over when the instances are created.
- What if we need several instances of the same class? What if we need to change the dependencies during runtime? There's no scalable way of supporting these and more features!
When we create the instance in the class, we are polluting it. Why? Because we are going from something more abstract (a class declaration) to something concrete (a class instance).
Now that we've figured out the problem, let’s find a solution. But first, we need to talk about Dependency Inversion and how it fits in the greater scheme of things.
#The Dependency Inversion Principle
Over the past issues of the newsletter we've seen a pattern that might have been overlooked:
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() ) } }
When we declare a class that has collaborators, we receive them through the constructor of the class rather than importing and using them directly. Why? Well, it's all about flexibility.
Do you remember if DestinationRepository is a class or an interface? Well, we don't care and that is good! We just know it has a method named findAll. This means that we can swap the instance, and as long as the method of DestinationRepository is named findAll we are golden.
DestinationRepository was in fact an interface! You might want to revisit this pattern in the newsletter issue where we talked about the Repository Pattern.
In essence, the Dependency Inversion Principle allows us to swap collaborators as long as they follow the contract.
When would be the case where we might swap the instances? During testing. The Dependency Inversion Principle is single-handedly the most important pattern to follow if you want your testing to be easy and reproducible.
“”If your code is hard to test it means that your code is not flexible enough.
Let's check the test for the GetDestinationsQry:
import { describe, expect, it } from 'vitest' import { GetDestinationsQry } from './get-destinations.qry' import type { DestinationRepository } from '@/features/destination/domain/destination.repository' import { DestinationMother } from '@/features/destination/tests/destination.mother' import { instance, mock, when } from '@typestrong/ts-mockito' import { DestinationOrderer } from '@/features/destination/destination-list/domain/destination-orderer' describe('GetDestinationsQry', () => { it('should get destinations', async () => { const { getDestinationsQry, destinationRepository } = setup() when(destinationRepository.findAll()).thenResolve([DestinationMother.europe()]) const result = await getDestinationsQry.handle() expect(result).toEqual([DestinationMother.europe()]) }) it('should get empty array if there are no destinations', async () => { const { getDestinationsQry, destinationRepository } = setup() when(destinationRepository.findAll()).thenResolve([]) const result = await getDestinationsQry.handle() expect(result).toEqual([]) }) }) function setup() { const destinationRepository = mock<DestinationRepository>() const getDestinationsQry = new GetDestinationsQry(instance(destinationRepository), new DestinationOrderer()) return { destinationRepository, getDestinationsQry } }
For unit testing I find ts-mockitoOpen in a new tab very pleasing to work with.
As you can see in the setup function we easily create the mocks or perhaps even pass the real collaborators to create integration tests.
Wondering what a Mother is in Software Development? Here you can read more about the Mother Pattern.
No need to create module mocksOpen in a new tab which tend to be fragile and are not type-safe.
Having seen the Dependency Inversion Principle now we can explain how the Dependency Injection Container synergizes on top of it.
#Dependency Injection Container
The Dependency Injection Container has the responsibility to hold all our instances. In order for it to be useful, we need two things:
- Register instances
- Retrieve instances
Let's create an interface to represent such operations:
export interface Container { register<Instance extends WithInjectionToken<AnyConstructor>>(instance: Instance): void get<Instance extends WithInjectionToken<AnyConstructor>>(key: Instance): InstanceType<Instance> }
Since we want the container to be fully typed I added a few utility types. Let's check them out:
export type InjectionToken = symbol export type WithInjectionToken<T extends AnyConstructor> = T & { readonly ID: InjectionToken } // This as a very dynamic type that represents any type of instanceable class export type AnyConstructor<T = unknown> = abstract new (...args: any[]) => T
Woah! What does symbol mean? Well, we need a unique identifier to store classes or even values in the container. It's also a JavaScript nativeOpen in a new tab.
You might find compelled to use class names as the identifiers, please note that bundlers tend to mangle class names to shorten them, which will essentially break the Dependency Injection Container in production.
How are we going to do that? Well, like this:
export class GetDestinationsQry implements Query<Destination[]> { static readonly ID: InjectionToken = Symbol('GetDestinationsQry') ... }
So, now, whenever we want to register a class in the container, we must add an ID to the class. With that now we need to implement the container:
const globalForApp = globalThis as unknown as { container?: Container } export class AppContainer implements Container { private readonly registry = new Map<InjectionToken, unknown>() static getInstance(): Container { // Reuse global instance if it exists globalForApp.celestia ??= new AppContainer() return globalForApp.container } get<Instance extends WithInjectionToken<AnyConstructor>>(key: Instance): InstanceType<Instance> { const token = key.ID if (!this.registry.has(token)) { throw new Error(`Instance with key '${token.toString()}' not found.`) } return this.registry.get(token) as InstanceType<Instance> } register<Instance extends object>(instance: Instance): void { const ctor = instance.constructor as WithInjectionToken<AnyConstructor> if (!('ID' in ctor) || typeof ctor.ID !== 'symbol') { const name = 'name' in ctor ? ctor.name : 'Unknown' throw new Error(`Missing static ID in ${name}`) } this.registry.set(ctor.ID, instance) } } export const container = AppContainer.getInstance()
This container follows the Singleton PatternOpen in a new tab, and is one of the very few times that I export an instanced value from the class. To me, it makes sense here since the container is where I'm going to store all the instances.
I also create a global value in cases where I might need to access the container outside the ESM Module System, like from a widget or other advanced use cases.
With all this set, now it's time to register the dependencies.
#Registering dependencies
When the container is created, we should automatically register artifacts, repositories and use cases. We can do that through the constructor:
export class AppContainer implements Container { private constructor() { this.registerArtifacts() this.registerRepositories() this.registerUseCases() } private registerArtifacts() { const eventEmitter = new EventEmitter() const emptyMiddleware = new EmptyMiddleware() const loggerMiddleware = new LoggerMiddleware() const errorMiddleware = new ErrorMiddleware(eventEmitter) const successMiddleware = new SuccessMiddleware(eventEmitter) const confirmMiddleware = new ConfirmMiddleware(eventEmitter) const middlewares = [ confirmMiddleware, errorMiddleware, loggerMiddleware, successMiddleware ] const useCaseService = new UseCaseService(middlewares, this) this.register(eventEmitter) this.register(emptyMiddleware) this.register(loggerMiddleware) this.register(errorMiddleware) this.register(confirmMiddleware) this.register(successMiddleware) this.register(useCaseService) } private registerRepositories() { const httpClient = this.get(HttpClient) const dateTransformer = this.get(DateTransformer) const destinationApiRepository = new DestinationApiRepository(httpClient, dateTransformer) this.register(destinationApiRepository) } private registerUseCases() { const destinationOrderer = new DestinationOrderer() const destinationApiRepository = this.get(DestinationApiRepository) const getDestinationsQry = new GetDestinationsQry(destinationApiRepository, destinationOrderer) this.register(destinationOrderer) this.register(getDestinationsQry) } }
We make the constructor private so we limit how instances are created. In this case is through the static factory method getInstance(). If the instance is not created, it will create it for us.
I prefer dividing the instances registration in different methods since it's easier to handle. If the container grows too much, we could look into making multiple containers. Now it's a matter of retrieving the dependencies.
#Retrieving dependencies
Now, the last part: retrieving the dependencies. Going back to the first example we would rewrite the component as follows:
import { type FC } from 'react' export const Comp: FC = () => { const useCaseService = container.get(UseCaseService) const getDestinationsQry = container.get(GetDestinationsQry) return <button onClick={() => useCaseService.execute(getDestinationsQry)}>Click here</button> }
Or better yet, we could move the container accessing logic now to the UseCaseService:
export class UseCaseService { static readonly ID: InjectionToken = Symbol('UseCaseService') constructor( private readonly middlewares: Middleware[], private readonly container: Container, ) {} execute<T extends UseCase>( useCaseClass: WithInjectionToken<AnyConstructor<T>>, params?: UseCaseParams<T>, options?: UseCaseOptions, ): Promise<UseCaseReturn<T>> { // Here! const useCase = this.container.get(useCaseClass) const requiredOptions = options ?? { logLevel: 'info', } let next = UseCaseHandler.create({ useCase, // And here! middleware: this.container.get(EmptyMiddleware), options: requiredOptions, }) for (let i = this.middlewares.length - 1; i >= 0; i--) { next = UseCaseHandler.create({ useCase: next, middleware: this.middlewares[i]!, options: requiredOptions }) } return next.handle(params) as Promise<UseCaseReturn<T>> } }
With this change we can now simplify the component:
import { type FC } from 'react' export const Comp: FC = () => { const useCaseService = container.get(UseCaseService) return <button onClick={() => useCaseService.execute(GetDestinationsQry)}>Click here</button> }
How cool is that?
#Conclusion
With this Dependency Injection Container we'll be able to register instances and retrieve them. This is invaluable to change dependencies during runtime, vastly changing the overall architecture of our applications.
- Need to mock dependencies when running tests? Load a
TestContainerinstead of the production one. - Need to have different dependencies in a multi-tenant app, environments or any other dynamic value? You can use environment variables to load different dependencies or any other custom logic.
- Need to delay instance creation, create multi-injection hierarchy contexts or more advanced use cases? You can do so by extending the container.
I hope with this issue it's clear that a Dependency Injection Container pairs well with the Dependency Inversion Principle and how both provide scalability and maintainability while making testing easier.
How would you improve the container? Feel free to reply to this email, and I'll get back to you!
P.S: I just released the best worst landing ever made for my book Software Cafrers. Check it out here: https://www.softwarecafrers.com/Open in a new tab.