Architecture Overview
Ndigo is a JavaScript-first application framework built on the enduring architectural principles that modern DI-and-dispatch frameworks share — inversion of control, declarative metadata, a layered object graph, and platform portability — adapted for the multi-paradigm (OOP + FP) JavaScript ecosystem.
Core Philosophy
Section titled “Core Philosophy”- Universal JavaScript: Designed to run anywhere—browser, Node.js, or hybrid environments.
- Composition Over Inheritance: Leveraging mixins and functional composition for flexibility.
- Convention Over Configuration: Sensible defaults with powerful override capabilities.
- Bottom-Up Development: Building a rock-solid foundation of utility and parser layers before implementing high-level application logic.
- Test-Driven Robustness: High test coverage (>80%) is a requirement, not an afterthought.
Foundational Principles
Section titled “Foundational Principles”Ndigo architecture is built upon industry-standard design principles adapted for JavaScript/TypeScript’s unique characteristics. Understanding these principles is essential for maintaining code quality, enabling extensibility, and facilitating collaboration.
SOLID Principles
Section titled “SOLID Principles”SOLID is a mnemonic for five object-oriented design principles that, when applied correctly, make software more maintainable, flexible, and scalable. While originating from traditional OOP languages, these principles are highly applicable to JavaScript/TypeScript with appropriate adaptations for its multi-paradigm nature.
Single Responsibility Principle (SRP)
Section titled “Single Responsibility Principle (SRP)”Definition: A class, function, or module should have one, and only one, reason to change.
Application in JavaScript/TypeScript:
In JavaScript/TypeScript, SRP applies at multiple levels:
- Classes: Each class should encapsulate a single concern
- Functions: Pure functions naturally follow SRP by doing one thing well
- Modules: Each module should address one specific domain or feature
Ndigo Example:
// ❌ Violates SRP - mixing multiple concernsclass UserController { validateEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }
hashPassword(password: string): string { return bcrypt.hashSync(password, 10); }
saveToDatabase(user: User): void { db.users.insert(user); }
sendWelcomeEmail(user: User): void { emailService.send(user.email, 'Welcome!'); }}
// ✅ Follows SRP - separated concernsclass EmailValidator { validate(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }}
class PasswordHasher { hash(password: string): string { return bcrypt.hashSync(password, 10); }}
class UserRepository { save(user: User): Promise<void> { return db.users.insert(user); }}
class EmailService { sendWelcome(user: User): Promise<void> { return this.send(user.email, 'Welcome to Ndigo!'); }}Benefits:
- Easier testing: Each class has a focused scope
- Improved maintainability: Changes to validation don’t affect email sending
- Better reusability: Components can be used independently
See also: Decorators & Separation of Concerns for how Ndigo uses decorators to achieve SRP.
Open-Closed Principle (OCP)
Section titled “Open-Closed Principle (OCP)”Definition: Software entities should be open for extension but closed for modification.
Application in JavaScript/TypeScript:
JavaScript’s dynamic nature and TypeScript’s structural typing provide multiple ways to achieve OCP:
- Composition over inheritance: Favor object composition
- Strategy pattern: Pass behavior as functions or objects
- TypeScript generics: Type-safe extensibility
- Higher-order functions: Functions that return or accept functions
Ndigo Example:
// ❌ Violates OCP - requires modification for new payment methodsclass PaymentProcessor { process(payment: Payment): void { if (payment.method === 'stripe') { // Stripe-specific logic stripeApi.charge(payment.amount); } else if (payment.method === 'paypal') { // PayPal-specific logic paypalApi.charge(payment.amount); } // Adding a new method requires modifying this class }}
// ✅ Follows OCP - extensible without modificationinterface PaymentMethod { process(payment: Payment): Promise<void>;}
class StripePayment implements PaymentMethod { async process(payment: Payment): Promise<void> { await stripeApi.charge(payment.amount); }}
class PayPalPayment implements PaymentMethod { async process(payment: Payment): Promise<void> { await paypalApi.charge(payment.amount); }}
class CryptoPayment implements PaymentMethod { async process(payment: Payment): Promise<void> { await blockchainApi.transfer(payment.amount); }}
// Payment processor is closed for modificationclass PaymentProcessor { constructor(private methods: Map<string, PaymentMethod>) {}
async process(payment: Payment): Promise<void> { const method = this.methods.get(payment.method); if (!method) throw new Error(`Unknown payment method: ${payment.method}`); await method.process(payment); }}
// Registration happens at application startupconst processor = new PaymentProcessor( new Map([ ['stripe', new StripePayment()], ['paypal', new PayPalPayment()], ['crypto', new CryptoPayment()], // New method added without changing processor ]));Benefits:
- Reduces risk: Existing code remains untouched
- Encourages decoupling: New features are isolated
- Supports plugins: Easy to add new implementations
See also: Design Patterns for Strategy and Factory patterns that enable OCP.
Liskov Substitution Principle (LSP)
Section titled “Liskov Substitution Principle (LSP)”Definition: Objects of a superclass should be replaceable with objects of a subclass without breaking the application.
Application in JavaScript/TypeScript:
LSP is particularly important in TypeScript due to its structural type system:
- Structural typing: Types are compatible if their structures match
- Duck typing in JavaScript: “If it walks like a duck and quacks like a duck…”
- Contract adherence: Subclasses must honor the base class contract
Common Violation - Rectangle/Square Problem:
// ❌ Violates LSP - Square breaks Rectangle's contractclass Rectangle { constructor( protected width: number, protected height: number ) {}
setWidth(width: number): void { this.width = width; }
setHeight(height: number): void { this.height = height; }
getArea(): number { return this.width * this.height; }}
class Square extends Rectangle { setWidth(width: number): void { this.width = width; this.height = width; // Unexpected side effect! }
setHeight(height: number): void { this.width = height; this.height = height; // Unexpected side effect! }}
// Code that works with Rectangle breaks with Squarefunction resizeRectangle(rect: Rectangle): void { rect.setWidth(10); rect.setHeight(5); console.assert(rect.getArea() === 50); // ❌ Fails with Square!}
// ✅ Follows LSP - proper abstractioninterface Shape { area(): number;}
class Rectangle implements Shape { constructor( private width: number, private height: number ) {} area(): number { return this.width * this.height; }}
class Square implements Shape { constructor(private side: number) {} area(): number { return this.side * this.side; }}
function calculateTotalArea(shapes: Shape[]): number { return shapes.reduce((sum, shape) => sum + shape.area(), 0);}Benefits:
- Predictable behavior: Polymorphism works as expected
- Type safety: TypeScript catches violations at compile time
- Reliable abstraction: Base class contracts are honored
Interface Segregation Principle (ISP)
Section titled “Interface Segregation Principle (ISP)”Definition: Clients should not be forced to depend on interfaces they don’t use.
Application in JavaScript/TypeScript:
ISP is especially relevant in TypeScript with its interface system:
- Small, focused interfaces: Break large interfaces into smaller ones
- Role interfaces: Interfaces based on client needs, not implementer structure
- Composition: Combine small interfaces for complex types
Ndigo Example:
// ❌ Violates ISP - fat interface forces unnecessary dependenciesinterface DatabaseConnection { connect(): Promise<void>; disconnect(): Promise<void>; query(sql: string): Promise<any[]>; beginTransaction(): Promise<void>; commitTransaction(): Promise<void>; rollbackTransaction(): Promise<void>; backup(): Promise<void>; restore(backupId: string): Promise<void>; optimize(): Promise<void>;}
// Simple read-only service forced to implement everythingclass ReportGenerator implements DatabaseConnection { async query(sql: string): Promise<any[]> { /* ... */ }
// Unused methods throw errors async beginTransaction(): Promise<void> { throw new Error('Transactions not supported'); } async backup(): Promise<void> { throw new Error('Backup not supported'); } // ... etc}
// ✅ Follows ISP - segregated interfacesinterface Queryable { query(sql: string): Promise<any[]>;}
interface Transactional { beginTransaction(): Promise<void>; commitTransaction(): Promise<void>; rollbackTransaction(): Promise<void>;}
interface Maintainable { backup(): Promise<void>; restore(backupId: string): Promise<void>; optimize(): Promise<void>;}
// Clients depend only on what they needclass ReportGenerator { constructor(private db: Queryable) {}
async generate(): Promise<Report> { const data = await this.db.query('SELECT * FROM reports'); return this.formatReport(data); }}
class OrderService { constructor(private db: Queryable & Transactional) {}
async createOrder(order: Order): Promise<void> { await this.db.beginTransaction(); try { await this.db.query('INSERT INTO orders ...'); await this.db.commitTransaction(); } catch (error) { await this.db.rollbackTransaction(); throw error; } }}Benefits:
- Reduced coupling: Clients don’t depend on unused functionality
- Clearer intent: Interface names express specific roles
- Easier testing: Mock only what’s needed
Dependency Inversion Principle (DIP)
Section titled “Dependency Inversion Principle (DIP)”Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Application in JavaScript/TypeScript:
DIP is fundamental to Ndigo’s architecture:
- Dependency injection: Constructor or function parameter injection
- Interface-based design: Depend on TypeScript interfaces, not concrete classes
- ApplicationContext: Ndigo’s DI container manages dependencies
Ndigo Example:
// ❌ Violates DIP - high-level code depends on low-level implementationimport { MongoDatabase } from './mongo-database';
class UserService { private db = new MongoDatabase(); // Hardcoded dependency!
async createUser(user: User): Promise<void> { await this.db.insert('users', user); }}
// ✅ Follows DIP - depends on abstractioninterface Database { insert(collection: string, document: any): Promise<void>; find(collection: string, query: any): Promise<any[]>;}
// High-level policyclass UserService { constructor(private db: Database) {} // Injected abstraction
async createUser(user: User): Promise<void> { await this.db.insert('users', user); }}
// Low-level implementationsclass MongoDatabase implements Database { async insert(collection: string, document: any): Promise<void> { await mongoClient.collection(collection).insertOne(document); }
async find(collection: string, query: any): Promise<any[]> { return await mongoClient.collection(collection).find(query).toArray(); }}
class PostgresDatabase implements Database { async insert(collection: string, document: any): Promise<void> { await pgClient.query(`INSERT INTO ${collection} ...`, document); }
async find(collection: string, query: any): Promise<any[]> { return await pgClient.query(`SELECT * FROM ${collection} ...`, query); }}
// Ndigo ApplicationContext wires dependencies@configurationclass AppConfig { @entry database(): Database { return new MongoDatabase(); // Can switch to PostgresDatabase easily }
@entry userService(db: Database): UserService { return new UserService(db); // Dependency injected }}Benefits:
- Testability: Easy to inject mocks
- Flexibility: Swap implementations without changing dependent code
- Decoupling: High-level logic independent of low-level details
See also: @ndigo/core package documentation for ApplicationContext and dependency injection patterns.
SOLID in Functional Programming
Section titled “SOLID in Functional Programming”Ndigo embraces both OOP and FP paradigms. SOLID principles have functional equivalents:
| SOLID Principle | Functional Equivalent |
|---|---|
| SRP | Single-purpose functions |
| OCP | Higher-order functions, composition |
| LSP | Function signature consistency |
| ISP | Small, focused functions |
| DIP | Function parameters (dependency injection) |
Example - Functional Approach:
// SRP: Each function does one thingconst validateEmail = (email: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);const hashPassword = (password: string): string => bcrypt.hashSync(password, 10);const saveUser = (user: User): Promise<void> => db.users.insert(user);
// OCP: Extend through compositionconst pipe = <T>(...fns: Array<(arg: T) => T>) => (x: T): T => fns.reduce((v, f) => f(v), x);
const processUser = pipe(normalizeUser, validateUser, enrichUserData);
// DIP: Dependencies injected as parametersconst createUserService = (repository: UserRepository, emailService: EmailService) => ({ async register(email: string, password: string): Promise<User> { const hashedPassword = await hashPassword(password); const user = await repository.save({ email, password: hashedPassword }); await emailService.sendWelcome(email); return user; },});DRY Principle
Section titled “DRY Principle”Definition: “Don’t Repeat Yourself” - Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
The DRY principle aims to reduce repetition of code patterns, business logic, or configuration. However, it must be balanced with other concerns to avoid premature abstraction.
When to Apply DRY
Section titled “When to Apply DRY”The Rule of Three:
- First occurrence: Write the code
- Second occurrence: Note the similarity, but resist abstraction
- Third occurrence: NOW consider abstracting
Rationale: You need at least three examples to understand the true axes of variability. Abstracting too early leads to the wrong abstraction, which is harder to fix than duplication.
Acceptable Duplication
Section titled “Acceptable Duplication”Accidental vs. Essential Duplication:
// These LOOK similar but change for different reasons// Order confirmation emailfunction sendOrderConfirmation(order: Order): Promise<void> { return emailService.send({ to: order.customerEmail, subject: 'Order #' + order.id + ' Confirmed', body: formatOrderDetails(order), });}
// Password reset emailfunction sendPasswordReset(user: User, token: string): Promise<void> { return emailService.send({ to: user.email, subject: 'Reset Your Password', body: formatResetLink(token), });}Why duplication is acceptable: These represent different business concerns (order processing vs. authentication). Centralizing them creates coupling between unrelated features.
Low Rate of Change:
// Guard clauses that rarely changeif (user === null) { throw new ArgumentNullException('user');}if (order === null) { throw new ArgumentNullException('order');}Mark Seemann’s formula: Cost of duplication = C × p where:
- C = Cost when change is needed
- p = Probability of change
If p ≈ 0, duplication cost is effectively zero.
AHA Programming
Section titled “AHA Programming”Coined by Cher Scarlett, popularized by Kent C. Dodds. AHA programming recognizes that both WET (Write Everything Twice) and overly-DRY solutions can create rigid, difficult-to-maintain software.
Core Principle: Optimize for change first. Abstract when duplication itself becomes the barrier, not when you think you see a pattern.
Sandi Metz’s Wisdom: “Prefer duplication over the wrong abstraction.”
Recovering from Wrong Abstractions:
- Re-introduce duplication by inlining the abstraction
- Within each caller, identify what actually executes
- Delete unused code for that caller
- Let new patterns emerge from simplified code
- Extract new abstractions only when pattern is clear
Techniques for Eliminating Duplication
Section titled “Techniques for Eliminating Duplication”1. Extract Function (Simple Cases):
// BEFORE: Duplicated validationfunction processOrder(order: Order): void { if (!order || !order.id || !order.items?.length) { throw new Error('Invalid order'); } // process...}
function shipOrder(order: Order): void { if (!order || !order.id || !order.items?.length) { throw new Error('Invalid order'); } // ship...}
// AFTER: Extractedfunction validateOrder(order: Order): void { if (!order || !order.id || !order.items?.length) { throw new Error('Invalid order'); }}
function processOrder(order: Order): void { validateOrder(order); // process...}2. Strategy Pattern (Behavioral Variation):
interface ShippingStrategy { calculate(order: Order): number;}
class StandardShipping implements ShippingStrategy { calculate(order: Order): number { return order.weight * 0.5 + 5; }}
class ExpressShipping implements ShippingStrategy { calculate(order: Order): number { return order.weight * 1.5 + 10; }}
const strategies: Record<string, ShippingStrategy> = { standard: new StandardShipping(), express: new ExpressShipping(),};3. TypeScript Utility Types (Type-Level DRY):
interface User { id: string; name: string; email: string; age: number; createdAt: Date;}
// Derive types from single sourcetype UserForm = Omit<User, 'id' | 'createdAt'>;type UserDTO = Pick<User, 'name' | 'email' | 'age'>;type UserUpdate = Partial<UserDTO>;4. Library Integration Layer (Ndigo Pattern):
// @ndigo/dom - the integration layer for its encapsulated DOM library.// The import lives inside @ndigo/dom (src/linkedom-dom-provider.ts) and is// re-exported through the package's stable surface:import { parseHTML } from 'linkedom';
export function createDOMProvider(html: string): DOMProvider { return new LinkedomDOMProvider(parseHTML(html));}
// Application code imports from @ndigo/dom, not from linkedom directlyimport { createDOMProvider } from '@ndigo/dom';
createDOMProvider('<p>Hello</p>');@ndigo/util is the framework’s zero-dependency leaf toolkit (type guards,
strings, environment, crypto). It no longer wraps third-party libraries; it
stays at layer 0 because every other layer may depend on it.
See also: Dependency Management Strategy for the Library Integration Layer pattern.
DRY vs. Other Principles
Section titled “DRY vs. Other Principles”DRY vs. KISS (Keep It Simple):
When these conflict, favor simplicity over cleverness. Dan Abramov: “Sometimes duplication is simpler than the wrong abstraction.”
DRY vs. SRP:
When removing duplication forces unrelated code into the same abstraction, prefer SRP over DRY. Sandi Metz: “Duplication is far cheaper than the wrong abstraction.”
DRY vs. YAGNI (You Aren’t Gonna Need It):
Don’t create abstractions for hypothetical future use. Abstract when you have actual duplication, not anticipated duplication.
Utility Function Organization
Section titled “Utility Function Organization”From Utility Function Organization, Ndigo uses a hierarchical approach:
- 1 file uses → Keep inline (regex patterns, constants)
- 3+ files (same category) → Extract to dedicated file (
email-validator.ts) - 4+ files (cross-category) → Add to internal file (
_util.ts,_validators.ts) - Multiple packages → Promote to
@ndigo/util
Critical Rule: Internal files must contain actual local utility functions, not just pass-through re-exports.
See also: Utility Function Organization for detailed examples and file size guidelines.
Practical Guidelines
Section titled “Practical Guidelines”Applying SOLID and DRY in Ndigo
Section titled “Applying SOLID and DRY in Ndigo”1. Start with Interfaces (TypeScript):
// Define contracts firstexport interface IUserRepository { findById(id: string): Promise<User | null>; save(user: User): Promise<void>;}
// Implement laterexport class MongoUserRepository implements IUserRepository { // Implementation}2. Prefer Composition Over Inheritance:
// ❌ Avoid deep hierarchiesclass Animal {}class Mammal extends Animal {}class Dog extends Mammal {}
// ✅ Prefer compositioninterface Behavior { execute(): void;}
class Dog { constructor(private behaviors: Behavior[]) {} act(): void { this.behaviors.forEach((b) => b.execute()); }}3. Use Dependency Injection:
// Ndigo ApplicationContext manages this@configurationclass AppConfig { @entry userService(repository: UserRepository, validator: UserValidator): UserService { return new UserService(repository, validator); }}4. Avoid Over-Engineering:
// ❌ Over-engineered for simple caseinterface INumberAdder { add(a: number, b: number): number;}
class NumberAdder implements INumberAdder { add(a: number, b: number): number { return a + b; }}
// ✅ Simple is betterconst add = (a: number, b: number): number => a + b;5. Balance All Principles:
- SOLID provides structure and extensibility
- DRY reduces maintenance burden
- KISS keeps code understandable
- YAGNI prevents over-engineering
The art is knowing when to apply which principle. Use pragmatism and team consensus.
Code Review Checklist
Section titled “Code Review Checklist”When reviewing code, check for:
SOLID:
- Does each class/function have a single, clear responsibility? (SRP)
- Can new behavior be added without modifying existing code? (OCP)
- Are subclasses truly substitutable for their base types? (LSP)
- Are interfaces small and focused on client needs? (ISP)
- Do dependencies flow toward abstractions, not concretions? (DIP)
DRY:
- Is duplicated code actually representing the same knowledge? (Not accidental similarity)
- Has the Rule of Three been satisfied before abstracting?
- Will the abstraction make future changes easier, not harder?
- Does the abstraction have a clear, focused purpose?
Balance:
- Is the code simple enough to understand quickly? (KISS)
- Are we solving actual problems, not hypothetical ones? (YAGNI)
- Do benefits of abstraction outweigh its complexity?
Layered Architecture
Section titled “Layered Architecture”The framework is organized into 8 architectural layers with clear dependency direction and platform-aware boundaries:
| Layer | Packages | Description | Design Analogue |
|---|---|---|---|
| 7: Bootstrap | @ndigo/boot |
Application bootstrap, startup orchestration, and context assembly | Application bootstrap entrypoint |
| 6: Application | @ndigo/service, @ndigo/management, @ndigo/openapi, @ndigo/mcp, @ndigo/platform-node, @ndigo/cluster, @ndigo/platform-web, @ndigo/auth-core, @ndigo/auth-client, @ndigo/auth-server, @ndigo/host, @ndigo/telemetry-node, @ndigo/telemetry-web, @ndigo/test |
Business services, platform adapters, API docs generation, MCP tool projection + MCP Apps, auth (contracts + client + server), process supervision, micro-frontend shell, Node/browser telemetry wiring, integration test harness | App services + platform adapters |
| 5: Request Processing | @ndigo/dispatcher, @ndigo/view, @ndigo/view-webcomponent, @ndigo/view-ejs, @ndigo/view-toon, @ndigo/ai-sdk |
Front controller, handler execution, view resolution and rendering adapters (Web Components, EJS, TOON), AI SDK adapter | Front controller + view resolution |
| 4: Web & Runtime Infra | @ndigo/http, @ndigo/router, @ndigo/telemetry, @ndigo/socket, @ndigo/scheduler, @ndigo/jobs, @ndigo/locks, @ndigo/redis, @ndigo/ai |
HTTP client/interceptors/transforms, routing primitives, websocket domain, cron scheduling, observability, durable jobs, distributed locks, distributed backends, provider-neutral agent loop | HTTP vocabulary + routing + telemetry |
| 3: Application Context | @ndigo/context |
Application context, environment, events, configuration metadata | DI container + application context |
| 2: Infrastructure | @ndigo/factory, @ndigo/content, @ndigo/content-i18next, @ndigo/cache, @ndigo/orm, @ndigo/orm-drizzle, @ndigo/proxy, @ndigo/resilience, @ndigo/flags, @ndigo/flags-openfeature, @ndigo/rate-limit |
IoC container internals, i18n/theming, caching, persistence abstractions + Drizzle adapter, method interception + resilience, feature flags, rate limiting | IoC container internals + theme/i18n |
| 1: Foundation | @ndigo/core, @ndigo/signals, @ndigo/log |
Core primitives, collection types, path matching, reactive state, logging core | Core primitives + reactive state |
| 0: Integration Layer | @ndigo/util, @ndigo/dom (+ tooling: @ndigo/oxlint-plugin) |
Zero-dependency leaf toolkit + encapsulated third-party libraries via stable Ndigo wrappers | Third-party facade + utility layer |
Package Map: the canonical per-package reference (purpose · platform · runtime deps) — see § Package Map below.
The ndigo-todo-web app sits above these layers as a reference implementation and integration proving ground.
Platform Portability
Section titled “Platform Portability”Ndigo is intentionally designed for platform portability. The isomorphic core (factory/context/dispatcher/service/content with foundational packages) can execute in browser, Node.js, or hybrid deployments. Platform-specific behavior is isolated in adapter packages such as @ndigo/platform-web and @ndigo/view-webcomponent.
This architecture supports multiple deployment topologies:
- Full-browser: Dispatch, service, and view all run client-side
- Hybrid split: View in browser with dispatch/service in Node.js
- Configurable split point: Boundary can shift based on latency, security, and UX requirements
Future platform bindings (for example @ndigo/platform-node) can provide server-specific router/environment/resource implementations without changing isomorphic core contracts.
Dependency Diagram
Section titled “Dependency Diagram”The following diagram illustrates dependency flow and major package groupings:
graph TD
subgraph L7["Layer 7: Bootstrap"]
Boot[@ndigo/boot]
end
subgraph L6["Layer 6: Application"]
Service[@ndigo/service]
Management[@ndigo/management]
OpenAPI[@ndigo/openapi]
Mcp[@ndigo/mcp]
PlatformNode[@ndigo/platform-node]
PlatformWeb[@ndigo/platform-web]
AuthCore[@ndigo/auth-core]
AuthClient[@ndigo/auth-client]
AuthServer[@ndigo/auth-server]
Host[@ndigo/host]
TelemetryNode[@ndigo/telemetry-node]
TelemetryWeb[@ndigo/telemetry-web]
Test[@ndigo/test]
end
subgraph L5["Layer 5: Request Processing"]
Dispatcher[@ndigo/dispatcher]
View[@ndigo/view]
ViewWebComponent[@ndigo/view-webcomponent]
ViewEJS[@ndigo/view-ejs]
ViewTOON[@ndigo/view-toon]
AiSdk[@ndigo/ai-sdk]
end
subgraph L4["Layer 4: Web & Runtime Infrastructure"]
Http[@ndigo/http]
Router[@ndigo/router]
Telemetry[@ndigo/telemetry]
Socket[@ndigo/socket]
Scheduler[@ndigo/scheduler]
Redis[@ndigo/redis]
Ai[@ndigo/ai]
end
subgraph L3["Layer 3: Application Context"]
Context[@ndigo/context]
end
subgraph L2["Layer 2: Infrastructure"]
Factory[@ndigo/factory]
Content[@ndigo/content]
ContentI18Next[@ndigo/content-i18next]
Cache[@ndigo/cache]
Orm[@ndigo/orm]
OrmDrizzle[@ndigo/orm-drizzle]
Proxy[@ndigo/proxy]
Resilience[@ndigo/resilience]
end
subgraph L1["Layer 1: Foundation"]
Core[@ndigo/core]
Signals[@ndigo/signals]
Log[@ndigo/log]
end
subgraph L0["Layer 0: Integration Layer"]
Util[@ndigo/util]
Dom[@ndigo/dom]
end
Boot --> Core
Boot --> Context
Boot --> Router
Host --> Core
Host --> Context
Host --> Router
Test --> Context
Test --> Dispatcher
Test --> PlatformNode
Test --> PlatformWeb
Test --> Router
Test --> Util
Test --> View
Management --> Core
Management --> Context
Management --> Dispatcher
Management --> Telemetry
Mcp --> Core
Mcp --> Dispatcher
Mcp --> PlatformNode
Mcp --> Router
Ai --> Core
AiSdk --> Ai
Service --> Core
Service --> Context
Service --> Factory
Service --> Http
Service --> Util
AuthClient --> AuthCore
AuthClient --> Http
AuthServer --> AuthCore
AuthServer --> Dispatcher
AuthServer --> Router
TelemetryNode --> Telemetry
TelemetryNode --> PlatformNode
TelemetryNode --> Dispatcher
TelemetryNode --> Router
TelemetryNode --> Socket
TelemetryNode --> View
TelemetryNode --> Context
TelemetryNode --> Core
TelemetryWeb --> Telemetry
TelemetryWeb --> PlatformWeb
TelemetryWeb --> Dispatcher
TelemetryWeb --> Router
TelemetryWeb --> Socket
TelemetryWeb --> Context
PlatformNode --> AuthCore
PlatformNode --> Cache
PlatformNode --> Scheduler
PlatformNode --> Socket
PlatformNode --> Dispatcher
PlatformNode --> Http
PlatformNode --> Router
PlatformNode --> View
PlatformNode --> Context
PlatformNode --> Core
PlatformNode --> Factory
PlatformNode --> Util
PlatformWeb --> Content
PlatformWeb --> Dispatcher
PlatformWeb --> Http
PlatformWeb --> Router
PlatformWeb --> Signals
PlatformWeb --> Socket
PlatformWeb --> View
PlatformWeb --> ViewWebComponent
PlatformWeb --> Context
PlatformWeb --> Core
PlatformWeb --> Util
Dispatcher --> Content
Dispatcher --> Context
Dispatcher --> Core
Dispatcher --> Factory
Dispatcher --> Http
Dispatcher --> Router
Dispatcher --> Socket
Dispatcher --> Util
Dispatcher --> View
View --> Context
View --> Core
View --> Factory
View --> Router
View --> Util
ViewWebComponent --> Context
ViewWebComponent --> Core
ViewWebComponent --> Factory
ViewWebComponent --> Router
ViewWebComponent --> View
ViewEJS --> Content
ViewEJS --> Context
ViewEJS --> Core
ViewEJS --> Router
ViewEJS --> View
Socket --> Core
Socket --> Signals
Scheduler --> Context
Scheduler --> Core
Scheduler --> Util
Telemetry --> Context
Telemetry --> Core
Telemetry --> Dispatcher
Telemetry --> Log
Telemetry --> Router
Router --> Core
Router --> Factory
Router --> Http
Router --> Util
Http --> Core
Http --> Factory
Http --> Util
Orm --> Core
Orm --> Factory
OrmDrizzle --> Core
OrmDrizzle --> Orm
OrmDrizzle --> Socket
Proxy --> Core
Proxy --> Factory
Resilience --> Proxy
Redis --> Cache
Redis --> Factory
Redis --> Socket
OpenAPI --> Core
OpenAPI --> Dispatcher
OpenAPI --> Http
Content --> Core
Content --> Factory
Content --> Util
ContentI18Next --> Content
Context --> Content
Context --> Core
Context --> Factory
Context --> Util
Factory --> Core
Factory --> Log
Factory --> Util
Core --> Util
style Boot fill:#f0f0f0
style Service fill:#fff4e1
style Management fill:#fff4e1
style PlatformNode fill:#fff4e1
style PlatformWeb fill:#fff4e1
style AuthCore fill:#fff4e1
style AuthClient fill:#fff4e1
style AuthServer fill:#fff4e1
style Host fill:#fff4e1
style TelemetryNode fill:#fff4e1
style TelemetryWeb fill:#fff4e1
style Test fill:#fff4e1
style Dispatcher fill:#f5e1ff
style View fill:#f5e1ff
style ViewWebComponent fill:#f5e1ff
style ViewEJS fill:#f5e1ff
style Http fill:#e1f5ff
style Router fill:#e1f5ff
style Telemetry fill:#e1f5ff
style Socket fill:#e1f5ff
style Scheduler fill:#e1f5ff
style Context fill:#e1ffe5
style Factory fill:#ffe1e1
style Content fill:#ffe1e1
style ContentI18Next fill:#ffe1e1
style Cache fill:#ffe1e1
style Orm fill:#ffe1e1
style OrmDrizzle fill:#ffe1e1
style Core fill:#fff9e1
style Signals fill:#fff9e1
style Log fill:#fff9e1
style Proxy fill:#ffe1e1
style Resilience fill:#ffe1e1
style Redis fill:#e1f5ff
style OpenAPI fill:#fff4e1
style Util fill:#e1f0ff
style Dom fill:#e1f0ff
Edges mirror runtime (
src/) imports.scripts/check-manifests.mjs(CI) enforces import ↔ manifest consistency in both directions; keep this diagram and the Package Map in step with the manifests when they change.@ndigo/cache,@ndigo/signals,@ndigo/dom,@ndigo/auth-core,@ndigo/util, and@ndigo/logdepend on no Ndigo package.
Key Principle: Higher layers depend on lower layers, never the reverse. Some cross-layer dependencies inside adjacent architectural tiers are intentional for integration and orchestration. The stack is acyclic — pnpm build fails on any cycle in the Nx task graph.
Package Map
Section titled “Package Map”Canonical reference for every publishable package: purpose · layer · platform · runtime dependencies. The Runtime deps column lists Ndigo dependencies only; notable external libraries appear in parentheses in the Purpose column (illustrative, not exhaustive — the package manifest is the source of truth for externals). scripts/check-manifests.mjs (CI, pnpm manifest:check) enforces that every runtime import is declared and every declared dependency is used; the columns below are maintained by hand from the manifests — update this table whenever manifests or imports change.
| Package | Layer | Platform | Purpose | Runtime deps |
|---|---|---|---|---|
@ndigo/util |
0 | iso | Zero-dependency leaf toolkit: type guards, strings, environment, crypto | — |
@ndigo/dom |
0 | iso | Isomorphic DOM parsing/creation (linkedom) |
— |
@ndigo/core |
1 | iso | Primitives, collections, radix tree, metadata, events (semver, zod) |
util |
@ndigo/signals |
1 | iso | Reactive state: signals, computed, effects (@preact/signals-core) |
log |
@ndigo/log |
1 | iso | Logging core: logger abstraction, severity mappings, threshold filtering (@opentelemetry/api) |
— |
@ndigo/factory |
2 | iso | IoC container: scopes, lifecycle, entry/aware processors | core, log, util |
@ndigo/content |
2 | iso | i18n content keys, themes, MessageSource contract |
core, factory, util |
@ndigo/content-i18next |
2 | iso | i18next MessageSource adapter (i18next) |
content |
@ndigo/cache |
2 | iso | Cache manager + @cached decorator |
context, factory, log |
@ndigo/orm |
2 | iso | Repository/data-mapper persistence abstractions, @entity decorators (zod) |
core, factory |
@ndigo/orm-drizzle |
2 | node | Drizzle ORM adapter for @ndigo/orm (drizzle-orm, drizzle-zod, @libsql/client) |
auth-core, core, jobs, orm, socket, util |
@ndigo/proxy |
2 | iso | Method interception: annotation-driven advice weaving (@tsmetadata/polyfill) |
core, factory, log |
@ndigo/resilience |
2 | iso | Resilience policies: @retry + @circuitBreaker interceptors |
proxy |
@ndigo/rate-limit |
2 | iso | Fixed-window rate limiting: RateLimiter SPI (async), FixedWindowRateLimiter, RATE_LIMITER token |
factory |
@ndigo/flags |
2 | iso | Feature flags: FlagEvaluator SPI (typed, async, never-throw), PropertyFlagProvider, @whenFeature gate metadata, FLAG_EVALUATOR token |
factory |
@ndigo/flags-openfeature |
2 | node | OpenFeature bridge (@openfeature/server-sdk, peer): FlagEvaluator over an OpenFeature client — vendors (flagd, OFREP, LaunchDarkly, …) behind the SPI |
flags |
@ndigo/context |
3 | iso | Application context, environments, configuration, events (zod) |
content, core, factory, log, util |
@ndigo/http |
4 | iso | Fetch client, interceptors, content parsing, transforms (@opentelemetry/api) |
core, factory, util |
@ndigo/router |
4 | iso | Route registry, path partitions, feature routing | core, factory, http, log, util |
@ndigo/telemetry |
4 | iso | OTel surface: metrics, tracing decorators, framework instrumentations; re-exports @ndigo/log |
context, core, dispatcher, log, router |
@ndigo/socket |
4 | iso | WebSocket domain: STOMP client, session contracts | core, signals |
@ndigo/scheduler |
4 | node | Cron scheduling: @scheduled, task registry, tracing (croner) |
context, core, factory, locks, log, util |
@ndigo/jobs |
4 | node | Durable job queues, worker execution, transactional outbox (@opentelemetry/api) |
context, core, factory, log, util |
@ndigo/locks |
4 | node | Distributed locks + fencing tokens: in-process default driver, @scheduled({lock}) seam |
core, factory |
@ndigo/redis |
4 | node | Redis backends: shared cache + pub/sub fan-out + jobs/lock/rate-limit drivers (@redis/client; peers cache, factory, jobs, locks, rate-limit, socket) |
core, log |
@ndigo/view |
5 | iso | Views, ViewTargets, content negotiation, serialization |
context, core, factory, http, router, util |
@ndigo/view-webcomponent |
5 | browser | Web Component view technology plus WebMCP tool declaration | context, core, dispatcher, factory, log, router, view |
@ndigo/view-ejs |
5 | node | EJS server-side template view technology (ejs) |
view, content, context, core, router |
@ndigo/view-toon |
5 | iso | TOON view technology: text/toon output, negotiation resolver, prompt encoder (@toon-format/toon) |
view, core |
@ndigo/dispatcher |
5 | iso | Front controller: request dispatch, parameter binding, radix handler mappings (zod) |
view, router, http, socket, context, content, core, factory, log, util |
@ndigo/service |
6 | iso | Typed service clients, SSE/streaming (zod) |
http, context, core, factory, util |
@ndigo/management |
6 | node | Actuator endpoints: /health, /metrics (meter snapshot — Prometheus text + JSON) |
context, core, dispatcher, http, log, platform-node, telemetry, util |
@ndigo/openapi |
6 | iso | OpenAPI 3.0.3 generation from dispatch metadata (zod) |
core, dispatcher, http |
@ndigo/mcp |
6 | node | MCP tools projected from dispatch metadata: stdio + Streamable HTTP, resources, GenAI spans (@modelcontextprotocol/sdk) |
core, dispatcher, flags, inspect, platform-node, router |
@ndigo/ai |
4 | iso | Provider-neutral agent contracts and a bounded tool-calling loop over MCP tools | core, flags, rate-limit |
@ndigo/ai-sdk |
5 | iso | Vercel AI SDK integration layer (ai): the only package that imports the SDK |
ai |
@ndigo/host |
6 | browser | Micro-frontend shell orchestration, feature mounting | context, core, router |
@ndigo/auth-core |
6 | iso | Auth contracts: principal, sessions, password encoders, @authenticated/@authorized |
— |
@ndigo/auth-client |
6 | browser | Client auth: AuthClient, ClientAuthGuard, OIDC (jose) |
auth-core, http, util |
@ndigo/auth-server |
6 | node | Server auth: AuthServer, AuthRouteGuard, JWT (jose) |
auth-core, dispatcher, router, util |
@ndigo/platform-node |
6 | node | Node platform: HTTP server adapter, auto-configuration, static files, multipart (busboy, helmet, ws) |
18 Ndigo deps — hub |
@ndigo/cluster |
6 | node | Process supervisor: replicated worker slots, signal relay, bounded restarts | log |
@ndigo/cli |
6 | node | Framework CLI: serve/build/test/db/generate/inspect/doctor, the UI registry and MCP server (ui), on @stricli/core + @modelcontextprotocol/sdk |
cluster, @stricli/core |
@ndigo/platform-web |
6 | browser | Browser platform glue: dispatcher, history/cookie/data-binding resolvers, theme | 12 Ndigo deps — hub |
@ndigo/telemetry-node |
6 | node | OTel NodeSDK wiring + Node instrumentations (10 OTel packages; peer ws) |
telemetry, platform-node, router, socket, view, log |
@ndigo/telemetry-web |
6 | browser | Browser SDK wiring + web instrumentations (8 OTel packages) | log, platform-web, socket, telemetry |
@ndigo/test |
6 | iso | In-process request dispatch test harness (MockMVC-style) | platform-node, platform-web, dispatcher, router, view, context, http, log, util |
@ndigo/boot |
7 | node | Application bootstrap: profiles, typed config, run() (zod; peer @toon-format/toon) |
context, core, factory, log, router, util |
@ndigo/oxlint-plugin |
tooling | — | Consumer-facing oxlint rules (CI) (@oxlint/plugins; peer oxlint); repo-only rules live in root src/oxlint/ |
— |
@ndigo/inspect |
tooling | — | App manifest: routes, handler mappings, declared configuration keys (programmatic API + demo script) | context, core, dispatcher, router |
@ndigo/ui |
tooling | iso | Design-system tokens, opt-in base layer, typed token catalog, and the copy-in registry contract | — |
create-ndigo-app |
tooling | — | Standalone Ndigo application scaffolder (node or browser) | — |
Repo-only tooling lives outside packages/: the internal lint rules in root
src/oxlint/, the Nx generator collection in tools/generators/, and
one-shot probes in scripts/.
Standalone use: every package resolves its own install closure — npm install @ndigo/http pulls exactly core, factory, util and its external deps; no hand-assembled peer list. peerDependencies is allowlisted per package for host-provided contracts (oxlint-plugin → oxlint, telemetry-node → ws, platform-node → swagger-ui-dist, boot → @toon-format/toon, flags-openfeature → @openfeature/server-sdk, redis → cache/factory/jobs/locks/rate-limit/socket). See Manifest Dependency Sections.
Paradigm Selection Rules
Section titled “Paradigm Selection Rules”The framework offers multiple facilities that can, superficially, solve the same problem. These rules define the single canonical use for each so developers do not invent competing idioms.
Reactive State vs. Application Events
Section titled “Reactive State vs. Application Events”| Facility | Canonical use | Anti-pattern |
|---|---|---|
@ndigo/signals (signal/computed/effect) |
Local, synchronous, derived state within one component or service — a value that other code reads and reacts to immediately. | Using signals to broadcast asynchronous cross-component notifications (a “signal as an event bus”). |
@ndigo/context EventEmitter (Emitter, AsEventEmitter) |
Cross-component, asynchronous application events — decoupled publish/subscribe with :-hierarchy dispatch (ApplicationEvent style). |
Using an emitter to model derived/inline state that components read synchronously. |
SSE (ServerSentEventView, serviceClient stream: true) |
Server→client one-way event stream — the island-reactivity channel; handler-owned, per-request state. | Using WebSockets for one-way notifications that SSE already covers. |
WebSocket + STOMP (@socketMapping, SocketSession rooms, STOMP /topic/*) |
Bidirectional, session-scoped channels — rooms/broadcast, heartbeat reaping, STOMP destinations; cross-node via opt-in fanout. | Broadcasting application-wide events over a socket channel instead of the context event bus. |
Async iterables (ServiceClient.stream, SocketSession.messages) |
Consumer-driven consumption of an open stream — typed, backpressured iteration over pushed values. | Materializing a whole stream into an array before reading it. |
Rule of thumb: derived value you read → signal; thing that happened that others should react to → event.
Removed (2026-09-05):
@ndigo/model‘sEventModel/asMinimalEventEmitter— the legacy pre-signals “observable model” emitter — has been deleted. Reactive models are now@ndigo/signals’@reactive/@signaldecorators, with validation via Zod schemas.
Validation
Section titled “Validation”- Zod is the canonical runtime-schema primitive (
safeParse/parse+z.infer). Schemas validate DTOs, route params, domain value objects, and signal state;drizzle-zodderives entity schemas fromndigo-orm’s decorator metadata so persistence, validation, and typing share one definition. See Guide 07 — Request Validation. ndigo-ormentities stay decorator-annotated (@entity/@column/@primaryColumn); validation is derived from those annotations, not a parallel decorator API.ndigo-validate(constraint/decorator system) andndigo-model(objectmodel) were retired 2026-09-05; do not re-add them.
Route → Handler Mapping
Section titled “Route → Handler Mapping”- Radix mapping (
RadixMetadataHandlerMapping) is the only handler mapping — trie lookup, deterministic precedence, 40–63× faster than the retired linear scan (measured inbench/routing.bench.ts). NamePathHandlerMappingremains the first element of the default strategy chain (name-based lookup fallback);SimplePathHandlerMappingis internal.- The former
LinearMetadataHandlerMappingwas deleted 2026-09-05 (radix proved faster in every benchmark scenario and is isomorphic — browser and node both default to radix).
Dependency Management Strategy
Section titled “Dependency Management Strategy”Library Integration Layer Pattern
Section titled “Library Integration Layer Pattern”Core Principle: External dependencies should be encapsulated by a single Ndigo package that acts as an integration layer. Other packages must import functionality from the integration layer, never directly from external libraries.
Current Integration Layers:
| Integration Layer | Encapsulated Libraries | Purpose |
|---|---|---|
@ndigo/dom |
linkedom |
Isomorphic DOM parsing and element creation |
@ndigo/ai-sdk |
ai |
Provider-neutral model adapter |
The encapsulated-library set is the set enforced by the
internal/no-direct-integration-imports oxlint rule (INTEGRATION_LAYER_MAP in
src/oxlint/_constants.ts, wired through .oxlintrc.json; the lint lane runs
--deny-warnings, so a violation fails CI). Add the library there first, then
here.
Manifest Dependency Sections
Section titled “Manifest Dependency Sections”Every package declares exactly the dependencies it uses, in three enforced
sections (scripts/check-manifests.mjs — CI, pnpm manifest:check):
dependencies— every import made fromsrc/, including@ndigo/*. This is the standalone-install closure:npm install @ndigo/httppullscore,factory,util, and OTel’s API automatically.devDependencies— imports used only bytest/,bench/, and rootvitest.config.ts/vite.config.ts, plus@types/*and build tooling.peerDependencies— reserved for host-provided contracts, allowlisted per package in the checker (PEER_ALLOWLISTinscripts/check-manifests.mjs— the single source of truth):@ndigo/oxlint-plugin → oxlint(plugin contract),@ndigo/telemetry-node → ws(host-provided types),@ndigo/platform-node → swagger-ui-dist(asset-only docs UI),@ndigo/redis → @ndigo/cache/@ndigo/factory/@ndigo/socket(node-only capability subpaths), and@ndigo/boot → @toon-format/toon(optional). Any new peer needs a deliberate decision + allowlist entry in the checker.
Rule of thumb: if a package cannot run right after npm install <pkg>,
the missing resolver is a manifest bug — not the consumer’s job. There is no
“consumer must also install these” section.
Usage Rules
Section titled “Usage Rules”- Only integration layer packages may import from their encapsulated libraries
- All other packages must import through the integration layer
- Test files (
*.spec.ts,*.test.ts) may import external libraries for mocking purposes - Type-only imports (
import type) are permitted when the library is declared inpeerDependencies— see Type-Only Imports
Examples
Section titled “Examples”✅ Correct:
// In @ndigo/core or any non-integration packageimport { createDOMProvider } from '@ndigo/dom'; // ✅ Import from integration layer❌ Incorrect:
// In @ndigo/core or any non-integration packageimport { parseHTML } from 'linkedom'; // ❌ Direct import bypasses integration layer✅ Allowed Exception (Tests):
// In @ndigo/core/test/my-service.spec.tsimport { vi } from 'vitest';// Mock external dependencies if needed for testingvi.mock('@ndigo/util');Rationale
Section titled “Rationale”- Version Control: Single source of truth for library versions across the monorepo
- API Stability: Integration layer can smooth over breaking changes in external libraries
- Migration Safety: Easier to swap or upgrade dependencies without touching consuming code
- Bundle Optimization: Centralized imports enable better tree-shaking and code splitting
- Type Safety: Integration layer can provide enhanced TypeScript definitions
Adding New Dependencies
Section titled “Adding New Dependencies”When adding a new external dependency:
- Evaluate if it should be in an existing integration layer or needs a new one
- Add the dependency to the appropriate integration layer package
- Re-export the necessary APIs through the integration layer’s
src/index.ts - Document the integration layer in this table
- Update
AGENTS.mdandARCHITECTURE.mdwith the new rule
Enforcement
Section titled “Enforcement”- Manual Review: Check imports during code review
- oxlint Plugin (implemented): Custom rule to detect direct imports of encapsulated libraries (
internal/no-direct-integration-imports, loaded from rootsrc/oxlint/via oxlint JS Plugins; consumer-facing rules ship in@ndigo/oxlint-plugin) - CI Manifest Checker (implemented):
scripts/check-manifests.mjsruns in CI (pnpm manifest:check;.github/workflows/ci.yml) and enforces the Manifest Dependency Sections contract plus the@ndigo/*~0.0.1protocol
Type-Only Imports
Section titled “Type-Only Imports”Status: Resolved. Type-only imports (import type) are permitted for
host-provided libraries when the library is declared in peerDependencies
(e.g. @ndigo/telemetry-node → ws) — the import is erased at build time and
the consumer opts into the host library explicitly. Runtime imports must go
through the integration layer; package-manager sections follow the
Manifest Dependency Sections policy.
Maintenance Note
Section titled “Maintenance Note”When adding or modifying integration layer rules, remember to update:
- This section in
ARCHITECTURE.md - The “Library Integration Layer Pattern” section in
AGENTS.md - The “Dependency Management Strategy” section in
ARCHITECTURE.md
See also: Utility Function Organization for concrete implementation guidelines on organizing utilities within packages, including the critical rule that internal files (_util.ts, _parser.ts) must contain actual local utilities, not just re-exports.
Design Patterns
Section titled “Design Patterns”Ndigo leverages established design patterns to achieve flexibility, extensibility, and maintainability. These patterns directly support the SOLID principles documented above.
- Strategy Pattern: Used extensively in parsers and routers. Enables Open-Closed Principle by allowing behavior to be swapped without modifying existing code.
- Factory Pattern: Central to component instantiation in
@ndigo/core. Supports Dependency Inversion Principle by abstracting object creation. - Observer Pattern: The backbone of the
@ndigo/coreevent system. Achieves Open-Closed Principle by allowing new observers without modifying subjects. - Decorator/Mixin Pattern: Used for horizontal feature composition (e.g.,
AsEventEmitter). Promotes Single Responsibility Principle by separating concerns across decorators. - Template Method: Used in base controller and service classes. Follows Open-Closed Principle by defining extension points.
See also: Foundational Principles for detailed SOLID and DRY guidance, including when and how to apply these patterns.
Constructor-Factory Dual Pattern
Section titled “Constructor-Factory Dual Pattern”Ndigo employs a Constructor-Factory Dual Pattern in several core classes, where the constructor is optimized for dependency injection scenarios, and a static factory method provides a cleaner API for direct instantiation.
Pattern Structure
Section titled “Pattern Structure”class Service { // Constructor: Optimized for DI - accepts pre-configured dependencies constructor(dependency1: Dependency1, dependency2: Dependency2, config: Config) { // Dependencies are injected by container }
// Static Factory: Optimized for direct use - accepts simple config static create(options: Options = {}): Service { // Factory creates dependencies from options const dependency1 = new Dependency1(options.dep1Config); const dependency2 = new Dependency2(options.dep2Config); return new Service(dependency1, dependency2, options.config); }}When to Use Each Pattern
Section titled “When to Use Each Pattern”Use Constructor When:
- Working with ApplicationContext or DI containers
- Dependencies are managed by framework
- Following IoC (Inversion of Control) principles
- Multiple dependencies need separate configuration
Use Static Factory When:
- Direct instantiation in tests or scripts
- Standalone usage without DI
- Creating multiple configured instances
- Simpler API is preferred
Example: HTTP Client
Section titled “Example: HTTP Client”The HTTP class in @ndigo/http demonstrates this pattern:
DI Pattern (Constructor):
// In AsHTTPStrategy mixin (packages/ndigo-dispatcher)class Application extends AsHTTPStrategy(StrategyInitializer) { initHTTP(context: ApplicationContext): void { // Framework collects interceptors from DI container this.initCollectionStrategy( context, 'httpInterceptors', 'httpInterceptor', HTTP_INTERCEPTORS, defaultHTTPInterceptors // 5 built-in interceptors ); // Framework loads settings from configuration this.initStrategy(context, 'httpSettings', HTTP_SETTINGS, {}); }
getHTTP(fetch?: Fetch): HTTP { // Three separate concerns, managed by DI container return new HTTP( this.httpInterceptors, // From DI container this.httpSettings, // From config fetch // Injected for testability ); }}Direct Pattern (Static Factory):
// In tests, scripts, or standalone usageconst api = HTTP.create({ baseURL: 'https://api.example.com/', headers: { Authorization: 'Bearer token' }, params: { version: 'v2' }, interceptors: [new TimeoutInterceptor(5000), new LoggingInterceptor()],});Benefits
Section titled “Benefits”- Separation of Concerns: Constructor parameters represent distinct concerns (behavior, configuration, implementation)
- Flexibility: Both DI and direct usage patterns are supported
- Testability: Constructor allows full control over dependencies for testing
- Ergonomics: Static factory provides clean API for common use cases
- Backward Compatibility: Adding factory method doesn’t break existing constructor usage
Implementation Guidelines
Section titled “Implementation Guidelines”When implementing this pattern:
- Constructor signature: Design for DI - accept fully-constructed dependencies
- Factory signature: Design for convenience - accept simple options/config objects
- Documentation: Clearly document which pattern to use when
- Consistency: Use consistent naming (
create()for factory methods) - Defaults: Provide sensible defaults in factory, require explicit values in constructor
See also: @ndigo/http package for a complete implementation example.
Decorators & Separation of Concerns
Section titled “Decorators & Separation of Concerns”Overview
Section titled “Overview”Decorators are a metaprogramming feature in JavaScript and TypeScript that allow behavior to be added to classes, methods, fields, or parameters at design time. They are functions that are invoked with the decorated item as an argument, enabling them to inspect, modify, or even replace the target.
In Ndigo, decorators are the primary mechanism for achieving separation of concerns and reducing boilerplate. They enable a declarative programming style where complex, cross-cutting logic—such as routing, dependency injection, and lifecycle management—is cleanly separated from core business logic. This directly supports the Single Responsibility Principle by allowing each class to focus on its core purpose while decorators handle cross-cutting concerns.
By annotating a class or its members, you instruct the framework to apply specific behaviors, turning complex imperative setup into a simple, readable declaration. This is all accomplished using the official TC39 Stage 3 decorator standard, ensuring future compatibility and adherence to the JavaScript language evolution.
Decorators in Ndigo
Section titled “Decorators in Ndigo”Ndigo provides 16 decorators across 5 core packages, each designed to address a common cross-cutting concern. Their key benefits include:
- Declarative Configuration: Define component behavior with simple annotations instead of complex setup code.
- Metadata-Driven: Decorators attach metadata to classes, which the framework uses at runtime to wire together behavior.
- Separation of Concerns: Keep routing, validation, and lifecycle logic out of your business components.
- Reduced Boilerplate: Eliminate repetitive setup code for common tasks like component registration and dependency wiring.
Cross-Cutting Concerns
Section titled “Cross-Cutting Concerns”Ndigo decorators are organized around five key cross-cutting concerns:
1. Configuration & Dependency Injection
Section titled “1. Configuration & Dependency Injection”These decorators manage application configuration and dependency injection, allowing components to receive their dependencies and configuration properties without needing to know where they come from.
- Decorators:
@configuration,@entry,@property,@propertySource,@whenProfile - Problem Solved: Eliminates manual dependency wiring, external configuration loading, and factory management.
Example:
@configurationclass AppConfig { @property('database.url') dbUrl: string;
@entry databaseConnection() { // This method's return value is automatically registered // as an injectable dependency in the application context. return new DatabaseConnection(this.dbUrl); }}2. Request Routing & Dispatching
Section titled “2. Request Routing & Dispatching”These decorators map incoming requests (e.g., HTTP requests) to specific controller methods, simplifying the creation of APIs and web controllers.
- Decorators:
@routeMapping,@errorMapping,@errorHandler - Problem Solved: Removes the need for manual route tables or large switch statements. Provides a declarative way to handle routing and errors.
Example:
@routeMapping('/api/users')class UserController { @routeMapping('/:id') getUserById(id: string) { return this.userService.findById(id); }
@errorHandler(UserNotFoundError) handleNotFound(error: UserNotFoundError) { return { status: 404, message: error.message }; }}3. Lifecycle & Scope Management
Section titled “3. Lifecycle & Scope Management”These decorators control the instantiation and lifecycle of objects within the application context, defining whether an object is a singleton, a new instance per request, etc.
- Decorators:
@scope,@singletonScope,@prototypeScope,@hashScope - Problem Solved: Manages object lifecycles automatically, ensuring predictable and efficient use of resources.
Example:
@singletonScopeclass UserService { // A single instance of UserService is shared across the entire application.}
@prototypeScopeclass RequestHandler { // A new instance of RequestHandler is created for each request.}4. Data Validation
Section titled “4. Data Validation”Entity decorators carry validation metadata that the framework derives into a Zod schema — validation logic is co-located with the field it protects, and the derived schema is the single source of truth.
- Decorators:
@column({ schema }),@primaryColumn,@generatedColumn - Problem Solved: Keeps validation with the data it protects while producing a runtime schema and a static type (
z.infer) from one declaration. Route params usevalidatedBy(schema, dataBinderValue('query'))from@ndigo/dispatcher; domain components overridevalidationSchemas.
Example:
import { z } from 'zod';import { entity, column, primaryColumn } from '@ndigo/orm';
@entity('users')class User { @primaryColumn() id: number;
@column({ type: 'varchar', schema: z.string().email() }) email: string;
@column({ type: 'varchar', schema: z.string().regex(/^[A-Za-z\s]+$/) }) name: string;}5. View Resolution
Section titled “5. View Resolution”View decorators link components to their corresponding view templates or custom element tags, simplifying UI rendering.
- Decorators:
@viewMapping,@tagMapping - Problem Solved: Automates the registration of UI components and their association with specific views or DOM elements.
Example:
@viewMapping('user-profile')@tagMapping('user-profile')class UserProfileComponent extends HTMLElement { // This class is automatically registered as the <user-profile> // custom element and linked to the 'user-profile' view.}Decorator Metadata
Section titled “Decorator Metadata”Ndigo decorators work by attaching metadata to classes, which is then read by the framework at runtime. This process follows the TC39 standard, using the Symbol.metadata property on class constructors.
The flow is as follows:
flowchart LR
A[Decorator Applied] --> B[Metadata Stored]
B --> C[Symbol.metadata]
C --> D[Runtime System]
D --> E[Metadata Retrieved]
E --> F[Behavior Applied]
style A fill:#e1f5ff
style C fill:#fff4e1
style F fill:#e8f5e9
Metadata Keys: To avoid collisions and provide a clear structure, metadata keys follow a consistent pattern:
| Pattern | Example | Usage |
|---|---|---|
concern:type |
'route:mapping' |
Class-level metadata |
concern:type:member |
'route:mapping:getUser' |
Method-level metadata |
concern:parameter:member |
'method:parameter:getUser' |
Parameter metadata |
MetadataManager: The @ndigo/dispatcher package provides a MetadataManager class that efficiently scans, caches (in a WeakMap), and retrieves this metadata, acting as the bridge between declarative decorators and the framework’s runtime behavior.
Performance Considerations
Section titled “Performance Considerations”While decorators are powerful, they introduce a small amount of overhead at class definition time. To ensure optimal performance, follow these best practices:
✅ Do:
- Keep decorator functions lightweight and focused on storing simple metadata values.
- Defer expensive logic (e.g., compiling a regex, fetching data) to the runtime components that consume the metadata.
- Use caching (like the
WeakMapinMetadataManager) for repeated metadata access.
❌ Don’t:
- Perform heavy computations or I/O operations inside a decorator function.
- Store large, complex objects or instances directly in metadata.
- Create unnecessary decorator instances in loops or high-frequency code paths.
Example:
// ✅ Good: Lightweight metadata storagefunction route(path: string) { return function (target, context) { // Only stores a simple string in metadata. context.metadata['route:path'] = path; return target; };}
// ❌ Bad: Heavy computation at class definition timefunction route(path: string) { return function (target, context) { // Don't compile regex or fetch data here! This runs // every time a class with this decorator is defined. const compiledPattern = compileComplexRoutePattern(path); context.metadata['route:path'] = compiledPattern; return target; };}Creating Custom Decorators
Section titled “Creating Custom Decorators”To create a custom decorator compatible with Ndigo, adhere to the following:
- Follow the TC39 Signature: Ensure your decorator function matches the standard signature (e.g.,
(target, context)). - Use
context.metadata: Store metadata on the providedcontext.metadataobject. - Follow Key Conventions: Use the
concern:type:membernaming pattern for metadata keys. - Keep it Lightweight: Defer heavy logic to a runtime processor that reads the metadata.
Minimal Example:
export function customDecorator(value: string) { return function (target: any, context: ClassDecoratorContext) { context.metadata['custom:value'] = value; return target; };}Standards Compliance
Section titled “Standards Compliance”Ndigo is fully compliant with modern JavaScript standards for decorators:
- TC39 Stage 3 Decorators: All 16 framework decorators use the official TC39 Stage 3 decorator specification.
Symbol.metadata: Metadata is stored in the standardSymbol.metadataproperty.@tsmetadata/polyfill: This polyfill is used to ensureSymbol.metadatais available across all supported JavaScript environments, providing a seamless and standards-compliant experience.
Naming Conventions
Section titled “Naming Conventions”- Predicates:
is*(e.g.,isString,isContextToken) for type/identity checks usingtypeoforinstanceof. Asks: “Is this an instance of X?” - Duck Typing:
like*(e.g.,likeController,likeFactory) for structural/shape checks using property presence. Asks: “Does this behave like X?” Never useinstanceofas the primary check in alike*function. - Mixins:
As*(PascalCase) for Class Mixins:class MyService extends AsEventEmitter(Base).as*(camelCase) for Function Mixins:asEventEmitter(target).
For comprehensive naming guidelines, see:
- Properties vs Methods - when to use properties, get-methods, or to-methods
- Method Naming Conventions - get*, is*, has*, set*, from*, to*, with*, etc.
- Self-Documenting Code - names carry meaning, comments carry rationale; why-not-what comments; assertion rationale;
// NOTE:as technical debt; one public type per file under 300 lines
These sections provide detailed decision matrices, examples from the codebase, and rationale for choosing between different API design patterns.
Module Visibility & API Design
Section titled “Module Visibility & API Design”Public API Surface
Section titled “Public API Surface”Each package defines its public API through src/index.ts. Only modules explicitly exported are considered:
- Stable: Subject to semantic versioning
- Documented: Included in API documentation
- Supported: Safe for cross-package and external use
Internal Modules
Section titled “Internal Modules”Files beginning with underscore (_) are internal implementation details:
| Type | Location | Purpose | Examples |
|---|---|---|---|
| Utilities | src/_util.ts |
Internal helper re-exports, package-specific utilities | Parser utilities, string helpers |
| Helpers | src/_parser.ts |
Low-level building blocks used by public modules | Parser primitives, internal combinators |
| Implementations | src/_natural-sort.ts |
Internal algorithms not exposed directly | Sorting logic, encoding details |
| Test Helpers | test/_expect.ts |
Custom test assertions and utilities | Parser test helpers, mock factories |
Enforcement:
- Underscore files MUST NOT be exported in
index.ts - Underscore files MUST NOT be imported across package boundaries
- Underscore files SHOULD have JSDoc explaining their internal purpose
- Linters/scripts MAY enforce this convention (future enhancement)
Rationale:
- Provides clear API boundaries without exposing implementation details
- Allows internal refactoring without breaking external consumers
- Reduces cognitive load when exploring package capabilities
Contract-First Patterns
Section titled “Contract-First Patterns”The framework ships one shared contract vocabulary instead of a vocabulary per
capability: Equatable/Comparable/Comparator (+ stringComparator,
numberComparator, compareBy, chainComparisons, invertComparison),
Enumerable, Serializable, Matchable/ResultMatcher/PatternMatcher,
Parser/Stringifier, Converter/ConversionService, the FormattingService
family, and — in @ndigo/factory — the lifecycle set (Initializable,
Destroyable/AsyncDestroyable, Closeable/AsyncCloseable,
Lifecycle/Phased/SmartLifecycle) with the Aware mixins. The
2026-11-06 contract-reuse survey grounded this vocabulary in usage: every equals()-carrying class declares
Equatable, the container’s bean shutdown is duck-typed through the like*
guards, and ConversionService/FormattingService are wired through the
context as beans.
The working rules:
- Declare what you structurally are. A class that carries
equals(),compareTo(),toJSON(),matches(),close(),init(), ordestroy()names the contract in itsimplementsclause; contract interfaces (JobDriver,OutboxStore,LockDriver) extend the matching lifecycle contract so every backend is compiler-checked. The container’s duck-typed shutdown (likeDestroyable/likeCloseable) does not depend on the declarations — declarations are compile-time intent, which is why deliberate exclusions (self-stopping servers, parameterizedclose()s) are documented next to the class. - Shape guards are
like, nottypeofchains. Duck-typing uses@ndigo/util’slike({...})one-liners wrapped by the owning package’slike*guards (likeJobDriver,likeLockDriver,likeMatchable, …). Hand-rolledtypeof x === 'function'ladders are a review flag. - Ordering goes through the comparator primitives. Inline
a.x < b.x ? -1 : …anda.x - b.xcomparisons compile fine but bypass the shared idiom; usestringComparator/numberComparator(+compareBy,chainComparisons,invertComparison). Locale-aware sorts keeplocaleCompare. - Errors carry their lineage. Closed-state guards throw
IllegalStateError; framework/denial errors extendFrameworkError; application-domain failures extendApplicationError. Dependency-free contract packages (@ndigo/auth-core) keep plainErrorby design.
Node Platform & Application Lifecycle
Section titled “Node Platform & Application Lifecycle”The Node server-side stack (@ndigo/platform-node + @ndigo/boot +
@ndigo/http + @ndigo/dispatcher) follows the same philosophy as the rest of
the framework — dependency injection, declarative metadata, and lifecycle
management — applied to HTTP services. The developer-facing story is covered
in the guides; this section captures the architecture.
Bootstrap
Section titled “Bootstrap”Applications are assembled through ApplicationBuilder.run()
(@ndigo/boot): it prepares the environment, registers platform
auto-configuration (NodeAutoConfiguration) plus the app’s
@configuration classes, and refreshes the context. Registration is
conditional on platform (@whenNode) and environment.
Lifecycle Model
Section titled “Lifecycle Model”The context drives two lifecycle passes:
- Refresh (
context.refresh()) — after entries are created, theLifecycleProcessorstartsSmartLifecyclebeans that areisAutoStartup(mapping, dispatcher). Ordering is by phase; the mapping and dispatcher initialize before the server would start. - Close (
context.close()) — the processor stops running lifecycle beans in reverse order, then_destroyValues()tears down values that implementDestroyable/Closeable(including plain@entry/setValueinstances).HTTPServeris a managed entry created byNodeAutoConfiguration(not manually); see Guide 04 — Dependency Injection, Scopes & Lifecycle for the full sequence.
Request Pipeline
Section titled “Request Pipeline”NodeHTTPServerAdapter → NodeDispatcher → handler mapping → interceptors → argument resolvers (extractors + conversion) → handler → view/JSON- Extractors bound via
@parametersresolve + type-convert arguments; a post-conversion Zod hook runs in every platform resolver. - Responses: plain objects → JSON;
ResponseEntity→ explicit status/headers/body;ModelAndView/View→ view resolution. - Errors: the adapter → dispatch error path →
RouteErrorResolverchain → the last-resort resolver guarantees a deterministic status for anything unhandled.
HTTP Error Contract
Section titled “HTTP Error Contract”Every 4xx/5xx response is an application/problem+json document:
type (urn:ndigo:* placeholder), title, status, detail, instance,
plus the errors extension for validation issues.
Status conventions are canonical — 400 binding, 401 + WWW-Authenticate,
403, 404, 405 + Allow, 413/408 transport, 415, 422 validation, 429, 503,
500 last-resort. Real HTTPS problem-type URIs replace the urn: scheme
before first public release.
Modules at a Glance
Section titled “Modules at a Glance”| Package | Responsibilities | Key exported concepts |
|---|---|---|
@ndigo/boot |
Bootstrap primitives | ApplicationBuilder, Application |
@ndigo/context |
DI container + lifecycle | Configuration, @entry, context hierarchy |
@ndigo/factory |
Bean lifecycle | SmartLifecycle, Destroyable, Closeable |
@ndigo/dispatcher |
Routing + dispatch | mappings, @parameters extractors, validation, interceptors, error resolvers |
@ndigo/platform-node |
Node adapter | NodeAutoConfiguration, HTTPServer, NodeDispatcher, argument resolvers, ProblemDetails, gracefulShutdown |
@ndigo/http |
HTTP vocabulary | ResponseStatuses, HTTPHeaders |
@ndigo/orm |
Persistence + schema | @entity/@column, insertSchemaForEntity |
@ndigo/telemetry |
Logging/tracing/metrics | getLogger, LoggerFactory |
Guides: start with Guide 00 — Core Concepts.
Environment/configuration docs (sources, profiles, binding, validation): Guide 03 — Environment & Configuration, Environment & Configuration.
Roadmap
Section titled “Roadmap”Current Status
Section titled “Current Status”As of November 2026.
- The 10-wave program shipped. Foundation (
@ndigo/util,@ndigo/core,@ndigo/signals), IoC/context, the web stack (@ndigo/http,@ndigo/router,@ndigo/dispatcher), observability, and the view/application layers are complete; the 1.0 checkpoint (Wave 10) is reached. - Auth shipped: credentials + session stores (memory and durable LibSQL), OIDC authorization-code + PKCE, JWTs, role guards, rate limiting, trusted-proxy client-IP resolution.
- ORM shipped: decorator entities, Drizzle adapter, relations, journaled migrations, Zod-derived schemas.
- Persistence baseline landed: durable sessions, shared Redis cache, cross-node WebSocket/STOMP fan-out; distributed scheduling is explicitly deferred.
- The framework CLI shipped:
@ndigo/cli(binndigo) on@stricli/coreas an embedded library —serve(with the--clustersurface),dev,generate(fronting@ndigo/generators),db:migrate,inspect, anddoctor; scripts run shell-free so signals reach the app. The command surface has since grown to the full lifecycle (build/test/lint/format/typecheck/clean), scaffolding (new --preset,add, tengeneratekinds), the engine-agnosticdbfamily (backed by theNdigoDatabaseAdaptercontract in@ndigo/orm), and maintenance/DX (info,update,config,env,openapi,routes,logs,completion). - Clustering shipped:
@ndigo/clustersupervises replicated worker slots —SIGINT/SIGTERMrelay into each child’s own graceful shutdown, bounded-backoff respawns behind a fail-closed crash-loop guard,NDIGO_CLUSTER_SLOT/SIZE+PORTderivation. Coordination stays in the leases and locks; the supervisor owns only the processes. - Distributed locks shipped:
@ndigo/locksowns theLockDriverSPI (non-blocking acquire, TTL leases, monotonic fencing tokens) with the in-process driver and Redis driver (@ndigo/redis/locks, Lua compare-and-delete);@scheduled({ lock })runs one node per fire. - Jobs and the transactional outbox shipped:
@ndigo/jobsowns theJobDriverSPI (lease-based at-least-once claims, retries, spans) with in-process, LibSQL, and Redis drivers, plus the outbox relay whose LibSQL store joins@transactionalcommits on the application’s data source. - ConversionService shipped — schema-target conversion is the supported
binding/conversion path; the former handwritten
Converter<S,T>design note is obsolete. - Feature flags shipped:
@ndigo/flagsowns theFlagEvaluatorSPI, the zero-configPropertyFlagProvider(live-refresh, percentage rollouts) and the@whenFeatureroute gate;@ndigo/flags-openfeaturebridges the standard on both the server and/webentries, so vendors arrive as providers behind the SPI. - Scaffolding shipped:
ndigo newplus the@ndigo/generatorsappgenerator stand up an in-repo app; the standalonecreate-ndigo-appscaffold waits on the 1.0 publish. - Pre-1.0 consistency closed: the decorator taxonomy with its renames, the
@ndigo/utilprune to the consumed surface, and the lint-rule split — repo-only rules live in rootsrc/,packages/stays the consumer surface. - Runtime floor is Node ≥ 24 (
engineswithengine-strict; CI runs 24) — the floor every package README and the README template state.
Near-Term
Section titled “Near-Term”- Full time-zone catalog generator — conditional: only if the curated
@ndigo/schedulerconstants prove insufficient. - Page concept design — the open Page concept question (independently bootable presentation container) gets its own design conversation if a concrete consumer appears.
- Release gate — changelog cuts
1.0.0(Keep a Changelog), package README/API docs re-verified (pnpm doc:check), CI gates green. The frozen surface is recorded.
Deferred Work
Section titled “Deferred Work”- API-freeze consistency review — completed 2026-10-31: the full decorator
taxonomy and naming conventions; profile gates unified on
@whenProfile. - Distributed scheduling and cross-node queue consumption — closed with revisit triggers; not speculative scope.
- The 2026-03-08 architecture roadmap and the 2026-05-27 feature-completeness roadmap (10-wave program) are archived history. The current design intent lives in this document.
© 2026 Ndigo Team. All rights reserved.