Skip to content

DynamicModule Refactor #36

@Evanion

Description

@Evanion

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?

Metadata

Metadata

Assignees

Labels

help wantedExtra attention is neededquestionFurther information is requested

Projects

Status

In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions