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.



Stay in touch!



View more in Software Design