Discussed in #34
Originally posted by Evanion June 28, 2025
Overview
This RFC proposes a refactor of the DynamicModule system in NexusDI to provide a type-safe, ergonomic, and unified API for module configuration. The goal is to eliminate boilerplate for module authors, support both static and DI-injected factory config, and ensure type safety for application developers.
Motivation
- Reduce boilerplate: Module authors should only declare the config token and expected config type, not write custom static methods.
- Type safety: The config passed to
.config() or .configAsync() should be type-checked against the module’s expected config type.
- Support DI-injected factories: Allow config to be provided via a factory function with dependencies (like
useFactory + deps in providers).
- Async support: Support async config factories and enforce
await before registration.
- Consistency: All modules use the same config pattern, making documentation and onboarding easier.
- Optional global flag: Allow modules/providers to be marked as global for cross-cutting concerns.
Proposed Features
- Generic, type-safe config API:
DynamicModule<TConfig> enforces the config type for .config() and .configAsync().
- Unified config method:
The base class provides a static config() method that supports:
- Static config objects
- Factory config with DI (
useFactory + deps)
- Async factories
- No custom static methods:
Module authors only declare the config token and expected config type.
- Global module/provider support:
Optionally, allow modules/providers to be marked as global.
- Container integration:
The container accepts both plain module classes and module config objects, and enforces await for async config.
Example Usage
Module Author
export interface ITypeOrmConfig {
type: string;
host: string;
port: number;
// ...other options
}
export const DB_CONFIG = Symbol('DB_CONFIG');
@Module({
providers: [TypeOrmService],
})
export class TypeOrmModule extends DynamicModule<ITypeOrmConfig> {
static configToken = DB_CONFIG;
// No need to write a static config() method!
}
Application Developer
import { ConfigService } from '@nexusdi/config';
@Module({
imports: [
// Static config (type-checked)
TypeOrmModule.config({
type: 'postgres',
host: 'localhost',
port: 5432,
}),
// Factory config with DI (type-checked)
TypeOrmModule.config({
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('db.host'),
port: config.get('db.port'),
}),
deps: [ConfigService]
}),
// Async factory config with DI (type-checked)
await TypeOrmModule.configAsync({
useFactory: async (config: ConfigService) => {
const host = await config.getAsync('db.host');
return {
type: 'postgres',
host,
port: 5432,
};
},
deps: [ConfigService]
}),
]
})
export class AppModule {}
Under the Hood
- The base
DynamicModule provides a static, generic config() and configAsync() method.
- These methods accept:
- A static config object (
TConfig)
- An object with
useFactory and deps (dependencies to inject)
- The config value is registered under the module’s
configToken.
- The container enforces that async configs are awaited before registration.
API Sketch
export abstract class DynamicModule<TConfig = any> {
static configToken: TokenType<TConfig>;
static config(
config:
| TConfig
| ProviderConfigObject<TConfig>
): ModuleConfig {
// Implementation: register provider for configToken
// with useValue or useFactory/inject
}
static async configAsync(
configProvider: ProviderConfigObject<Promise<TConfig>>
): Promise<ModuleConfig> {
// Implementation: resolve deps, call useFactory, return ModuleConfig
}
}
Complexity & Implementation Review
What’s Already Supported (Low Complexity to Keep/Refine)
- Type-safe config via generics: Already present; just needs to be enforced and documented.
- Static config objects: Already supported via
.config(config: TConfig).
- Async config factories: Already supported via
.configAsync(() => Promise<TConfig>), though not with DI.
What Would Add Some Complexity (But Is Reasonable)
- Factory Config with DI (
useFactory + deps):
- Requires the container to resolve dependencies before calling the factory.
- Pattern is well-established (used in NestJS, Angular, etc.).
- Adds minimal bloat if you already have a DI system.
- Global Module/Provider Support:
- Only complex if you want true encapsulation; a simple flag is low bloat.
- Validation Hooks:
- Low complexity if optional and opt-in.
What Would Add Significant Bloat/Complexity (Not Recommended)
- Registering Promises Directly in the Container:
- Would complicate the container’s internal state and error handling.
- Recommendation: Do not support; enforce
await before set().
- Live/Observable Config Injection:
- Would require proxies, subscriptions, and possibly re-injection or re-binding.
- Not needed for most use cases; can be opt-in via a separate utility.
Summary Table
| Feature/Option |
Complexity |
Bloat |
Recommendation |
| Type-safe generics |
Low |
None |
Keep |
| Static config |
Low |
None |
Keep |
| Async config (awaited) |
Low |
None |
Keep |
Factory + DI (useFactory) |
Moderate |
Low |
Add (well-established pattern) |
| Global module/provider |
Moderate |
Low |
Add if needed, keep simple |
| Validation hooks/schemas |
Low |
Low |
Add as opt-in |
| Registering promises in set() |
High |
High |
Do not add |
| Live/observable config |
High |
High |
Optional, not default |
Migration Notes
- Module authors:
- Remove custom static
config()/configAsync() methods.
- Declare the config token and expected config type.
- App developers:
- Use the new unified
config()/configAsync() API.
- Always
await the result of configAsync() before passing to container.set().
Open Questions
- Should we support both sync and async factories in
.config() or require .configAsync() for async?
- Should we allow modules/providers to be marked as global, and how should that be enforced?
- Should the container support registering promises directly, or always require
await?
- How should validation be handled (e.g., via schema, manual, or both)?
Call for Feedback
- Does this pattern reduce boilerplate and improve DX for you?
- Are there use cases for module config that this pattern does not cover?
- Should we support additional config patterns (e.g., environment merging, validation hooks)?
- Any pain points with the current or proposed API?
Discussed in #34
Originally posted by Evanion June 28, 2025
Overview
This RFC proposes a refactor of the
DynamicModulesystem in NexusDI to provide a type-safe, ergonomic, and unified API for module configuration. The goal is to eliminate boilerplate for module authors, support both static and DI-injected factory config, and ensure type safety for application developers.Motivation
.config()or.configAsync()should be type-checked against the module’s expected config type.useFactory+depsin providers).awaitbefore registration.Proposed Features
DynamicModule<TConfig>enforces the config type for.config()and.configAsync().The base class provides a static
config()method that supports:useFactory+deps)Module authors only declare the config token and expected config type.
Optionally, allow modules/providers to be marked as global.
The container accepts both plain module classes and module config objects, and enforces
awaitfor async config.Example Usage
Module Author
Application Developer
Under the Hood
DynamicModuleprovides a static, genericconfig()andconfigAsync()method.TConfig)useFactoryanddeps(dependencies to inject)configToken.API Sketch
Complexity & Implementation Review
What’s Already Supported (Low Complexity to Keep/Refine)
.config(config: TConfig)..configAsync(() => Promise<TConfig>), though not with DI.What Would Add Some Complexity (But Is Reasonable)
useFactory+deps):What Would Add Significant Bloat/Complexity (Not Recommended)
awaitbeforeset().Summary Table
useFactory)Migration Notes
config()/configAsync()methods.config()/configAsync()API.awaitthe result ofconfigAsync()before passing tocontainer.set().Open Questions
.config()or require.configAsync()for async?await?Call for Feedback