Why I Don't Use a DI Container | Node.js w/ TypeScript

Last updated Invalid date
Instead of a DI Container, I just package features by component and use logical naming conventions.

I get a lot of emails about which DI container to use. InversifyJS? Awilix?

"Angular and NestJS use their own inversion of control containers, so why aren't you?"

Because I really haven't needed to yet.

I've been waiting for the moment where my dependencies got out of hand, so that I could honestly and sincerely see the need to utilize a DI container.

But I haven't gotten there yet.

I should also add in that my primary codebase is over 150K lines of code and has been alive and actively developed on top of for about 3 years now.

Although, before I learned about the Clean Architecture and how to organize logic into layers

Prerequisites:

You must be this tall to ride this ride.

Here's some stuff you should be familiar with in with in order to join in on the conversation.

Building a blog

Let's imagine we're building out a website that has a blog where you can sign up as a author, create posts, and write comments on posts.

Also, assume other users can comment on your posts too.

What's the first thing to do? Figure out the actors and the use cases in order to package by component.

Step 1: Package by component

Front-end developers are already doing this with Angular and they might not even know it.

Package by component is something that Bob Martin wrote about in Clean Architecture and that I wrote about in my free ebook, "Name, Construct & Structure | Organizing Readable Codebases".

In it, he says that we should "organize our code around the Use Cases" of the application. Doing that will create a project where the names of the folders practically scream the domain of the problem we're solving. He calls it "Screaming Architecture".

Peep the folder structure for a front-end app enabling people to trade vinyl. It might look like this.

src
  modules 
    admin             # Admin "actor" (all admin modules/components below)
      analytics         # Analytics module
        components/     # Dumb components
        containers      # Smart components
        pages/          # Pages
        redux/          # State management for this module
        services/       # API adapters
        styles/         # Styles
        index.ts        
      dashboard/        # Admin Dashboard module...
      users/            # Admin view of users module...
    shared/           # Shared (admins, traders, etc)
      login/            # Login module...
    traders           # Trader "actor" (all trader modules/components below)
      dashboard/        # Trader Dashboard module...
      register/         # Trader registration module...
      trades/           # Trades module...
  models/
  utils/

Project structure of a Vinyl-Trading front-end app

Notice that Admin has an analytics, dashboard and users module? In Angular, you're forced to export modules and link everything up using those.

Angular forces you to think about cohesively packaging components together.

The word component can also mean module in this case (some call it Package by Module instead of Package by Component).

That's what I do in backend development as well.

Organize code into cohesive modules... centered around the use cases.


Structuring the project around the use cases

Going back to the blog, if I had to structure the project around the use cases, the folder structure would look like this:

src
  modules
    blog                    # Blog module
      authors               # Authors component
        domain                # Author module entities
          Author.ts
        repos                 # Author module repos
          AuthorRepo.ts
        useCases              # Author module use cases
          createAuthor                # <== Let's look at this one
            CreateAuthorController.ts
            CreateAuthorErrors.ts
            CreateAuthorUseCase.ts
            CreateAuthorUseCase.spec.ts
            index.ts
          getAuthorById/
          getAllAuthors/
          editAuthor/
          deleteAuthor/       
        index.ts               # Exports dependencies from the Authors component
      comments/              # Comments module
      posts/                 # Posts module
    users                    # Users module

Notice that the structure is similar? We're just simply following Package By Component and grouping together the features into cohesive units.

Let's focus specifically on the CreateUser use case from the Authors component for now.

Why do this?

2 reasons.

  1. It makes it much easier to see what your code can do.
  2. People are the reason why software eventually needs to be changed. By organzing code around the people and their use cases, it makes finding where to go to change code, trivial. Not only that, but having features segregated reduces the possibility of ripple.

What's in a Use Case?

In any use case folder, I'll strive for cohesion by having everything related to that use case there. That means there's a contoller, the use case itself, a test for the use case, and all possible errors that the use case might generate. I'll export what's necessary for the world outside of this module using the folder index.ts.

authors
  domain/
  repos/
  useCases              # Author module use cases
    createAuthor                
      CreateAuthorController.ts     # Application controller
      CreateAuthorErrors.ts         # Errors namespace
      CreateAuthorUseCase.ts        # Use case
      CreateAuthorUseCase.spec.ts   # Test for the use case
      index.ts                      # Compose and export use case + controller
    getAuthorById/
    getAllAuthors/
    editAuthor/
    deleteAuthor/  

Errors

Using a TypeScript namespace, I can represent all the errors for this use case.

CreateAuthorErrors.ts
import { Result } from "core/Result";
import { DomainError } from "core/DomainErrror";

export namespace CreateAuthorErrors {

  export class AuthorExistsError extends Result<DomainError> {
    constructor () {
      super(false, {
        message: `Author already exists`
      } as DomainError)
    }
  }

  export class UserNotYetCreatedError extends Result<DomainError> {
    constructor () {
      super(false, {
        message: `Need to create the user account first`
      } as DomainError)
    }
  }

}

Use Case

The use case contains the actual feature.

For this particular use case, the CreateAuthorUseCase, it relies on two external dependencies.

One of them, the IAuthorRepo, is from the authors module.

The other one, the IUserRepo, is from the users module.

It also utilizes functional error handling techniques that we explored in a previous article.

Take a look:

CreateAuthorUseCase.ts
import { UseCase } from "core/UseCase";
import { Either, Result, left, right } from "core/Result";
import { CreateAuthorErrors } from "./CreateAuthorErrors";
import { AppError } from "core/AppError";
import { IUserRepo } from "modules/users/repos/UserRepo";
import { IAuthorRepo } from "../../repos/AuthorRepo";
import { Author } from "../../domain/Author";

// All we need to execute this is a userId: string.

interface Request {
  userId: string;
}

// The response is going to be either one of these
// failure states, or a Result<void> if successful.

type Response = Either<
  CreateAuthorErrors.AuthorExistsError |
  CreateAuthorErrors.UserNotYetCreatedError |
  AppError.UnexpectedError,
  Result<any>
>

export class CreateAuthorUseCase implements UseCase<Request, Promise<Response>> {

  // This use case relies on an IUserRepo and an IAuthorRepo to work
  private userRepo: IUserRepo;
  private authorRepo: IAuthorRepo;

  public constructor (userRepo: IUserRepo, authorRepo: IAuthorRepo) {
    this.userRepo = userRepo;
    this.authorRepo = authorRepo;
  }

  public async execute (req: Request): Promise<Response> {
    const { userId } = req;

    const user = await this.userRepo.getUserById(userId);
    const userExists = !!user;

    // If the user doesn't exist yet, we can't make them an author
    if (!userExists) {
      return left(
        new CreateAuthorErrors.UserNotYetCreatedError()
      ) as Response;
    }

    // If the user was already made an author, we can return a failed result.
    const alreadyCreatedAuthor = await this.authorRepo
      .getAuthorByUserId(user.userId);

    if (alreadyCreatedAuthor) {
      return left(
        new CreateAuthorErrors.AuthorExistsError()
      ) as Response;
    }

    // If validation logic fails to create an author, we can return a failed result
    const authorOrError: Result<Author> = Author
      .create({ userId: user.userId });

    if (authorOrError.isFailure) {
      return left(
        new AppError.UnexpectedError(authorOrError.error)
      ) as Response;
    }


    // Save the author to the repo
    const author = authorOrError.getValue();
    await this.authorRepo.save(author);

    // Successfully created the author
    return right(Result.ok<void>()) as Response;
  }
}

Controller

While the controller may be an infrastructure adapter, we still want to couple it with the use case to keep this module cohesive.

The controller has one dependency, and it's the CreateAuthorUseCase from this module.

We will need to compose the controller with that as a dependency in order to create an instance of this controller.

Base controller: We're using the BaseController from this guide.

CreateAuthorController.ts
import { CreateAuthorUseCase } from "./CreateAuthorUseCase";
import { AppError } from "core/AppError";
import { CreateAuthorErrors } from "./CreateAuthorErrors";

export class CreateAuthorController extends BaseController {
  private useCase: CreateAuthorUseCase;

  constructor (useCase: CreateAuthorUseCase) {
    super();
    this.useCase = useCase;
  }

  public async executeImpl (): Promise<any> {
    const req = this.req as DecodedExpressRequest;
    const { userId } = req.decoded;
    try {
      const result = await this.useCase.execute({ userId });

      if (result.isLeft()) {
        const error = result.value;

        // Based on the error, map to the appropriate HTTP code
        switch (error.constructor) {
          case CreateAuthorErrors.AuthorExistsError:
            return this.notFound(error.errorValue().message)
          case CreateAuthorErrors.UserNotYetCreatedError:
            return this.fail(error.errorValue().message)
          case AppError.UnexpectedError:
          default:
            return this.fail(error.errorValue().message);
        }
      } else {
        return this.ok<void>(this.res);
      }
    } catch (err) {
      console.log(err);
      return this.fail("Something went wrong on our end.")
    }
  }
}

We have everything we need in order to execute this feature.

We just need to hook it up.

Step 2: Create and export features from module

Recall that the folder structure looks like this.

authors
  domain/
  repos/
  useCases              # Author module use cases
    createAuthor                
      CreateAuthorController.ts     # Application controller
      CreateAuthorErrors.ts         # Errors namespace
      CreateAuthorUseCase.ts        # Use case
      CreateAuthorUseCase.spec.ts   # Test for the use case
      index.ts                      # Compose and export use case + controller
    getAuthorById/
    getAllAuthors/
    editAuthor/
    deleteAuthor/  
...
users/
  ...

The first thing to create is the CreateAuthorUseCase.

But CreateAuthorUseCase relies on some dependencies (IUserRepo and IAuthorRepo).

author/useCases/createAuthor/index.ts
import { CreateAuthorUseCase } from './CreateAuthorUseCase';

const createAuthorUseCase = new CreateAuthorUseCase() /* An argument 
for 'userRepo' and 'authorRepo' was not provided. */

My style guide for exporting dependencies is this:

  • Always use index.ts to export what needs to be used by others from your module.
  • Always use lowercase names to signal that an exported dependency is an instance, not a class.
  • Only export dependencies from the direct parent folder. Only users/repos/index.ts is allowed to export UserRepo, because it resides as users/repos/UserRepo.ts. users/index.ts (or anywhere else) is not allowed.

Over in the users module, I would have exported an instance of an IUserRepo as userRepo like this:

modules/users/repos/index.ts
import { models } from '../../../core/infra/models' 
import { UserRepo } from './UserRepo';

const userRepo = new UserRepo(models);

export { 
  userRepo
}

And then I would have imported it into the authors/useCases/index.ts which is generally a great experience using Visual Studio Code's intellisense.

Using Intellisense to automatically trace dependencies by name.

Nice. Now that the use case is created, I need to inject that into my controller.

Once that's done, I'll export them both from this module.

author/useCases/createAuthor/index.ts
import { CreateAuthorUseCase } from "./CreateAuthorUseCase";
import { userRepo } from "modules/users/repos";
import { authorRepo } from "../../repos";
import { CreateAuthorController } from "./CreateAuthorController";

const createAuthorUseCase = new CreateAuthorUseCase(
  userRepo, authorRepo
)

// Inject the use case into the controller to create it
const createAuthorController = new CreateAuthorController(
  createAuthorUseCase
)

export {
  // Export instances as lowercase to signify they're instances
  createAuthorUseCase,
  createAuthorController
}

Using the Use Case Features

Now, if I want to use the CreateAuthorUseCase, all I have to do is start typing createAuthor... and I'll be shown both the CreateAuthorUseCase in addition to the CreateAuthorUseCaseController instances.

import { 
  createAuthorUseCaseController 
} from './modules/author/useCases/createAuthor`

...

const authorRouter = express.Router();

authorRouter.post('/',
  (req, res) => createAuthorUseCaseController.execute(req, res)
)

...

Benefits of not using a DI container

Less complexity

Just use the module system! Node.js automatically makes everything a singleton the first time it's imported, if that matters to you.

Better software composition

Not using a DI container forces you to understand how you compose your classes a lot better, rather than just creating a new CatsService with an injectable decorator on it.

This is exactly the type of thing that Eric Elliot has written about extensively in his Composing Software book.

Less circular dependencies

Not using a DI container makes it very hard for you to introduce circular dependencies because you have a better understanding of what is being exported and imported, and in what order. You're forced to compose software components together.

This is how stuff stays testable.

Less decorator noise

Decorators are low-level details that makes it's way into your application level code. I'd argue that that's not very clean.

Disadvantages

Working on teams

I think working on teams is sometimes more challenging than working with yourself.

The reasons why frameworks like Angular and NestJS are nodded towards for enterprise software more often than tools like React and Vue, is because frameworks are opinionated.

Frameworks tell you how to do things- their way and their way only.

There is exactly one way to create a Route Guard in Angular.

In React, that's a different story.

When working with several other people on a team, it can be very challenging to get everyone to adhere to a style guide and do things a certain way, like package features into cohesive components.

Frameworks are successful here because they reduce the total surface area of ways you can do something.

But if the team is small, disciplined, and the experience level is similar, I think that it can work.

Everyone needs to follow the style guide

This is what works for me! Like any project style guide, other developers working within the codebase need to adopt it.



Discussion

Liked this? Sing it loud and proud 👨‍🎤.


12 Comments

Commenting has been disabled for now. To ask questions and discuss this post, join the community.

DecebalOnProgramming.net
4 years ago

Love your idea with the index at the use case level, I am curious how you deal with dependencies that tend to repeat

Juan Vernaza
4 years ago

It is interesting that Angular's defense is based on a false idea, that which says that in React you can make horrors, because they do not give you a work guide, the general rules are not only given by a framework. React gives you the freedom to implement with the tools that best suit your use case and once defined the development should be based on that implementation. Companies do not choose to work in Angular, what happens is that Angular was already there before solutions such as React or Vue existed. I think Angular only has to survive based on a false idea. For now I will continue to see applications made in Angular where it can be built in any way, regardless of whether it is a framework

Zlatko
4 years ago

@Juan Vernaza where did you read that Angular bases its "defense" (from what?) on an idea that in react you make horrors?


Angular is slightly more opinionated then React in some areas, but you can still do horrors with it, if you so desire. But that's completely off the mark, and you hint at it yourself - Angular is older then React and so cannot be based on it, more like React was based on what they wanted to do differently then Angular. Angular's way of doing things are not set in stone, but on the good foundations that the article mentions. And yes, some of those are not best suited so you can adapt.


Companies do not choose Angular over React because it was there already - React is there as well for quite a few years, but so was (is) ember, polymer and other projects. The fact is that Angular is simply very suitable for enterprise applications. The use cases are not all over the place like startups using React would have, they mostly concern backoffice business applications or app suites and having a uniform way of doing things accross many projects and teams is very beneficial. Or, as you say it, Angular "best suits the use case".




Anit Shrestha Manandhar
4 years ago

Good read!


To use a DI or not to depends on the preference I guess. But why would frameworks like Spring and .NET Core be built on it? And are very successful.


I am exploring this space of TypeScript deeper and learning from some of your implementations new and so learning from it :)


I would have loved to see some code samples with this article as well!


Thank you for the write-up Khalil!

SoTrue
4 years ago

This is something I also thought about, essentially creating your own factory pattern in angular. IOC is meant to be beneficial as it stops you having to create factories all over the place, but because angular needs to have its dependencies all wired up in separate modules otherwise it cant figure out how to load on demand you essentially end up having to do just as much work as you would with hand rolled factory classes, not to mention angular needs to know the use case of the dependency - entry component, provider - tree shaken or not, limited scope or not <= all this angular should be able to almost automatically figure out instead but because it doesnt it makes it more complex and creates confusion.

grug
3 years ago

A little misleading. DI embrace low coupling, which means the objects injected with the same interface are interchangeable. In the sample code, UseCase( do you mean AppService?) and Controller are coupled.

Chris
3 years ago

This Looks good,

I really like that idea and can be used for third party projects. But when I want to test the express route (maybe via supertest), how would I change my dependencies for example to an in-memory database. The controller and the usecase are coupled.

Or assuming that within my usecase im relying on a message adapter (such as Apache Kafka) how could I switch implementations to mock that adapter?

Milan
3 years ago

To really prove that "package by component" remedies a need for containers, please show a case without using this approach. I'm still not sure if the PBC helped or the example was plain simple and thus lacking complex dependency trees.

Mike
3 years ago

I too think this was a relative poor example. It doesn't really prove anything. You are still using the same concept, that is, Dependency Injection. The reason why DIC's were created was to solve the wiring up of dependencies. You just choose to do that manually, the old-fashioned way. But PBC is not a cure for that.


Also, although my memories are kind of blurry, Composing Software is not against Dependency Injection Containers, but against Dependency Injection at all, so I'm not sure how valid your argument is.


His argument is, if you need to mock dependencies to test a method or a function, it is poorly designed ( in his opinion ).


What Elliot is suggesting, instead of unit testing the entire function as a whole, mocking its dependencies ( like how testing is done here ), you unit-test the smaller functional components within the method ( like composed pipes and other pure functions ), and you leave the method unit test for Integration tests.

Abdulrahman Yusuf
2 years ago

Thank you, I've been trying to figure this out.

Fábio Nunes
a year ago

I know the post is kind old, but it is good stuff. The only thing I would change in your approach is to avoid classes and use curried functions in order to inject these dependencies. The curry would occur in the index, while exporting the curried function with the context and making use of the function in the express route.

BTW, good article, I'm thinking here in a smart design for a challenge and your article gave me a light.

Oh, one last thing, I don't think it's a drawback when you don't have a non opinionated framework, you can have a good design well documented based on market standards good practices for the team. I worked for a big streaming product which was implemented in node (JS) and there were a great code base with great smart readable solutions without any opinionated framework.

BartekCK
9 months ago

Nice article! I have a question. Sometimes dependencies require some async work during orchestration. How do you handle it? e.g Environment Container which load data from AWS SSM parameter store.


Stay in touch!



About the author

Khalil Stemmler,
Software Essentialist ⚡

I'm Khalil. I turn code-first developers into confident crafters without having to buy, read & digest hundreds of complex programming books. Using Software Essentialism, my philosophy of software design, I coach developers through boredom, impostor syndrome, and a lack of direction to master software design and architecture. Mastery though, is not the end goal. It is merely a step towards your Inward Pull.



View more in Software Design



You may also enjoy...

A few more related articles

Dependency Injection & Inversion Explained | Node.js w/ TypeScript
Dependency Injection and Depedency Inversion are two related but commonly misused terms in software development. In this article, ...
How I Write Testable Code | Khalil's Simple Methodology
The single biggest thing that improved the quality of my designs was understanding how dependencies influence my ability to write ...
Why I Recommend a Feature-Driven Approach to Software Design
Features represent the essential complexity of software design. It's the complexity that can't be avoided. Everything else — the l...
Comparison of Domain-Driven Design and Clean Architecture Concepts
Eric Evans' "Domain-Driven Design" and Uncle Bob's "Clean Architecture" are books that have introduced tactical approaches towards...

Want to be notified when new content comes out?

Join 15000+ other Software Essentialists learning how to master The Essentials of software design and architecture.

Get updates