Skip to main content

Lazy Loading Modules trong NestJS

Lazy Loading Modules là một kỹ thuật để load modules một cách động tại runtime thay vì load tất cả modules khi ứng dụng khởi động. Điều này giúp cải thiện startup time, giảm memory footprint, và chỉ load những modules cần thiết.

Khái Niệm Lazy Loading

Lazy loading cho phép bạn:

  • Defer module loading cho đến khi cần
  • Cải thiện startup time của ứng dụng
  • Giảm memory usage ban đầu
  • Load modules conditionally dựa trên configuration
  • Implement plugin systems một cách hiệu quả
// Thay vì import tất cả modules từ đầu
@Module({
imports: [UsersModule, ProductsModule, OrdersModule], // Heavy!
})
export class AppModule {}

// Chỉ load những modules cần thiết
@Module({
imports: [], // Lightweight!
})
export class AppModule {}

// Load modules dynamically khi cần
const module = await this.moduleRef.load(UsersModule);

ModuleRef.load() vs ModuleRef.create()

import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';

@Injectable()
export class ModuleLoaderService {
constructor(private moduleRef: ModuleRef) {}

// load() - Load module with all its providers
async loadModule(moduleClass: any) {
const loadedModule = await this.moduleRef.load(moduleClass);
return loadedModule;
}

// create() - Create individual provider instance
async createProvider(providerClass: any) {
const provider = await this.moduleRef.create(providerClass);
return provider;
}
}

Basic Lazy Loading

1. Load Module on Demand

// users.module.ts
@Module({
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}

// users.service.ts
@Injectable()
export class UsersService {
getUsers() {
return ['Alice', 'Bob', 'Charlie'];
}
}

// users.controller.ts
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}

@Get()
findAll() {
return this.usersService.getUsers();
}
}

// app.service.ts
@Injectable()
export class AppService {
constructor(private moduleRef: ModuleRef) {}

// Load UsersModule khi cần
async loadUsersModule() {
console.log('Loading UsersModule...');
const usersModule = await this.moduleRef.load(UsersModule);
console.log('UsersModule loaded successfully');
return usersModule;
}

// Sử dụng UsersModule sau khi load
async getUsersData() {
const usersModule = await this.moduleRef.load(UsersModule);
const usersService = usersModule.get(UsersService);
return usersService.getUsers();
}
}

// app.controller.ts
@Controller()
export class AppController {
constructor(private appService: AppService) {}

@Get('users-lazy')
async getUsersLazy() {
return this.appService.getUsersData();
}
}

2. Conditional Module Loading

// payment-stripe.module.ts
@Module({
providers: [StripePaymentService],
exports: [StripePaymentService],
})
export class PaymentStripeModule {}

// payment-paypal.module.ts
@Module({
providers: [PayPalPaymentService],
exports: [PayPalPaymentService],
})
export class PaymentPayPalModule {}

// payment.service.ts
@Injectable()
export class PaymentService {
constructor(
private moduleRef: ModuleRef,
private configService: ConfigService,
) {}

async getPaymentProvider() {
const provider = this.configService.get('PAYMENT_PROVIDER');

if (provider === 'stripe') {
const module = await this.moduleRef.load(PaymentStripeModule);
return module.get(StripePaymentService);
} else if (provider === 'paypal') {
const module = await this.moduleRef.load(PaymentPayPalModule);
return module.get(PayPalPaymentService);
}

throw new BadRequestException('Unknown payment provider');
}

async processPayment(amount: number) {
const provider = await this.getPaymentProvider();
return provider.pay(amount);
}
}

// payment.controller.ts
@Controller('payment')
export class PaymentController {
constructor(private paymentService: PaymentService) {}

@Post('process')
async processPayment(@Body('amount') amount: number) {
return this.paymentService.processPayment(amount);
}
}

3. Route-Based Lazy Loading

// admin.module.ts
@Module({
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

// admin.service.ts
@Injectable()
export class AdminService {
getDashboard() {
return { data: 'Admin Dashboard' };
}
}

// admin.controller.ts
@Controller('admin')
export class AdminController {
constructor(private adminService: AdminService) {}

@Get('dashboard')
getDashboard() {
return this.adminService.getDashboard();
}
}

// module-loader.middleware.ts
@Injectable()
export class ModuleLoaderMiddleware implements NestMiddleware {
constructor(private moduleRef: ModuleRef) {}

async use(req: Request, res: Response, next: NextFunction) {
const path = req.path;

// Load AdminModule chỉ khi access /admin routes
if (path.startsWith('/admin')) {
try {
await this.moduleRef.load(AdminModule);
console.log('AdminModule loaded');
} catch (error) {
console.error('Failed to load AdminModule', error);
}
}

next();
}
}

// app.module.ts
@Module({
controllers: [AppController],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(ModuleLoaderMiddleware).forRoutes('*');
}
}

Advanced Lazy Loading Patterns

1. Module Registry with Lazy Loading

// module.registry.ts
interface ModuleEntry {
name: string;
moduleClass: any;
loaded: boolean;
module?: any;
}

@Injectable()
export class ModuleRegistry {
private modules: Map<string, ModuleEntry> = new Map();

register(name: string, moduleClass: any) {
this.modules.set(name, {
name,
moduleClass,
loaded: false,
});
}

async load(name: string) {
const entry = this.modules.get(name);

if (!entry) {
throw new NotFoundException(`Module ${name} not registered`);
}

if (!entry.loaded) {
console.log(`Loading module: ${name}`);
entry.module = await this.moduleRef.load(entry.moduleClass);
entry.loaded = true;
}

return entry.module;
}

getStatus() {
const status = [];
this.modules.forEach((entry) => {
status.push({
name: entry.name,
loaded: entry.loaded,
});
});
return status;
}

async getProvider(moduleName: string, providerClass: any) {
const module = await this.load(moduleName);
return module.get(providerClass);
}
}

// app.module.ts
@Module({
providers: [
ModuleRegistry,
{
provide: 'INIT_MODULES',
useFactory: (registry: ModuleRegistry) => {
registry.register('users', UsersModule);
registry.register('products', ProductsModule);
registry.register('orders', OrdersModule);
registry.register('admin', AdminModule);
return registry;
},
inject: [ModuleRegistry],
},
],
})
export class AppModule {}

// module-status.controller.ts
@Controller('modules')
export class ModuleStatusController {
constructor(private registry: ModuleRegistry) {}

@Get('status')
getStatus() {
return this.registry.getStatus();
}

@Post('load/:name')
async loadModule(@Param('name') name: string) {
const module = await this.registry.load(name);
return { success: true, module: name };
}
}

2. Feature-Based Lazy Loading

// features.config.ts
export const FEATURES_CONFIG = {
analytics: {
enabled: process.env.FEATURE_ANALYTICS === 'true',
module: AnalyticsModule,
},
notifications: {
enabled: process.env.FEATURE_NOTIFICATIONS === 'true',
module: NotificationsModule,
},
payments: {
enabled: process.env.FEATURE_PAYMENTS === 'true',
module: PaymentsModule,
},
reports: {
enabled: process.env.FEATURE_REPORTS === 'true',
module: ReportsModule,
},
};

// feature-loader.service.ts
@Injectable()
export class FeatureLoaderService {
private loadedFeatures: Set<string> = new Set();

constructor(private moduleRef: ModuleRef) {}

async loadFeature(featureName: string) {
const featureConfig = FEATURES_CONFIG[featureName];

if (!featureConfig) {
throw new NotFoundException(`Feature ${featureName} not found`);
}

if (!featureConfig.enabled) {
throw new BadRequestException(`Feature ${featureName} is disabled`);
}

if (this.loadedFeatures.has(featureName)) {
console.log(`Feature ${featureName} already loaded`);
return;
}

console.log(`Loading feature: ${featureName}`);
await this.moduleRef.load(featureConfig.module);
this.loadedFeatures.add(featureName);
}

isFeatureLoaded(featureName: string) {
return this.loadedFeatures.has(featureName);
}

getLoadedFeatures() {
return Array.from(this.loadedFeatures);
}
}

// app.service.ts
@Injectable()
export class AppService {
constructor(private featureLoader: FeatureLoaderService) {}

async getAnalytics() {
await this.featureLoader.loadFeature('analytics');
const module = await this.moduleRef.load(AnalyticsModule);
const service = module.get(AnalyticsService);
return service.getAnalytics();
}

async getNotifications() {
await this.featureLoader.loadFeature('notifications');
const module = await this.moduleRef.load(NotificationsModule);
const service = module.get(NotificationsService);
return service.getNotifications();
}
}

3. Plugin System with Lazy Loading

// plugin.interface.ts
export interface Plugin {
name: string;
version: string;
execute(): void;
}

// plugins/email-plugin.module.ts
@Module({
providers: [EmailPluginService],
exports: [EmailPluginService],
})
export class EmailPluginModule {}

@Injectable()
export class EmailPluginService implements Plugin {
name = 'Email Plugin';
version = '1.0.0';

execute() {
console.log('Executing email plugin');
}
}

// plugins/slack-plugin.module.ts
@Module({
providers: [SlackPluginService],
exports: [SlackPluginService],
})
export class SlackPluginModule {}

@Injectable()
export class SlackPluginService implements Plugin {
name = 'Slack Plugin';
version = '1.0.0';

execute() {
console.log('Executing slack plugin');
}
}

// plugin-manager.service.ts
@Injectable()
export class PluginManager {
private plugins: Map<string, Plugin> = new Map();
private pluginModules = {
email: EmailPluginModule,
slack: SlackPluginModule,
};

constructor(private moduleRef: ModuleRef) {}

async loadPlugin(pluginName: string) {
if (this.plugins.has(pluginName)) {
console.log(`Plugin ${pluginName} already loaded`);
return;
}

const ModuleClass = this.pluginModules[pluginName];

if (!ModuleClass) {
throw new NotFoundException(`Plugin ${pluginName} not found`);
}

console.log(`Loading plugin: ${pluginName}`);
const module = await this.moduleRef.load(ModuleClass);

// Get plugin service based on naming convention
const pluginServiceName = `${pluginName.charAt(0).toUpperCase() + pluginName.slice(1)}PluginService`;
const plugin = module.get(pluginServiceName);

this.plugins.set(pluginName, plugin);
}

async executePlugin(pluginName: string) {
const plugin = this.plugins.get(pluginName);

if (!plugin) {
throw new NotFoundException(`Plugin ${pluginName} not loaded`);
}

plugin.execute();
return {
plugin: plugin.name,
version: plugin.version,
executed: true,
};
}

getLoadedPlugins() {
const plugins = [];
this.plugins.forEach((plugin) => {
plugins.push({
name: plugin.name,
version: plugin.version,
});
});
return plugins;
}
}

// plugins.controller.ts
@Controller('plugins')
export class PluginsController {
constructor(private pluginManager: PluginManager) {}

@Post('load/:name')
async loadPlugin(@Param('name') name: string) {
await this.pluginManager.loadPlugin(name);
return { success: true, plugin: name };
}

@Post('execute/:name')
async executePlugin(@Param('name') name: string) {
return this.pluginManager.executePlugin(name);
}

@Get('loaded')
getLoadedPlugins() {
return this.pluginManager.getLoadedPlugins();
}
}

4. Cache with Lazy Loading

@Injectable()
export class CachedModuleLoader {
private cache: Map<string, any> = new Map();
private loading: Map<string, Promise<any>> = new Map();

constructor(private moduleRef: ModuleRef) {}

async load(moduleClass: any) {
const moduleName = moduleClass.name;

// Return cached module nếu đã load
if (this.cache.has(moduleName)) {
console.log(`Using cached module: ${moduleName}`);
return this.cache.get(moduleName);
}

// Nếu đang loading, chờ promise
if (this.loading.has(moduleName)) {
console.log(`Waiting for module: ${moduleName}`);
return this.loading.get(moduleName);
}

// Load module mới
console.log(`Loading module: ${moduleName}`);
const loadPromise = this.moduleRef.load(moduleClass);

this.loading.set(moduleName, loadPromise);

try {
const module = await loadPromise;
this.cache.set(moduleName, module);
return module;
} finally {
this.loading.delete(moduleName);
}
}

clearCache() {
this.cache.clear();
}

getCacheStatus() {
return {
cached: Array.from(this.cache.keys()),
loading: Array.from(this.loading.keys()),
};
}
}

5. Timeout-Based Module Unloading

@Injectable()
export class AutoUnloadingModuleLoader {
private modules: Map<
string,
{ module: any; timestamp: number }
> = new Map();
private timers: Map<string, NodeJS.Timeout> = new Map();
private readonly UNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes

constructor(private moduleRef: ModuleRef) {}

async load(moduleClass: any) {
const moduleName = moduleClass.name;

// Nếu module đã load, reset timer
if (this.modules.has(moduleName)) {
this.resetTimer(moduleName);
return this.modules.get(moduleName).module;
}

// Load module
const module = await this.moduleRef.load(moduleClass);
this.modules.set(moduleName, {
module,
timestamp: Date.now(),
});

this.setTimer(moduleName);

return module;
}

private setTimer(moduleName: string) {
const timer = setTimeout(() => {
console.log(`Unloading module: ${moduleName}`);
this.modules.delete(moduleName);
this.timers.delete(moduleName);
}, this.UNLOAD_TIMEOUT);

this.timers.set(moduleName, timer);
}

private resetTimer(moduleName: string) {
const existingTimer = this.timers.get(moduleName);
if (existingTimer) {
clearTimeout(existingTimer);
this.setTimer(moduleName);
}
}

getLoadedModules() {
return Array.from(this.modules.keys());
}
}

6. Error Handling and Retry Logic

@Injectable()
export class ResilientModuleLoader {
private readonly MAX_RETRIES = 3;
private readonly RETRY_DELAY = 1000; // 1 second

constructor(private moduleRef: ModuleRef) {}

async loadWithRetry(moduleClass: any, retries: number = 0): Promise<any> {
try {
console.log(`Loading ${moduleClass.name}...`);
return await this.moduleRef.load(moduleClass);
} catch (error) {
if (retries < this.MAX_RETRIES) {
console.warn(
`Failed to load ${moduleClass.name}. Retrying (${retries + 1}/${this.MAX_RETRIES})...`,
);
await this.delay(this.RETRY_DELAY);
return this.loadWithRetry(moduleClass, retries + 1);
}

console.error(
`Failed to load ${moduleClass.name} after ${this.MAX_RETRIES} attempts`,
);
throw error;
}
}

async loadMultiple(moduleClasses: any[]) {
const results = await Promise.allSettled(
moduleClasses.map((moduleClass) =>
this.loadWithRetry(moduleClass),
),
);

const loaded = [];
const failed = [];

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
loaded.push(moduleClasses[index].name);
} else {
failed.push({
module: moduleClasses[index].name,
error: result.reason.message,
});
}
});

return { loaded, failed };
}

private delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

Best Practices

1. Load Modules at Startup for Known Features

// ❌ Sai - Load tất cả modules dynamically
@Module({
imports: [],
})
export class AppModule {}

// ✅ Đúng - Load core modules at startup, optional modules lazily
@Module({
imports: [UsersModule, AuthModule], // Core modules
})
export class AppModule {}

2. Use Caching to Avoid Reloading

// ✅ Đúng - Cache loaded modules
@Injectable()
export class ModuleLoader {
private cache = new Map();

async load(moduleClass: any) {
if (this.cache.has(moduleClass.name)) {
return this.cache.get(moduleClass.name);
}

const module = await this.moduleRef.load(moduleClass);
this.cache.set(moduleClass.name, module);
return module;
}
}

3. Handle Errors Gracefully

// ✅ Đúng - Handle load failures
async loadOptionalModule(moduleClass: any) {
try {
return await this.moduleRef.load(moduleClass);
} catch (error) {
console.warn(`Failed to load optional module: ${moduleClass.name}`, error);
return null; // Or use fallback
}
}

4. Use for Heavy or Optional Features

// ✅ Đúng - Lazy load heavy modules
@Injectable()
export class AppService {
constructor(private moduleRef: ModuleRef) {}

// Load report generation module chỉ khi cần
async generateReport() {
const module = await this.moduleRef.load(ReportGenerationModule);
const service = module.get(ReportService);
return service.generate();
}
}

5. Monitoring and Metrics

@Injectable()
export class MonitoredModuleLoader {
private metrics: Map<string, any> = new Map();

constructor(private moduleRef: ModuleRef) {}

async load(moduleClass: any) {
const moduleName = moduleClass.name;
const startTime = Date.now();

try {
const module = await this.moduleRef.load(moduleClass);
const duration = Date.now() - startTime;

this.recordMetric(moduleName, {
status: 'success',
duration,
timestamp: new Date(),
});

return module;
} catch (error) {
const duration = Date.now() - startTime;

this.recordMetric(moduleName, {
status: 'error',
duration,
error: error.message,
timestamp: new Date(),
});

throw error;
}
}

private recordMetric(moduleName: string, metric: any) {
const existing = this.metrics.get(moduleName) || [];
existing.push(metric);
this.metrics.set(moduleName, existing);
}

getMetrics() {
const result = {};
this.metrics.forEach((metrics, moduleName) => {
result[moduleName] = {
count: metrics.length,
lastLoaded: metrics[metrics.length - 1].timestamp,
averageDuration:
metrics.reduce((sum, m) => sum + m.duration, 0) / metrics.length,
};
});
return result;
}
}

Complete Example

// modules/analytics.module.ts
@Module({
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

@Injectable()
export class AnalyticsService {
getAnalytics() {
return { pageViews: 1000, users: 500 };
}
}

// modules/reports.module.ts
@Module({
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

@Injectable()
export class ReportsService {
generateReport() {
return { report: 'Monthly Report', data: [] };
}
}

// services/lazy-loader.service.ts
@Injectable()
export class LazyLoaderService {
private loadedModules: Map<string, any> = new Map();

constructor(private moduleRef: ModuleRef) {}

async loadModule(moduleClass: any) {
const moduleName = moduleClass.name;

if (this.loadedModules.has(moduleName)) {
console.log(`Module ${moduleName} already loaded`);
return this.loadedModules.get(moduleName);
}

console.log(`Loading module: ${moduleName}`);
const module = await this.moduleRef.load(moduleClass);
this.loadedModules.set(moduleName, module);

return module;
}

async getAnalytics() {
const module = await this.loadModule(AnalyticsModule);
const service = module.get(AnalyticsService);
return service.getAnalytics();
}

async generateReport() {
const module = await this.loadModule(ReportsModule);
const service = module.get(ReportsService);
return service.generateReport();
}

getLoadedModules() {
return Array.from(this.loadedModules.keys());
}
}

// controllers/app.controller.ts
@Controller()
export class AppController {
constructor(private lazyLoader: LazyLoaderService) {}

@Get('analytics')
async getAnalytics() {
return this.lazyLoader.getAnalytics();
}

@Get('report')
async getReport() {
return this.lazyLoader.generateReport();
}

@Get('modules/loaded')
getLoadedModules() {
return this.lazyLoader.getLoadedModules();
}
}

// app.module.ts
@Module({
controllers: [AppController],
providers: [LazyLoaderService],
})
export class AppModule {}

Performance Comparison

Với Lazy Loading:
- Startup time: ~200ms (chỉ load core modules)
- Initial memory: ~50MB

Không Lazy Loading:
- Startup time: ~2000ms (load tất cả modules)
- Initial memory: ~200MB

Savings: 90% startup time, 75% initial memory!

Kết Luận

Lazy Loading Modules là công cụ mạnh mẽ để:

  • Cải thiện startup time của ứng dụng
  • Giảm memory usage ban đầu
  • Load modules conditionally dựa trên requirements
  • Implement plugin systems một cách hiệu quả
  • Scale ứng dụng với nhiều features

Sử dụng Lazy Loading đúng cách giúp bạn:

  • Xây dựng highly performant applications
  • Manage large codebases hiệu quả
  • Implement feature flags
  • Build extensible plugin architectures
  • Optimize resource usage

Tuy nhiên, hãy cân bằng giữa lazy loading với eager loading - trong hầu hết trường hợp, eager load core modules và lazy load optional/heavy modules là best practice!