From Spaghetti to Swappable: Learning InversifyJS by Building a Task API

When you first start writing Node.js/TypeScript apps, it feels natural to just new things up where you need them:

js2 lines
1const repo = new MemoryTaskRepository();
2const logger = new ConsoleLogger();

It works fine
 until your project grows. Suddenly, swapping implementations, writing tests, or adding logging everywhere feels like untangling spaghetti. That’s where InversifyJS steps in.


A Quick Analogy: Making Coffee ☕#

Think about coffee.

  • Manual wiring (no DI): You buy the beans, grind them, boil the water, froth the milk, and make your latte by hand every single time. It works, but if tomorrow you want a cappuccino instead, you’ll need to learn and redo everything yourself.

  • With Dependency Injection: You walk into a coffee shop and just say “I’d like a latte.” You don’t care if they use oat milk, almond milk, or a fancy new machine—they just deliver what you asked for.

That’s what InversifyJS does for your code: instead of classes making their own “coffee,” they just ask for it. The container (the barista) takes care of the details.

The Pain Without DI#

Imagine a controller that creates tasks:

ts12 lines
1class TasksController {
2  private repo = new MemoryTaskRepository();
3  private logger = new ConsoleLogger();
4
5  async createTask(title: string) {
6    if (!title.trim()) {
7      this.logger.error("Missing title");
8      return { error: "title required" };
9    }
10    return this.repo.create(title);
11  }
12}

This controller directly depends on concrete classes (MemoryTaskRepository, ConsoleLogger).

  • Want to test it? You can’t fake the repo or spy on the logger without rewriting the code.

  • Want to switch to PostgreSQL? Time to edit every file that uses the repo.

That’s tight coupling. That’s spaghetti.

Contracts: The Secret Sauce#

Here’s where contracts come in. A contract is just an interface—an agreement between code pieces.

ts8 lines
1export interface TaskRepository {
2  create(title: string): Promise<Task>;
3}
4
5export interface Logger {
6  info(msg: string, meta?: unknown): void;
7  error(msg: string, meta?: unknown): void;
8}

The controller now depends on contracts:

ts7 lines
1class TasksController {
2  constructor(private repo: TaskRepository, private logger: Logger) {}
3
4  async createTask(title: string) {
5    /* ... */
6  }
7}

Now, the controller doesn’t care how the repo works. Memory, Postgres, fake in-memory test repo—it all fits the same contract.

Enter InversifyJS#

InversifyJS is a tiny library that acts like a factory for your app’s dependencies. You tell it:

  • “Whenever someone asks for TaskRepository, give them a MemoryTaskRepository.”

  • “Whenever someone asks for Logger, give them a ConsoleLogger.”

Container setup

ts5 lines
1container
2  .bind<TaskRepository>(TYPES.TaskRepository)
3  .to(MemoryTaskRepository)
4  .inSingletonScope();
5container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inSingletonScope();

Controller with DI

ts7 lines
1@injectable()
2class TasksController {
3  constructor(
4    @inject(TYPES.TaskRepository) private repo: TaskRepository,
5    @inject(TYPES.Logger) private logger: Logger
6  ) {}
7}

No new keywords in sight. Just clean dependencies.

Visual: Manual Wiring vs InversifyJS#

Manual Wiring (tight coupling)

csharp5 lines
1[TasksController]
2      |
3      +--> new MemoryTaskRepository()
4      |
5      +--> new ConsoleLogger()
  • The controller creates its own dependencies.

  • Hard to swap, hard to test, everything is glued together.

InversifyJS (loose coupling with contracts)#

csharp6 lines
1[TasksController]
2      |          ^
3      | inject   | bind
4      v          |
5   [TaskRepository] <--- Container ---> [MemoryTaskRepository]
6   [Logger]        <--- Container ---> [ConsoleLogger]
  • The controller asks for contracts (TaskRepository, Logger).

  • The container provides the implementations (MemoryTaskRepository, ConsoleLogger).

  • Want Postgres instead of Memory? Just change one line in the container.

Choosing Scopes: Singleton, Transient, Request#

One of the coolest parts of InversifyJS is that you can choose how often a dependency gets created. This is what scopes are about. It’s like deciding whether your service should be one shared instance or a fresh one every time.

☕ Coffee Cup Analogy#

Imagine you walk into a café and order coffee:

  • Singleton (shared mug): The shop gives you one sturdy mug that everyone in the cafĂ© shares. Each person who orders coffee drinks from the same cup. (Great if it’s a reusable mug, terrible if it’s a toothbrush đŸȘ„).

  • Transient (new paper cup each time): Every single order gets a brand-new disposable cup. No sharing at all. Perfectly fresh, but maybe wasteful if you don’t need it.

  • Request (one mug per customer per visit): Each customer gets their own mug for the duration of their visit. They can refill it as much as they want while they’re there, but once they leave, the mug goes back to the counter.

That’s exactly how inSingletonScope, inTransientScope, and inRequestScope work in InversifyJS.

ts17 lines
1@injectable()
2class Counter {
3  private count = 0;
4  increment() {
5    this.count++;
6    return this.count;
7  }
8}
9
10// Singleton: one shared mug
11container.bind(Counter).toSelf().inSingletonScope();
12
13// Transient: new paper cup each time
14container.bind(Counter).toSelf().inTransientScope();
15
16// Request: one mug per customer/request
17container.bind(Counter).toSelf().inRequestScope();

Why It Matters#

  • Singleton → for global services (loggers, config, DB pools)

  • Transient → for lightweight helpers (formatters, validators)

  • Request → for web apps where each request needs isolation (per-request cache, user session, correlation IDs)

Testing Becomes a Breeze#

Want to test without touching real repos? Just swap the bindings in a test container:

ts23 lines
1class FakeRepo implements TaskRepository {
2  async create(title: string) {
3    return {
4      id: "1",
5      title,
6      done: false,
7      createdAt: new Date(),
8      updatedAt: new Date(),
9    };
10  }
11}
12
13class SpyLogger implements Logger {
14  info = vi.fn();
15  error = vi.fn();
16}
17
18const c = new Container();
19c.bind<TaskRepository>(TYPES.TaskRepository).to(FakeRepo);
20c.bind<Logger>(TYPES.Logger).toConstantValue(new SpyLogger());
21c.bind(TasksController).toSelf();
22
23const controller = c.get(TasksController);

Now you can assert logs, simulate errors, swap repos—all without editing production code.

Real API in Minutes#

With inversify-express-utils, you can even wire controllers straight to routes:

ts7 lines
1@controller("/tasks")
2class TasksController {
3  @httpPost("/")
4  async create(@requestBody() body: { title: string }) {
5    return this.repo.create(body.title);
6  }
7}

And just like that, you’ve got a REST API where every dependency is swappable.

Why It Matters#

When your codebase is small, InversifyJS might feel like extra work. But as soon as you need:

  • Testing with fakes/spies

  • Swapping implementations (memory vs DB)

  • Scaling without spaghetti


DI pays for itself.

Think of Inversify as a universal plug adapter: your controller doesn’t care if it’s plugged into Memory, Postgres, or Fake—it just knows the plug fit

Takeaways#

  • Contracts = interfaces. They define the “what,” not the “how.”

  • DI container = wiring central. Keeps consumers clean.

  • Scopes let you control lifetime: singleton, transient, or request.

  • InversifyJS = decorator-powered DI for TypeScript, making your code swappable and testable.

Final Thought#

Next time you feel the urge to sprinkle new everywhere in your code, pause. Ask yourself: “What if I want to swap this out later? What if I need to test it?” That’s when you reach for contracts and InversifyJS.

MB

Mehrshad Baqerzadegan

Sharing thoughts on technology and best practices.