-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Discussed in #38
Originally posted by Evanion July 1, 2025
Status: Draft
Authors: NexusDI Core Team
Summary
This RFC proposes refactoring NexusDI to be async-first, meaning all container operations (set, get, resolve) return Promises. While this introduces the need for await keywords, it unlocks significant architectural benefits that make the library more powerful, modern, and future-proof.
Motivation
Current Limitations
NexusDI's current synchronous architecture creates several constraints:
- No Async Factory Support: Factories that need to perform async operations (database connections, API calls, file I/O) are not supported
- No Async Resource Management: Cannot properly handle resources that require async initialization or cleanup
- No Dynamic Module Loading: Cannot load modules from external sources or perform async configuration
- Limited Initialization Patterns: Cannot support complex initialization sequences that modern applications require
Real-World Use Cases That Are Currently Impossible
// ❌ IMPOSSIBLE with current sync architecture
await container.set({
token: DATABASE_CONNECTION,
useFactory: async () => {
const db = new Database();
await db.connect();
await db.migrate();
return db;
},
});
// ❌ IMPOSSIBLE: Async configuration loading
await container.set({
token: CONFIG_SERVICE,
useFactory: async () => {
const response = await fetch('/api/config');
return await response.json();
},
});Detailed Design
Core API Changes
Before (Sync)
// Current sync API
const container = new Nexus();
container.set(MyService); // void
container.set(TOKEN, { useValue: value }); // void
const service = container.get(MyService); // T
const instance = container.resolve(Class); // TAfter (Async-First)
// New async-first API
const container = new Nexus();
await container.set(MyService); // Promise<this>
await container.set(TOKEN, { useValue: value }); // Promise<this>
const service = await container.get(MyService); // Promise<T>
const instance = await container.resolve(Class); // Promise<T>
// Parallel registration for performance
await container.setMany(ServiceA, ServiceB, ServiceC);New Capabilities Unlocked
1. Async Factories
// Database connection with async initialization
await container.set({
token: DATABASE_CONNECTION,
useFactory: async () => {
const db = new Database(process.env.DATABASE_URL);
await db.connect();
await db.runMigrations();
return db;
},
});
// Configuration loaded from external API
await container.set({
token: APP_CONFIG,
useFactory: async () => {
const response = await fetch('/api/config');
if (!response.ok) {
throw new Error('Failed to load configuration');
}
return await response.json();
},
});
// Service that depends on async-initialized resources
await container.set({
token: USER_SERVICE,
useFactory: async (db: Database, config: AppConfig) => {
const cache = new RedisCache(config.redis);
await cache.connect();
return new UserService(db, cache);
},
deps: [DATABASE_CONNECTION, APP_CONFIG],
});2. Dynamic Module Loading
// Load modules from external sources
const remoteModule = await import('./modules/FeatureModule.js');
await container.set(remoteModule.FeatureModule);
// Conditional module loading based on environment
if (process.env.NODE_ENV === 'development') {
const devModule = await import('./dev/DevToolsModule.js');
await container.set(devModule.DevToolsModule);
}3. Micro-Frontend Architecture Support
// Load micro-frontend modules dynamically
class MicroFrontendLoader {
constructor(private container: Nexus) {}
async loadMicroFrontend(name: string, remoteUrl: string) {
// Load remote module federation entry
const remoteContainer = await import(/* webpackIgnore: true */ remoteUrl);
// Get the micro-frontend's DI module
const microFrontendModule = await remoteContainer.get('./Module');
// Register the entire micro-frontend's services
await this.container.set(microFrontendModule.default);
// Load micro-frontend's configuration
const config = await remoteContainer.get('./config');
await this.container.set({
token: `${name}_CONFIG`,
useValue: config.default,
});
}
}
// Usage in shell application
const loader = new MicroFrontendLoader(container);
// Dynamically load micro-frontends based on user permissions
const userPermissions = await authService.getPermissions();
if (userPermissions.includes('analytics')) {
await loader.loadMicroFrontend(
'analytics',
'http://analytics.example.com/remoteEntry.js'
);
}
if (userPermissions.includes('admin')) {
await loader.loadMicroFrontend(
'admin',
'http://admin.example.com/remoteEntry.js'
);
}
// Services from micro-frontends are now available
const analyticsService = await container.get('AnalyticsService');4. Complex Initialization Sequences
// Initialize services in specific order with dependencies
await container.setMany(
DatabaseService, // Initialize first
CacheService, // Initialize second
UserService, // Depends on both above
NotificationService // Initialize last
);
// Services can have async constructors/initialization
@Service()
class EmailService {
private transporter: any;
async initialize() {
this.transporter = await createTransporter({
host: process.env.SMTP_HOST,
auth: await getEmailCredentials(),
});
}
}Performance Optimizations
Parallel Registration
// Before: Sequential registration (slow)
container.set(ServiceA);
container.set(ServiceB);
container.set(ServiceC);
// After: Parallel registration (fast)
await container.setMany(ServiceA, ServiceB, ServiceC);Lazy Async Resolution
// Async resolution allows for lazy loading and caching
const service = await container.get(ExpensiveService);
// Service is created once and cached for subsequent callsBenefits Analysis
1. Modern JavaScript Alignment
- Embraces async/await patterns that are standard in modern JavaScript
- Aligns with Node.js patterns and modern web development practices
- Supports modern Promise-based APIs and error handling
2. Enterprise-Ready Features
- Supports complex initialization patterns required in enterprise applications
- Enables proper resource lifecycle management
- Supports dynamic configuration and feature toggling
3. Better Error Handling
- Async operations can be properly caught and handled
- Initialization errors are propagated correctly through the Promise chain
- Better debugging experience with async stack traces
4. Performance Improvements
- Parallel registration with
setMany()improves startup time - Lazy async resolution reduces memory footprint
- Efficient resource cleanup prevents memory leaks
5. Framework Integration
- Better integration with async frameworks (Express, Fastify, Next.js)
- Supports async middleware and interceptors
- Enables proper request-scoped container patterns
6. Micro-Frontend Architecture
- Dynamic loading of micro-frontend modules and their dependencies
- Runtime feature toggling based on user permissions or A/B tests
- Shared service registration across multiple micro-frontends
- Isolation and scoping of micro-frontend services
Addressing Developer Friction
The await "Friction" is Actually a Benefit
While adding await keywords might seem like friction, it actually provides several developer benefits:
1. Explicit Async Operations
// Clear intent: this operation might take time
await container.set(DatabaseService);
// vs unclear sync operation that might hide complexity
container.set(DatabaseService); // Is this instantaneous? Does it do I/O?2. Proper Error Handling
// Can catch initialization errors properly
try {
await container.set(DatabaseService);
} catch (error) {
console.error('Failed to initialize database:', error);
}
// vs silent failures in sync version
container.set(DatabaseService); // Errors might be swallowed3. Better IDE Support
- TypeScript/IDE can properly track async operations
- Better autocomplete and error detection
- Clear indication of operations that might fail
4. Testability
// Easy to test async initialization
test('should initialize database service', async () => {
await container.set(DatabaseService);
expect(container.has(DatabaseService)).toBe(true);
});Implementation Strategy
Since NexusDI is still in 0.x.y releases and doesn't have established users yet, this is the perfect time to make this foundational architectural decision:
Direct Implementation
- Phase 1: Implement async-first architecture in next
0.xrelease - Phase 2: Update all documentation and examples to reflect async patterns
- Phase 3: Gather community feedback on the new architecture
- Phase 4: Stabilize API based on feedback before
1.0release
Advantages of Pre-1.0 Implementation
- No breaking changes for existing users (since there are none)
- Can iterate on the API design based on community feedback
- Establishes the correct architectural foundation from the start
- Avoids technical debt that would accumulate with sync-first approach
Comparison with Other Libraries
Spring Framework (Java)
// Spring's ApplicationContext is inherently async-ready
ApplicationContext context = new AnnotationConfigApplicationContext();
// Bean creation can involve async operations.NET Core DI
// .NET Core supports async service registration and resolution
services.AddSingletonAsync<IDataService>(async provider => {
var service = new DataService();
await service.InitializeAsync();
return service;
});NestJS
// NestJS embraces async patterns throughout
@Injectable()
export class DatabaseService implements OnModuleInit {
async onModuleInit() {
await this.connect();
}
}Implementation Considerations
Performance
- Async operations add minimal overhead (microseconds)
- Parallel registration improves overall performance
- Lazy resolution reduces memory usage
Bundle Size
- Core async machinery adds ~2KB to bundle
- Significant new capabilities justify size increase
- Tree-shaking eliminates unused features
Developer Experience
- Embraces modern JavaScript patterns that developers expect
- Clear, explicit async operations improve code readability
- Better TypeScript integration with Promise-based APIs
Alternatives Considered
1. Hybrid Approach: Keep sync API, add async methods
- Problem: Doubles API surface area, confusing for developers
- Problem: Sync methods can't benefit from async capabilities
2. Opt-in Async: Make async optional
- Problem: Limited benefits, complexity in implementation
- Problem: Two different mental models for same operations
3. Wrapper Pattern: Async wrapper around sync core
- Problem: Leaky abstraction, async benefits are superficial
- Problem: Cannot truly support async factories or initialization
Micro-Frontend Architecture Deep Dive
Async-first NexusDI would be particularly powerful for micro-frontend architectures, enabling sophisticated runtime composition patterns:
Shell Application with Dynamic Micro-Frontend Loading
// Shell application's main container
class ShellApplication {
private container = new Nexus();
private loadedMicroFrontends = new Set<string>();
async bootstrap() {
// Register shell services
await this.container.setMany(
AuthService,
NavigationService,
ThemeService,
NotificationService
);
// Load initial micro-frontends
await this.loadUserSpecificMicroFrontends();
}
async loadUserSpecificMicroFrontends() {
const user = await this.container.get(AuthService).getCurrentUser();
// Load micro-frontends based on user role
const microFrontends = await this.getMicroFrontendsForUser(user);
await Promise.all(microFrontends.map((mf) => this.loadMicroFrontend(mf)));
}
async loadMicroFrontend(config: MicroFrontendConfig) {
if (this.loadedMicroFrontends.has(config.name)) {
return; // Already loaded
}
try {
// Create isolated container for micro-frontend
const mfContainer = this.container.createChildContainer();
// Load remote module
const remoteModule = await import(config.remoteEntry);
const mfModule = await remoteModule.get('./Module');
// Register micro-frontend's module
await mfContainer.set(mfModule.default);
// Register micro-frontend specific configuration
await mfContainer.set({
token: `${config.name}_CONFIG`,
useValue: config.settings,
});
// Initialize micro-frontend
const mfBootstrap = await mfContainer.get('MicroFrontendBootstrap');
await mfBootstrap.initialize();
this.loadedMicroFrontends.add(config.name);
} catch (error) {
console.error(`Failed to load micro-frontend ${config.name}:`, error);
// Graceful degradation - app continues without this micro-frontend
}
}
}Micro-Frontend Module Definition
// Analytics micro-frontend module
@Module({
providers: [
AnalyticsService,
ReportingService,
ChartService,
{
token: 'DATA_SOURCE',
useFactory: async (config: AnalyticsConfig) => {
const dataSource = new AnalyticsDataSource(config.apiUrl);
await dataSource.connect();
return dataSource;
},
deps: ['ANALYTICS_CONFIG'],
},
],
exports: [AnalyticsService, ReportingService],
})
export class AnalyticsMicroFrontendModule {}
// Bootstrap class for the micro-frontend
@Service()
export class MicroFrontendBootstrap {
constructor(
private analyticsService: AnalyticsService,
private navigationService: NavigationService // Injected from shell
) {}
async initialize() {
// Register routes with shell navigation
this.navigationService.addRoutes([
{ path: '/analytics', component: 'AnalyticsDashboard' },
{ path: '/reports', component: 'ReportsPage' },
]);
// Initialize analytics tracking
await this.analyticsService.initialize();
}
}Feature Toggling and A/B Testing
// Dynamic feature loading based on feature flags
class FeatureToggleService {
constructor(private container: Nexus) {}
async loadConditionalFeatures() {
const featureFlags = await this.getFeatureFlags();
// Load features dynamically based on flags
if (featureFlags.newDashboard) {
const newDashboard = await import('./features/NewDashboard');
await this.container.set(newDashboard.NewDashboardModule);
}
if (featureFlags.experimentalAnalytics) {
const analytics = await import('./features/ExperimentalAnalytics');
await this.container.set(analytics.ExperimentalAnalyticsModule);
}
// A/B test: Load different implementations
const variant = await this.getABTestVariant('checkout-flow');
const checkoutModule = await import(`./checkout/${variant}`);
await this.container.set(checkoutModule.CheckoutModule);
}
}Service Sharing Across Micro-Frontends
// Shared services registry
class SharedServicesRegistry {
private sharedContainer: Nexus;
async initialize() {
this.sharedContainer = new Nexus();
// Register commonly shared services
await this.sharedContainer.setMany(
AuthService,
EventBus,
ApiClient,
ConfigService,
{
token: 'SHARED_CACHE',
useFactory: async () => {
const cache = new RedisCache();
await cache.connect();
return cache;
},
}
);
}
// Micro-frontends can access shared services
getSharedService<T>(token: TokenType<T>): Promise<T> {
return this.sharedContainer.get(token);
}
// Register a service to be shared across micro-frontends
async shareService(token: TokenType, provider: any): Promise<void> {
await this.sharedContainer.set({ token, ...provider });
}
}Benefits for Micro-Frontend Architecture
- Runtime Composition: Load micro-frontends based on user permissions, feature flags, or A/B tests
- Service Isolation: Each micro-frontend has its own container scope while sharing common services
- Graceful Degradation: Failed micro-frontend loads don't crash the shell application
- Resource Management: Proper cleanup when micro-frontends are unloaded
- Configuration Management: Dynamic configuration loading for each micro-frontend
- Dependency Sharing: Shared services prevent duplication across micro-frontends
Questions for Community
-
Are there specific use cases where async-first would be particularly beneficial for your projects?
-
What async patterns or capabilities would you like to see supported?
-
For micro-frontend use cases: What specific patterns would be most valuable for your architecture?
-
Are there any concerns about the async-first approach that we should address?
-
What would make the async API feel natural and intuitive for your use cases?
-
Should we prioritize certain features (async factories, resource management, dynamic loading) over others?
Conclusion
While async-first architecture requires adding await keywords, it transforms NexusDI from a simple service container into a modern, enterprise-ready dependency injection framework capable of handling complex real-world scenarios.
The "friction" of await is outweighed by:
- Unlocking impossible use cases (async factories, resource management)
- Better error handling and debugging experience
- Modern JavaScript alignment with current best practices
- Enterprise features required by complex applications
- Performance improvements through parallel operations
This change positions NexusDI as a forward-thinking framework ready for the next generation of JavaScript applications.
Next Steps: Community feedback and discussion to refine the proposal and implementation strategy.