Design Philosophy

Understanding the theory, decisions, and optimizations behind NovaDI

Decorator-Free TypeScript-First Browser-First Performance-Obsessed

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:

  1. Parse: The TypeScript compiler parses your code into an AST
  2. Transform: NovaDI transformer finds as<T>() calls
  3. Extract: Extracts the type argument "T" (e.g., "ILogger")
  4. Inject: Adds it as a string parameter: as<ILogger>("ILogger")
  5. 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.