@hwy-fm/di
v0.0.1-beta.9.1
Published
[](https://opensource.org/licenses/MIT) [](https://www.typescriptlang.org/)
Downloads
618
Readme
@hwy-fm/di
A lightweight, high-performance, and concurrency-safe Dependency Injection (DI) container for TypeScript and Node.js.
Designed for developers who love Java Spring / Angular style dependency injection but want a module-free, pure DI experience.
✨ Key Capabilities (Highlights)
1. 🛡️ Environment Awareness & Isolation
The DI system uses a Two-Phase Isolation Strategy (Tagging vs. Filtering) to manage services across different environments (e.g., Micro-Frontends, Dev/Prod).
Phase 1: Tagging (Global)
You label services with an environment tag (env). This can be done manually per provider or valid automatically for a whole bundle.
Auto-Tagging (Micro-Frontends):
By setting InstantiationPolicy.activeEnv synchronously before importing a module, all services defined in that module will be "stamped" with that environment tag.
⚠️ Warning:
InstantiationPolicy.activeEnvis a global mutable state. Ensure module loading is synchronous or properly guarded. If you use async imports withawait, be careful that other concurrent imports don't overwrite this value before your module finishes defining its services.
import { InstantiationPolicy } from '@hwy-fm/di';
// 1. Start Tagging
InstantiationPolicy.activeEnv = 'marketing-app';
// 2. Load Module (All @Injectable() inside will get env='marketing-app')
// NOTE: Ensure this executes before changing activeEnv back
const { MarketingModule } = await import('./marketing/module');
// 3. Stop Tagging
InstantiationPolicy.activeEnv = null; Phase 2: Filtering (Per-Injector)
You define an Admission Policy to decide which tags are allowed in a specific Injector.
A. Define the Policy:
import { InstantiationPolicy, INJECTOR_ENV, InjectFlags } from '@hwy-fm/di';
// Compare Provider's Tag vs Injector's Context
InstantiationPolicy.globalAdmission = (token, provider, injector) => {
const allowedEnv = injector.get(INJECTOR_ENV, InjectFlags.Optional | InjectFlags.Self);
const targetEnv = (provider as any).env;
if (!allowedEnv) return true; // No restriction
if (!targetEnv) return true; // Shared/Common service
return targetEnv === allowedEnv;
};B. Create the Injector:
Bind INJECTOR_ENV to enforce the context.
Tip: Bind
INJECTOR_ENVin your Root Injector. Since child injectors inherit configuration, this effectively sets the strategy for the entire application.
import { Injector, INJECTOR_ENV, INJECTOR_SCOPE, ROOT_SCOPE } from '@hwy-fm/di';
const rootInjector = Injector.create([
{ provide: INJECTOR_SCOPE, useValue: ROOT_SCOPE },
{ provide: INJECTOR_ENV, useValue: 'marketing-app' }, // Applies to this injector AND all children
...MarketingModule.providers
]);2. 🚀 Async Governance (Transactional Rollback)
The DI engine treats complex dependency graphs as transactions.
- Automatic Rollback: If you resolve a tree of async services (A -> B -> C), and 'C' fails to initialize, the engine automatically disposes of the successfully created 'B' instance.
- Shared Protection: Singletons reused from parent injectors or
useExistingare protected from accidental disposal during rollback.
@Injectable()
class Connection {
// If this fails...
async onInit() {
throw new Error('Connection failed');
}
}
@Injectable()
class Service {
// This service (already created) will be automatically destroyed
constructor(private info: InfoService) {}
}
const injector = Injector.create([Connection, Service, InfoService]);
// The entire resolution fails, and InfoService is safely disposed.
try {
await injector.getAsync(Connection);
} catch (e) {
// Rollback complete
}3. 🔥 Smart Scope Proxy (Scope Mismatch Governance)
A sophisticated solution for the classic "Request Scope Contagion" problem found in many Node.js frameworks (like NestJS).
The Problem:
In standard DI, if a Singleton service depends on a Request-scoped service (e.g., ThreadLocal context), the Singleton effectively degrades to Request scope. This forces the entire dependency chain to be recreated for every request, causing severe performance degradation.
Our Solution: Transparent Hook-based Proxying
Utilizing the powerful HookMetadata and customFactory capabilities, you can inject a Lazy Proxy instead of the real instance. The Singleton service remains a Singleton, but when it accesses the dependency, the Proxy transparently fetches the correct instance for the current request context (e.g., via AsyncLocalStorage).
import { HookMetadata, InjectorRecord, AbstractContextStorage } from '@hwy-fm/di';
// 1. Define the Governance Strategy
function ScopeMismatchGovernance(record: InjectorRecord, next: () => any, injector: Injector) {
const isSingletonHost = injector.scope === 'Root'; // or checks against record.scope
const isRequestDependency = record.scope === 'Request';
// Detect Mismatch: Singleton requesting Request-scoped service
if (isSingletonHost && isRequestDependency) {
// Return a Proxy instead of the real instance
return new Proxy({}, {
get(target, prop) {
// Resolve the actual instance from the active request context at Runtime
// AbstractContextStorage is an abstraction over AsyncLocalStorage
const requestInjector = AbstractContextStorage.getStore();
if (!requestInjector) throw new Error('No active request scope');
const realInstance = requestInjector.get(record.token);
return Reflect.get(realInstance, prop);
}
});
}
return next();
}
// 2. Apply Policy (e.g., globally or on specific services)
HookMetadata.hook(RequestContextService, {
customFactory: ScopeMismatchGovernance
});
// 3. Result
@Injectable()
class UserService { // Remains Singleton! Included in startup optimization.
constructor(private ctx: RequestContextService) {} // Injected with Proxy
getUser() {
return this.ctx.currentUser; // Accesses data specific to the current request
}
}This feature allows you to maintain a Singleton-first architecture with high performance, while safely accessing request-scoped data where needed.
4. 🪝 The "Open Kernel" (Metadata Hooks)
A powerful metaprogramming API that lets you intercept and rewrite the DI engine's internal behavior for specific tokens.
import { HookMetadata } from '@hwy-fm/di';
// Intercept creation logic (AOP / Mocking)
HookMetadata.hook(MyService, {
customFactory: (record, next) => {
console.log('Intercepting creation...');
return next();
},
// Dynamic Scoping Control
onScopeCheck: (def, scope) => scope === 'root'
});🏗️ Real-World Application: The HWY-FM Core
While @hwy-fm/di provides the raw dependency injection capabilities, @hwy-fm/core demonstrates how to build a full-scale isomorphic framework on top of it.
If you are looking for:
- Application Bootstrapping: How to manage root injectors and platform binding.
- Logic Orchestration: How to use DI to build an AOT-compiled pipeline engine (Kernel).
- Environment Abstraction: How to swap implementations based on Context (Server vs Client).
👉 Check out the @hwy-fm/core documentation to see this DI container in action.
The Core framework extends DI with:
@Application: Auto-configuring root injectors.@Register: Dynamic provider registration for plugins.KernelLoader: A complex service built entirely using DI patterns.
📦 Installation
npm install @hwy-fm/di reflect-metadataMake sure to enable decorators in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}📚 Core Concepts
1. Basic Usage
import { Injectable, Inject, Injector, INJECTOR_SCOPE, ROOT_SCOPE } from '@hwy-fm/di';
// 1. Define
@Injectable()
class ConfigService {
apiUrl = 'https://api.com';
}
@Injectable()
class UserService {
// 2. Inject (Constructor Injection)
constructor(private config: ConfigService) {}
getUrl() { return this.config.apiUrl; }
}
// 3. Create Injector (Must bind ROOT_SCOPE for global services)
const injector = Injector.create([
{ provide: INJECTOR_SCOPE, useValue: ROOT_SCOPE }
]);
// 4. Resolve
const user = injector.get(UserService);2. Provider Recipes
const providers = [
// Class Provider (Standard)
{ provide: Logger, useClass: ConsoleLogger },
// Value Provider (Config/Constants)
{ provide: 'API_URL', useValue: 'https://api.com' },
// Factory Provider (Dynamic Creation)
{
provide: Database,
useFactory: (cfg: Config) => new Database(cfg.host),
deps: [Config]
},
// Existing Provider (Aliasing)
{ provide: 'AliasedLogger', useExisting: Logger }
];3. Tokens & Interfaces
Since TypeScript interfaces are erased at runtime, use InjectorToken.
export const API_CONFIG = new InjectorToken<AppConfig>('API_CONFIG');⚙️ Feature Deep Dive
1. Scoping & Hierarchy
Control where a service is created (Singleton vs Per-Injector).
@Injectable()(Default): Root Singleton. Shared globally across the entire app.@Scope('any'): Per-Injector Singleton. A new instance is created for each injector logic that requests it, but shared within that injector.@Scope('custom'): Restricted Singleton. Only visible and created in injectors specifically created with{ scope: 'custom' }.
// Created once per injector (e.g., per-request or per-module)
@Scope('any')
@Injectable()
class RequestContext {}2. Resolution Modifiers
Control how dependencies are resolved.
import { Optional, SkipSelf, Self } from '@hwy-fm/di';
constructor(
// return null instead of throwing if missing
@Optional() private service?: MyService,
// Start search from Parent Injector (skip local)
@SkipSelf() private parentConfig: Config,
// Only search in Current Injector
@Self() private localData: LocalData
) {}3. Lifecycle Hooks
Services can hook into their lifecycle by implementing specific methods.
| Method | Description |
| :--- | :--- |
| onInit() | Called immediately after instantiation. Can return a Promise. |
| destroy() | Called when injector.destroy() is invoked. |
@Injectable()
class DatabaseService {
private connection: any;
// 1. Initialization (Sync or Async)
async onInit() {
this.connection = await createConnection();
console.log('DB Connected');
}
// 2. Cleanup
destroy() {
this.connection.close();
console.log('DB Closed');
}
}4. Concurrency & Context Isolation (Node.js)
In high-concurrency Node.js applications, global state is dangerous. Use AsyncLocalStorage to isolate dependency scopes per request.
import { Injector, runInInjectionContext, INJECTOR_SCOPE, Injectable, Scope, InjectorToken, Inject } from '@hwy-fm/di';
// 1. Define Request-Scoped Token & Service
const REQUEST_ID = new InjectorToken<string>('REQUEST_ID');
@Scope('request') // Only created in injectors with scope='request'
@Injectable()
class RequestHandler {
constructor(@Inject(REQUEST_ID) private id: string) {}
process() {
console.log(`Processing request: ${this.id}`);
}
}
// 2. Handle Request
async function handleRequest(req, res) {
const reqInjector = Injector.create([
{ provide: INJECTOR_SCOPE, useValue: 'request' }, // Bind scope name
{ provide: REQUEST_ID, useValue: req.headers['x-request-id'] }
], rootInjector);
// 3. Run in Context (ALS)
await runInInjectionContext(reqInjector, async () => {
// Async work here preserves the active injector context
const handler = reqInjector.get(RequestHandler);
handler.process();
});
}🔥 Enterprise Patterns
1. Async Factories
The DI container fully supports async/await in factories.
{
provide: DB_CONNECTION,
useFactory: async () => {
const db = await connectToDatabase();
return db;
}
}
// ... later ...
// Use getAsync for tokens that might be async
const db = await injector.getAsync(DB_CONNECTION);2. Standalone Bootstrapping (resolveMinimal)
For usage outside a container (e.g., app startup), use resolveMinimal. It creates a temporary sandbox to resolve a dependency and returns a cleanup function.
import { resolveMinimalAsync } from '@hwy-fm/di';
const [loader, cleanup] = await resolveMinimalAsync(AppLoader);
await loader.initialize();
await cleanup(); // Dispose ephemeral instances3. Global Admission Policy (The Gatekeeper)
Centralize your DI security and configuration logic. This function runs before any provider is added to any injector.
import { InstantiationPolicy } from '@hwy-fm/di';
InstantiationPolicy.globalAdmission = (token, provider, injector) => {
// Scenario: Feature Flags
const meta = provider as any;
if (meta.featureFlag && !FeatureFlags.isEnabled(meta.featureFlag)) {
return false; // REJECT
}
return true;
};4. Global Service Registration
Register providers without passing them to Injector.create manually.
import { register, ROOT_SCOPE } from '@hwy-fm/di';
// Register globally
register({ provide: Logger, useClass: ConsoleLogger }, ROOT_SCOPE);5. Multi-Providers (Plugins)
Bind multiple services to a single token.
⚠️ Warning: Ensure all providers bound to a Token (especially logical groups like
@MultiToken) share a consistentScope. Mixing conflicting scopes (e.g., combiningSingletonandScopedservices in one array) may result in unpredictable lifecycle behavior or resolution errors.
const PLUGINS = new InjectorToken('PLUGINS');
@Injectable()
@MultiToken(PLUGINS)
class AuthPlugin {}
@Injectable()
@MultiToken(PLUGINS)
class LoggerPlugin {}
// Returns array: [AuthPlugin, LoggerPlugin]
const plugins = injector.get(PLUGINS); Note: Classes using
@Tokenor@MultiTokenmust also be decorated with@Injectable()to be properly processed by the DI system.
6. Global Interception (The Middleware)
You can define a global interception strategy that runs for every service created by an injector. This is perfect for logging, metrics, or auditing.
import { INTERCEPTORS, Injector } from '@hwy-fm/di';
const injector = Injector.create([
{
provide: INTERCEPTORS,
useValue: (instance, token) => {
console.log(`[Audit] Created instance of: ${token.name}`);
return instance;
},
multi: true
}
]);7. Private Providers & Visibility
Enforce strict encapsulation. A private provider is visible to the injector where it is defined, but hidden from children.
const parent = Injector.create([
{ provide: SecretService, useClass: SecretService, private: true }
]);
const child = Injector.create([], parent);
parent.get(SecretService); // OK
child.get(SecretService); // Error: Not visible🔌 Metaprogramming & AOP
1. Aspect-Oriented Programming (Method Proxy)
Automatically enables Method Parameter Injection for specific methods. This allows methods to receive dependencies defined via @Inject directly in their arguments without breaking standard usage.
Dual-Mode Behavior:
- User Calls:
instance.method(arg)behaves normally (arguments are passed through as-is). - System Calls: When invoked via
createSystemInvoker, parameters are resolved from the Injector.
import { MethodProxy, HookMetadata } from '@hwy-fm/di';
// 1. Enable Proxy during instantiation
HookMetadata.hook(Service, {
after: (instance, token, injector) => {
// Patches 'handleRequest' to support dependency injection
injector.get(MethodProxy).proxyMethod(instance, 'handleRequest');
return instance;
}
});
// 2. Invoke as System (Triggers Injection)
// Useful for creating Routers, Job Runners, or Event Handlers
const proxy = injector.get(MethodProxy);
const invoker = proxy.createSystemInvoker(instance, 'handleRequest');
await invoker(); // Arguments are automatically injected!
// 3. Invoke Manually (Standard JS behavior)
// Useful for unit testing or internal calls
instance.handleRequest('manual-data');2. Custom Decorators
Create your own semantic decorators.
Class Decorators:
import { makeDecorator, setInjectableDef } from '@hwy-fm/di';
export const Controller = makeDecorator(
'Controller',
(path: string) => ({ path }),
(cls) => setInjectableDef(cls, { scope: 'root' })
);Parameter Decorators (With Data Transformation / Pipelines):
Use markInject with DecoratorFlags.Pipeline to create decorators that process data through a transform class (Pipe).
import { makeParamDecorator, markInject, DecoratorFlags, InjectorToken, Injectable, Inject } from '@hwy-fm/di';
const APPLICATION_METADATA = new InjectorToken('APP_META');
// 1. Define a Transform Pipe
@Injectable()
class ConfigExtractorPipe {
// Inject the source data
constructor(@Inject(APPLICATION_METADATA) private config: any) {}
// The 'transform' method is invoked by the DI system.
// 'context.meta' corresponds to the object returned by the decorator factory below.
transform(context: any) {
const key = context.meta.key;
return this.config?.[key];
}
}
// 2. Define the Decorator @Input(key)
export const Input = markInject(
makeParamDecorator(
'InputParamDecorator',
(key: string) => ({
token: ConfigExtractorPipe, // The token used to resolve the Pipe
key: key // Metadata passed to the pipe context
})
),
DecoratorFlags.Pipeline // <--- Critical: Enables the pipeline behavior
);
// Usage: constructor(@Input('theme') theme: string)3. Hook Metadata API Reference
| Hook | Type | Description |
| :--- | :--- | :--- |
| onTransientCheck | boolean \| (token, record, ctx) => boolean | Forces transient behavior. If true, the instance is never cached and never tracked for disposal. |
| onScopeCheck | (def, scope, ctx) => boolean | Custom logic to match the provider against the injector's scope. |
| customFactory | (record, next, ctx) => any | Intercept creation. Call next() to run the original factory. |
| onAllow | (token, provider, ctx) => boolean | Admission control. Return false to prevent the provider from being used. |
| before | (token, record, ctx) => void | Runs before instantiation begins. |
| after | (instance, token, ctx) => void | Runs after instantiation. Useful for property injection or proxies. |
| onError | (error, token, ctx) => any | Catch creation errors. Return a fallback value or rethrow. |
| onDispose | (instance, ctx) => void | Custom cleanup logic when the injector is destroyed. |
⚠️ Pure Transient Warning: Services using
onTransientCheck(Pure Transient) are NOT tracked by the injector for disposal. Theirdestroy()method will NEVER be called automatically.Note: Services with
@Scope('any')ARE tracked. They behave as "Per-Injector Singletons" (one instance per injector) and will be disposed when that specific injector is destroyed.
🍳 Recipes & Patterns
1. Virtual Modules (Zero-Boilerplate)
Organize code using simple arrays. No complex Module classes required.
// features/auth.ts
export const AUTH_PROVIDERS = [ AuthService, JwtStrategy ];
// app.ts
const appInjector = Injector.create([
...AUTH_PROVIDERS, // Just spread it!
AppService
]);2. Lazy Loading / Code Splitting
Load heavy dependencies only when requested.
{
provide: PDF_SERVICE,
useFactory: async () => {
const { PdfServiceImpl } = await import('./services/heavy-pdf');
return new PdfServiceImpl(); // loaded on demand
}
}3. Assisted Injection (Runtime Arguments)
Combine DI with runtime parameters (like userId) using a Factory Function pattern.
type UserSessionFactory = (userId: string) => UserSession;
const providers = [
{
provide: 'SESSION_FACTORY',
// Inject 'Database', return function accepting 'userId'
useFactory: (db: Database) => (userId: string) => new UserSession(db, userId),
deps: [Database]
}
];
// Usage: constructor(@Inject('SESSION_FACTORY') createSession: UserSessionFactory)
// this.createSession('user-123') -> new UserSession(db, 'user-123')4. Circular Dependencies
Use forwardRef to break cycles between interdependent services.
import { forwardRef, Inject } from '@hwy-fm/di';
class Parent {
constructor(@Inject(forwardRef(() => Child)) child: any) {}
}
class Child {
constructor(@Inject(forwardRef(() => Parent)) parent: any) {}
}5. Express.js Middleware (Context Isolation)
Ensure every request runs in its own isolated scope using AsyncLocalStorage.
import { Injector, runInInjectionContext, InjectorToken } from '@hwy-fm/di';
const REQ = new InjectorToken('REQ');
// Middleware
const diMiddleware = (rootInjector: Injector) => (req, res, next) => {
const reqInjector = Injector.create([
{ provide: REQ, useValue: req }
], rootInjector);
runInInjectionContext(reqInjector, next);
};6. React Integration
Provide dependency injection context to your React component tree.
const InjectorContext = createContext<Injector>(null!);
export const DIProvider = ({ providers, children }) => {
const injector = useMemo(() => Injector.create(providers), []);
return <DIContext.Provider value={injector}>{children}</DIContext.Provider>;
};7. Hot-Swappable Configuration
Use Proxy to serve dynamic configuration that can change at runtime without restarting.
let runtimeConfig = { theme: 'dark' };
const injector = Injector.create([
{
provide: 'CONFIG',
// Always reads the latest value from runtimeConfig
useFactory: () => new Proxy({}, { get: (_, k) => runtimeConfig[k] })
}
]);8. Global Content Registry (Plugin Architecture)
Use TokenRegistry to create global "Contribution Points" (like VS Code Extensions) without needing an Injector context.
import { TokenRegistry } from '@hwy-fm/di';
// 1. Define a Contribution Point
const MENU_ITEMS = TokenRegistry.createScope('MENU_ITEMS', { multi: true });
// 2. Register items from anywhere (e.g., in other files)
TokenRegistry.register(MENU_ITEMS, { label: 'File' });
TokenRegistry.register(MENU_ITEMS, { label: 'Edit' });
// 3. Retrieve all registered items
const menus = TokenRegistry.getAll(MENU_ITEMS); 🧪 Testing & Mocking
Writing unit tests with @hwy-fm/di is straightforward. You can easily override providers with mocks.
1. Unit Testing Services
import { Injector } from '@hwy-fm/di';
// Real Service
class DatabaseService {
connect() { /* real connection */ }
}
// Mock
const mockDb = { connect: jest.fn() };
test('UserService should use mock DB', () => {
const injector = Injector.create([
UserService,
// Override with Mock
{ provide: DatabaseService, useValue: mockDb }
]);
const user = injector.get(UserService);
user.doWork();
expect(mockDb.connect).toHaveBeenCalled();
});2. Testing Logic outside Container (resolveMinimal)
For isolated logic tests without setting up a full injector hierarchy.
import { resolveMinimal } from '@hwy-fm/di';
test('Isolated logic', async () => {
const [service, dispose] = resolveMinimal(ComplexService);
// ... test service ...
dispose();
});🛡 Strict Mode & Troubleshooting
Enable Strict Mode:
import { InstantiationPolicy } from '@hwy-fm/di';
InstantiationPolicy.strictAsyncLifecycle = true;
InstantiationPolicy.strictMultiInjection = true;Debug Logging:
import { DEBUG_MODE } from '@hwy-fm/di';
DEBUG_MODE.enabled = true;⚡ Performance
- Pre-compiled Factories: Optimized for V8.
- O(1) Resolution: Map-based lookups.
- No Class Scanning: Explicit exports only.
🛠 Decorators API
| Decorator | Target | Description |
| :--- | :--- | :--- |
| @Injectable(options?) | Class | Marks a class as available to the injector. Options: { scope: 'root' }. |
| @Inject(token) | Constructor Param | Optimizes injection when Type metadata is insufficient. |
| @Token(token) | Class | Single binding to a token. |
| @MultiToken(token) | Class | Multi binding to a token. |
| @Optional() | Constructor Param | Returns null if not found. |
| @Self() | Constructor Param | Only resolves from local injector. |
| @SkipSelf() | Constructor Param | Starts resolution from parent injector. |
📄 License
MIT © 2024
