A Rollercoaster Ride into Dependency Injection and Functional Programming in Javascript
Let's take a look at dependency injection and how it works with functional programming in Javascript / Typescript. My goal here is to ask a question, and I don't have a great answer to that question.
This is a long one that took me a while to write. I want to give a clear example of these concepts and give you something concrete to look at.
What is Functional Programming and Why?
First, I'm not the FP guru. I just skimmed this article, and it's a great overview of FP principles. FP is a focus on pure functions, immutable data structures, and composition instead of mutable class instances and methods with side effects.
class Car {
public speed: number;
public goFaster() { this.speed += 10; }
}
The above is not FP. A car is an object that maintains its own state and has methods that mutate that state. FP is not that.
Pure functions are easier to test, and avoiding mutations avoids strange bugs where data changes and the current call stack doesn't clearly tell you why.
What is Dependency Injection and Why?
Dependency injection is a technique where a module's external dependencies are passed in to that code from outside rather than constructed or configured within the module. This means that a dependency is easier to replace with a new dependency - as long as the interface matches - by simply passing the replacement into the module.
This also makes code much easier to test. You can simply make a mock version of an external dependency and pass that into your function to perform a test.
class Notifier {
constructor(public http: Http) {}
public send(message: string) {
await this.http.post("/notify", { message });
}
}
In a FP world, this might look like this:
// notifier.ts
export function send(http: Http, message: string) {
await http.post("/notify", { message });
}
In the OOP version, the dependency is passed to the constructor. In the FP version, the dependency is passed at call time.
Ok, strap in
This is going to get ugly before it gets better. I'm going to ask questions about whether that last example is the right thing to do.
Let's take a fairly standard REST API setup:
BookController - handles HTTP requests for information about books
BookService - Loads information about Books formatted for consumption as data transfer objects - objects that are ideal for consumption by whoever is looking, and may differ from the underlying storage.
BookDAO - Loads information about Books from the core data store (MongoDB in this case) formatted as database Documents
... and an AuthorService + AuthorDAO as well.
If you want to follow along at home, all of these examples are pushed up to GitHub here.
OOP without DI
Here's my BookDAO
import { ObjectId, WithId } from "mongodb";
import mongoConnector from "./mongo-connector";
export interface BookDocument {
name: string;
author: ObjectId;
}
class BookDAO {
private collection;
constructor() {
this.collection = mongoConnector.getConnection().db().collection<BookDocument>("books");
}
public async getOne(id: string): Promise<WithId<BookDocument> | null> {
return await this.collection.findOne({ _id: new ObjectId(id) });
}
public async list(): Promise<WithId<BookDocument>[]> {
return await this.collection.find().toArray();
}
}
const bookDao = new BookDAO();
export default bookDao;
Here's my BookService.
import { WithId } from "mongodb";
import authorService, { AuthorDTO } from "./author-service";
import bookDao, { BookDocument } from "./book-dao";
export interface BookDTO {
id: string;
name: string;
author: AuthorDTO | null;
}
class BookService {
public async list(): Promise<BookDTO[]> {
const bookDocuments = await bookDao.list();
return Promise.all(bookDocuments.map(this.toDTO));
}
public async getOne(id: string): Promise<BookDTO | null> {
const bookDocument = await bookDao.getOne(id);
if (bookDocument) {
return this.toDTO(bookDocument);
}
return null;
}
public async toDTO(doc: WithId<BookDocument>): Promise<BookDTO> {
return {
id: doc._id.toString(),
name: doc.name,
author: await authorService.getOne(doc.author.toString())
};
}
}
const bookService = new BookService();
export default bookService;
So, bookDao
is a global object that depends on a global database configuration. bookService
loads the global bookDao
class and uses this. The bookService
also uses the authorService
to load the author for each book and include that in the data transfer object.
All of this is happening when modules are loaded, and nothing is injected.
Let's improve this with DI.
OOP with constructor injection
The BookDAO.
import { Collection, ObjectId, WithId } from "mongodb";
export interface BookDocument {
name: string;
author: ObjectId;
}
export default class BookDAO {
constructor(private collection: Collection<BookDocument>) {}
public async getOne(id: string): Promise<WithId<BookDocument> | null> {
return await this.collection.findOne({ _id: new ObjectId(id) });
}
public async list(): Promise<WithId<BookDocument>[]> {
return await this.collection.find().toArray();
}
}
Oh, that's nice. The mongodb driver's collection
object is configured outside of this. The BookDAO doesn't have to care how that happened. This is easier to read.
The BookService
import { WithId } from "mongodb";
import AuthorService, { AuthorDTO } from "./author-service";
import BookDAO, { BookDocument } from "./book-dao";
export interface BookDTO {
id: string;
name: string;
author: AuthorDTO | null;
}
export default class BookService {
constructor(private bookDao: BookDAO, private authorService: AuthorService) {}
public async list(): Promise<BookDTO[]> {
const bookDocuments = await this.bookDao.list();
return Promise.all(bookDocuments.map(this.toDTO));
}
public async getOne(id: string): Promise<BookDTO | null> {
const bookDocument = await this.bookDao.getOne(id);
if (bookDocument) {
return this.toDTO(bookDocument);
}
return null;
}
public async toDTO(doc: WithId<BookDocument>): Promise<BookDTO> {
return {
id: doc._id.toString(),
name: doc.name,
author: await this.authorService.getOne(doc.author.toString())
};
}
}
This looks good to me. I don't care how BookDAO and AuthorService work. I expect them to be configured before this code is reached.
This code is testable because I can make a mock BookDAO and AuthorService using mock libraries very easily and pass those to this constructor.
This is what I do quite often. The only difference is that Nest or Inversify might have an @Inject
decorator in front of those constructor arguments to automatically load them from a centralized configuration.
The FP version without DI
The book-dao.ts
module.
import { ObjectId, WithId } from "mongodb";
import getConnection from "./mongo-connector";
export interface BookDocument {
name: string;
author: ObjectId;
}
const collection = getConnection().db().collection<BookDocument>("books");
export async function getOne(id: string): Promise<WithId<BookDocument> | null> {
return await collection.findOne({ _id: new ObjectId(id) });
}
export async function list(): Promise<WithId<BookDocument>[]> {
return await collection.find().toArray();
}
This seems pretty simple as far as FP goes. We get the DB connection from an external config and select the books
collection.
The book-service.ts
module.
import { WithId } from "mongodb";
import * as authorService from "./author-service";
import * as bookDao from "./book-dao";
export interface BookDTO {
id: string;
name: string;
author: authorService.AuthorDTO | null;
}
export async function list(): Promise<BookDTO[]> {
const bookDocuments = await bookDao.list();
return Promise.all(bookDocuments.map(toDTO));
}
export async function getOne(id: string): Promise<BookDTO | null> {
const bookDocument = await bookDao.getOne(id);
if (bookDocument) {
return toDTO(bookDocument);
}
return null;
}
async function toDTO(doc: WithId<bookDao.BookDocument>): Promise<BookDTO> {
return {
id: doc._id.toString(),
name: doc.name,
author: await authorService.getOne(doc.author.toString())
};
}
The individual functions are pretty simple. The book DAO and author service methods are loaded from their namespaces and used directly. Easy to read, but it isn't using DI.
FP with argument injection
To use DI with FP, as I've been told, I can simply make the dependencies be arguments of the function. It's that simple.
Here's a blog post (See the heading "Functional Programming Config")
Here's another (Examples 1-3, but, spoilers, it gets a little better)
The book-dao.ts
functions
import { Collection, ObjectId, WithId } from "mongodb";
export interface BookDocument {
name: string;
author: ObjectId;
}
export async function getOne(collection: Collection<BookDocument>, id: string): Promise<WithId<BookDocument> | null> {
return await collection.findOne({ _id: new ObjectId(id) });
}
export async function list(collection: Collection<BookDocument>): Promise<WithId<BookDocument>[]> {
return await collection.find().toArray();
}
export type TList = typeof list;
export type TGetOne = typeof getOne;
This seems simple. The collection
object is configured elsewhere and passed in when these functions are called. This is even shorter than the version without DI because it doesn't load the collection
object.
I exported TList
and TGetOne
as types for the methods so they can be used where these methods will be injected.
Now let's look at the book-service.ts
methods
import { Collection, WithId } from "mongodb";
import * as authorService from "./author-service";
import * as bookDao from "./book-dao";
import * as authorDao from "./author-dao";
export interface BookDTO {
id: string;
name: string;
author: authorService.AuthorDTO | null;
}
export async function list(
bookCollection: Collection<bookDao.BookDocument>,
bookDaoList: bookDao.TList,
authorCollection: Collection<authorDao.AuthorDocument>,
authorDaoGetOne: authorDao.TGetOne,
authorServiceGetOne: authorService.TGetOne
): Promise<BookDTO[]> {
const bookDocuments = await bookDaoList(bookCollection);
return Promise.all(bookDocuments.map(async (bookDocument) => {
return toDTO(authorCollection, authorDaoGetOne, authorServiceGetOne, bookDocument);
}));
}
export async function getOne(
bookCollection: Collection<bookDao.BookDocument>,
bookGetOne: bookDao.TGetOne,
authorCollection: Collection<authorDao.AuthorDocument>,
authorDaoGetOne: authorDao.TGetOne,
authorServiceGetOne: authorService.TGetOne,
id: string
): Promise<BookDTO | null> {
const bookDocument = await bookGetOne(bookCollection, id);
if (bookDocument) {
return toDTO(authorCollection, authorDaoGetOne, authorServiceGetOne, bookDocument);
}
return null;
}
async function toDTO(
authorCollection: Collection<authorDao.AuthorDocument>,
authorDaoGetOne: authorDao.TGetOne,
authorServiceGetOne: authorService.TGetOne,
doc: WithId<bookDao.BookDocument>
): Promise<BookDTO> {
return {
id: doc._id.toString(),
name: doc.name,
author: await authorServiceGetOne(authorCollection, authorDaoGetOne, doc._id.toString())
};
}
export type TList = typeof list;
export type TGetOne = typeof getOne;
Oh. Oh no. Yeah, I want to use the list
function from the bookDao
namespace, so I want that injected as an argument. But it depends on that collection object, which is passed in at call time. So... I guess I have to accept that as an argument as well? author-service
and author-dao
functions are going to follow the same pattern, so I need those dependencies passed in.
This is hell. Don't do this. Dependents of dependents are caring about things they shouldn't care about.
For fun, here's where the book-controller list
function is called:
const app = express();
app.get("/books", (req, res, next) => {
return bookController.list(
bookCollection,
bookDao.list,
authorCollection,
authorDao.getOne,
authorService.getOne,
bookService.list,
req, res, next
);
});
No. No no no. This is bad. We don't want this.
FP with currying
Wait, what is currying? A function that returns a function. A higher-order function. I've also heard it called a "thunk".
Let's look at the book-dao.ts
functions
import { Collection, ObjectId, WithId } from "mongodb";
export interface BookDocument {
name: string;
author: ObjectId;
}
export const getOne = (collection: Collection<BookDocument>) => async (id: string): Promise<WithId<BookDocument> | null> => {
return await collection.findOne({ _id: new ObjectId(id) });
};
export const list = (collection: Collection<BookDocument>) => async (): Promise<WithId<BookDocument>[]> => {
return await collection.find().toArray();
};
export type TList = ReturnType<typeof list>;
export type TGetOne = ReturnType<typeof getOne>;
Wow, this is the shortest one yet! I still export the TList
and TGetOne
types to be used by dependents.
And book-service.ts
import { WithId } from "mongodb";
import * as authorService from "./author-service";
import * as bookDao from "./book-dao";
export interface BookDTO {
id: string;
name: string;
author: authorService.AuthorDTO | null;
}
export const list = (
bookDaoList: bookDao.TList,
authorServiceGetOne: authorService.TGetOne
) => async (): Promise<BookDTO[]> => {
const docToDTO = toDTO(authorServiceGetOne);
const bookDocuments = await bookDaoList();
return Promise.all(bookDocuments.map(async (bookDocument) => {
return docToDTO(bookDocument);
}));
};
export const getOne = (
bookDaoGetOne: bookDao.TGetOne,
authorServiceGetOne: authorService.TGetOne
) => async (
id: string
): Promise<BookDTO | null> => {
const docToDTO = toDTO(authorServiceGetOne);
const bookDocument = await bookDaoGetOne(id);
if (bookDocument) {
return docToDTO(bookDocument);
}
return null;
};
const toDTO = (authorServiceGetOne: authorService.TGetOne) => async (doc: WithId<bookDao.BookDocument>): Promise<BookDTO> => {
return {
id: doc._id.toString(),
name: doc.name,
author: await authorServiceGetOne(doc._id.toString())
};
};
export type TList = ReturnType<typeof list>;
export type TGetOne = ReturnType<typeof getOne>;
Ok, well, this is better. Now list
doesn't care how bookDao.list
learns about collection
- that should be cooked in before we get here. It doesn't care how authorService.getOne
learns about authorDao.getOne
.
Let's look at the application entry point. How do these functions get built?
const client = getConnection(config);
const authorCollection: Collection<authorDao.AuthorDocument> = client.db().collection<authorDao.AuthorDocument>("authors");
const authorDaoGetOne = authorDao.getOne(authorCollection);
const authorServiceGetOne = authorService.getOne(authorDaoGetOne);
const bookCollection: Collection<bookDao.BookDocument> = client.db().collection<bookDao.BookDocument>("books");
const bookDaoList = bookDao.list(bookCollection);
const bookDaoGetOne = bookDao.getOne(bookCollection);
const bookServiceList = bookService.list(bookDaoList, authorServiceGetOne);
const bookServiceGetOne = bookService.getOne(bookDaoGetOne, authorServiceGetOne);
const bookControllerGetOne = bookController.getOne(bookServiceGetOne);
const bookControllerList = bookController.list(bookServiceList);
Each of the functions is created by calling the curry function to cook in the dependencies. This happens when the application starts.
book-service
is still a mess. It has two functions that both depend on the same function from author-service
, and they both depend on methods from book-dao
even though they are different methods.
FP with module factories
Let's try a slightly different approach. Let's have each module export a factory function. That factory will build the module's real functions with the dependencies baked in. That module/namespace of functions will be returned as an object.
import { Collection, ObjectId, WithId } from "mongodb";
export interface BookDocument {
name: string;
author: ObjectId;
}
const bookDao = (collection: Collection<BookDocument>) => {
return {
getOne: async (id: string): Promise<WithId<BookDocument> | null> => {
return await collection.findOne({ _id: new ObjectId(id) });
},
list: async (): Promise<WithId<BookDocument>[]> => {
return await collection.find().toArray();
}
};
};
export default bookDao;
export type TBookDao = ReturnType<typeof bookDao>;
The code is a little longer, but now the collection
dependency is passed in once and shared by both functions in the module.
Let's look at book-service.ts
:
import { WithId } from "mongodb";
import { AuthorDTO, TAuthorService } from "./author-service";
import { BookDocument, TBookDao } from "./book-dao";
export interface BookDTO {
id: string;
name: string;
author: AuthorDTO | null;
}
const bookService = (bookDao: TBookDao, authorService: TAuthorService) => {
const toDTO = async (doc: WithId<BookDocument>): Promise<BookDTO> => {
return {
id: doc._id.toString(),
name: doc.name,
author: await authorService.getOne(doc._id.toString())
};
};
return {
list: async (): Promise<BookDTO[]> => {
const bookDocuments = await bookDao.list();
return Promise.all(bookDocuments.map(toDTO));
},
getOne: async (id: string): Promise<BookDTO | null> => {
const bookDocument = await bookDao.getOne(id);
if (bookDocument) {
return toDTO(bookDocument);
}
return null;
}
};
};
export default bookService;
export type TBookService = ReturnType<typeof bookService>;
Oh, this is the best one yet. Since I'm injecting modules instead of bare functions, I can take in authorService
in one place and bookDao
just once. The toDTO
function can also share the dependencies without being exposed externally.
Wait. Hold up. I just invented a class with a private method and using constructor injection.
Yes, I did.
Let's look at the application setup again:
const authorCollection: Collection<AuthorDocument> = client.db().collection<AuthorDocument>("authors");
const myAuthorDao = authorDao(authorCollection);
const myAuthorService = authorService(myAuthorDao);
const bookCollection: Collection<BookDocument> = client.db().collection<BookDocument>("books");
const myBookDao = bookDao(bookCollection);
const myBookService = bookService(myBookDao, myAuthorService);
const myBookController = bookController(myBookService);
Yeah, that's easier.
But I can't use Inversify or Nest.js without jumping through a lot of hoops.
If I use Classes, I can just put @injectable
on each class, use the autoBindInjectable
object, and suddenly, I don't have to add those Classes to the pre-configuration.
So, the questions...
How are you doing DI in pure FP applications at any level of scale? Where am I wrong? What am I missing?
Are you using the higher-order functions or are you using module factories? If you're using module factories... why are you not just using classes?
My Conclusion
Just use classes.
A class is a function factory, so you are welcome to use it as such. You also get neat public
and private
descriptors and the ability to use decorators.
You can focus on FP principles - pure functions, immutability, and composition - but still use classes. Only use constructor arguments for dependencies. Don't create a stateful Car
class with a goFaster
method. Use classes as factories for behaviors, not concrete entities. Avoid inheritance. Create a CarAccelerator
class and CarPainter
class that takes a car and returns a new car with newly computed properties. That's how you can get the benefits of the FP design pattern, but benefit from classes.
When I started writing this, I considered making Inversify for FP - a magic utility that could take your function's arguments and automatically inject the dependencies. That way you can write your pure functions without worrying about curries and factories, but still get the benefits. I had a hard time juggling types, trying to use bind
and making those call-time dependencies work. Everything led me back to factories. And classes are function factories.