Design Philosophy
Understanding the theory, decisions, and optimizations behind NovaDI
The Core Idea
NovaDI is built on a foundation of pragmatic design decisions aimed at delivering the best developer experience and runtime performance for TypeScript applications.
🎨 Design Principles
🚫 Decorator-Free
No decorators required. Work seamlessly with minification, tree-shaking, and any build tool. Decorators add complexity, bundle size, and build-time dependencies.
📘 TypeScript-First
Designed specifically for TypeScript, leveraging the type system for compile-time safety and developer experience. Not a JavaScript library with TypeScript types bolted on.
🌐 Browser-First
Optimized for browser environments with minimal bundle size (3.93KB minified+gzipped). Server-side is supported, but the web is the priority.
⚡ Performance-Obsessed
Every optimization matters. Ultra-fast singleton cache, fast transient paths, and smart context pooling deliver industry-leading performance.
The TypeScript Transformer Decision
The transformer is the most distinctive design choice in NovaDI. Here's why it exists and what problems it solves.
The Problem: Type Information at Runtime
TypeScript's type system is erased at compile-time. When you write
.as<ILogger>(), the runtime code has no idea what "ILogger" is.
Traditional DI containers solve this in two ways:
❌ String Literals (Manual)
// Verbose and error-prone
container.register("ILogger", ConsoleLogger)
container.resolve<ILogger>("ILogger") // String must match exactly
Problems: Typos, refactoring nightmares, no IDE support, duplicated information.
❌ Decorators (Metadata)
// Requires decorators + reflect-metadata
@injectable()
class ConsoleLogger implements ILogger {
constructor(@inject("ILogger") logger: ILogger) {}
}
Problems: Larger bundle size, experimental features, complex build setup, minification issues.
✅ NovaDI Solution: Compile-Time Transformer
// Clean, intuitive API
builder.registerType(ConsoleLogger).as<ILogger>()
const logger = app.resolveType<ILogger>()
// The transformer automatically converts to:
builder.registerType(ConsoleLogger).as<ILogger>("ILogger")
const logger = app.resolveType<ILogger>("ILogger")
Benefits: Clean code, type-safe, zero runtime overhead, works with minification, no decorators needed.
How It Works
The transformer is a TypeScript AST (Abstract Syntax Tree) transformation plugin that runs during compilation. It analyzes your code and injects type names as string arguments where needed:
- Parse: The TypeScript compiler parses your code into an AST
- Transform: NovaDI transformer finds
as<T>()calls - Extract: Extracts the type argument "T" (e.g., "ILogger")
- Inject: Adds it as a string parameter:
as<ILogger>("ILogger") - Emit: TypeScript outputs the transformed JavaScript
This happens entirely at build-time. The runtime code receives the type names without any decorators, metadata, or reflection.
💡 Key Insight
The transformer bridges the gap between TypeScript's compile-time type system and runtime behavior, giving you the best of both worlds: clean syntax AND type safety.
Performance Optimizations
NovaDI includes several sophisticated optimizations that make it 10-45x faster than competitors.
1. Ultra-Fast Singleton Cache
Three-tier caching system for singleton instances:
// Tier 1: Ultra-fast direct lookup (zero overhead)
ultraFastSingletonCache.get(token) // ~0.001ms
// Tier 2: Standard singleton cache
singletonCache.get(token) // ~0.002ms
// Tier 3: Full resolution with circular detection
resolveInternal(token, context) // ~0.01ms
Once a singleton is resolved once, subsequent resolutions use the ultra-fast cache, reducing resolution time by 90%+.
2. Fast Transient Path
For transient services with NO dependencies, NovaDI uses a specialized fast path:
// Zero-dependency class detection at registration time
if (binding.lifetime === 'transient' && dependencies.length === 0) {
// Register in fast cache with pre-compiled factory
fastTransientCache.set(token, () => new Constructor())
}
// Resolution skips ResolutionContext allocation entirely
const factory = fastTransientCache.get(token)
if (factory) return factory() // Direct instantiation
This optimization makes simple transient resolutions 4.3x faster by avoiding unnecessary context allocation and circular dependency checks.
3. Smart Context Pooling
ResolutionContext objects are pooled and reused to reduce garbage collection pressure:
// Instead of creating new context every time:
const context = new ResolutionContext() // ❌ Allocates memory
// Pool and reuse contexts:
const context = contextPool.acquire() // ✅ Reuse existing
// ... use context ...
contextPool.release(context) // ✅ Return to pool
This reduces memory allocations by ~80% during heavy resolution workloads.
4. Nested Resolution Optimization
Multi-level dependency graphs benefit from aggressive caching:
// Example: AutomationService → EventBus → Logger
// Without optimization: 3 full resolutions
// With optimization: 1 full resolution + 2 cache hits
// First resolution builds the entire graph
app.resolveType<AutomationService>()
→ resolves EventBus (cached)
→ resolves Logger (cached)
// Second resolution: all cache hits
app.resolveType<AutomationService>() // ~0.002ms (14x faster!)
Performance Results
| Test | NovaDI | Best Competitor | Speedup |
|---|---|---|---|
| Singleton (1000 resolutions) | 0.05ms | 2.23ms | 45x faster |
| Transient (1000 resolutions) | 0.11ms | 1.09ms | 10x faster |
| Complex Graph (1000 resolutions) | 0.52ms | 7.34ms | 14x faster |
See the Benchmarks page for live performance comparison with other frameworks.
API Design Decisions
NovaDI's API is designed to be intuitive, discoverable, and type-safe.
Fluent Builder Pattern
Method chaining provides a natural, readable flow:
builder
.registerType(ConsoleLogger) // What to register
.as<ILogger>() // How to expose it
.autoWire({ map: {...} }) // How to resolve dependencies
.singleInstance() // Lifetime management
.keyed('console') // Optional: keyed access
Each method returns the builder, allowing you to chain configuration in a logical order. IDE autocomplete guides you through the options.
Interface-Based Resolution
NovaDI encourages programming to interfaces, not implementations:
// ✅ Good: Depend on interface
const logger = app.resolveType<ILogger>()
// ❌ Discouraged: Depend on concrete class
const logger = app.resolve(ConsoleLogger)
This promotes loose coupling and makes it easy to swap implementations for testing or feature flags.
AutoWire Strategies
Three different strategies for dependency resolution, each with different trade-offs:
1. ParamName (Default)
// Automatic: matches parameter names to interface names
class EventBus {
constructor(logger: ILogger) {} // "logger" → "ILogger"
}
builder.registerType(EventBus).as<IEventBus>() // No autowire needed!
Best for: Rapid development, conventions-based teams
2. Map (Explicit)
// Explicit mapping: full control, minification-safe
builder
.registerType(EventBus)
.as<IEventBus>()
.autoWire({
map: {
logger: (c) => c.resolveType<ILogger>()
}
})
Best for: Production builds, primitive parameters, complex resolution logic
3. Class (Future)
// Build-time AST analysis generates autowire map automatically
builder.registerType(EventBus).as<IEventBus>() // Everything automatic!
Best for: Zero-configuration, maximum type safety (coming soon)
Lifetime Management
Clear, explicit lifetime semantics:
.singleInstance()- One instance per container (shared).instancePerDependency()- New instance every time (transient).instancePerRequest()- One instance per resolution graph (scoped)
Architecture Decisions
How NovaDI is structured internally to support all these features.
Builder Pattern Separation
Registration and resolution are intentionally separated:
// 1. Build phase: Register services (mutable)
const builder = container.builder()
builder.registerType(Logger).as<ILogger>()
builder.registerType(EventBus).as<IEventBus>()
// 2. Build: Create immutable container
const app = builder.build()
// 3. Runtime: Resolve services (immutable, fast)
const logger = app.resolveType<ILogger>() // Cannot add new registrations
This separation allows NovaDI to optimize the container structure after all registrations are known, and prevents runtime registration bugs.
Binding System
Each registration creates a Binding that encapsulates all configuration:
interface Binding {
token: Token<T> // What identifies this service
factory: Factory // How to create instances
lifetime: Lifetime // When to create instances
dependencies: Token[] // What it depends on
key?: string // Optional: keyed access
}
Parent-Child Containers
Hierarchical containers support scoped services:
// Parent: Shared services
const parent = new Container().builder()
parent.registerType(Logger).as<ILogger>().singleInstance()
const parentApp = parent.build()
// Child: Request-specific services
const child = parentApp.createChild().builder()
child.registerType(RequestContext).as<IRequestContext>()
const childApp = child.build()
// Child can resolve from parent
childApp.resolveType<ILogger>() // ✅ From parent
childApp.resolveType<IRequestContext>() // ✅ From child
Design Trade-offs
Every design decision involves trade-offs. Here's what we chose and why.
Transformer Required
Trade-off: Requires build-time setup (ttypescript or ts-patch)
Why worth it: Eliminates decorators, provides clean API, zero runtime overhead
Alternative considered: String literals everywhere (rejected: poor DX)
TypeScript-Only
Trade-off: Cannot be used from plain JavaScript
Why worth it: Leverages TypeScript's type system for safety and DX
Alternative considered: JavaScript-first design (rejected: compromises type safety)
Build-then-Resolve
Trade-off: Cannot add registrations after build()
Why worth it: Allows optimization, prevents runtime registration bugs
Alternative considered: Dynamic registration (rejected: hurts performance)
Interface-Based API
Trade-off: Requires defining interfaces (more boilerplate)
Why worth it: Promotes loose coupling, better testability, clearer architecture
Alternative considered: Class-based only (rejected: tight coupling)
Future Directions
Where NovaDI is headed next.
- Class AutoWire Strategy: Full build-time AST analysis for zero-config autowiring
- Dependency Graph Visualization: Generate visual graphs of your service dependencies
- Advanced Lifetimes: Per-request scoping for server-side scenarios
- Performance Profiler: Built-in tools to analyze resolution performance
- Module System: Better organization for large applications
See the NovaDI Roadmap & Ideas for detailed feature planning and timelines.