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.
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/coreSetup - 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.
Option 1: Modern Bundlers (Recommended ⭐)
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 installAdd to tsconfig.json:
{
"compilerOptions": {
"plugins": [
{ "transform": "@novadi/core/transformer" }
]
}
}Option 3: Manual Type Names (⚠️ Not Recommended)
⚠️ 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
tsxorts-nodewhere 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 safetyFuture 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: AliceThat'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 defaultUse 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
OrderServicewithout 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:
OrderServicecan 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.**