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:
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:
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.
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:
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
1container
2 .bind<TaskRepository>(TYPES.TaskRepository)
3 .to(MemoryTaskRepository)
4 .inSingletonScope();
5container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inSingletonScope();
Controller with DI
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)
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)#
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.
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:
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:
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.