Documentation
Decorator-free Dependency Injection for TypeScript
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()
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!
.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
.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
- 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")
- 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!'
)
})
})
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
Cause: You're trying to resolve an interface that wasn't registered.
Fix: Make sure you called
.registerType(...).as() before building.
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.
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)
}