diff --git a/README.md b/README.md
index 90bbf27..115ad10 100644
--- a/README.md
+++ b/README.md
@@ -161,7 +161,7 @@ container.set(
// Asynchronous configuration
container.set(
- DatabaseModule.configAsync(async () => ({
+ await DatabaseModule.configAsync(async () => ({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
database: process.env.DB_NAME,
diff --git a/docs/docs/modules/dynamic-modules.md b/docs/docs/modules/dynamic-modules.md
index 3eb3815..c2780f7 100644
--- a/docs/docs/modules/dynamic-modules.md
+++ b/docs/docs/modules/dynamic-modules.md
@@ -6,8 +6,6 @@ sidebar_position: 4
Dynamic modules allow you to configure modules at runtime with different settings for different environments or use cases.
-> **Note**: Dynamic module configuration patterns are planned for future releases. The current implementation supports basic module registration with `container.set()`.
-
## Overview
Dynamic modules enable:
@@ -17,15 +15,18 @@ Dynamic modules enable:
- **Runtime configuration** (user preferences, A/B testing)
- **Validation** of configuration at registration time
-## Planned Implementation
+## New Pattern: Type-Safe Dynamic Module Configuration
+
+> **As of v0.4.0+, module authors define their own static `config`/`configAsync` methods using the provided helper `createModuleConfig`.**
+> This gives consumers full type inference and strict errors, with no need for generics or helpers at the call site. **You only need `createModuleConfig` for both sync and async configuration.**
-
-⚠️ Planned Feature - Currently Non-Functional
+### How It Works
-Dynamic module configuration with `.config()` and `.configAsync()` methods is planned for future releases. This will allow runtime configuration of modules.
+1. **Module authors** define static `config`/`configAsync` methods using the single helper from `@nexusdi/core`:
```typescript
-// Planned API - Not yet implemented
+import { Module, Token, createModuleConfig } from '@nexusdi/core';
+
interface DatabaseConfig {
host: string;
port: number;
@@ -34,17 +35,31 @@ interface DatabaseConfig {
password?: string;
}
-@Module({
- providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }],
-})
-class DatabaseModule extends DynamicModule {
- protected readonly configToken = DATABASE_CONFIG;
- protected readonly moduleConfig = {
- providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }],
- };
+const DATABASE_CONFIG = Token('DATABASE_CONFIG');
+
+@Module({ providers: [DatabaseService] })
+export class DatabaseModule {
+ static configToken = DATABASE_CONFIG;
+
+ static config(config: DatabaseConfig | ProviderConfigObject) {
+ // Optionally validate config here
+ return createModuleConfig(DatabaseModule, config);
+ }
+
+ static configAsync(
+ config:
+ | Promise
+ | ProviderConfigObject>
+ ) {
+ // Optionally validate config here (after resolving if needed)
+ return createModuleConfig(DatabaseModule, config);
+ }
}
+```
-// Usage
+2. **Consumers** get full type inference and strict errors:
+
+```typescript
const container = new Nexus();
// Synchronous configuration
@@ -57,27 +72,29 @@ container.set(
);
// Asynchronous configuration
-container.set(
- DatabaseModule.configAsync(async () => ({
- host: process.env.DB_HOST,
- port: parseInt(process.env.DB_PORT),
- database: process.env.DB_NAME,
+const config = await DatabaseModule.configAsync(
+ Promise.resolve({
+ host: process.env.DB_HOST!,
+ port: parseInt(process.env.DB_PORT!),
+ database: process.env.DB_NAME!,
username: process.env.DB_USER,
password: process.env.DB_PASS,
- }))
+ })
);
+container.set(config);
```
-
+- **Type errors** are shown for missing, extra, or wrong-typed properties.
+- No need for generics or helper functions at the call site.
+- **Note:** The return type of `createModuleConfig` (and thus your static config methods) is `ModuleConfig` for sync input, or `Promise` for async input.
-## Current Implementation
+---
-For now, you can achieve similar functionality using the current module system:
+## Environment-Specific Modules
-### Environment-Specific Modules
+You can still use separate modules for different environments if you prefer:
```typescript
-// Development module
@Module({
providers: [
DatabaseService,
@@ -93,25 +110,22 @@ For now, you can achieve similar functionality using the current module system:
})
class DevelopmentDatabaseModule {}
-// Production module
@Module({
providers: [
DatabaseService,
{
token: DATABASE_CONFIG,
useValue: {
- host: process.env.DB_HOST,
- port: parseInt(process.env.DB_PORT),
- database: process.env.DB_NAME,
+ host: process.env.DB_HOST!,
+ port: parseInt(process.env.DB_PORT!),
+ database: process.env.DB_NAME!,
},
},
],
})
class ProductionDatabaseModule {}
-// Usage
const container = new Nexus();
-
if (process.env.NODE_ENV === 'production') {
container.set(ProductionDatabaseModule);
} else {
@@ -119,80 +133,11 @@ if (process.env.NODE_ENV === 'production') {
}
```
-### Configuration Injection in Services
-
-Services within the module can inject the configuration:
-
-```typescript
-@Service(DATABASE_SERVICE)
-class DatabaseService {
- constructor(@Inject(DATABASE_CONFIG) private config: DatabaseConfig) {}
-
- async connect() {
- console.log(
- `Connecting to ${this.config.host}:${this.config.port}/${this.config.database}`
- );
- // Connection logic
- }
-}
-```
-
-## Advanced Configuration Patterns
-
-### Environment-Specific Configuration
-
-
-⚠️ Planned Feature - Currently Non-Functional
-
-This pattern will be supported with dynamic module configuration in future releases.
-
-```typescript
-@Module({
- providers: [LoggerService, { token: LOG_CONFIG, useValue: {} }],
-})
-class LoggingModule extends DynamicModule {
- protected readonly configToken = LOG_CONFIG;
- protected readonly moduleConfig = {
- providers: [LoggerService, { token: LOG_CONFIG, useValue: {} }],
- };
-}
-
-// Usage based on environment
-const container = new Nexus();
-
-// Development configuration
-container.set(
- LoggingModule.config({
- level: 'debug',
- format: 'detailed',
- })
-);
-
-// Production configuration
-container.set(
- LoggingModule.config({
- level: 'info',
- format: 'json',
- })
-);
-
-// Testing configuration
-container.set(
- LoggingModule.config({
- level: 'error',
- format: 'minimal',
- })
-);
-```
-
-
-
-### Feature-Based Configuration
+---
-
-⚠️ Planned Feature - Currently Non-Functional
+## Feature-Based and Composite Configuration
-This pattern will be supported with dynamic module configuration in future releases.
+You can use the same pattern for feature-based or composite configuration:
```typescript
interface EmailConfig {
@@ -205,20 +150,18 @@ interface EmailConfig {
};
}
-@Module({
- providers: [EmailService, { token: EMAIL_CONFIG, useValue: {} }],
-})
-class EmailModule extends DynamicModule {
- protected readonly configToken = EMAIL_CONFIG;
- protected readonly moduleConfig = {
- providers: [EmailService, { token: EMAIL_CONFIG, useValue: {} }],
- };
+const EMAIL_CONFIG = Symbol('EMAIL_CONFIG');
+
+@Module({ providers: [EmailService] })
+export class EmailModule {
+ static configToken = EMAIL_CONFIG;
+ static config(config: EmailConfig | ProviderConfigObject) {
+ return createModuleConfig(EmailModule, config);
+ }
}
// Usage
const container = new Nexus();
-
-// Choose email provider based on configuration
if (process.env.EMAIL_PROVIDER === 'sendgrid') {
container.set(
EmailModule.config({
@@ -238,8 +181,8 @@ if (process.env.EMAIL_PROVIDER === 'sendgrid') {
EmailModule.config({
provider: 'smtp',
smtpConfig: {
- host: process.env.SMTP_HOST,
- port: parseInt(process.env.SMTP_PORT),
+ host: process.env.SMTP_HOST!,
+ port: parseInt(process.env.SMTP_PORT!),
secure: process.env.SMTP_SECURE === 'true',
},
})
@@ -247,48 +190,28 @@ if (process.env.EMAIL_PROVIDER === 'sendgrid') {
}
```
-
-
-### Composite Configuration
-
-
-⚠️ Planned Feature - Currently Non-Functional
+---
-This pattern will be supported with dynamic module configuration in future releases.
+## Composite Modules
```typescript
-@Module({
- providers: [AppService, { token: APP_CONFIG, useValue: {} }],
-})
-class AppModule extends DynamicModule<{
- database: DatabaseConfig;
- email: EmailConfig;
- logging: LogConfig;
-}> {
- protected readonly configToken = APP_CONFIG;
- protected readonly moduleConfig = {
- providers: [AppService, { token: APP_CONFIG, useValue: {} }],
- imports: [
- DatabaseModule.config({} as DatabaseConfig),
- EmailModule.config({} as EmailConfig),
- LoggingModule.config({} as LogConfig),
- ],
- };
+@Module({ providers: [AppService] })
+export class AppModule {
+ static configToken = Symbol('APP_CONFIG');
+ static config(config: {
+ database: DatabaseConfig;
+ email: EmailConfig;
+ logging: LogConfig;
+ }) {
+ return createModuleConfig(AppModule, config);
+ }
}
-// Usage
const container = new Nexus();
container.set(
AppModule.config({
- database: {
- host: 'localhost',
- port: 5432,
- database: 'myapp',
- },
- email: {
- provider: 'sendgrid',
- apiKey: process.env.SENDGRID_API_KEY,
- },
+ database: { host: 'localhost', port: 5432, database: 'myapp' },
+ email: { provider: 'sendgrid', apiKey: process.env.SENDGRID_API_KEY },
logging: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
},
@@ -296,50 +219,31 @@ container.set(
);
```
-
+---
## Configuration Validation
-
-⚠️ Planned Feature - Currently Non-Functional
-
-Configuration validation will be supported with dynamic module configuration in future releases.
+You can add validation logic in your static config method before calling the helper:
```typescript
-@Module({
- providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }],
-})
-class DatabaseModule extends DynamicModule {
- protected readonly configToken = DATABASE_CONFIG;
- protected readonly moduleConfig = {
- providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }],
- };
-
+@Module({ providers: [DatabaseService] })
+export class DatabaseModule {
+ static configToken = Symbol('DATABASE_CONFIG');
static config(config: DatabaseConfig) {
- // Validate configuration
- if (!config.host) {
- throw new Error('Database host is required');
- }
- if (!config.port || config.port < 1 || config.port > 65535) {
+ if (!config.host) throw new Error('Database host is required');
+ if (!config.port || config.port < 1 || config.port > 65535)
throw new Error('Database port must be between 1 and 65535');
- }
- if (!config.database) {
- throw new Error('Database name is required');
- }
-
- return super.config(config);
+ if (!config.database) throw new Error('Database name is required');
+ return createModuleConfig(DatabaseModule, config);
}
}
```
-
+---
## Testing with Dynamic Modules
-
-⚠️ Planned Feature - Currently Non-Functional
-
-Testing with dynamic module configuration will be supported in future releases.
+You can test modules with different configurations by calling the static config method:
```typescript
describe('DatabaseModule', () => {
@@ -352,7 +256,6 @@ describe('DatabaseModule', () => {
database: 'test_db',
})
);
-
const databaseService = container.get(DATABASE_SERVICE);
expect(databaseService).toBeInstanceOf(DatabaseService);
});
@@ -372,50 +275,9 @@ describe('DatabaseModule', () => {
});
```
-
-
-## Current Testing Approach
-
-For now, you can test modules using the current approach:
-
-```typescript
-describe('DatabaseModule', () => {
- it('should work with test configuration', () => {
- const container = new Nexus();
-
- // Use a test-specific module
- @Module({
- providers: [
- DatabaseService,
- {
- token: DATABASE_CONFIG,
- useValue: {
- host: 'localhost',
- port: 5432,
- database: 'test_db',
- },
- },
- ],
- })
- class TestDatabaseModule {}
-
- container.set(TestDatabaseModule);
-
- const databaseService = container.get(DATABASE_SERVICE);
- expect(databaseService).toBeInstanceOf(DatabaseService);
- });
-});
-```
-
-## Next Steps
-
-- **[Module Basics](module-basics.md)** - Learn the fundamentals of modules
-- **[Module Patterns](module-patterns.md)** - Explore common module patterns
-- **[Advanced Providers & Factories](advanced/advanced-providers-and-factories.md)** - Advanced provider configuration
-
-Dynamic modules will provide powerful runtime configuration capabilities in future releases! 🚀
+---
-## 🚀 Async Dynamic Module Registration
+## Async Dynamic Module Registration
Sometimes, your modules need to fetch secrets, load configs, or call APIs before they're ready to join the party. That's where `configAsync()` comes in!
@@ -430,4 +292,21 @@ container.set(MyModule.configAsync(options)); // Not supported
> **Heads up:** Always `await` the result of `configAsync()` before passing it to `set`. The container expects a fully-baked config, not a promise.
-With this pattern, your modules will be ready to serve—no half-baked configs allowed. For more on dynamic modules, check out the rest of this guide!
+---
+
+## Rationale & Benefits
+
+- **Type safety for consumers**: No need for generics or helpers at the call site; errors are caught at compile time.
+- **Low boilerplate for module authors**: Just call the helper in your static method.
+- **Consistent logic**: All modules use the same config creation logic, reducing bugs and copy-paste errors.
+- **Extensible**: You can add validation, async config, and more advanced patterns as needed.
+
+---
+
+## Next Steps
+
+- **[Module Basics](module-basics.md)** - Learn the fundamentals of modules
+- **[Module Patterns](module-patterns.md)** - Explore common module patterns
+- **[Advanced Providers & Factories](advanced/advanced-providers-and-factories.md)** - Advanced provider configuration
+
+Dynamic modules will provide powerful runtime configuration capabilities in future releases! 🚀
diff --git a/libs/core/src/decorators/inject.test.ts b/libs/core/src/decorators/inject.test.ts
index adaea34..e6671df 100644
--- a/libs/core/src/decorators/inject.test.ts
+++ b/libs/core/src/decorators/inject.test.ts
@@ -3,6 +3,9 @@ import { Inject } from './inject';
import { Token } from '../token';
import { getMetadata } from '../helpers';
import { METADATA_KEYS } from '../constants';
+import { Service } from './provider';
+import { Nexus } from '../container';
+import { NoProvider } from '../exceptions';
describe('@Inject', () => {
it('should add injection metadata for constructor parameter', () => {
@@ -32,4 +35,72 @@ describe('@Inject', () => {
expect(indices).toContain(0);
expect(indices).toContain(1);
});
+ describe('constructor injection', () => {
+ it('should inject a value', () => {
+ const TEST_VALUE = 123;
+ const TEST_TOKEN = new Token('TEST');
+ @Service(TEST_TOKEN)
+ class TestService {
+ constructor(
+ @Inject(TEST_TOKEN) private dependency: typeof TEST_VALUE
+ ) {}
+
+ test() {
+ return this.dependency;
+ }
+ }
+
+ const container = new Nexus();
+ container.set(TEST_TOKEN, { useValue: TEST_VALUE });
+ const service = container.resolve(TestService);
+
+ expect(service.test()).toBe(TEST_VALUE);
+ });
+
+ it('should throw an error if the token is not found', () => {
+ const TEST_TOKEN = new Token('TEST');
+
+ @Service()
+ class TestService {
+ constructor(@Inject(TEST_TOKEN) dependency: any) {}
+ }
+ const container = new Nexus();
+ expect(() => container.resolve(TestService)).toThrow(NoProvider);
+ });
+ });
+
+ describe('property injection', () => {
+ it('should inject a value', () => {
+ const TEST_VALUE = 123;
+ const TEST_TOKEN = new Token('TEST');
+ @Service(TEST_TOKEN)
+ class TestService {
+ @Inject(TEST_TOKEN)
+ private dependency!: typeof TEST_VALUE;
+
+ test() {
+ return this.dependency;
+ }
+ }
+
+ const container = new Nexus();
+ container.set(TEST_TOKEN, { useValue: TEST_VALUE });
+ const service = container.resolve(TestService);
+
+ expect(service.test()).toBe(TEST_VALUE);
+ });
+
+ it('should throw an error if the token is not found', () => {
+ const TEST_VALUE = 123;
+ const TEST_TOKEN = new Token('TEST');
+
+ @Service(TEST_TOKEN)
+ class TestService {
+ @Inject(TEST_TOKEN)
+ private dependency!: typeof TEST_VALUE;
+ }
+ const container = new Nexus();
+ expect(() => container.resolve(TestService)).toThrow(NoProvider);
+ });
+ });
});
diff --git a/libs/core/src/decorators/module.test.ts b/libs/core/src/decorators/module.test.ts
index ab974aa..5d8bbc8 100644
--- a/libs/core/src/decorators/module.test.ts
+++ b/libs/core/src/decorators/module.test.ts
@@ -3,7 +3,9 @@ import { Module } from './module';
import { Token } from '../token';
import { getMetadata } from '../helpers';
import { METADATA_KEYS } from '../constants';
+import { Provider, Service } from './provider';
+// --- Basic metadata attachment ---
describe('@Module', () => {
beforeEach(() => {
if ((class TestModule {} as any)[(Symbol as any).metadata])
@@ -23,30 +25,109 @@ describe('@Module', () => {
});
});
- it('should handle module with services', () => {
- class TestService {}
- @Module({ providers: [TestService] })
- class TestModule {}
- const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA);
- expect(metadata.providers).toEqual([TestService]);
- });
-
it('should handle module with providers', () => {
const TEST_TOKEN = new Token('TEST');
- @Module({
- providers: [{ token: TEST_TOKEN, useClass: class TestProvider {} }],
- })
+ class TestProvider {}
+
+ @Module({ providers: [{ token: TEST_TOKEN, useClass: TestProvider }] })
class TestModule {}
+
const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA);
expect(metadata.providers).toHaveLength(1);
expect(metadata.providers[0].token).toBe(TEST_TOKEN);
});
it('should handle module with imports', () => {
+ @Module({ providers: [], exports: [] })
class ImportedModule {}
+
@Module({ imports: [ImportedModule] })
class TestModule {}
const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA);
expect(metadata.imports).toEqual([ImportedModule]);
});
+
+ it('should attach metadata to a decorated class (robust)', () => {
+ @Provider()
+ class ProviderA {
+ foo = 'bar';
+ }
+
+ @Service()
+ class ServiceA {
+ constructor(private provider: ProviderA) {}
+
+ test() {
+ return this.provider.foo;
+ }
+ }
+
+ @Module({
+ providers: [ProviderA, ServiceA],
+ imports: [],
+ exports: [],
+ })
+ class BasicModule {}
+
+ const metadata = getMetadata(BasicModule, METADATA_KEYS.MODULE_METADATA);
+ expect(metadata).toBeDefined();
+ expect(metadata.providers).toBeInstanceOf(Array);
+ expect(metadata.imports).toBeInstanceOf(Array);
+ expect(metadata.exports).toBeInstanceOf(Array);
+ });
+
+ it('should support providers with class and symbol tokens', () => {
+ const SymbolToken = Symbol('SYMBOL_TOKEN');
+ class ClassToken {}
+ @Module({
+ providers: [
+ { token: SymbolToken, useValue: 2 },
+ { token: ClassToken, useValue: 3 },
+ ],
+ })
+ class TokenModule {}
+ const metadata = getMetadata(TokenModule, METADATA_KEYS.MODULE_METADATA);
+ expect(metadata.providers.some((p: any) => p.token === SymbolToken)).toBe(
+ true
+ );
+ expect(metadata.providers.some((p: any) => p.token === ClassToken)).toBe(
+ true
+ );
+ });
+
+ it('should handle modules with empty or missing arrays', () => {
+ @Module({})
+ class EmptyModule {}
+ const metadata = getMetadata(EmptyModule, METADATA_KEYS.MODULE_METADATA);
+ expect(metadata).toBeDefined();
+ expect(metadata.providers ?? []).toBeInstanceOf(Array);
+ expect(metadata.imports ?? []).toBeInstanceOf(Array);
+ expect(metadata.exports ?? []).toBeInstanceOf(Array);
+ });
+});
+
+// --- Inheritance ---
+describe('Module inheritance', () => {
+ it('should require @Module on subclass to be treated as a module', () => {
+ class ProviderB {}
+ @Module({ providers: [ProviderB] })
+ class ParentModule {}
+ class SubModule extends ParentModule {}
+ // Should not have its own Symbol.metadata property
+ expect(
+ Object.prototype.hasOwnProperty.call(SubModule, (Symbol as any).metadata)
+ ).toBe(false);
+ // Should inherit parent's MODULE_METADATA value
+ const parentMeta = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA);
+ const subMeta = getMetadata(SubModule, METADATA_KEYS.MODULE_METADATA);
+ expect(subMeta).toEqual(parentMeta);
+ });
+
+ it('should have metadata on parent if decorated', () => {
+ @Module({ providers: [class ProviderB {}] })
+ class ParentModule {}
+ const metadata = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA);
+ expect(metadata).toBeDefined();
+ expect(metadata.providers).toBeInstanceOf(Array);
+ });
});
diff --git a/libs/core/src/dynamic-module-examples.test.ts b/libs/core/src/dynamic-module-examples.test.ts
new file mode 100644
index 0000000..be1e5ab
--- /dev/null
+++ b/libs/core/src/dynamic-module-examples.test.ts
@@ -0,0 +1,445 @@
+import { describe, it, expect } from 'vitest';
+import { Module } from './decorators/module';
+import { DynamicModule, createModuleConfig } from './dynamic-module';
+import { Nexus } from './container';
+import type { ModuleConfig } from './types';
+import { Token } from './token';
+
+/**
+ * Real-world Dynamic Module Examples
+ *
+ * These examples demonstrate practical usage patterns for dynamic modules
+ * that users can follow in their own applications.
+ */
+
+describe('Real-world Dynamic Module Usage Examples', () => {
+ // Example 1: Simple Configuration Module
+ describe('Example 1: Database Configuration', () => {
+ interface DatabaseConfig {
+ host: string;
+ port: number;
+ database: string;
+ }
+
+ const DB_CONFIG_TOKEN = new Token('DB_CONFIG');
+
+ @Module({})
+ class DatabaseModule extends DynamicModule {
+ static override configToken = DB_CONFIG_TOKEN;
+
+ static config(config: DatabaseConfig): ModuleConfig {
+ return createModuleConfig(this, config);
+ }
+
+ static async configAsync(
+ configPromise: Promise
+ ): Promise {
+ return createModuleConfig(this, configPromise);
+ }
+ }
+
+ it('should create sync configuration', () => {
+ const config: DatabaseConfig = {
+ host: 'localhost',
+ port: 5432,
+ database: 'myapp',
+ };
+
+ const moduleConfig = DatabaseModule.config(config);
+
+ // The module config should contain a provider for the config
+ expect(moduleConfig.providers).toHaveLength(1);
+
+ const configProvider = moduleConfig.providers?.[0] as any;
+ expect(configProvider.token).toBe(DB_CONFIG_TOKEN);
+ expect(configProvider.useValue).toEqual(config);
+ });
+
+ it('should create async configuration', async () => {
+ const config: DatabaseConfig = {
+ host: 'remote-db.com',
+ port: 5432,
+ database: 'production',
+ };
+
+ const moduleConfig = await DatabaseModule.configAsync(
+ Promise.resolve(config)
+ );
+
+ expect(moduleConfig.providers).toHaveLength(1);
+
+ const configProvider = moduleConfig.providers?.[0] as any;
+ expect(configProvider.token).toBe(DB_CONFIG_TOKEN);
+ expect(configProvider.useValue).toEqual(config);
+ });
+
+ it('should work with container', () => {
+ const config: DatabaseConfig = {
+ host: 'localhost',
+ port: 5432,
+ database: 'test',
+ };
+
+ const moduleConfig = DatabaseModule.config(config);
+ const container = new Nexus();
+
+ // Register the module configuration
+ container.set(moduleConfig);
+
+ // Get the config from the container
+ const resolvedConfig = container.get(DB_CONFIG_TOKEN);
+ expect(resolvedConfig).toEqual(config);
+ });
+ });
+
+ // Example 2: Environment-based Configuration
+ describe('Example 2: Environment Configurations', () => {
+ interface ApiConfig {
+ baseUrl: string;
+ timeout: number;
+ apiKey?: string;
+ }
+
+ const API_CONFIG_TOKEN = new Token('API_CONFIG');
+
+ @Module({})
+ class ApiModule extends DynamicModule {
+ static override configToken = API_CONFIG_TOKEN;
+
+ static config(config: ApiConfig): ModuleConfig {
+ return createModuleConfig(this, config);
+ }
+
+ // Environment-specific factory methods
+ static forDevelopment(): ModuleConfig {
+ return this.config({
+ baseUrl: 'http://localhost:3000/api',
+ timeout: 5000,
+ });
+ }
+
+ static forProduction(apiKey: string): ModuleConfig {
+ return this.config({
+ baseUrl: 'https://api.myapp.com',
+ timeout: 10000,
+ apiKey,
+ });
+ }
+
+ static forTesting(): ModuleConfig {
+ return this.config({
+ baseUrl: 'https://mock.api.com',
+ timeout: 1000,
+ });
+ }
+ }
+
+ it('should provide development configuration', () => {
+ const moduleConfig = ApiModule.forDevelopment();
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(API_CONFIG_TOKEN);
+ expect(config.baseUrl).toBe('http://localhost:3000/api');
+ expect(config.timeout).toBe(5000);
+ expect(config.apiKey).toBeUndefined();
+ });
+
+ it('should provide production configuration', () => {
+ const apiKey = 'prod-api-key-123';
+ const moduleConfig = ApiModule.forProduction(apiKey);
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(API_CONFIG_TOKEN);
+ expect(config.baseUrl).toBe('https://api.myapp.com');
+ expect(config.timeout).toBe(10000);
+ expect(config.apiKey).toBe(apiKey);
+ });
+
+ it('should provide testing configuration', () => {
+ const moduleConfig = ApiModule.forTesting();
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(API_CONFIG_TOKEN);
+ expect(config.baseUrl).toBe('https://mock.api.com');
+ expect(config.timeout).toBe(1000);
+ });
+ });
+
+ // Example 3: Factory-based Configuration
+ describe('Example 3: Factory Configuration', () => {
+ interface LoggerConfig {
+ level: 'debug' | 'info' | 'warn' | 'error';
+ format: 'json' | 'text';
+ outputs: string[];
+ }
+
+ const LOGGER_CONFIG_TOKEN = new Token('LOGGER_CONFIG');
+
+ @Module({})
+ class LoggerModule extends DynamicModule {
+ static override configToken = LOGGER_CONFIG_TOKEN;
+
+ static config(config: LoggerConfig): ModuleConfig {
+ return createModuleConfig(this, config);
+ }
+
+ static withFactory(factory: () => LoggerConfig): ModuleConfig {
+ return createModuleConfig(this, { useFactory: factory });
+ }
+
+ static async configAsync(
+ factory: () => Promise
+ ): Promise {
+ return createModuleConfig(this, { useFactory: factory });
+ }
+ }
+
+ it('should work with sync factory', () => {
+ const moduleConfig = LoggerModule.withFactory(() => ({
+ level: 'info',
+ format: 'json',
+ outputs: ['console', 'file'],
+ }));
+
+ expect(moduleConfig.providers).toHaveLength(1);
+
+ const configProvider = moduleConfig.providers?.[0] as any;
+ expect(configProvider.token).toBe(LOGGER_CONFIG_TOKEN);
+ expect(configProvider.useFactory).toBeDefined();
+ expect(typeof configProvider.useFactory).toBe('function');
+ });
+
+ it('should work with async factory', async () => {
+ const moduleConfig = await LoggerModule.configAsync(async () => {
+ // Simulate loading config from external source
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ return {
+ level: 'debug' as const,
+ format: 'text' as const,
+ outputs: ['console'],
+ };
+ });
+
+ expect(moduleConfig.providers).toHaveLength(1);
+
+ const configProvider = moduleConfig.providers?.[0] as any;
+ expect(configProvider.token).toBe(LOGGER_CONFIG_TOKEN);
+ expect(configProvider.useFactory).toBeDefined();
+ });
+
+ it('should resolve factory in container', () => {
+ const moduleConfig = LoggerModule.withFactory(() => ({
+ level: 'warn',
+ format: 'json',
+ outputs: ['file'],
+ }));
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(LOGGER_CONFIG_TOKEN);
+ expect(config.level).toBe('warn');
+ expect(config.format).toBe('json');
+ expect(config.outputs).toEqual(['file']);
+ });
+ });
+
+ // Example 4: Conditional Module Registration
+ describe('Example 4: Conditional Registration', () => {
+ interface FeatureConfig {
+ enabled: boolean;
+ options: Record;
+ }
+
+ const FEATURE_CONFIG_TOKEN = new Token('FEATURE_CONFIG');
+
+ @Module({})
+ class FeatureModule extends DynamicModule {
+ static override configToken = FEATURE_CONFIG_TOKEN;
+
+ static config(config: FeatureConfig): ModuleConfig {
+ return createModuleConfig(this, config);
+ }
+
+ static enable(options: Record = {}): ModuleConfig {
+ return this.config({ enabled: true, options });
+ }
+
+ static disable(): ModuleConfig {
+ return this.config({ enabled: false, options: {} });
+ }
+
+ static conditional(
+ condition: boolean,
+ options: Record = {}
+ ): ModuleConfig {
+ return condition ? this.enable(options) : this.disable();
+ }
+ }
+
+ it('should enable feature with options', () => {
+ const options = { maxRetries: 3, timeout: 5000 };
+ const moduleConfig = FeatureModule.enable(options);
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(FEATURE_CONFIG_TOKEN);
+ expect(config.enabled).toBe(true);
+ expect(config.options).toEqual(options);
+ });
+
+ it('should disable feature', () => {
+ const moduleConfig = FeatureModule.disable();
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(FEATURE_CONFIG_TOKEN);
+ expect(config.enabled).toBe(false);
+ expect(config.options).toEqual({});
+ });
+
+ it('should conditionally register based on environment', () => {
+ const isProduction = false;
+ const moduleConfig = FeatureModule.conditional(isProduction, {
+ debugMode: true,
+ });
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(FEATURE_CONFIG_TOKEN);
+ expect(config.enabled).toBe(false); // because isProduction is false
+ });
+ });
+
+ // Example 5: Multiple Dynamic Modules
+ describe('Example 5: Combining Multiple Dynamic Modules', () => {
+ // Define separate config tokens and modules
+ const DATABASE_TOKEN = new Token<{ host: string; port: number }>(
+ 'DATABASE'
+ );
+ const CACHE_TOKEN = new Token<{ maxSize: number; ttl: number }>('CACHE');
+ const METRICS_TOKEN = new Token<{ enabled: boolean; endpoint: string }>(
+ 'METRICS'
+ );
+
+ @Module({})
+ class DatabaseModule extends DynamicModule {
+ static override configToken = DATABASE_TOKEN;
+ static config(config: { host: string; port: number }) {
+ return createModuleConfig(this, config);
+ }
+ }
+
+ @Module({})
+ class CacheModule extends DynamicModule {
+ static override configToken = CACHE_TOKEN;
+ static config(config: { maxSize: number; ttl: number }) {
+ return createModuleConfig(this, config);
+ }
+ }
+
+ @Module({})
+ class MetricsModule extends DynamicModule {
+ static override configToken = METRICS_TOKEN;
+ static config(config: { enabled: boolean; endpoint: string }) {
+ return createModuleConfig(this, config);
+ }
+ }
+
+ it('should register multiple dynamic modules', () => {
+ const dbConfig = DatabaseModule.config({
+ host: 'localhost',
+ port: 5432,
+ });
+
+ const cacheConfig = CacheModule.config({
+ maxSize: 1000,
+ ttl: 3600,
+ });
+
+ const metricsConfig = MetricsModule.config({
+ enabled: true,
+ endpoint: '/metrics',
+ });
+
+ const container = new Nexus();
+
+ // Register all modules
+ container.set(dbConfig);
+ container.set(cacheConfig);
+ container.set(metricsConfig);
+
+ // All configurations should be available
+ const db = container.get(DATABASE_TOKEN);
+ const cache = container.get(CACHE_TOKEN);
+ const metrics = container.get(METRICS_TOKEN);
+
+ expect(db).toEqual({ host: 'localhost', port: 5432 });
+ expect(cache).toEqual({ maxSize: 1000, ttl: 3600 });
+ expect(metrics).toEqual({ enabled: true, endpoint: '/metrics' });
+ });
+ });
+
+ // Example 6: Using with Class Providers
+ describe('Example 6: Class-based Configuration', () => {
+ interface AppConfig {
+ version: string;
+ environment: string;
+ }
+
+ const APP_CONFIG_TOKEN = new Token('APP_CONFIG');
+
+ class DevelopmentConfig implements AppConfig {
+ version = '1.0.0-dev';
+ environment = 'development';
+ }
+
+ class ProductionConfig implements AppConfig {
+ version = '1.0.0';
+ environment = 'production';
+ }
+
+ @Module({})
+ class AppModule extends DynamicModule {
+ static override configToken = APP_CONFIG_TOKEN;
+
+ static config(config: AppConfig): ModuleConfig {
+ return createModuleConfig(this, config);
+ }
+
+ static withClass AppConfig>(
+ configClass: T
+ ): ModuleConfig {
+ return createModuleConfig(this, { useClass: configClass });
+ }
+ }
+
+ it('should work with class provider', () => {
+ const moduleConfig = AppModule.withClass(DevelopmentConfig);
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(APP_CONFIG_TOKEN);
+ expect(config.version).toBe('1.0.0-dev');
+ expect(config.environment).toBe('development');
+ });
+
+ it('should work with different class providers', () => {
+ const moduleConfig = AppModule.withClass(ProductionConfig);
+
+ const container = new Nexus();
+ container.set(moduleConfig);
+
+ const config = container.get(APP_CONFIG_TOKEN);
+ expect(config.version).toBe('1.0.0');
+ expect(config.environment).toBe('production');
+ });
+ });
+});
diff --git a/libs/core/src/dynamic-module.test.ts b/libs/core/src/dynamic-module.test.ts
new file mode 100644
index 0000000..673f8c6
--- /dev/null
+++ b/libs/core/src/dynamic-module.test.ts
@@ -0,0 +1,282 @@
+import { describe, it, expect } from 'vitest';
+import { Module } from './decorators/module';
+import { DynamicModule, createModuleConfig } from './dynamic-module';
+import { InvalidModule } from './exceptions/invalid-module.exception';
+import type { ProviderConfigObject, ModuleConfig } from './types';
+
+// Test config type
+type TestConfig = {
+ foo: string;
+ quz: number;
+};
+
+// Valid config class for testing
+class ValidConfigClass {
+ foo = 'ok';
+ quz = 42;
+}
+
+// Invalid config class for testing (wrong types)
+class InvalidConfigClass {
+ foo = 'bad';
+ quz = 'not-a-number'; // Should be number
+}
+
+// Dummy config token for testing
+const TEST_CONFIG_TOKEN = Symbol('TEST_CONFIG');
+
+// Test dynamic module
+@Module({
+ providers: [],
+})
+class TestDynamicModule extends DynamicModule {
+ static override configToken = TEST_CONFIG_TOKEN;
+
+ static config(config: TestConfig | ProviderConfigObject) {
+ return createModuleConfig(this, config);
+ }
+
+ static configAsync(
+ config: Promise | ProviderConfigObject>
+ ) {
+ return createModuleConfig(this, config);
+ }
+}
+
+// Module without @Module decorator for error testing
+class NotDecorated extends DynamicModule {
+ protected readonly configToken = Symbol('NOT_DECORATED');
+}
+
+describe('DynamicModule', () => {
+ it('should read @Module metadata from subclasses', () => {
+ const metadata = TestDynamicModule.getModuleConfig();
+ expect(metadata).toBeDefined();
+ expect(metadata.providers).toEqual([]);
+ });
+
+ it('should return the correct configToken from subclass', () => {
+ expect(TestDynamicModule.getConfigToken()).toBe(TEST_CONFIG_TOKEN);
+ });
+
+ it('should create a config module with config() using useValue', () => {
+ const result = TestDynamicModule.config({
+ useValue: { foo: 'bar', quz: 123 },
+ });
+ // ^?
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useValue' in p
+ );
+ expect(provider).toBeDefined();
+ expect((provider as any).useValue.foo).toBe('bar');
+ expect((provider as any).useValue.quz).toBe(123);
+ });
+
+ it('should create a config module with config() using useClass', () => {
+ class MyConfigClass {
+ foo = 'classy';
+ quz = 42;
+ }
+ const result = TestDynamicModule.config({
+ useClass: MyConfigClass,
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useClass' in p
+ );
+ expect(provider).toBeDefined();
+ expect((provider as any).useClass).toBe(MyConfigClass);
+ });
+
+ it('should create a config module with config() using useFactory', () => {
+ const result = TestDynamicModule.config({
+ useFactory: () => ({ foo: 'factory', quz: 1 }),
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p
+ );
+ expect(provider).toBeDefined();
+ expect(typeof (provider as any).useFactory).toBe('function');
+ expect((provider as any).useFactory().foo).toBe('factory');
+ expect((provider as any).useFactory().quz).toBe(1);
+ });
+
+ it('should create a config module with config() using a plain value', () => {
+ const result = TestDynamicModule.config({
+ useValue: { foo: 'plain', quz: 123 },
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useValue' in p
+ );
+ expect(provider).toBeDefined();
+ expect((provider as any).useValue.foo).toBe('plain');
+ expect((provider as any).useValue.quz).toBe(123);
+ });
+
+ it('should create a config module with configAsync() using async useFactory', async () => {
+ const result = await TestDynamicModule.configAsync({
+ useFactory: async () => ({ foo: 'baz', quz: 2 }),
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p
+ );
+ expect(provider).toBeDefined();
+ expect(typeof (provider as any).useFactory).toBe('function');
+ // Test that the factory can be called and returns the expected result
+ const factoryResult = await (provider as any).useFactory();
+ expect(factoryResult.foo).toBe('baz');
+ expect(factoryResult.quz).toBe(2);
+ });
+
+ it('should create a config module with configAsync() using async useValue', async () => {
+ const result = await TestDynamicModule.configAsync(
+ Promise.resolve({ foo: 'asyncValue', quz: 3 })
+ );
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useValue' in p
+ );
+ expect(provider).toBeDefined();
+ const value = await (provider as any).useValue;
+ expect(value.foo).toBe('asyncValue');
+ expect(value.quz).toBe(3);
+ });
+
+ it('should inherit configToken from parent if not overridden', () => {
+ class ParentDynamicModule extends DynamicModule {
+ static override configToken = Symbol('PARENT_TOKEN');
+ }
+ @Module({ providers: [] })
+ class ChildDynamicModule extends ParentDynamicModule {}
+ expect(ChildDynamicModule.getConfigToken()).toBe(
+ ParentDynamicModule.configToken
+ );
+ });
+
+ it('should allow subclass to override configToken', () => {
+ class ParentDynamicModule extends DynamicModule {
+ static override configToken = Symbol('PARENT_TOKEN');
+ }
+ const CHILD_TOKEN = Symbol('CHILD_TOKEN');
+ @Module({ providers: [] })
+ class ChildDynamicModule extends ParentDynamicModule {
+ static override configToken = CHILD_TOKEN;
+ }
+ expect(ChildDynamicModule.getConfigToken()).toBe(CHILD_TOKEN);
+ expect(ChildDynamicModule.getConfigToken()).not.toBe(
+ ParentDynamicModule.configToken
+ );
+ });
+
+ it('should return undefined for configToken if not set on any class', () => {
+ @Module({ providers: [] })
+ class NoTokenDynamicModule extends DynamicModule {}
+ expect(NoTokenDynamicModule.getConfigToken()).toBeUndefined();
+ });
+
+ it('should ensure configToken is unique per subclass', () => {
+ const TOKEN_A = Symbol('TOKEN_A');
+ const TOKEN_B = Symbol('TOKEN_B');
+ @Module({ providers: [] })
+ class ModuleA extends DynamicModule {
+ static override configToken = TOKEN_A;
+ }
+ @Module({ providers: [] })
+ class ModuleB extends DynamicModule {
+ static override configToken = TOKEN_B;
+ }
+ expect(ModuleA.getConfigToken()).toBe(TOKEN_A);
+ expect(ModuleB.getConfigToken()).toBe(TOKEN_B);
+ expect(ModuleA.getConfigToken()).not.toBe(ModuleB.getConfigToken());
+ });
+
+ it('should throw if not decorated with @Module', () => {
+ expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule);
+ });
+});
+
+describe('createModuleConfig (strict type safety)', () => {
+ it('accepts a valid config object', () => {
+ const result = TestDynamicModule.config({ foo: 'bar', quz: 123 });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useValue' in p
+ );
+ expect(provider).toBeDefined();
+ expect((provider as any).useValue.foo).toBe('bar');
+ expect((provider as any).useValue.quz).toBe(123);
+ });
+
+ it('accepts a valid useClass', () => {
+ const result = TestDynamicModule.config({ useClass: ValidConfigClass });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useClass' in p
+ );
+ expect(provider).toBeDefined();
+ expect((provider as any).useClass).toBe(ValidConfigClass);
+ });
+
+ it('rejects an invalid useClass', () => {
+ // This should error at compile time
+ // TestDynamicModule.config({ useClass: InvalidConfigClass });
+ expect(true).toBe(true);
+ });
+
+ it('accepts a valid useFactory', () => {
+ const result = TestDynamicModule.config({
+ useFactory: () => ({ foo: 'factory', quz: 1 }),
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p
+ );
+ expect(provider).toBeDefined();
+ expect(typeof (provider as any).useFactory).toBe('function');
+ expect((provider as any).useFactory().foo).toBe('factory');
+ expect((provider as any).useFactory().quz).toBe(1);
+ });
+
+ it('rejects a useFactory with wrong return type', () => {
+ // This should error at compile time
+ // TestDynamicModule.config({ useFactory: () => ({ foo: 'bad', quz: 'not-a-number' }) });
+ expect(true).toBe(true);
+ });
+
+ it('rejects extra properties in config object', () => {
+ // This should error at compile time
+ // TestDynamicModule.config({ foo: 'bar', quz: 1, baz: 'extra' });
+ expect(true).toBe(true);
+ });
+});
+
+describe('createModuleConfigAsync', () => {
+ it('accepts a valid async useFactory', async () => {
+ const result = await TestDynamicModule.configAsync({
+ useFactory: async () => ({ foo: 'baz', quz: 2 }),
+ });
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p
+ );
+ expect(provider).toBeDefined();
+ expect(typeof (provider as any).useFactory).toBe('function');
+ // Test that the factory can be called and returns the expected result
+ const factoryResult = await (provider as any).useFactory();
+ expect(factoryResult.foo).toBe('baz');
+ expect(factoryResult.quz).toBe(2);
+ });
+
+ it('accepts a valid async useValue', async () => {
+ const result = await TestDynamicModule.configAsync(
+ Promise.resolve({ foo: 'asyncValue', quz: 3 })
+ );
+ const provider = (result.providers ?? []).find(
+ (p: any) => typeof p === 'object' && p !== null && 'useValue' in p
+ );
+ expect(provider).toBeDefined();
+ const value = await (provider as any).useValue;
+ expect(value.foo).toBe('asyncValue');
+ expect(value.quz).toBe(3);
+ });
+});
+
+describe('Module error handling', () => {
+ it('should throw if not decorated with @Module', () => {
+ expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule);
+ });
+});
diff --git a/libs/core/src/dynamic-module.ts b/libs/core/src/dynamic-module.ts
new file mode 100644
index 0000000..c903abc
--- /dev/null
+++ b/libs/core/src/dynamic-module.ts
@@ -0,0 +1,131 @@
+// DynamicModule and related types moved from module.ts
+
+import type { ModuleConfig, TokenType, ProviderConfigObject } from './types';
+import { METADATA_KEYS } from './constants';
+import { getMetadata } from './helpers';
+import { InvalidModule } from './exceptions/invalid-module.exception';
+import { isProvider, isFactory, isPromise } from './guards';
+
+/**
+ * Represents a dynamic module, allowing for runtime configuration of providers and imports.
+ *
+ * @example
+ * import { DynamicModule } from '@nexusdi/core';
+ * const dynamic = DynamicModule.forRoot({ providers: [LoggerService] });
+ * @see https://nexus.js.org/docs/modules/dynamic-modules
+ */
+export abstract class DynamicModule {
+ static configToken: TokenType;
+
+ /**
+ * Gets the module configuration from the decorator metadata
+ */
+ static getModuleConfig(
+ this: T
+ ): ModuleConfig {
+ const moduleConfig = getMetadata(this, METADATA_KEYS.MODULE_METADATA);
+ if (!moduleConfig) {
+ throw new InvalidModule(this);
+ }
+ return moduleConfig;
+ }
+
+ /**
+ * Returns the config token for the module
+ */
+ static getConfigToken(
+ this: T
+ ): TokenType {
+ return this.configToken;
+ }
+}
+
+/**
+ * Creates a ModuleConfig for a dynamic module from a config object, provider config, or async variant.
+ *
+ * @param moduleClass The module class (should have a static configToken property)
+ * @param config The config object, provider config, promise, or provider config with promise
+ * @returns ModuleConfig or Promise
+ */
+// Overload for sync configs (non-promise values)
+export function createModuleConfig(
+ moduleClass: { configToken: TokenType },
+ config: T | ProviderConfigObject
+): ModuleConfig;
+
+// Overload for async configs (promises)
+export function createModuleConfig(
+ moduleClass: { configToken: TokenType },
+ config: Promise | ProviderConfigObject>
+): Promise;
+
+// Implementation
+export function createModuleConfig(
+ moduleClass: { configToken: TokenType },
+ config:
+ | T
+ | ProviderConfigObject
+ | Promise
+ | ProviderConfigObject>
+): ModuleConfig | Promise {
+ // If config is a factory provider
+ if (isFactory(config)) {
+ // Don't execute the factory here - let the DI container handle it
+ // Just pass the factory configuration through with the token
+ return {
+ providers: [
+ {
+ ...config,
+ token: moduleClass.configToken,
+ },
+ ],
+ };
+ }
+
+ // If config is a provider config (but not a factory)
+ if (isProvider(config)) {
+ // If useValue is a promise
+ if ('useValue' in config && isPromise(config.useValue)) {
+ return Promise.resolve(config.useValue).then((resolved) => ({
+ providers: [
+ {
+ ...config,
+ useValue: resolved,
+ token: moduleClass.configToken,
+ },
+ ],
+ }));
+ }
+ // Otherwise, sync provider config
+ return {
+ providers: [
+ {
+ ...config,
+ token: moduleClass.configToken,
+ },
+ ],
+ };
+ }
+
+ // If config is a Promise
+ if (isPromise(config)) {
+ return Promise.resolve(config).then((resolved) => ({
+ providers: [
+ {
+ useValue: resolved,
+ token: moduleClass.configToken,
+ },
+ ],
+ }));
+ }
+
+ // Otherwise, it's a plain config object
+ return {
+ providers: [
+ {
+ useValue: config,
+ token: moduleClass.configToken,
+ },
+ ],
+ };
+}
diff --git a/libs/core/src/guards.ts b/libs/core/src/guards.ts
index a66b0c4..5cada64 100644
--- a/libs/core/src/guards.ts
+++ b/libs/core/src/guards.ts
@@ -101,3 +101,7 @@ export function isModuleConfig(
!!obj && typeof obj === 'object' && ('providers' in obj || 'imports' in obj)
);
}
+
+export function isPromise(val: any): val is Promise {
+ return !!val && typeof val.then === 'function';
+}
diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts
index 83e2aca..838d969 100644
--- a/libs/core/src/index.ts
+++ b/libs/core/src/index.ts
@@ -29,7 +29,7 @@ export {
} from './exceptions';
// Dynamic Module
-export { DynamicModule } from './module';
+export { DynamicModule } from './dynamic-module';
// Types
export type {
diff --git a/libs/core/src/module.test.ts b/libs/core/src/module.test.ts
deleted file mode 100644
index 96a3ec8..0000000
--- a/libs/core/src/module.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { Module } from './decorators';
-import { DynamicModule } from './module';
-import { getMetadata } from './helpers';
-import { METADATA_KEYS } from './constants';
-import { InvalidModule } from './exceptions/invalid-module.exception';
-
-// Dummy config token for testing
-const TEST_CONFIG_TOKEN = Symbol('TEST_CONFIG');
-
-/**
- * TestDynamicModule: Ensures that subclasses of DynamicModule decorated with @Module
- * are recognized correctly and their metadata is accessible. This prevents regressions
- * where subclass metadata was not read due to static method context issues.
- */
-@Module({
- providers: [],
-})
-class TestDynamicModule extends DynamicModule {
- protected readonly configToken = TEST_CONFIG_TOKEN;
-}
-
-/**
- * BasicModule: Tests that the @Module decorator attaches metadata to a class and
- * that all properties (providers, services, imports, exports) are reflected correctly.
- * This ensures the decorator works for standard modules, not just DynamicModule.
- */
-@Module({
- providers: [class ProviderA {}, class ServiceA {}],
- imports: [],
- exports: [],
-})
-class BasicModule {}
-
-/**
- * InheritedModule: Tests that a subclass of a module can override or inherit metadata.
- * This ensures that module inheritance does not break metadata reflection.
- */
-@Module({ providers: [class ProviderB {}] })
-class ParentModule {}
-
-/**
- * NotDecorated: Used to test error handling when getModuleConfig is called on a class
- * that is not decorated with @Module. This ensures clear error messages for misconfiguration.
- */
-class NotDecorated extends DynamicModule {
- protected readonly configToken = Symbol('NOT_DECORATED');
-}
-
-describe('DynamicModule', () => {
- /**
- * Ensures that subclasses of DynamicModule decorated with @Module are recognized
- * and their metadata is accessible. Prevents regressions of the metadata bug.
- */
- it('should read @Module metadata from subclasses', () => {
- const config = TestDynamicModule.getModuleConfig();
- expect(config).toBeDefined();
- expect(config.providers).toBeInstanceOf(Array);
- });
-
- /**
- * Ensures that the configToken is correctly returned from the subclass.
- * This is important for dynamic module configuration and DI token management.
- */
- it('should return the correct configToken from subclass', () => {
- expect(TestDynamicModule.getConfigToken()).toBe(TEST_CONFIG_TOKEN);
- });
-
- /**
- * Ensures that config() creates a module config with the provided config value.
- * This is important for passing runtime configuration to modules.
- */
- it('should create a config module with config()', () => {
- const result = TestDynamicModule.config({ foo: 'bar' });
- const hasConfigProvider = result.providers.some(
- (p) =>
- typeof p === 'object' &&
- p !== null &&
- 'useValue' in p &&
- (p as any).useValue &&
- (p as any).useValue.foo === 'bar'
- );
- expect(hasConfigProvider).toBe(true);
- });
-
- /**
- * Ensures that configAsync() creates a module config with an async factory.
- * This is important for supporting async configuration (e.g., from env or remote sources).
- */
- it('should create a config module with configAsync()', async () => {
- const result = TestDynamicModule.configAsync(async () => ({ foo: 'baz' }));
- const provider = result.providers.find(
- (p) =>
- typeof p === 'object' &&
- p !== null &&
- 'useFactory' in p &&
- typeof (p as any).useFactory === 'function'
- );
- expect(provider).toBeDefined();
- const value = await (provider as any).useFactory();
- expect(value.foo).toBe('baz');
- });
-});
-
-describe('Module decorator', () => {
- /**
- * Tests that the @Module decorator attaches metadata to a decorated class and
- * that all properties are reflected correctly. This ensures the decorator works
- * for standard modules, not just DynamicModule.
- */
- it('should attach metadata to a decorated class', () => {
- const metadata = getMetadata(BasicModule, METADATA_KEYS.MODULE_METADATA);
- expect(metadata).toBeDefined();
- expect(metadata.providers).toBeInstanceOf(Array);
- expect(metadata.providers).toBeInstanceOf(Array);
- expect(metadata.imports).toBeInstanceOf(Array);
- expect(metadata.exports).toBeInstanceOf(Array);
- });
-});
-
-describe('Module inheritance', () => {
- /**
- * Test: Subclass without @Module is not a valid module
- * Validates: Subclasses must be decorated with @Module to be treated as modules
- * Value: Ensures that only explicitly decorated classes are treated as modules, preventing accidental inheritance of module status.
- */
- it('should require @Module on subclass to be treated as a module', () => {
- class ProviderB {}
- @Module({ providers: [ProviderB] })
- class ParentModule {}
- class SubModule extends ParentModule {}
-
- // Should not have its own Symbol.metadata property
- expect(
- Object.prototype.hasOwnProperty.call(SubModule, (Symbol as any).metadata)
- ).toBe(false);
-
- // Should inherit parent's MODULE_METADATA value
- const parentMeta = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA);
- const subMeta = getMetadata(SubModule, METADATA_KEYS.MODULE_METADATA);
- expect(subMeta).toEqual(parentMeta);
- });
-
- it('should have metadata on parent if decorated', () => {
- const metadata = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA);
- expect(metadata).toBeDefined();
- expect(metadata.providers).toBeInstanceOf(Array);
- });
-});
-
-describe('Module error handling', () => {
- /**
- * Tests that calling getModuleConfig on a class not decorated with @Module throws an error.
- * This ensures clear error messages for misconfiguration and helps developers debug quickly.
- */
- it('should throw if not decorated with @Module', () => {
- expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule);
- });
-});
-
-describe('Module token handling', () => {
- /**
- * Tests that providers with different token types (class, string, symbol) are handled correctly.
- * This ensures the DI system is robust to various token types.
- */
- it('should support providers with class and symbol tokens', () => {
- const SymbolToken = Symbol('SYMBOL_TOKEN');
- class ClassToken {}
- @Module({
- providers: [
- { token: SymbolToken, useValue: 2 },
- { token: ClassToken, useValue: 3 },
- ],
- })
- class TokenModule {}
- const metadata = getMetadata(TokenModule, METADATA_KEYS.MODULE_METADATA);
- expect(metadata.providers.some((p: any) => p.token === SymbolToken)).toBe(
- true
- );
- expect(metadata.providers.some((p: any) => p.token === ClassToken)).toBe(
- true
- );
- });
-});
-
-describe('Module edge cases', () => {
- /**
- * Tests that modules with empty or missing arrays for providers, services, etc., do not break.
- * This ensures the module system is robust to minimal or incomplete configs.
- */
- it('should handle modules with empty or missing arrays', () => {
- @Module({})
- class EmptyModule {}
- const metadata = getMetadata(EmptyModule, METADATA_KEYS.MODULE_METADATA);
- expect(metadata).toBeDefined();
- expect(metadata.providers ?? []).toBeInstanceOf(Array);
- expect(metadata.providers ?? []).toBeInstanceOf(Array);
- expect(metadata.imports ?? []).toBeInstanceOf(Array);
- expect(metadata.exports ?? []).toBeInstanceOf(Array);
- });
-});
diff --git a/libs/core/src/module.ts b/libs/core/src/module.ts
deleted file mode 100644
index 9b8a24c..0000000
--- a/libs/core/src/module.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Module } from './decorators';
-import type { ModuleConfig, TokenType } from './types';
-import { METADATA_KEYS } from './constants';
-import type { Token } from './token';
-import { setMetadata, getMetadata } from './helpers';
-import { InvalidModule } from './exceptions/invalid-module.exception';
-
-/**
- * Represents a dynamic module, allowing for runtime configuration of providers and imports.
- *
- * @example
- * import { DynamicModule } from '@nexusdi/core';
- * const dynamic = DynamicModule.forRoot({ providers: [LoggerService] });
- * @see https://nexus.js.org/docs/modules/dynamic-modules
- */
-export abstract class DynamicModule {
- protected abstract readonly configToken: Token | string | symbol;
-
- /**
- * Gets the module configuration from the decorator metadata
- */
- static getModuleConfig(
- this: T
- ): ModuleConfig {
- const moduleConfig = getMetadata(this, METADATA_KEYS.MODULE_METADATA);
- if (!moduleConfig) {
- throw new InvalidModule(this);
- }
- return moduleConfig;
- }
-
- /**
- * Returns the config token for the module
- */
- static getConfigToken(this: T): TokenType {
- // Cast to a constructor type that has a parameterless constructor and configToken property
- type WithConfigToken = { new (): { configToken: TokenType } };
- return new (this as unknown as WithConfigToken)().configToken;
- }
-
- /**
- * Creates a dynamic module configuration with the provided config
- */
- static config(
- this: T,
- config: TConfig
- ) {
- const moduleConfig = this.getModuleConfig();
- const configToken = this.getConfigToken();
- return {
- ...moduleConfig,
- providers: [
- ...(moduleConfig.providers || []),
- { token: configToken, useValue: config },
- ],
- };
- }
-
- /**
- * Creates a dynamic module configuration with an async config factory
- */
- static configAsync(
- this: T,
- configFactory: () => TConfig | Promise
- ) {
- const moduleConfig = this.getModuleConfig();
- const configToken = this.getConfigToken();
- return {
- ...moduleConfig,
- providers: [
- ...(moduleConfig.providers || []),
- { token: configToken, useFactory: configFactory },
- ],
- };
- }
-
- /**
- * Create a dynamic module with the given configuration.
- *
- * @param config Module configuration
- * @returns A dynamic module class
- * @example
- * import { DynamicModule } from '@nexusdi/core';
- * const MyDynamic = DynamicModule.forRoot({ providers: [LoggerService] });
- * @see https://nexus.js.org/docs/modules/dynamic-modules
- */
- static forRoot(config: any): any {
- class RuntimeDynamicModule {}
- setMetadata(RuntimeDynamicModule, METADATA_KEYS.MODULE_METADATA, config);
- return RuntimeDynamicModule;
- }
-}
-
-// Re-export the Module decorator for convenience
-export { Module };
diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts
index 071f7fe..abb81fe 100644
--- a/libs/core/src/types.ts
+++ b/libs/core/src/types.ts
@@ -174,6 +174,12 @@ export type InjectionMetadata = {
optional?: boolean;
};
+export type DynamicModuleConfig =
+ | Config
+ | ProviderConfigObject;
+export type DynamicModuleConfigAsync =
+ | Promise
+ | ProviderConfigObject>;
// Export internal types for container use only (not public API)
export type {
InternalClassProvider,