Documentation

Decorator-free Dependency Injection for TypeScript

TypeScript 5+ Zero Decorators Browser-First Type-Safe 3.9KB gzipped

Core Concepts

NovaDI is built around three simple concepts: Container, Builder, and Convention.

Inspired by Autofac, built for modern TypeScript projects. Fast, type-safe, and framework-agnostic.

Container & Builder

The Container stores your service registrations. The Builder configures them:

import { Container } from '@novadi/core'

// Step 1: Create container
const container = new Container()
const builder = container.builder()

// Step 2: Register services
builder.registerType(MyService).as().singleInstance()

// Step 3: Build and resolve
const app = builder.build()
const service = app.resolveType()
💡 Composition Root Pattern
All DI configuration should live in ONE place - your application's entry point. This is called the "Composition Root" and it's a core principle of clean architecture.

Registration API

Register services with a fluent API:

// Register implementation to interface
builder.registerType(ConsoleLogger).as()

// Register with lifetime
builder.registerType(Database).as().singleInstance()

// Register with autowiring
builder.registerType(UserService).as().autoWire()

// Chain everything together
builder
  .registerType(PostgresDB)
  .as()
  .singleInstance()
  .autoWire()

AutoWire - Convention Over Configuration

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

The Standard Way: Type Injection by Convention

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

// This is all you need - autowiring by convention!
builder.registerType(UserService).as().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!
✅ Best Practice: Use .autoWire() without arguments for ALL your services. This is how NovaDI is meant to be used.

Explicit Mapping (Edge Cases Only)

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

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

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

Lifetimes control when and how instances are created. Default is transient (new instance every time).

Singleton - One Instance Forever

builder.registerType(Database).as().singleInstance()

Use for: Loggers, database connections, configuration, caches, stateless services

  • ✅ One instance created on first resolution
  • ✅ Reused for entire application lifetime
  • ✅ Thread-safe caching
  • ⚡ Ultra-fast (cached lookup)

Transient - New Instance Every Time (DEFAULT)

builder.registerType(RequestHandler).as()
// No .singleInstance() = transient by default

Use for: Request handlers, commands, stateful operations, short-lived services

  • ✅ Fresh instance on every resolution
  • ✅ No shared state between uses
  • ✅ Safe for concurrent operations

Per-Request - One Instance Per Resolution Tree

builder.registerType(UnitOfWork).as().instancePerRequest()

Use for: Database transactions, request-scoped state, HTTP request context

  • ✅ One instance per resolution tree
  • ✅ Shared across dependencies in same resolve call
  • ✅ New instance for next top-level resolution
  • 💡 Perfect for transactions that span multiple services
💡 Choosing the Right Lifetime:
  • Singleton: Can it be safely shared? → Yes → Singleton
  • Transient: Does it hold state? → Yes → Transient
  • Per-Request: Needs to be shared in one operation? → Yes → Per-Request

Real-World Example

A complete example showing typical application architecture:

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(sql: string): Promise
}

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

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

interface IEmailService {
  sendEmail(to: string, subject: string, body: string): Promise
}

class EmailService implements IEmailService {
  constructor(private logger: ILogger) {}

  async sendEmail(to: string, subject: string, body: string) {
    this.logger.info(`Sending email to ${to}`)
    // Implementation...
  }
}

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

  async createUser(email: string, name: string) {
    this.logger.info(`Creating user: ${name}`)

    // Save to database
    await this.database.query(`INSERT INTO users...`)

    // Send welcome email
    await this.emailService.sendEmail(
      email,
      'Welcome!',
      `Hello ${name}!`
    )
  }
}

// ========================================
// Composition Root - ALL config in ONE place
// ========================================
const container = new Container()
const builder = container.builder()

// Infrastructure (singletons - shared across app)
builder.registerType(ConsoleLogger).as().singleInstance()
builder.registerType(PostgresDatabase).as().singleInstance().autoWire()
builder.registerType(EmailService).as().singleInstance().autoWire()

// Application services (transient by default)
builder.registerType(UserService).as().autoWire()

const app = builder.build()

// Use it
const userService = app.resolveType()
await userService.createUser('alice@example.com', 'Alice')

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 (Composition Root)
  • ✅ Testing is trivial: new UserService(mockDB, mockEmail, mockLogger)

Transformer Magic

The TypeScript transformer is what makes NovaDI's clean API possible. It automatically injects type names at compile-time.

What the Transformer Does

// You write:
builder.registerType(ConsoleLogger).as().singleInstance()
const logger = app.resolveType()

// Transformer generates:
builder.registerType(ConsoleLogger).as("ILogger").singleInstance()
const logger = app.resolveType("ILogger")
✅ Benefits:
  • Compile-time validation of all dependencies
  • Zero typo risk - TypeScript checks everything
  • Refactoring safety - rename types freely
  • Future: Dependency graph generation, missing registration detection

Bundler Compatibility

The transformer works across all major bundlers via unplugin:

Bundler Status Method
Vite ✅ Works unplugin
webpack ✅ Works unplugin
Rollup ✅ Works unplugin or ts-patch
esbuild ✅ Works unplugin
tsc ✅ Works ts-patch

See Getting Started for setup instructions.


Testing Strategies

One of NovaDI's biggest advantages: your business logic is completely framework-free, making testing trivially easy.

Unit Testing (No Container Needed)

import { describe, it, expect, vi } from 'vitest'

describe('UserService', () => {
  it('should create user and send welcome email', async () => {
    // Create mocks (no framework, just plain TypeScript!)
    const mockDB: IDatabase = {
      query: vi.fn().mockResolvedValue([])
    }

    const mockEmail: IEmailService = {
      sendEmail: vi.fn().mockResolvedValue(undefined)
    }

    const mockLogger: ILogger = {
      info: vi.fn(),
      error: vi.fn()
    }

    // Instantiate directly - no container needed!
    const service = new UserService(mockDB, mockEmail, mockLogger)

    // Test
    await service.createUser('alice@example.com', 'Alice')

    // Assert
    expect(mockDB.query).toHaveBeenCalled()
    expect(mockEmail.sendEmail).toHaveBeenCalledWith(
      'alice@example.com',
      'Welcome!',
      'Hello Alice!'
    )
  })
})
✅ This is why "no decorators" matters!
Your services are just TypeScript classes. No framework coupling. No special test setup. Just pure, testable code.

Integration Testing (With Container)

describe('UserService Integration', () => {
  it('should work with real container', async () => {
    // Build test container
    const container = new Container()
    const builder = container.builder()

    // Register with test implementations
    builder.registerType(InMemoryDatabase).as().singleInstance()
    builder.registerType(MockEmailService).as().singleInstance()
    builder.registerType(ConsoleLogger).as().singleInstance()
    builder.registerType(UserService).as().autoWire()

    const app = builder.build()

    // Resolve and test
    const service = app.resolveType()
    await service.createUser('bob@example.com', 'Bob')

    // Assert using test implementations
    const db = app.resolveType() as InMemoryDatabase
    expect(db.users).toHaveLength(1)
  })
})

Common Patterns

Factory Pattern

// Register factory function
builder.registerFactory(() => {
  return loadConfigFromFile('./config.json')
}, 'IConfig').singleInstance()

// Use in services
class MyService {
  constructor(private config: IConfig) {}
}

Module Pattern

// Define reusable modules
const databaseModule = (builder: Builder) => {
  builder.registerType(PostgresDB).as().singleInstance()
  builder.registerType(RedisCache).as().singleInstance()
}

const emailModule = (builder: Builder) => {
  builder.registerType(SendGridService).as().singleInstance()
}

// Apply modules
databaseModule(builder)
emailModule(builder)

Conditional Registration

// Different implementations based on environment
if (process.env.NODE_ENV === 'production') {
  builder.registerType(ProductionLogger).as().singleInstance()
} else {
  builder.registerType(DevLogger).as().singleInstance()
}

Troubleshooting

Common Issues

❌ Error: "No binding found for interface XYZ"
Cause: You're trying to resolve an interface that wasn't registered.
Fix: Make sure you called .registerType(...).as() before building.
❌ Error: "Circular dependency detected"
Cause: Service A depends on B, B depends on C, C depends on A.
Fix: Refactor to break the cycle. Consider using events, callbacks, or redesigning responsibilities.
❌ Transformer not working (type names not injected)
Cause: Transformer not configured correctly.
Fix: Check setup Documentation. For unplugin, make sure plugin is in config. For ts-patch, run npx ts-patch install.

Debugging Tips

// Check what's registered
console.log('Building container...')
const app = builder.build()
console.log('Container built successfully!')

// Check resolution
try {
  const service = app.resolveType()
  console.log('Service resolved:', service)
} catch (error) {
  console.error('Resolution failed:', error)
}

🚀 Next Steps

🏗️ Design Philosophy

Understand the "why" behind NovaDI's architecture

Read Design Doc →

💻 GitHub

View source code, examples, and contribute

View on GitHub →

📦 NPM

Latest version and package details

View on NPM →