My TypeScript Software Design & Architecture book just prelaunched! Check out solidbook.io.
Close

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

Sep 16th, 2019 / 11 min read / Share / Edit on GitHub
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 👨‍🎤.


4 Comments

Submit
DecebalOnProgramming.net
3 months 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
2 months 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
2 months 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
2 months 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!


Stay in touch!



About the author

Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚡

Khalil is a software developer, writer, and musician. He frequently publishes articles about Domain-Driven Design, software design and Advanced TypeScript & Node.js best practices for large-scale applications.



View more in Software Design



You may also enjoy...

A few more related articles

Dependency Injection & Inversion Explained | Node.js w/ TypeScript
Sep 11th, 2019 / 11 min read
Dependency Injection and Depedency Inversion are two related but commonly misused terms in software development. In this article, ...
Comparison of Domain-Driven Design and Clean Architecture Concepts
Nov 5th, 2019 / 8 min read
Eric Evans' "Domain-Driven Design" and Uncle Bob's "Clean Architecture" are books that have introduced tactical approaches towards...
How to Learn Software Design and Architecture | The Full-stack Software Design & Architecture Map
Sep 28th, 2019 / 17 min read
Software Design and Architecture is pretty much it's own field of study within the realm of computing, like DevOps or UX Design. H...
The 6 Most Common Types of Logic in Large Applications [with Examples]
Sep 16th, 2019 / 12 min read
In this article, you'll learn about the Clean Architecture, why we should separate the concerns of large applications into layers,...

Want to be notified when new content comes out?

Join 3000+ other developers learning about Domain-Driven Design and Enterprise Node.js.

I won't spam ya. 🖖 Unsubscribe anytime.

Get updates