Dependency Injection without classes
Cherif Bouchelaghem
Posted on January 6, 2024
Dependency Injection (DI) is a pattern that enhances the maintainability, scalability, and testability of your code. In TypeScript, we can leverage higher-order functions to implement Dependency Injection seamlessly, promoting a more modular and flexible codebase.
While it may sound like a complex pattern, many tools out there make it look very complicated. However, the reality is that DI is simply passing parameters, mostly to the constructor. As Rúnar Bjarnason casually once said, dependency injection is "really just a pretentious way to say 'taking an argument.'"
To provide a short and easy example, let's imagine we are working on a cab (taxi) Uber-like application. We've received a request to implement a rides pricing feature. For the sake of this article, the only rule is that the price of a ride must be $2 per kilometer for a given route.
Example
- The distance from my house address to the airport is 20 KM for the available itinerary.
- The price returned by the application should be 20 * 2 = $40.
We'll follow the domain model:
type Address = {
street: string;
city: string;
}
type Route = {
origin: Address;
destination: Address;
};
type Itinerary = {
route: Route;
distance: number;
}
type PricedRide = {
itinerary: Itinerary;
price: number;
}
However, the implementation of the ride pricing feature needs to know the itinerary to calculate the price based on the distance. The route itinerary can be fetched using a third-party service like Google Maps, Google Addresses, or others. Let's assume we haven't made a decision on which service to use.
Implementation using class-based Dependency Injection:
Since the itinerary 3rd-party service provider is not known yet, we can abstract it behind an interface and inject it into the class that calculates the price as a constructor parameter, like the following:
interface ItineraryService {
getItinerary(route: Route): Promise<Itinerary>
}
class RideService {
constructor(private itineraryService: ItineraryService) {}
async priceRide(route: Route): Promise<PricedRide> {
const itinerary = await this.itineraryService.getItinerary(route)
const price = itinerary.distance * 20;
return {
itinerary,
price
}
}
}
Let's break down the code above
-
ItineraryService
is an interface that exposes thegetItinerary
method signature. The implementation details of that interface can be done once a decision is made on what itinerary service provider to use, or even use a fake implementation. -
RideService
class has a constructor that takes an ItineraryService implementation instance. It doesn't matter what the concrete implementation to use is since it respects the interface contract. -
RideService
exposes thepriceRide
method that takes a Route object and returns a promise of aPricedRide
object after getting an itinerary for the passed route.
I kept the code short by removing some details like error handling.
Towards function-based DI
Now let's try to get rid of that verbose class-based version and replace it with another version that uses just functions. Since DI is basically passing parameters and functions are first-class citizens in JavaScript and TypeScript, we can just use a function to calculate the price and pass the itinerary fetching dependency as a function as well. The class-based code can be refactored to:
const priceRide = async (route: Route, getItinerary: Function): Promise<PricedRide> => {
const itinerary = await getItinerary(route)
const price = itinerary.distance * 20
return {
itinerary,
price
}
}
The code is now shorter and simpler, isn't it?
We have a small issue in the function implementation above: getItinerary
function type accepts any function. Let's fix this by adding the following type to our domain model:
type GetItinerary = (route: Route) => Promise<Itinerary>
Dependencies First, Data Last
If we want to mimic the class-based approach, we can just FLIP the order of the priceRide
function arguments like the following:
const priceRide = async (getItinerary: GetItinerary, route: Route) => {
const itinerary = await getItinerary(route)
const price = itinerary.distance * 20
return {
itinerary,
price
}
}
Flipping the arguments order as above is more of a functional style, where the input data are always set as the last function argument. This will allow us to partially apply priceRide
.
Partial application
It is a functional programming concept that can be applied to create a new function by just passing some arguments to a function. For example, we can use lodash.partial
to get a new function from our priceRide
like the following:
const partialPriceRide = _.partial(priceRide, getItinerary) // create a function with a dependency
async partialPriceRide(route) // -> ride price
Where getItinerary
and route are concrete implementations.
This is exactly the steps to make to use the class-based version:
const rideService = new RideService(itineraryService) // create an object with a dependency
async rideService.priceRide(route) // -> ride price
Great! Now, since partial application is a function that returns a function, instead of using lodash
to create our priceRide
function, we can define the function like the following:
const priceRide = (getItinerary: GetItinerary): PriceRide => async (route: Route): Promise<PricedOrder> => {
const itinerary = await getItinerary(route)
const price = itinerary.distance * 20
return {
itinerary,
price
}
}
We need to add PriceRide
function definition to the domain model:
type PriceRide = (route: Route) => Promise<PricedRide>
- The function now returns a function instead of a promise of
PricedRide
by taking all dependencies as parameters. - The returned function returns a promise of
PricedRide
and uses the dependency to fetch an itinerary. - Since
priceRide
creates a closure, all dependencies are accessible for the returned functions but private for the outside world (like in the class-based version).
Runtime configuration checking
We can take advantage of the priceRide
function and add a runtime check that verifies that the passed GetItinerary
parameter is really a function:
const priceRide = (getItinerary: GetItinerary): PriceRide => {
if (typeof getItinerary !== "function") throw new TypeError("Get Itinerary is not a function")
return async (route: Route): Promise<PricedRide> => {
const itinerary = await getItinerary(route)
const price = itinerary.distance * 20
return {
itinerary,
price
}
}
}
The major downside of this approach is that the function creates a closure for every call, which means a new object is created every time we call the function, however this can avoided by calling the function once at the composition root level which will be a topic for another article.
Posted on January 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024