Skip to main content

Guide

InversifyJS vs Awilix vs TSyringe 2026

Compare InversifyJS, Awilix, and TSyringe for dependency injection in Node.js. Decorator vs functional DI, reflect-metadata, TypeScript support, NestJS.

·PkgPulse Team·
0

TL;DR

InversifyJS is the most popular decorator-based DI container — @injectable, @inject, and a familiar pattern for developers coming from NestJS or Spring. Awilix takes the opposite approach — no decorators, no reflect-metadata, just a functional container that auto-wires by naming convention. TSyringe is Microsoft's lightweight DI container — similar to InversifyJS but simpler, with lower boilerplate. For most Node.js apps: Awilix (no decorators, simpler). For teams comfortable with NestJS-style decorators: InversifyJS or TSyringe. And honestly: if you're using NestJS, just use NestJS's built-in DI.

Key Takeaways

  • inversify: ~1.5M weekly downloads — decorator-based, @injectable/@inject, most popular IoC container
  • awilix: ~400K weekly downloads — functional, no decorators, auto-wire by naming, simpler
  • tsyringe: ~600K weekly downloads — Microsoft, lighter than InversifyJS, same decorator pattern
  • All require experimentalDecorators: true and emitDecoratorMetadata: true — except Awilix
  • Dependency injection is most useful for: testability, swapping implementations, large applications
  • For small apps, DI containers add complexity without benefit — just pass dependencies manually

PackageWeekly DownloadsDecoratorsreflect-metadataAuto-wire
inversify~1.5M✅ Required
awilix~400K
tsyringe~600K✅ Required

InversifyJS

InversifyJS — the most popular IoC container for TypeScript:

Setup

// tsconfig.json — required for InversifyJS:
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

// main.ts — import reflect-metadata FIRST:
import "reflect-metadata"

Define interfaces and implementations

import "reflect-metadata"
import { injectable, inject, Container } from "inversify"

// Symbols as identifiers (recommended over string tokens):
const TYPES = {
  PackageRepository: Symbol.for("PackageRepository"),
  CacheService: Symbol.for("CacheService"),
  PackageService: Symbol.for("PackageService"),
} as const

// Interface:
interface IPackageRepository {
  findByName(name: string): Promise<Package | null>
  getTopPackages(limit: number): Promise<Package[]>
}

interface ICacheService {
  get<T>(key: string): Promise<T | null>
  set<T>(key: string, value: T, ttl: number): Promise<void>
}

// Implementations:
@injectable()
class NpmPackageRepository implements IPackageRepository {
  async findByName(name: string): Promise<Package | null> {
    const res = await fetch(`https://registry.npmjs.org/${name}`)
    if (!res.ok) return null
    return res.json()
  }

  async getTopPackages(limit: number): Promise<Package[]> {
    // Implementation...
    return []
  }
}

@injectable()
class RedisCache implements ICacheService {
  async get<T>(key: string): Promise<T | null> {
    // Redis get...
    return null
  }

  async set<T>(key: string, value: T, ttl: number): Promise<void> {
    // Redis set...
  }
}

// Service with injected dependencies:
@injectable()
class PackageService {
  constructor(
    @inject(TYPES.PackageRepository) private repo: IPackageRepository,
    @inject(TYPES.CacheService) private cache: ICacheService
  ) {}

  async getPackageHealth(name: string): Promise<PackageHealth> {
    const cached = await this.cache.get<PackageHealth>(`health:${name}`)
    if (cached) return cached

    const pkg = await this.repo.findByName(name)
    if (!pkg) throw new Error(`Package ${name} not found`)

    const health = calculateHealth(pkg)
    await this.cache.set(`health:${name}`, health, 3600)
    return health
  }
}

Container setup and resolution

import { Container } from "inversify"

// Configure container:
const container = new Container()

container.bind<IPackageRepository>(TYPES.PackageRepository)
  .to(NpmPackageRepository)
  .inSingletonScope()  // One instance shared across all consumers

container.bind<ICacheService>(TYPES.CacheService)
  .to(RedisCache)
  .inSingletonScope()

container.bind<PackageService>(TYPES.PackageService)
  .to(PackageService)
  .inSingletonScope()

// Resolve:
const packageService = container.get<PackageService>(TYPES.PackageService)
// Dependencies injected automatically

const health = await packageService.getPackageHealth("react")

Testing with InversifyJS

import { Container } from "inversify"

// Swap implementations for tests:
const testContainer = new Container()

// Mock repository:
const mockRepo: IPackageRepository = {
  findByName: vi.fn().mockResolvedValue({ name: "react", version: "18.3.1" }),
  getTopPackages: vi.fn().mockResolvedValue([]),
}

testContainer.bind<IPackageRepository>(TYPES.PackageRepository)
  .toConstantValue(mockRepo)

testContainer.bind<ICacheService>(TYPES.CacheService)
  .toConstantValue({ get: vi.fn().mockResolvedValue(null), set: vi.fn() })

testContainer.bind<PackageService>(TYPES.PackageService).to(PackageService)

const service = testContainer.get<PackageService>(TYPES.PackageService)
// service uses mock implementations

Awilix

Awilix — functional DI without decorators:

Setup (no reflect-metadata needed)

import { createContainer, asClass, asValue, asFunction, Lifetime } from "awilix"

// No tsconfig changes needed — no decorators

Register and resolve

import { createContainer, asClass, asValue, Lifetime } from "awilix"

// Classes — dependencies injected by parameter name matching:
class PackageRepository {
  async findByName(name: string): Promise<Package | null> {
    const res = await fetch(`https://registry.npmjs.org/${name}`)
    if (!res.ok) return null
    return res.json()
  }
}

class CacheService {
  private cache = new Map<string, { value: unknown; expires: number }>()

  get<T>(key: string): T | null {
    const entry = this.cache.get(key)
    if (!entry || entry.expires < Date.now()) return null
    return entry.value as T
  }

  set(key: string, value: unknown, ttl: number): void {
    this.cache.set(key, { value, expires: Date.now() + ttl * 1000 })
  }
}

// Awilix injects by matching constructor parameter names to container registrations:
class PackageService {
  constructor(
    private packageRepository: PackageRepository,  // ← name matches registration
    private cacheService: CacheService             // ← name matches registration
  ) {}

  async getHealth(name: string) {
    const cached = this.cacheService.get<PackageHealth>(`health:${name}`)
    if (cached) return cached

    const pkg = await this.packageRepository.findByName(name)
    // ...
  }
}

// Container:
const container = createContainer()

container.register({
  packageRepository: asClass(PackageRepository, { lifetime: Lifetime.SINGLETON }),
  cacheService: asClass(CacheService, { lifetime: Lifetime.SINGLETON }),
  packageService: asClass(PackageService, { lifetime: Lifetime.SINGLETON }),
})

// Resolve:
const packageService = container.resolve<PackageService>("packageService")

Scoped lifetime (per-request)

import { createContainer, asClass, Lifetime } from "awilix"
import express from "express"

const container = createContainer()

container.register({
  packageRepository: asClass(PackageRepository, { lifetime: Lifetime.SINGLETON }),
  requestLogger: asClass(RequestLogger, { lifetime: Lifetime.SCOPED }),  // Per-request
  packageService: asClass(PackageService, { lifetime: Lifetime.SCOPED }),
})

const app = express()

// Create a scope per request:
app.use((req, res, next) => {
  req.container = container.createScope()  // New scope for each request
  next()
})

app.get("/packages/:name", async (req, res) => {
  // resolve from the request scope:
  const service = req.container.resolve<PackageService>("packageService")
  const health = await service.getHealth(req.params.name)
  res.json(health)
})

TSyringe

TSyringe — Microsoft's lightweight DI container:

Setup

// tsconfig.json (same as InversifyJS):
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

import "reflect-metadata"  // Import at app entry point

Usage

import { injectable, inject, container, singleton } from "tsyringe"

// Service identifier tokens:
const PackageRepositoryToken = "PackageRepository"

@injectable()
class NpmPackageRepository implements IPackageRepository {
  async findByName(name: string): Promise<Package | null> {
    const res = await fetch(`https://registry.npmjs.org/${name}`)
    return res.ok ? res.json() : null
  }
}

@singleton()  // Register as singleton automatically
class PackageService {
  constructor(
    @inject(PackageRepositoryToken) private repo: IPackageRepository
  ) {}

  async getHealth(name: string) {
    const pkg = await this.repo.findByName(name)
    return pkg ? calculateHealth(pkg) : null
  }
}

// Register:
container.register(PackageRepositoryToken, { useClass: NpmPackageRepository })

// Resolve:
const service = container.resolve(PackageService)

Testing with TSyringe

import { container } from "tsyringe"

beforeEach(() => {
  container.clearInstances()  // Clear between tests
})

test("gets package health", async () => {
  // Register mock:
  container.register(PackageRepositoryToken, {
    useValue: {
      findByName: vi.fn().mockResolvedValue({ name: "react", version: "18.3.1" }),
    },
  })

  const service = container.resolve(PackageService)
  const health = await service.getHealth("react")
  expect(health).toBeDefined()
})

Feature Comparison

FeatureInversifyJSAwilixTSyringe
Decorators
reflect-metadata✅ Required✅ Required
Auto-wire✅ By name
Scoped lifetime
TypeScript
Circular deps✅ (lazyInject)⚠️
Bundle size~100KB~30KB~15KB
ComplexityHighMediumLow
TestingGoodExcellentGood

When to Use Each

Choose InversifyJS if:

  • Large enterprise TypeScript applications with complex dependency graphs
  • You're familiar with Spring or NestJS-style DI patterns
  • You need full IoC features: middleware, conditional binding, factory injection
  • Team has experience with IoC containers

Choose Awilix if:

  • You want DI without decorator/reflect-metadata overhead
  • ESM or Bun environments where decorators are problematic
  • Functional style is preferred over OOP decorators
  • Scoped container per HTTP request (Awilix excels here)

Choose TSyringe if:

  • You want InversifyJS-style decorators with less boilerplate
  • Building a smaller application where full InversifyJS is overkill
  • Microsoft ecosystem — TSyringe pairs well with other MS tools

Consider NOT using a DI container if:

  • Small/medium application — manual dependency passing is simpler
  • Using NestJS — its built-in DI is already excellent
  • The overhead of decorators and reflect-metadata isn't worth it
  • Team isn't familiar with DI patterns — can add confusion without clear benefit

reflect-metadata and the Decorator Pipeline Complexity

InversifyJS and TSyringe both require reflect-metadata and emitDecoratorMetadata, and this dependency carries more operational weight than most articles acknowledge. emitDecoratorMetadata is a TypeScript compiler option that emits runtime type information for decorated classes — specifically, it serializes constructor parameter types as metadata using the Reflect API. This metadata is how InversifyJS knows which concrete type to inject without explicit @inject() decorators for each parameter.

The problem is that emitDecoratorMetadata is only available in TypeScript's tsc compiler and is not supported by esbuild, SWC, or Vite's default transform pipeline. This means InversifyJS and TSyringe projects cannot use esbuild or SWC as their TypeScript compiler without additional configuration or a Babel plugin shim (babel-plugin-transform-typescript with optimizeConstEnums). In 2026, esbuild and SWC are the dominant TypeScript compilers for Node.js toolchains (used by Vite, Next.js, tsx, and Bun), making this incompatibility a genuine maintenance burden.

Awilix sidesteps this entirely. Because it resolves dependencies by matching constructor parameter names against the container registry, it needs no compile-time type metadata — it uses standard JavaScript function introspection. This works with every TypeScript transpiler including esbuild and SWC, and it works under Bun's native TypeScript execution without any configuration. For new projects in 2026 that want to avoid the decorator/reflect-metadata ecosystem, Awilix is the only viable option among these three.

The TypeScript 5.x decorator proposal (Stage 3 ECMAScript decorators) is not compatible with the legacy decorator metadata emitted by emitDecoratorMetadata. InversifyJS v7 (in development) is planned to support the new decorator standard, but the migration from v6 requires changes throughout the codebase. Teams committing to InversifyJS today should factor in this upcoming migration.


Testing Patterns Across All Three Containers

Dependency injection's primary value in testing is the ability to swap production implementations for test doubles without modifying application code. Each container has idiomatic patterns for this.

InversifyJS testing works cleanly when you create a separate container for each test suite. A beforeEach hook creates a fresh container, binds mock implementations using toConstantValue or toFunction, and binds the system under test. Because toConstantValue accepts any object — including vi.fn() mocks — binding a mock repository is a single line. The key discipline is never using the global application container in tests; always create an isolated container with explicit bindings.

Awilix's testing story is arguably the cleanest of the three. Because dependencies are resolved by name, you can replace a single registration with a mock while leaving all other registrations intact. container.register({ packageRepository: asValue(mockRepo) }) overrides just the repository, and the real cacheService and packageService from your application container remain in place. This targeted override approach is harder to do cleanly with decorator-based containers where the full resolution chain must be explicitly configured.

TSyringe's container.clearInstances() method resets all singleton instances between tests without clearing the registrations. This is the correct pattern for testing singletons: registrations persist (so you don't need to re-register the production implementations) but the singleton instances are recreated, giving each test a fresh object with no state carried over from previous tests. Combine this with container.register(Token, { useValue: mockImpl }) to inject mocks selectively. Call container.reset() (clears registrations too) only in tests where you need complete isolation from other test files.


Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on inversify v6.x, awilix v10.x, and tsyringe v4.x.

Compare developer tool and architecture packages on PkgPulse →

In 2026, dependency injection in Node.js is less common than in Java or C# ecosystems, but all three libraries serve their niches well. InversifyJS is the most mature and well-documented option for large TypeScript applications with complex object graphs. Awilix is the lightweight pragmatic choice for Node.js backends where decorator syntax feels heavy. TSyringe is the natural choice for teams already committed to the Microsoft TypeScript decorator ecosystem (Angular, NestJS adjacents). For most new Node.js projects in 2026, the trend is away from full DI containers toward module-level singletons and dependency passing — these libraries shine most in large teams and complex service graphs.

The question of whether to use a DI container at all is worth addressing directly. In Node.js applications, the most common argument for dependency injection is testability — being able to substitute real implementations with mocks without modifying production code. But this goal is achievable without a DI container: if you design your modules to accept dependencies as constructor parameters or function arguments rather than importing them at the top of the file, you can pass mocks directly in tests. The container provides value when the dependency graph is large enough that manually wiring it up at startup is error-prone, or when you need lifecycle management (singletons, scoped instances per request). For applications with fewer than 10-15 distinct service classes, the overhead of configuring a container may exceed the benefit.

A useful mental model for choosing between these three libraries is to think about who pays the cognitive cost: with InversifyJS and TSyringe, developers pay up front at definition time (adding decorators, writing symbols, managing token registrations) but resolution is automatic. With Awilix, the cost is front-loaded in the naming convention discipline — constructor parameter names must exactly match container registrations — but there's no metadata overhead at runtime. For teams transitioning from Java or C# backgrounds, InversifyJS's decorator model is immediately familiar. For teams coming from a pure JavaScript or functional background, Awilix's explicit registration without decorators feels cleaner. TSyringe occupies the middle ground: the decorator syntax is simpler than InversifyJS (fewer binding steps) but still requires the reflect-metadata dependency that creates the esbuild/SWC incompatibility.

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.