Transform NestJS modules into composable, type-safe building blocks. Configure once, compose everywhere.
Transform any service into a smart module in seconds:
import { Injectable, Module } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
// 1. Define your configuration
export class DatabaseConfig {
url: string
poolSize?: number = 10
}
// 2. Create your service with embedded module definition
@Injectable()
export class DatabaseService {
static smartModule = smartModule({
smartConfigs: [DatabaseConfig],
providers: [DatabaseService],
exports: [DatabaseService],
})
constructor(private config: DatabaseConfig) {}
query(sql: string) {
return `Executing: ${sql} on ${this.config.url}`
}
}
// 3. Create a feature service that depends on DatabaseService
@Injectable()
export class FeatureService {
static smartModule = smartModule({
smartImports: [DatabaseService.smartModule],
providers: [FeatureService],
exports: [FeatureService],
})
constructor(private db: DatabaseService) {}
getUsers() {
return this.db.query('SELECT * FROM users')
}
}
// 4. Use it anywhere with full type safety - configuration flows down automatically
@Module({
imports: [
FeatureService.smartModule({
url: 'postgres://localhost:5432/mydb',
}),
],
})
export class AppModule {}
✅ Test easily - Provide only what each module needs at any level
Smart modules automatically build your dependency tree and merge configuration requirements:
// Your dependency tree
AppModule
└── FeatureService.smartModule({ url: "..." }) // Pass config at the top level
└── DatabaseService.smartModule // Gets config automatically!
└── DatabaseConfig // Configured once at the root
This works in three steps:
ConfigurableModuleBuilder?npm install nestjs-smart-modules
| nestjs-smart-modules | NestJS (peer) | Node.js |
|---|---|---|
| 1.x | ^8 || ^9 || ^10 || ^11 |
>= 18.16.0 |
CI runs the full compatibility grid — every supported NestJS major against every Node line it supports: Nest 8/9/10 on Node 18.16 (the declared floor), 20, 22, 24 and 26, and Nest 11 on Node 20+ (its own engines requirement). NestJS 12 (@next) is tracked by a warning-only job until its RC. The package ships dual builds: CommonJS for require() and ESM for import. Development happens on the Node version pinned in .nvmrc. Built with TypeScript 5.8; public factory type inference is verified in CI down to TypeScript 5.0.
This is the most common use case. You provide a configuration class via smartConfigs, and smartModule handles the configuration and dependency injection.
Config Class:
// auth.config.ts
export class AuthConfig {
// A required property must be provided when configuring the module.
jwtSecret: string
// An optional property can be omitted. It will be `undefined` if not provided.
audience?: string
// A property with a default value.
// For this to be truly optional at the configuration stage,
// it MUST be marked with a `?`.
expiresIn?: string = '60s'
}
Important: Always mark properties with default values as optional with a
?(e.g.,myProp?: string = 'default'). Without the?, TypeScript will require you to provide a value for that property when you configure the module, even though it has a default.
Smart Module and Service:
// auth.service.ts
import { Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
import { AuthConfig } from './auth.config'
@Injectable()
export class AuthService {
static smartModule = smartModule({
smartConfigs: [AuthConfig], // Use the `smartConfigs` property here
providers: [AuthService],
exports: [AuthService],
})
constructor(private readonly config: AuthConfig) {
// You can access the fully instantiated config here
}
}
Usage:
// app.module.ts
import { Module } from '@nestjs/common'
import { AuthService } from './auth.service'
@Module({
imports: [
AuthService.smartModule({
jwtSecret: 'your-super-secret-key',
// `audience` is omitted
// `expiresIn` is omitted, so it will use the default '60s'
}),
],
})
export class AppModule {}
The example above illustrates a key design pattern encouraged by this library: treating the service class as the module itself.
By defining a static smartModule property on LoggerService, we have effectively merged the service and its module definition into a single class. This is the recommended pattern and provides several advantages over traditional NestJS module organization:
my-service.module.ts files. The service class is self-contained and provides its own module definition, which significantly reduces the number of files and boilerplate code in your application.LoggerService, you import LoggerService.smartModule(), which is more direct and intuitive than importing a separate LoggerModule. This flattens the mental model of your application, making it easier to navigate and reason about.This pattern is the foundation for the composability and simplicity that nestjs-smart-modules aims to provide.
When you define smartModule as a static property on a class (e.g., MyService), the library automatically generates a descriptive name for the underlying DynamicModule, such as MyServiceSmartModule. This is extremely useful for debugging, as it makes dependency graphs much easier to read in NestJS error logs or graphical representations.
Similarly, when you use configuration classes with smartConfigs, the library automatically creates named modules for them as well. For example, if you have an AuthConfig class, the library will generate a module named AuthConfigSmartConfigModule that handles the configuration instantiation and dependency injection. This automatic naming applies to all configuration classes, making it easy to trace configuration-related issues in your application.
To ensure this feature works correctly, always define your smart modules on a named class and use named configuration classes.
Both labels and prefixes solve the same problem: avoiding configuration property conflicts. When composing multiple modules or using multiple instances of the same module, you need ways to separate their configurations.
Important: When defining a
labelorprefixinline, always useas const. This tells TypeScript to treat it as a literal type, which is essential for correct type inference.Note on
smartImports: Applying alabelorprefixto asmartImportonly affects how configuration is passed down to it. It does not change the providers or injection tokens within the imported module itself.
Labels create a nested object structure in your configuration. This is useful for logically grouping related settings or avoiding property name conflicts.
Example: Using Labels
// Two configs with a conflicting 'port' property.
export class DatabaseConfig {
port: number
}
export class CacheConfig {
port: number
}
export class AppService {
static smartModule = smartModule({
smartConfigs: [
{ smartConfig: DatabaseConfig, label: 'db' as const },
{ smartConfig: CacheConfig, label: 'cache' as const },
],
})
}
@Module({
imports: [
AppService.smartModule({
db: { port: 5432 },
cache: { port: 6379 },
}),
],
})
export class AppModule {}
You can also define a default label directly on the configuration class using a static property. It will be used automatically unless overridden.
Example: Static Labels
// Two configs with a conflicting 'port' property and static labels.
export class EmailConfig {
static label = 'email' as const
port: number
}
export class SmsConfig {
static label = 'sms' as const
port: number
}
export class NotificationsService {
static smartModule = smartModule({
// The static `label` on each class is used automatically.
smartConfigs: [EmailConfig, SmsConfig],
})
}
@Module({
imports: [
NotificationsService.smartModule({
email: { port: 587 },
sms: { port: 443 },
}),
],
})
export class AppModule {}
Note: A static
labelon a config class will be overridden if you provide alabelproperty inline where the class is used.
Prefixes add a prefix to each property name, creating a flat configuration structure. This is useful for avoiding property name conflicts while keeping all settings at the same level.
Example: Using Prefixes
// Two configs with a conflicting 'port' property.
export class DatabaseConfig {
port: number
}
export class CacheConfig {
port: number
}
export class AppService {
static smartModule = smartModule({
smartConfigs: [
{ smartConfig: DatabaseConfig, prefix: 'db_' as const },
{ smartConfig: CacheConfig, prefix: 'cache_' as const },
],
})
}
@Module({
imports: [
AppService.smartModule({
db_port: 5432,
cache_port: 6379,
}),
],
})
export class AppModule {}
A static prefix can also be defined directly on the configuration class.
Example: Static Prefixes
// Two configs with a conflicting 'port' property and static prefixes.
export class EmailConfig {
static prefix = 'email_' as const
port: number
}
export class SmsConfig {
static prefix = 'sms_' as const
port: number
}
export class NotificationsService {
static smartModule = smartModule({
// The static `prefix` on each class is used automatically.
smartConfigs: [EmailConfig, SmsConfig],
})
}
@Module({
imports: [
NotificationsService.smartModule({
email_port: 587,
sms_port: 443,
}),
],
})
export class AppModule {}
Note: A static
prefixon a config class will be overridden if you provide aprefixproperty inline where the class is used.
Module composition enables building applications as dependency trees. Each service defines its smartModule with dependencies, and the library merges all configuration requirements into a single root object.
For instance, your AppModule might import a BooksService, which in turn depends on a DatabaseService. Even if other services also depend on DatabaseService, its configuration is required only once at the application's root.
The library automates the entire process:
This approach provides powerful benefits:
Ultimately, this shifts the paradigm from managing loosely-coupled modules to building a well-defined, composable tree of services.
To align with NestJS conventions and enhance clarity, this library promotes a specific naming pattern for its static factory methods. The choice between smartModule and forRoot clarifies a module's role in your application architecture.
static smartModule = smartModule(...): For Composable Service Blocks
This pattern is for creating reusable, injectable services that act as building blocks for larger features. Use smartModule when creating a service intended to be a dependency for other smart modules. It should encapsulate a distinct piece of business logic meant for composition.
Think of these as the internal components that form your application's dependency tree.
// users.service.ts
export class UsersService {
static smartModule = smartModule({
// It IMPORTS another service's smartModule
smartImports: [DatabaseService.smartModule],
providers: [UsersService],
exports: [UsersService],
})
// ...
}
static forRoot = smartModule(...): For Top-Level Feature Modules
This pattern is for top-level modules that configure application-wide features and are typically imported only once, directly into your AppModule. Use forRoot when a module's primary purpose is not to provide a composable service, but to:
GlobalConfigModule).UsersModule).These are "leaf" nodes in your dependency tree, consumed by the application root rather than other services.
// app.module.ts
@Module({
imports: [
// AppModule IMPORTS a feature module using .forRoot()
UsersModule.forRoot({ ... }),
GlobalConfigModule.forRoot({ ... }),
],
})
export class AppModule {}
Summary: Use smartModule for services that are dependencies of other services. Use forRoot for feature modules that are dependencies of the application itself. This cleanly separates reusable logic from top-level application structure.
Use smartImports to compose services. When one smart module imports another, the library merges configuration requirements. You only need to provide configuration for "leaf" nodes at the application's top level.
Example: A Multi-Level Dependency Tree
This example demonstrates a dependency tree:
graph TD
App["AppModule"] --> Books["BooksService"]
Books --> Users["UsersService"]
Books --> DB1["DatabaseService"]
Users --> DB2["DatabaseService"]
Config["{ url: 'postgres://my-app-db' }"] --> App
Config -.->|"shared config"| DB1
Config -.->|"shared config"| DB2
style App fill:#fff3e0
style Books fill:#e8f5e8
style Users fill:#e1f5fe
style DB1 fill:#f3e5f5
style DB2 fill:#f3e5f5
style Config fill:#fce4ec
// 1. The deepest dependency, requiring a `url` for its configuration.
export class DatabaseConfig {
url: string
}
@Injectable()
export class DatabaseService {
static smartModule = smartModule({
smartConfigs: [DatabaseConfig],
providers: [DatabaseService],
exports: [DatabaseService],
})
constructor(private readonly config: DatabaseConfig) {}
}
// 2. A service that depends on DatabaseService.
@Injectable()
export class UsersService {
static smartModule = smartModule({
smartImports: [DatabaseService.smartModule],
providers: [UsersService],
exports: [UsersService],
})
constructor(private readonly dbService: DatabaseService) {}
}
// 3. A top-level service that depends on both.
@Injectable()
export class BooksService {
static smartModule = smartModule({
smartImports: [UsersService.smartModule, DatabaseService.smartModule],
providers: [BooksService],
exports: [BooksService],
})
constructor(
private readonly usersService: UsersService,
private readonly dbService: DatabaseService,
) {}
}
// 4. The root module.
@Module({
// By importing BooksService, we also implicitly import its dependencies.
// The library gathers all configuration requirements, so we only need to
// provide the `url` for the deeply nested DatabaseService here.
imports: [BooksService.smartModule({ url: 'postgres://my-app-db' })],
})
export class AppModule {}
In this example, AppModule only configures BooksService. The library automatically merges the requirements from BooksService and UsersService, recognizes that both need DatabaseService with a url, and passes the single url to both instances. This prevents configuration duplication and simplifies dependency management.
You can apply a label or prefix to an entire smartImport to namespace the complete configuration tree. This is useful when integrating complex third-party smart modules to avoid configuration key collisions.
Example:
// 1. Define the dependency services and their configs.
export class LoggerConfig {
log_level: string
}
@Injectable()
export class LoggerService {
static smartModule = smartModule({
smartConfigs: [LoggerConfig],
providers: [LoggerService],
exports: [LoggerService],
})
constructor(private readonly config: LoggerConfig) {}
}
export class MetricsConfig {
metrics_url: string
}
@Injectable()
export class MetricsService {
static smartModule = smartModule({
smartConfigs: [MetricsConfig],
providers: [MetricsService],
exports: [MetricsService],
})
constructor(private readonly config: MetricsConfig) {}
}
// 2. This third-party service bundles its own dependencies.
@Injectable()
export class ThirdPartyAnalyticsService {
static smartModule = smartModule({
smartImports: [LoggerService.smartModule, MetricsService.smartModule],
providers: [ThirdPartyAnalyticsService],
exports: [ThirdPartyAnalyticsService],
})
constructor(
private readonly logger: LoggerService,
private readonly metrics: MetricsService,
) {}
}
// 3. To avoid potential configuration conflicts, we wrap its import with a label.
@Injectable()
export class AppService {
static smartModule = smartModule({
smartImports: [
{
smartImport: ThirdPartyAnalyticsService.smartModule,
label: 'analytics' as const,
},
],
providers: [AppService],
exports: [AppService],
})
constructor(private readonly analyticsService: ThirdPartyAnalyticsService) {}
}
@Module({
imports: [
// Now, the entire configuration for the third-party module and all of its
// dependencies is nested under the 'analytics' key.
AppService.smartModule({
analytics: {
log_level: 'debug',
metrics_url: 'http://metrics.service.com',
},
}),
],
})
export class AppModule {}
Every call to a smart module factory creates its own DynamicModule — and therefore its own provider instances. In the composition example above, BooksService and UsersService each import DatabaseService.smartModule, so the application ends up with two independent DatabaseService instances receiving the same merged configuration. "Configured once" applies to configuration, not to instances.
This holds on every supported NestJS major (verified on 10 and 11 under both module id algorithms — see spec/module-identity.spec.ts): the generated module classes are unique per call, so NestJS never deduplicates them.
Practical consequences:
DynamicModule once and reuse the same object reference (NestJS deduplicates modules by reference), or provide the service through a global: true module — see the Global Configuration recipe.Configuration uses its class as the injection token by default. You can override this with custom string or symbol tokens to avoid class-based injection or conflicts.
Note: The
tokenoption is only available forsmartConfigs, not forsmartImports.
Example: Inline Token
// redis.service.ts
import { Inject, Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
export class RedisConfig {
host: string
port: number
}
export const REDIS_CONFIG_TOKEN = 'REDIS_CONFIG'
@Injectable()
export class RedisService {
static smartModule = smartModule({
smartConfigs: [{ smartConfig: RedisConfig, token: REDIS_CONFIG_TOKEN }],
providers: [RedisService],
exports: [RedisService],
})
constructor(@Inject(REDIS_CONFIG_TOKEN) private readonly config: RedisConfig) {}
}
Example: Static Token on Config Class
// notifications.config.ts
export class NotificationsConfig {
static token = 'NOTIFICATIONS_CONFIG' // Static token
apiKey: string
}
// notifications.service.ts
import { Inject, Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
import { NotificationsConfig } from './notifications.config.ts'
@Injectable()
export class NotificationsService {
static smartModule = smartModule({
smartConfigs: [NotificationsConfig],
providers: [NotificationsService],
exports: [NotificationsService],
})
constructor(@Inject(NotificationsConfig.token) private readonly config: NotificationsConfig) {}
}
Note: A static
tokenon a config class will be overridden if you provide atokenproperty inline where the class is used.
Recommendation: Use synchronous configuration whenever possible. Only use async configuration for exceptional cases when config must be loaded from a running NestJS provider.
While asynchronous configuration is fully supported, the recommended best practice is a synchronous setup. The ideal approach is to gather all configuration (e.g., from environment variables or a config service) before the NestJS application starts. This allows you to bootstrap the entire application with a single, complete configuration object, leading to a more predictable and robust startup process.
Asynchronous configuration should only be used for exceptional cases, such as when a piece of configuration is only available from a provider within a running NestJS application.
For exceptional cases where synchronous setup isn't possible, smartModule can handle asynchronous configuration. This is useful when integrating with legacy modules or when configuration comes from running NestJS providers.
Example:
Imagine you have a ConfigModule that asynchronously provides a ConfigService.
// auth.service.ts
import { Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
export class AuthConfig {
jwtSecret: string
expiresIn: string
}
@Injectable()
export class AuthService {
static smartModule = smartModule({
smartConfigs: [AuthConfig],
providers: [AuthService],
exports: [AuthService],
})
constructor(private readonly config: AuthConfig) {}
}
// app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthService } from './auth.service'
@Module({
imports: [
ConfigModule.forRoot(), // Standard NestJS ConfigModule
AuthService.smartModule({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
jwtSecret: configService.getOrThrow('JWT_SECRET'),
expiresIn: configService.getOrThrow('JWT_EXPIRES_IN'),
}),
}),
],
})
export class AppModule {}
The factory pattern provides an "escape hatch" when module definitions depend on resolved configuration values. Use this only when static module definitions aren't sufficient.
Common use cases include:
useValue is derived from one or more configuration properties.MockService for testing).@nestjs/jwt) using values from your smart configuration.While you can pass configurations as inline arguments, the recommended pattern is to use the
smartConfigsandsmartImportsproperties inside the module definition object for better clarity. The primary use case for passing configs as inline arguments is to use them with this factory pattern.
Signature:
smartModule(
...configs: AnySmartConfig[], // Passed inline for factory access
factory: (imports: DynamicModule[], ...instantiatedConfigs: object[]) => SmartModule
)
The most common reason to use the factory pattern is to create providers whose definitions depend on the values from your configuration. Because the factory function only runs after the DatabaseConfig has been instantiated, you can access its properties.
Note: For this pattern to work, configuration classes must be passed as inline arguments to
smartModule, not inside thesmartConfigsproperty. This is only available during synchronous module creation.
Example:
import { Inject, Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
export class ReportingConfig {
apiUrl: string
}
@Injectable()
export class ReportingService {
static smartModule = smartModule(
ReportingConfig, // Passed inline to be available in the factory
(imports, reportingConfig: ReportingConfig) => ({
imports,
providers: [
ReportingService,
{
provide: 'REPORTING_SERVICE_URL',
// Use the config value at definition time
useValue: `${reportingConfig.apiUrl}/reports`,
},
],
exports: [ReportingService],
}),
)
constructor(@Inject('REPORTING_SERVICE_URL') private readonly reportingUrl: string) {}
}
@Module({
imports: [
ReportingService.smartModule({
apiUrl: 'http://api.service.com',
}),
],
})
export class AppModule {}
The factory pattern is especially powerful when you need to wrap a traditional NestJS module that uses a standard .forRoot() or .register() configuration pattern. This allows you to integrate third-party modules into your smartModule ecosystem seamlessly.
By using the factory, you can instantiate your configuration class first and then pass its values to the third-party module's configuration method.
Example: Wrapping JwtModule
import { Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
import { JwtModule } from '@nestjs/jwt'
// 1. Define the configuration for the third-party module.
export class JwtConfig {
secret: string
signOptions: { expiresIn: string }
}
// 2. Create a wrapper service.
@Injectable()
export class JwtWrapperService {
static smartModule = smartModule(
JwtConfig, // Pass config inline to access it in the factory.
(imports, jwtConfig: JwtConfig) => {
// 3. Dynamically register the third-party module using the config.
const jwtModule = JwtModule.register({
secret: jwtConfig.secret,
signOptions: jwtConfig.signOptions,
})
return {
imports: [...imports, jwtModule],
providers: [JwtWrapperService],
// 4. Export both your service and the third-party module.
exports: [JwtWrapperService, jwtModule],
}
},
)
}
// 5. Use the wrapper service in your application.
@Module({
imports: [
JwtWrapperService.smartModule({
secret: 'your-secret-key',
signOptions: { expiresIn: '60s' },
}),
],
})
export class AppModule {}
This pattern allows any other service that imports JwtWrapperService.smartModule to inject NestJS's JwtService directly, without needing to know about the underlying configuration details.
The first argument to the factory function, imports, is an array of DynamicModule instances. These modules are automatically generated by smartModule for each inline SmartConfig class that you provided.
Each of these dynamic modules is responsible for providing one of your configured dependencies. For example, if you pass DatabaseConfig as an inline smart config, one of the modules in the imports array will be the one that provides the configured instance of DatabaseConfig.
While smartModule automatically adds this imports array to the imports array of the final module it creates, you might want to access it for advanced use cases, such as re-exporting the modules.
Example:
import { Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
export class DatabaseConfig {
url: string
}
export class CacheConfig {
host: string
}
@Injectable()
export class MyService {
static smartModule = smartModule(
DatabaseConfig, // Pass SmartConfig classes inline
CacheConfig,
imports => ({
providers: [MyService],
// You can now access the created modules for DatabaseConfig and CacheConfig
// and re-export them.
exports: [MyService, ...imports],
}),
)
}
@Module({
imports: [
MyService.smartModule({
url: 'postgres://localhost:5432',
host: 'redis://localhost:6379',
}),
],
})
export class AppModule {}
This section covers advanced patterns and solutions to common problems.
The Challenge: With static @Module classes, importing the same module everywhere yields one shared provider instance. Smart module factories already create separate instances per call (see Instance Semantics) — but those instances are anonymous and interchangeable. For primary/replica-style setups you need separate instances that are individually addressable and configurable; that is what this recipe provides.
The Solution: A Labeled Module Factory
Create a static factory function on your service class that generates uniquely-provided, labeled modules. This encapsulates the complexity and provides a clean API using smartImports and smartConfigs.
Step 1: Enhance the Base Service
Add three static helper methods to your DatabaseService class:
getTokenForLabel(label: string): A public method that creates a predictable, unique injection token.Inject(label: string): A decorator that simplifies injecting the service with a specific label.smartModuleCustom(label: string): A factory that returns a smartModule that provides the DatabaseService under a unique token.// database.service.ts
import { Inject, Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
export class DatabaseConfig {
url: string
}
@Injectable()
export class DatabaseService {
// The base smartModule for a single, default instance.
static smartModule = smartModule({
smartConfigs: [DatabaseConfig],
providers: [DatabaseService],
exports: [DatabaseService],
})
static getTokenForLabel(label: string) {
return `${label.toUpperCase()}_DATABASE_SERVICE`
}
static Inject = (label: string) => Inject(DatabaseService.getTokenForLabel(label))
static smartModuleCustom = function (this: typeof DatabaseService, label: string) {
const token = this.getTokenForLabel(label)
return smartModule({
smartConfigs: [DatabaseConfig],
providers: [
DatabaseService, // Provide the base service...
{
provide: token,
useClass: DatabaseService, // ...and create a new instance for the unique token.
},
],
exports: [token],
})
}
constructor(private readonly config: DatabaseConfig) {}
find(query: string) {
return `${this.config.url} for ${query}`
}
}
Step 2: Create a Higher-Level Service
Create a higher-level service that consumes the uniquely provided database instances and encapsulates its own dependencies with its own smartModule.
graph TD
App["AppModule"] --> DBSvc["DatabasesService"]
DBSvc --> Primary["DatabaseService<br/>(labeled: 'primary')"]
DBSvc --> Replica["DatabaseService<br/>(labeled: 'replica')"]
Primary --> PrimaryConfig["DatabaseConfig"]
Replica --> ReplicaConfig["DatabaseConfig"]
Config["Configuration Object<br/>{ primary: { url: 'postgres://primary-db' }, replica: { url: 'postgres://replica-db' } }"] --> App
Config -.->|"primary.url"| PrimaryConfig
Config -.->|"replica.url"| ReplicaConfig
style App fill:#fff3e0
style DBSvc fill:#e8f5e8
style Primary fill:#e3f2fd
style Replica fill:#f3e5f5
style PrimaryConfig fill:#e8eaf6
style ReplicaConfig fill:#fce4ec
style Config fill:#fff8e1
// databases.service.ts
import { Injectable } from '@nestjs/common'
import { DatabaseService } from './database.service'
import { smartModule } from 'nestjs-smart-modules'
@Injectable()
export class DatabasesService {
// This service becomes a self-contained smart module.
static smartModule = smartModule({
// It declares its dependencies on the labeled database modules.
smartImports: [
{
smartImport: DatabaseService.smartModuleCustom('primary'),
label: 'primary' as const,
},
{
smartImport: DatabaseService.smartModuleCustom('replica'),
label: 'replica' as const,
},
],
providers: [DatabasesService],
exports: [DatabasesService],
})
constructor(
@DatabaseService.Inject('primary')
private readonly primaryDb: DatabaseService,
@DatabaseService.Inject('replica')
private readonly replicaDb: DatabaseService,
) {}
// Example method that uses both database connections.
find(query: string) {
return [this.primaryDb.find(query), this.replicaDb.find(query)]
}
}
Step 3: Compose the Application
The final application composition becomes simple. Just import DatabasesService.smartModule and provide the expected configuration object.
// app.module.ts
import { Module } from '@nestjs/common'
import { DatabasesService } from './databases.service'
@Module({
imports: [
// Import the top-level service and provide the combined config.
DatabasesService.smartModule({
primary: {
url: 'postgres://primary-db',
},
replica: {
url: 'postgres://replica-db',
},
}),
],
})
export class AppModule {}
forRoot pattern)When to use this pattern: While this pattern is powerful, it's important to understand its place. If you are building an entire application from the ground up with
nestjs-smart-modules, you typically do not need a.forRoot()module. The recommended approach is to build a dependency tree and provide a single configuration object to your rootAppModule, as described in the Module Composition section.This
forRootpattern is most useful in two scenarios:
- Incremental Adoption: When you want to introduce
nestjs-smart-modulesinto an existing, traditional NestJS application.- Creating Standalone Libraries: If you are building a reusable library for others to consume, providing a familiar
.forRoot()method for configuration is a common and well-understood convention.
A common requirement is having a single, globally available configuration that any service can access without importing specific modules everywhere. Traditional NestJS uses .forRoot() static methods. You can achieve the same result cleanly with smartModule.
Step 1: Define the Global Configuration and Module
Create GlobalConfig class and GlobalConfigModule that provides it globally. Use the factory pattern with smartModule to re-export the configuration module.
// global-config.module.ts
import { Injectable } from '@nestjs/common'
import { smartModule } from 'nestjs-smart-modules'
// Define the shape of your global configuration.
export class GlobalConfig {
appName: string
isProduction: boolean
}
// Create a self-contained module for the config.
@Injectable()
export class GlobalConfigModule {
// Use smartModule to create a configurable, global module.
static forRoot = smartModule(GlobalConfig, imports => ({
global: true, // This makes the providers available everywhere.
exports: imports,
// exports: [GlobalConfig], // will not work because smart config not injected into a smart module but into a new module that goes into imports array, we cant export others module exports only re export whole imported module
}))
}
Step 2: Inject the Global Config in a Feature Service
Any service can now inject GlobalConfig directly. This service demonstrates using configuration in business logic.
// feature.service.ts
import { Injectable } from '@nestjs/common'
import { GlobalConfig } from './global-config.module'
@Injectable()
export class FeatureService {
constructor(private readonly config: GlobalConfig) {}
doSomething() {
if (!this.config.isProduction) {
return 'not production'
}
return 'production'
}
}
Step 3: Provide the Configuration in AppModule
Create AppModule using smartModule that imports global configuration. Demonstrates synchronous configuration pattern.
// app.module.ts
import { smartModule } from 'nestjs-smart-modules'
import { GlobalConfigModule } from './global-config.module'
import { FeatureService } from './feature.service'
export class AppModule {
static smartModule = smartModule({
imports: [
GlobalConfigModule.forRoot({
appName: 'My Awesome App',
isProduction: true,
}),
],
providers: [FeatureService],
})
}
This pattern provides clean, type-safe global configuration management using the factory pattern with smartModule. The global flag makes configuration available throughout the application.
Asynchronous Configuration
The factory pattern supports asynchronous configuration using standard useFactory pattern. This example integrates with NestJS's ConfigModule for environment-based configuration.
Example:
// async-app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config'
import { smartModule } from 'nestjs-smart-modules'
import { GlobalConfigModule } from './global-config.module'
import { FeatureService } from './feature.service'
export class AsyncAppModule {
static smartModule = smartModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // Make the standard ConfigService available
GlobalConfigModule.forRoot({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
appName: configService.get('APP_NAME') || 'My Async App',
isProduction: configService.get('NODE_ENV') === 'production',
}),
}),
],
providers: [FeatureService],
})
}
ConfigurableModuleBuilder?NestJS ships ConfigurableModuleBuilder for building configurable modules, and it is a fine choice for a single module with options. smartModule targets a different problem — composing an application out of many configurable pieces:
ConfigurableModuleBuilder |
nestjs-smart-modules |
|
|---|---|---|
| Scope | One module's options | A tree of modules |
| Consumer wiring | register() / registerAsync() per module |
One config object at the root |
| Config typing | Options interface per module | Merged and inferred from the whole tree |
| Dependencies between modules | Wired manually | smartImports compose and merge requirements |
| Boilerplate | Module class + options type + builder + names | The service class is the module |
If you maintain one standalone library module with a forRoot(), the builder is enough. If you are assembling an application from composable blocks with centralized, fully typed configuration — that is what this library is for.
The generated API reference (from the JSDoc in the sources) lives at webwayer.github.io/nestjs-smart-modules.
smartModule() FunctionThe core function that transforms your service into a configurable module factory.
Signature:
smartModule(definition: SmartModuleDefinition): (config: object) => DynamicModule
Parameters:
definition - Object containing module definition and smart configurationsSmartModuleDefinition Interface:
interface SmartModuleDefinition {
// Standard NestJS DynamicModule properties
module?: any // Default: the service class itself
imports?: any[]
controllers?: any[]
providers?: any[]
exports?: any[]
global?: boolean
// Smart module specific properties
smartConfigs?: SmartConfig[] // Configuration classes
smartImports?: SmartImport[] // Other smart modules to import
}
SmartConfig Options:
type SmartConfig =
| ConfigClass // Simple config class
| {
// Advanced config options
smartConfig: ConfigClass
label?: string // Namespace under nested object
prefix?: string // Prefix all properties
token?: string | symbol // Custom injection token
}
SmartImport Options:
type SmartImport =
| SmartModuleFactory // Simple import
| {
// Advanced import options
smartImport: SmartModuleFactory
label?: string // Namespace configuration
prefix?: string // Prefix configuration
}
Configuration classes should follow these patterns:
export class MyConfig {
// Required properties
requiredProp: string
// Optional properties
optionalProp?: string
// Properties with defaults (must be optional!)
defaultProp?: number = 42
// Static configuration
static label?: string = 'myConfig'
static prefix?: string = 'my_'
static token?: string = 'MY_CONFIG_TOKEN'
}
For advanced use cases where module definition depends on configuration values:
smartModule(
...configs: ConfigClass[],
factory: (imports: DynamicModule[], ...configs: object[]) => SmartModuleDefinition
): (config: object) => DynamicModule
For cases requiring async configuration:
MyService.smartModule({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
// Return your configuration object
}),
})