JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 13
  • Score
    100M100P100Q92895F
  • License MIT

Annotation-free, Autofac-style dependency injection container for TypeScript

Package Exports

  • @novadi/core
  • @novadi/core/transformer
  • @novadi/core/unplugin

Readme

NovaDI Core

Annotation-free, blazing-fast dependency injection for TypeScript

NovaDI is a modern dependency injection container that keeps your business logic clean from framework code. No decorators, no annotations, no runtime reflection - just pure TypeScript and compile-time type safety.

Version License TypeScript Bundle Size


Why NovaDI?

Most TypeScript DI frameworks force you to pollute your code with decorators:

// ❌ Other frameworks - tight coupling everywhere
@Injectable()
class UserService {
  constructor(
    @Inject('ILogger') private logger: ILogger,
    @Inject('IDatabase') private db: IDatabase
  ) {}
}

NovaDI keeps your code clean:

// ✅ NovaDI - clean, testable code
class UserService {
  constructor(
    private logger: ILogger,
    private database: IDatabase
  ) {}
}

// DI configuration lives in ONE place (Composition Root)
const container = new Container()
const builder = container.builder()

builder.registerType(ConsoleLogger).asInterface<ILogger>().singleInstance()
builder.registerType(PostgresDatabase).asInterface<IDatabase>().singleInstance()
builder.registerType(UserService).asInterface<UserService>().autoWire()

const app = builder.build()
const userService = app.resolveInterface<UserService>()

Your business logic stays framework-agnostic. Your tests stay simple. Your architecture stays clean.


Features

  • Zero Annotations - No decorators in your business code
  • Convention Over Configuration - .autoWire() automatically wires ALL dependencies by convention
  • It Just Works - No manual configuration needed
  • Blazing Fast - Multi-tier caching, object pooling, zero-overhead singletons
  • Type-Safe - Full TypeScript type inference and compile-time checking
  • Composition Root - All DI configuration in one place
  • Multiple Lifetimes - Singleton, Transient (default), Per-Request scoping
  • TypeScript Transformer - Compile-time type name injection
  • Tiny Bundle - Only ~59 KB compiled

Quick Start

Installation

npm install @novadi/core
# or
yarn add @novadi/core
# or
pnpm add @novadi/core

Setup - Choose Your Integration Method

NovaDI uses a TypeScript transformer to automatically inject type names at compile-time. This enables clean, annotation-free code while maintaining full type safety.

Why a transformer? TypeScript erases all type information at runtime. The transformer captures type names during compilation, enabling powerful features like dependency graph generation, compile-time validation, circular dependency detection, and automated wiring - all with zero runtime overhead.

Use unplugin for universal bundler support. This is the easiest and most reliable approach.

Vite:

// vite.config.ts
import { defineConfig } from 'vite'
import { NovadiUnplugin } from '@novadi/core/unplugin'

export default defineConfig({
  plugins: [NovadiUnplugin.vite()]
})

webpack:

// webpack.config.js
const { NovadiUnplugin } = require('@novadi/core/unplugin')

module.exports = {
  plugins: [NovadiUnplugin.webpack()]
}

Rollup:

// rollup.config.js
import { NovadiUnplugin } from '@novadi/core/unplugin'

export default {
  plugins: [NovadiUnplugin.rollup()]
}

esbuild:

// esbuild.config.js
const { NovadiUnplugin } = require('@novadi/core/unplugin')

require('esbuild').build({
  plugins: [NovadiUnplugin.esbuild()]
})

Option 2: TypeScript Compiler (tsc)

For direct tsc compilation, use ts-patch:

npm install -D ts-patch
npx ts-patch install

Add to tsconfig.json:

{
  "compilerOptions": {
    "plugins": [
      { "transform": "@novadi/core/transformer" }
    ]
  }
}

⚠️ WARNING: This approach is considered bad practice and should be avoided.

Using manual type name literals:

  • ❌ Introduces potential for typos and errors
  • ❌ Creates maintenance burden (refactoring becomes error-prone)
  • ❌ Loses all transformer benefits (validation, graphs, analysis)
  • ❌ No compile-time safety for type names
  • ❌ Verbose and repetitive code

Only use this if you absolutely cannot use a transformer (e.g., runtime-only environments like tsx or ts-node where there's no build step).

If you must use manual type names:

// ⚠️ NOT RECOMMENDED - Manual type name literals
builder.registerType(ConsoleLogger).asInterface<ILogger>("ILogger")
const logger = app.resolveInterface<ILogger>("ILogger")

builder
  .registerType(UserService)
  .asInterface<UserService>("UserService")
  .autoWire({
    map: {
      logger: (c) => c.resolveInterface<ILogger>("ILogger")
    }
  })

Why the transformer is superior:

// ✅ With transformer - type names auto-injected
.asInterface<ILogger>()           // Becomes: .asInterface<ILogger>("ILogger")
.resolveInterface<ILogger>()      // Becomes: .resolveInterface<ILogger>("ILogger")

// Plus you get:
// ✅ Compile-time validation of all dependencies
// ✅ Dependency graph generation
// ✅ Circular dependency detection before runtime
// ✅ Missing registration warnings
// ✅ IDE integration for inline errors
// ✅ Zero typo risk
// ✅ Refactoring safety

Future transformer capabilities (see roadmap):

  • Generate visual dependency graphs
  • Detect unused registrations
  • Validate entire container at compile-time
  • Export dependency information for documentation
  • Integration with development tools

Basic Usage - It Just Works!

import { Container } from '@novadi/core'

// 1. Define your services (clean code, no decorators!)
interface ILogger {
  log(message: string): void
}

class ConsoleLogger implements ILogger {
  log(message: string) {
    console.log(`[LOG] ${message}`)
  }
}

class UserService {
  constructor(private logger: ILogger) {}

  createUser(name: string) {
    this.logger.log(`Creating user: ${name}`)
  }
}

// 2. Configure container (Composition Root)
const container = new Container()
const builder = container.builder()

// Register implementations
builder.registerType(ConsoleLogger).asInterface<ILogger>().singleInstance()

// AutoWire does ALL the wiring by convention!
builder.registerType(UserService).asInterface<UserService>().autoWire()

const app = builder.build()

// 3. Resolve and use
const userService = app.resolveInterface<UserService>()
userService.createUser('Alice') // [LOG] Creating user: Alice

That's it! No manual configuration. No mapping. Just .autoWire() - convention over configuration.

The logger parameter automatically resolves to the registered ILogger interface by naming convention. This is THE way to use NovaDI.


AutoWire - Convention Over Configuration

Autowiring by convention is THE way you wire dependencies. No manual configuration, no boilerplate - it just works.

The Standard Way - Type Injection by Convention

class UserService {
  constructor(
    private logger: ILogger,      // Automatically resolves ILogger by convention
    private database: IDatabase   // Automatically resolves IDatabase by convention
  ) {}
}

// This is all you need - autowiring by convention!
builder.registerType(UserService).asInterface<UserService>().autoWire()

How it works:

  • Extracts parameter names from constructor (logger, database)
  • Tries multiple naming conventions (ILogger, Logger, logger)
  • Automatically resolves the matching registered interfaces
  • Zero configuration - pure convention!

This is how you should wire ALL your services. Convention over configuration - always.

Explicit Mapping (Edge Cases Only)

Only use explicit mapping for rare cases where autowiring can't help:

builder
  .registerType(SmartLight)
  .asInterface<IDevice>()
  .autoWire({
    map: {
      id: () => 'light-123',              // Primitive value injection
      name: () => 'Living Room Light',    // String injection
      logger: (c) => c.resolveInterface<ILogger>()  // Custom resolution logic
    }
  })

Only use explicit mapping when:

  • Injecting primitives, strings, or configuration values
  • You need custom resolution logic (rare)
  • You're NOT using the transformer AND code is minified

For regular service dependencies, always use .autoWire() without arguments!


Lifetimes

Important: Default lifetime is transient (new instance every time).

Singleton - One instance for the container lifetime

builder.registerType(Database).asInterface<IDatabase>().singleInstance()

Use for: Loggers, database connections, configuration, caches

Transient - New instance every resolution (DEFAULT)

builder.registerType(RequestHandler).asInterface<IRequestHandler>()
// No .singleInstance() = transient by default

Use for: Request handlers, commands, stateful operations

Per-Request - One instance per resolution tree

builder.registerType(UnitOfWork).asInterface<IUnitOfWork>().instancePerRequest()

Use for: Database transactions, request-scoped state


Real-World Example

import { Container } from '@novadi/core'

// Services (clean code, no framework imports!)
interface ILogger {
  info(message: string): void
  error(message: string, error?: Error): void
}

class ConsoleLogger implements ILogger {
  info(message: string) { console.log(`[INFO] ${message}`) }
  error(message: string, error?: Error) { console.error(`[ERROR] ${message}`, error) }
}

interface IDatabase {
  query<T>(sql: string): Promise<T[]>
}

class PostgresDatabase implements IDatabase {
  constructor(private logger: ILogger) {}

  async query<T>(sql: string): Promise<T[]> {
    this.logger.info(`Executing query: ${sql}`)
    // Implementation...
    return []
  }
}

class UserService {
  constructor(
    private database: IDatabase,
    private logger: ILogger
  ) {}

  async getUser(id: number) {
    this.logger.info(`Fetching user ${id}`)
    return this.database.query(`SELECT * FROM users WHERE id = ${id}`)
  }
}

// Composition Root
const container = new Container()
const builder = container.builder()

builder.registerType(ConsoleLogger).asInterface<ILogger>().singleInstance()
builder.registerType(PostgresDatabase).asInterface<IDatabase>().singleInstance().autoWire()
builder.registerType(UserService).asInterface<UserService>().autoWire()

const app = builder.build()

// Use it
const userService = app.resolveInterface<UserService>()
await userService.getUser(123)

Notice:

  • All service files are pure TypeScript - no decorators, no framework imports
  • .autoWire() handles ALL dependency wiring by convention
  • No manual mapping needed - it just works
  • Configuration lives in ONE place
  • Testing is trivial: new UserService(mockDB, mockLogger)

Why No Decorators?

Many DI frameworks (NestJS, InversifyJS, TypeDI, TSyringe) rely heavily on decorators. While convenient, this approach violates fundamental software design principles:

1. Violation of Separation of Concerns

Your business logic should not know about the DI framework:

// ❌ BAD: Business logic tightly coupled to framework
import { Injectable, Inject } from 'some-di-framework'

@Injectable()
class OrderService {
  constructor(
    @Inject('PaymentGateway') private payment: IPaymentGateway,
    @Inject('EmailService') private email: IEmailService,
    @Inject('Logger') private logger: ILogger
  ) {}

  processOrder(order: Order) {
    // Business logic here...
  }
}

Problems:

  • Cannot use OrderService without the DI framework
  • Tests must mock the framework's injection mechanism
  • Framework is now a core dependency, not infrastructure
  • Changing DI frameworks requires modifying all service files
// ✅ GOOD: Clean business logic
class OrderService {
  constructor(
    private payment: IPaymentGateway,
    private email: IEmailService,
    private logger: ILogger
  ) {}

  processOrder(order: Order) {
    // Same business logic, zero framework coupling
  }
}

// DI configuration lives separately (Composition Root)
const container = new Container()
const builder = container.builder()

// Convention over configuration - autowiring by parameter names
builder.registerType(OrderService).asInterface<OrderService>().autoWire()

Benefits:

  • OrderService can be instantiated without any framework
  • Unit tests are trivial: new OrderService(mockPayment, mockEmail, mockLogger)
  • Framework is swappable without touching business code
  • Code is portable across projects

2. Testing Becomes Harder

// ❌ With decorators - need framework in tests
import { Test } from '@nestjs/testing'

describe('OrderService', () => {
  it('processes order', async () => {
    // Must set up entire DI framework for a simple test
    const module = await Test.createTestingModule({
      providers: [
        OrderService,
        { provide: 'PaymentGateway', useValue: mockPayment },
        { provide: 'EmailService', useValue: mockEmail },
        { provide: 'Logger', useValue: mockLogger }
      ]
    }).compile()

    const service = module.get<OrderService>(OrderService)
    // Finally can test...
  })
})

// ✅ Without decorators - pure unit tests
describe('OrderService', () => {
  it('processes order', () => {
    const service = new OrderService(mockPayment, mockEmail, mockLogger)
    // Test immediately, no framework needed
  })
})

3. Breaks the Dependency Inversion Principle

The Dependency Inversion Principle states: "High-level modules should not depend on low-level modules. Both should depend on abstractions."

When you add @Injectable() to a class, you're making it depend on the DI framework (a low-level module).

// ❌ Depends on DI framework (violation)
import { Injectable } from 'framework' // <- Infrastructure dependency

@Injectable() // <- Framework coupling
class BusinessService { /* ... */ }

// ✅ Depends on nothing (correct)
class BusinessService { /* ... */ }

4. Composition Root Pattern

NovaDI follows the Composition Root pattern - all DI configuration happens in ONE place at the application's entry point:

// main.ts - The ONLY place that knows about DI
import { Container } from '@novadi/core'

// All wiring happens here
const container = new Container()
const builder = container.builder()

// Infrastructure layer - singletons
builder.registerType(ConsoleLogger).asInterface<ILogger>().singleInstance()
builder.registerType(PostgresDatabase).asInterface<IDatabase>().singleInstance().autoWire()
builder.registerType(StripePayment).asInterface<IPaymentGateway>().singleInstance()
builder.registerType(SendGridEmail).asInterface<IEmailService>().singleInstance()

// Service layer - autowired by convention
builder.registerType(OrderService).asInterface<OrderService>().autoWire()
builder.registerType(UserService).asInterface<UserService>().autoWire()

// Application layer
builder.registerType(Application).asInterface<Application>().autoWire()

const app = builder.build()

// Start application
const application = app.resolveInterface<Application>()
application.start()

Everything else is clean business code with zero DI knowledge.

Comparison: Decorator Hell vs Clean Code

NestJS/InversifyJS Style (Decorators Everywhere):

// user.service.ts
import { Injectable, Inject } from '@nestjs/common'

@Injectable()
export class UserService {
  constructor(
    @Inject('ILogger') private logger: ILogger,
    @Inject('IDatabase') private db: IDatabase
  ) {}
}

// order.service.ts
import { Injectable, Inject } from '@nestjs/common'

@Injectable()
export class OrderService {
  constructor(
    @Inject('IPayment') private payment: IPayment,
    @Inject(UserService) private users: UserService
  ) {}
}

// Every file imports framework code!
// Every class is coupled to the DI container!
// Cannot test without framework!

NovaDI Style (Clean Separation):

// user.service.ts
export class UserService {
  constructor(
    private logger: ILogger,
    private database: IDatabase
  ) {}
}

// order.service.ts
export class OrderService {
  constructor(
    private payment: IPayment,
    private users: UserService
  ) {}
}

// main.ts (Composition Root)
const container = new Container()
const builder = container.builder()
builder.registerType(UserService).asInterface<UserService>().autoWire()
builder.registerType(OrderService).asInterface<OrderService>().autoWire()
const app = builder.build()

// Business code knows nothing about DI!
// Tests are trivial: new UserService(mockLogger, mockDb)
// Framework can be swapped without touching services!

Advanced Usage

Factories

builder
  .register((c) => {
    const config = c.resolveInterface<IConfig>()
    const logger = c.resolveInterface<ILogger>()
    return new ComplexService(config, logger, new Date())
  })
  .asInterface<IComplexService>()
  .singleInstance()

Instances

const config = { apiKey: 'secret', timeout: 5000 }
builder.registerInstance(config).asInterface<IConfig>()

Scoped Containers

// Create child scope per request
app.use((req, res, next) => {
  const requestScope = app.createChild()
  req.container = requestScope
  next()
})

// Resolve per-request services
const handler = req.container.resolveInterface<IRequestHandler>()

Keyed Services

// Register multiple implementations
builder.registerType(RedisCache).asInterface<ICache>().keyed('redis')
builder.registerType(MemoryCache).asInterface<ICache>().keyed('memory')

// Resolve specific implementation
const redisCache = app.resolveKeyed<ICache>('redis')

Technical Deep Dive

For the curious developers who want to know how it works under the hood.

Performance Architecture

NovaDI uses a three-tier resolution strategy for maximum speed:

Tier 1: Ultra-Fast Path (Singletons)

private readonly ultraFastSingletonCache: Map<Token<any>, any> = new Map()

resolve<T>(token: Token<T>): T {
  // Zero overhead - direct Map lookup
  const ultraFast = this.ultraFastSingletonCache.get(token)
  if (ultraFast !== undefined) {
    return ultraFast // ⚡ Instant return, no checks
  }
  // ...
}

Performance: O(1) - Hash map lookup, ~1-2 CPU cycles Use case: Singleton services (most common in real apps)

Tier 2: Fast Path (Zero-dependency Transients)

private readonly fastTransientCache: Map<Token<any>, Factory<any>> = new Map()

// Skip ResolutionContext entirely for simple cases
const fastTransient = this.fastTransientCache.get(token)
if (fastTransient) {
  return fastTransient(this) // No context overhead
}

Performance: O(1) - Direct factory call, no context allocation Use case: Transient services with no dependencies

Tier 3: Standard Path (Complex Dependencies)

// Full resolution with circular dependency detection
const context = this.currentContext || Container.contextPool.acquire()
context.enterResolve(token)
try {
  return this.resolveWithContext(token, context)
} finally {
  context.exitResolve(token)
}

Performance: O(n) where n = dependency chain depth Use case: Per-request scoped or complex dependency graphs

Object Pooling

To reduce garbage collection pressure, NovaDI pools ResolutionContext objects:

class ResolutionContextPool {
  private pool: ResolutionContext[] = []
  private readonly maxSize = 10

  acquire(): ResolutionContext {
    return this.pool.pop() ?? new ResolutionContext()
  }

  release(context: ResolutionContext): void {
    if (this.pool.length < this.maxSize) {
      context.reset() // Clear state
      this.pool.push(context)
    }
  }
}

Benefit: Reduces heap allocations by ~90% for typical usage patterns

Lazy Path Building

Dependency resolution paths are only built when errors occur:

class ResolutionContext {
  private path?: string[] // Lazy initialization

  getPath(): string[] {
    if (!this.path) {
      // Only build when needed (error reporting)
      this.path = Array.from(this.resolvingStack).map(t => t.toString())
    }
    return this.path
  }
}

Benefit: Avoids expensive toString() calls during successful resolutions

Memory Footprint

Container instance: ~4 KB
+ Bindings: ~100 bytes per service
+ Singleton cache: ~50 bytes per singleton
+ Context pool: ~2 KB (10 pooled contexts)

For a typical app with 50 services:

  • Container: 4 KB
  • 50 bindings: 5 KB
  • 30 singletons cached: 1.5 KB
  • Total: ~10.5 KB runtime memory

Benchmark Results

Run on Node.js 20, M1 MacBook Pro

Operation Time Ops/sec
Resolve singleton (ultra-fast) ~10 ns 100M
Resolve transient (fast) ~50 ns 20M
Resolve with dependencies ~200 ns 5M
Container build (50 services) ~2 ms -

Comparison:

  • NovaDI singleton: ~10 ns
  • InversifyJS singleton: ~500 ns (50x slower)
  • TSyringe singleton: ~300 ns (30x slower)

Code Metrics

Metric Value
Total Lines of Code 2,079 lines
Bundle Size (compiled) ~59 KB
Public API Surface 22 exports
Avg. Cyclomatic Complexity ~3.4 (low, maintainable)
Runtime Dependencies 0 (only TypeScript)

File Breakdown:

  • container.ts - 706 lines (resolution engine)
  • builder.ts - 498 lines (fluent API)
  • transformer/index.ts - 544 lines (compile-time magic)
  • autowire.ts - 229 lines (autowiring strategies)
  • token.ts - 61 lines (type-safe tokens)
  • errors.ts - 25 lines (error types)

Comparison with Other Frameworks

Feature NovaDI InversifyJS TSyringe TypeDI Awilix
No Decorators
AutoWire ✅ Automatic ❌ Manual ❌ Manual ❌ Manual ✅ Automatic
Type Safety ✅ Full ⚠️ Partial ⚠️ Partial ⚠️ Partial ✅ Full
Transformer
Performance ⚡ ~10ns 🐢 ~500ns 🐢 ~300ns 🐢 ~400ns ⚡ ~50ns
Bundle Size 59 KB 90 KB 20 KB 50 KB 30 KB
Composition Root

AI-Assisted Onboarding Prompt

Copy this prompt when asking an AI assistant to help you use NovaDI:

I want to use the @novadi/core dependency injection library in my TypeScript project.

Key Principles:
- Package: @novadi/core
- NO decorators/annotations in business code
- Convention over configuration
- Uses .asInterface<T>() and .resolveInterface<T>()
- TypeScript transformer handles type names automatically

Core API:
1. Import: import { Container } from '@novadi/core'

2. Build container:
   const container = new Container()
   const builder = container.builder()

3. Register services:
   builder.registerType(ConsoleLogger).asInterface<ILogger>().singleInstance()

4. Autowire dependencies BY CONVENTION (recommended):
   builder.registerType(UserService).asInterface<UserService>().autoWire()
   // Parameters automatically match registered interfaces by naming convention

5. Build and resolve:
   const app = builder.build()
   const service = app.resolveInterface<UserService>()

Lifetimes:
- .singleInstance() - singleton
- .instancePerDependency() - transient (DEFAULT)
- .instancePerRequest() - per resolution tree

AutoWire (Convention Over Configuration):
- Automatic: .autoWire() - matches parameters to interfaces by naming convention
- Explicit: .autoWire({ map: { logger: (c) => c.resolveInterface<ILogger>() } })
- Use automatic for ALL services, explicit only for primitives/values

Transformer Setup (tsconfig.json):
{
  "compilerOptions": {
    "plugins": [
      { "transform": "@novadi/core/transformer" }
    ]
  }
}

Then: npm install -D ts-patch && npx ts-patch install

Simple Hello World example:
```typescript
import { Container } from '@novadi/core'

interface IGreeter {
  greet(name: string): string
}

class ConsoleGreeter implements IGreeter {
  greet(name: string): string {
    return `Hello, ${name}!`
  }
}

class Application {
  constructor(private greeter: IGreeter) {}

  run() {
    console.log(this.greeter.greet('World'))
  }
}

// Composition Root
const container = new Container()
const builder = container.builder()

builder.registerType(ConsoleGreeter).asInterface<IGreeter>().singleInstance()
builder.registerType(Application).asInterface<Application>().autoWire() // Convention!

const app = builder.build()
const application = app.resolveInterface<Application>()
application.run() // Outputs: Hello, World!

Please help me set up NovaDI following these patterns.


**For AI to reference:** Full README at https://github.com/janus007/NovaDI

---

## Documentation

- [Getting Started Guide](../../docs/getting-started.md)
- [Transformer Setup](../../docs/transformer-setup.md)
- [Best Practices](../../docs/best-practices.md)
- [Roadmap & Ideas](../../docs/roadmap.md)

---

## License

MIT © Janus Knudsen

---

## Acknowledgments

Inspired by:
- **Autofac** (.NET) - Composition Root pattern, fluent API
- **Awilix** (Node.js) - Clean, annotation-free API
- **Mark Seemann's** work on Dependency Injection patterns

Built for developers who believe in:
- Clean Architecture
- Separation of Concerns
- Testable Code
- SOLID Principles
- Convention over configuration

---

**Keep your code clean. Keep your architecture pure. Use NovaDI.**