7. Guided Project 2: A Configuration Management System
This project will challenge you to build a comprehensive and flexible configuration management system using Injection-JS. This is a common requirement in most applications, where different environments (development, staging, production) need distinct settings. We’ll leverage advanced DI features like multi-providers, InjectionToken with interfaces, and factory providers.
Project Objective:
- Load configuration from various sources (e.g., default values, environment variables, feature flags).
- Provide a single, merged configuration object to services.
- Support feature toggles, allowing features to be enabled/disabled via configuration.
- Demonstrate environment-specific configuration overrides using Injection-JS.
Project Setup
We’ll continue working in our injection-js-tutorial project. Create a new sub-directory:
mkdir -p src/config-project
touch src/config-project/tokens.ts
touch src/config-project/config.interface.ts
touch src/config-project/config-sources.ts
touch src/config-project/config.service.ts
touch src/config-project/feature-flag.service.ts
touch src/config-project/main.ts # Entry point for this project
Step 1: Define Configuration Abstractions
First, let’s define the interface for our application’s configuration and an InjectionToken for configuration sources.
src/config-project/config.interface.ts:
// src/config-project/config.interface.ts
export interface AppSettings {
databaseUrl: string;
apiBaseUrl: string;
debugMode: boolean;
featureFlags: {
newDashboard: boolean;
betaAuth: boolean;
};
}
// This represents a partial configuration, as sources will provide parts of it
export type PartialAppSettings = Partial<AppSettings>;
src/config-project/tokens.ts:
// src/config-project/tokens.ts
import { InjectionToken } from 'injection-js';
import { PartialAppSettings, AppSettings } from './config.interface';
// Token for the fully resolved application settings
export const APP_SETTINGS = new InjectionToken<AppSettings>('APP_SETTINGS');
// Token for configuration sources (multi-provider)
// Each source will provide a PartialAppSettings
export const CONFIG_SOURCES = new InjectionToken<PartialAppSettings>('CONFIG_SOURCES');
Step 2: Create Configuration Sources
Configuration can come from many places. We’ll create three types of sources:
DefaultConfig: Provides base settings.EnvVarConfig: Overrides defaults based on environment variables.FeatureFlagConfig: Provides feature flag settings.
src/config-project/config-sources.ts:
// src/config-project/config-sources.ts
import { Injectable } from 'injection-js';
import { PartialAppSettings } from './config.interface';
// Default values - lowest precedence
@Injectable()
export class DefaultConfig implements PartialAppSettings {
databaseUrl = 'mongodb://localhost:27017/default_db';
apiBaseUrl = 'http://localhost:3000/api/v1';
debugMode = false;
featureFlags = {
newDashboard: false,
betaAuth: false
};
}
// Environment Variable Config - overrides defaults if present
// (Simulated with hardcoded values for demonstration, normally read from process.env)
@Injectable()
export class EnvVarConfig implements PartialAppSettings {
databaseUrl = process.env.DB_URL || undefined; // If DB_URL env var exists, use it
apiBaseUrl = process.env.API_URL || undefined;
debugMode = process.env.DEBUG_MODE === 'true' ? true : (process.env.DEBUG_MODE === 'false' ? false : undefined);
}
// Feature Flag specific config - can be managed separately
@Injectable()
export class FeatureFlagConfig implements PartialAppSettings {
// Imagine these flags come from a remote feature flag service or a specific file
featureFlags = {
newDashboard: true, // New dashboard is enabled by default for this source
betaAuth: false
};
}
Note: For EnvVarConfig, in a real Node.js application, process.env.VAR_NAME would be used. For demonstration, you might need to manually set process.env.DB_URL = 'your_test_db_url' before running, or use hardcoded values for testing.
Step 3: Implement the Configuration Service
This service will collect all CONFIG_SOURCES (using multi-providers) and merge them into a single, comprehensive AppSettings object. We’ll use a factory provider for APP_SETTINGS to perform this merge.
src/config-project/config.service.ts:
// src/config-project/config.service.ts
import { Injectable, Inject } from 'injection-js';
import { AppSettings, PartialAppSettings } from './config.interface';
import { CONFIG_SOURCES, APP_SETTINGS } from './tokens';
// Helper function to deep merge objects
// A simple shallow merge for this example. For production, consider a library.
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key as keyof Partial<T>];
const targetValue = target[key as keyof T];
if (sourceValue !== undefined && typeof sourceValue === 'object' && !Array.isArray(sourceValue) &&
typeof targetValue === 'object' && !Array.isArray(targetValue)) {
// Recurse for nested objects
(target as any)[key] = deepMerge(targetValue as object, sourceValue as object);
} else if (sourceValue !== undefined) {
// Otherwise, directly assign if sourceValue is not undefined
(target as any)[key] = sourceValue;
}
}
}
return target;
}
@Injectable()
export class ConfigService {
private mergedSettings: AppSettings;
constructor(@Inject(CONFIG_SOURCES) private sources: PartialAppSettings[]) {
// Initial merge with an empty base object for type safety
let finalSettings: AppSettings = {
databaseUrl: '', // Provide defaults for required properties
apiBaseUrl: '',
debugMode: false,
featureFlags: { newDashboard: false, betaAuth: false }
};
// Apply sources in order of precedence (last one wins for same keys)
// Here we assume sources are ordered from lowest to highest precedence
this.sources.forEach(source => {
finalSettings = deepMerge(finalSettings, source);
});
this.mergedSettings = finalSettings;
}
getSettings(): AppSettings {
return this.mergedSettings;
}
}
// Factory function for APP_SETTINGS
export function appSettingsFactory(configService: ConfigService): AppSettings {
return configService.getSettings();
}
Step 4: Create a Feature Flag Service
This service will simplify access to feature flags from the AppSettings.
src/config-project/feature-flag.service.ts:
// src/config-project/feature-flag.service.ts
import { Injectable, Inject } from 'injection-js';
import { AppSettings } from './config.interface';
import { APP_SETTINGS } from './tokens';
@Injectable()
export class FeatureFlagService {
constructor(@Inject(APP_SETTINGS) private settings: AppSettings) {}
isFeatureEnabled(flagName: keyof AppSettings['featureFlags']): boolean {
return this.settings.featureFlags[flagName] === true;
}
}
Step 5: Put It All Together (Main Application File)
This main.ts will demonstrate various configuration scenarios.
src/config-project/main.ts:
// src/config-project/main.ts
import "reflect-metadata"; // KEEP THIS AT THE TOP
import { ReflectiveInjector, Injectable, Inject, Optional } from 'injection-js';
import { APP_SETTINGS, CONFIG_SOURCES } from './tokens';
import { AppSettings, PartialAppSettings } from './config.interface';
import { DefaultConfig, EnvVarConfig, FeatureFlagConfig } from './config-sources';
import { ConfigService, appSettingsFactory } from './config.service';
import { FeatureFlagService } from './feature-flag.service';
// --- Example Services using the Configuration ---
// Re-using the logger from previous project for better visibility
// (Assuming you have src/logger-project/tokens.ts, logger.interface.ts, transports.ts, logger.service.ts, config.ts setup)
import { ILogger, APP_LOGGER, LogLevel, LOG_TRANSPORTS, LOGGER_CONFIG } from '../logger-project/tokens';
import { ConsoleTransport } from '../logger-project/transports';
import { AppLogger } from '../logger-project/logger.service';
@Injectable()
class MainApplicationService {
constructor(
@Inject(APP_SETTINGS) private appSettings: AppSettings,
private featureFlags: FeatureFlagService,
@Inject(APP_LOGGER) private logger: ILogger // Inject our flexible logger
) {
this.logger.info("MainApplicationService initialized.", "MainApp");
}
run(): void {
this.logger.info(`Application starting with API: ${this.appSettings.apiBaseUrl}, DB: ${this.appSettings.databaseUrl}`, "MainApp");
this.logger.info(`Debug Mode: ${this.appSettings.debugMode}`, "MainApp");
if (this.featureFlags.isFeatureEnabled('newDashboard')) {
this.logger.info("New Dashboard feature is ENABLED!", "MainApp");
} else {
this.logger.info("New Dashboard feature is disabled.", "MainApp");
}
if (this.featureFlags.isFeatureEnabled('betaAuth')) {
this.logger.warn("Beta Authentication is ENABLED - use with caution!", "MainApp");
} else {
this.logger.info("Beta Authentication is disabled.", "MainApp");
}
}
}
// --- Setup Injector Scenarios ---
// Common providers for the logger
const commonLoggerProviders = [
{ provide: LOGGER_CONFIG, useValue: { minimumLogLevel: LogLevel.DEBUG } },
{ provide: LOG_TRANSPORTS, useClass: ConsoleTransport, multi: true },
{ provide: APP_LOGGER, useClass: AppLogger },
];
// Scenario 1: Default Configuration + Feature Flag Overrides
console.log("\n--- Scenario 1: Default Config + Feature Flags ---");
// Order matters: DefaultConfig provides base, then FeatureFlagConfig can override
const providers1 = [
...commonLoggerProviders, // Add our logger
{ provide: CONFIG_SOURCES, useClass: DefaultConfig, multi: true },
{ provide: CONFIG_SOURCES, useClass: FeatureFlagConfig, multi: true }, // Overrides newDashboard to true
ConfigService,
{ provide: APP_SETTINGS, useFactory: appSettingsFactory, deps: [ConfigService] },
FeatureFlagService,
MainApplicationService
];
const injector1 = ReflectiveInjector.resolveAndCreate(providers1);
const appService1 = injector1.get(MainApplicationService);
appService1.run();
// Scenario 2: Environment Variables Override Defaults
console.log("\n--- Scenario 2: Environment Variables Override ---");
// Simulate setting environment variables
process.env.DB_URL = 'postgres://prod_db:5432';
process.env.API_URL = 'https://prod.api.example.com/v2';
process.env.DEBUG_MODE = 'false';
// Order matters: DefaultConfig provides base, then EnvVarConfig overrides
const providers2 = [
...commonLoggerProviders,
{ provide: LOGGER_CONFIG, useValue: { minimumLogLevel: LogLevel.INFO } }, // Different log level
{ provide: LOG_TRANSPORTS, useClass: ConsoleTransport, multi: true }, // Keep console for output
{ provide: APP_LOGGER, useClass: AppLogger },
{ provide: CONFIG_SOURCES, useClass: DefaultConfig, multi: true },
{ provide: CONFIG_SOURCES, useClass: EnvVarConfig, multi: true }, // Reads from process.env
ConfigService,
{ provide: APP_SETTINGS, useFactory: appSettingsFactory, deps: [ConfigService] },
FeatureFlagService,
MainApplicationService
];
const injector2 = ReflectiveInjector.resolveAndCreate(providers2);
const appService2 = injector2.get(MainApplicationService);
appService2.run();
// Clean up env vars (important for subsequent tests/runs)
delete process.env.DB_URL;
delete process.env.API_URL;
delete process.env.DEBUG_MODE;
// Scenario 3: Mixed Configuration and Specific Overrides in Child Injector
console.log("\n--- Scenario 3: Child Injector Override (Dev Specific) ---");
// Base providers for a "Production" setup
const baseProdProviders = [
...commonLoggerProviders,
{ provide: LOGGER_CONFIG, useValue: { minimumLogLevel: LogLevel.ERROR } }, // Production logs ERROR only
{ provide: LOG_TRANSPORTS, useClass: ConsoleTransport, multi: true }, // Console is primary in this base
{ provide: APP_LOGGER, useClass: AppLogger },
{ provide: CONFIG_SOURCES, useClass: DefaultConfig, multi: true },
{ provide: CONFIG_SOURCES, useClass: EnvVarConfig, multi: true }, // Env vars for prod
{ provide: CONFIG_SOURCES, useClass: FeatureFlagConfig, multi: true }, // Base feature flags
ConfigService,
{ provide: APP_SETTINGS, useFactory: appSettingsFactory, deps: [ConfigService] },
FeatureFlagService,
MainApplicationService
];
const productionRootInjector = ReflectiveInjector.resolveAndCreate(baseProdProviders);
// Now, create a child injector for a "Development session" that needs specific overrides
const devSessionOverrides: PartialAppSettings = {
apiBaseUrl: 'http://localhost:4200/dev-api', // Local dev API
debugMode: true,
featureFlags: { newDashboard: true, betaAuth: true } // Enable all features for dev
};
const devSessionProviders = [
// Provide a new CONFIG_SOURCES value that represents development overrides
{ provide: CONFIG_SOURCES, useValue: devSessionOverrides, multi: true },
// Also override the LoggerConfig to INFO for dev session
{ provide: LOGGER_CONFIG, useValue: { minimumLogLevel: LogLevel.INFO } },
// We need to re-provide ConfigService, APP_SETTINGS, FeatureFlagService, MainApplicationService
// because their factory functions/constructors depend on CONFIG_SOURCES and LOGGER_CONFIG,
// which are now updated in this child injector. If we didn't, they would get the parent's versions.
ConfigService,
{ provide: APP_SETTINGS, useFactory: appSettingsFactory, deps: [ConfigService] },
FeatureFlagService,
MainApplicationService
];
const devSessionInjector = ReflectiveInjector.resolveAndCreate(devSessionProviders, productionRootInjector);
const devAppService = devSessionInjector.get(MainApplicationService);
devAppService.run();
// Verify original production root is untouched
console.log("\n--- Verification: Production Root is Unchanged ---");
const prodAppService = productionRootInjector.get(MainApplicationService);
prodAppService.logger.error("This is an error from the production root (should be logged as ERROR only).", "ProdRoot");
prodAppService.run();
To run this project:
- Open your
package.jsonand add a new script:"scripts": { "start": "tsc && node dist/app.js", "logger-project": "tsc && node dist/logger-project/main.js", "config-project": "tsc && node dist/config-project/main.js", // Add this line "test": "echo \"Error: no test specified\" && exit 1" }, - Now, compile and run:Observe how
npm run config-projectAPP_SETTINGSchanges based on the providers, demonstrating configuration precedence and environment-specific settings.
Encouraging Independent Problem-Solving
You’ve built a powerful configuration system. Here are some challenges to extend it further:
Add a
JsonFileConfigSource:- Create a new class
JsonFileConfigSourcethat implementsPartialAppSettings. - Its constructor should take an
InjectionToken<string>forCONFIG_FILE_PATH. - The
JsonFileConfigSourceshould read and parse a JSON file from the provided path to returnPartialAppSettings. - Modify
main.tsto include this source, demonstrating how to load configuration from a file. (Remember to create a sampleconfig.jsonfile).
- Create a new class
Separate Feature Flag Management:
- Currently,
FeatureFlagConfigis a static class. Imagine feature flags are managed by a remote service. - Create an
IFeatureFlagApiinterface and anInjectionTokenfor it. - Implement
RemoteFeatureFlagApi(simulating HTTP calls) and provide it. - Modify
FeatureFlagConfig(or create a new source) to injectIFeatureFlagApiand use its results to populate feature flags. This adds a dependency to the config source itself.
- Currently,
Validate Final Configuration:
- Create a
ConfigValidatorService. - After
APP_SETTINGSis created by the factory, you might want to validate that all critical settings are present (e.g.,databaseUrlis not empty in production). - Modify the
appSettingsFactoryto also injectConfigValidatorServiceand run a validation check on thefinalSettingsbefore returning it. Throw an error if validation fails. This ensures a robust system start.
- Create a
These advanced projects push you to combine multiple Injection-JS features for realistic application development scenarios. By tackling these, you’ll gain confidence in building complex, maintainable, and highly configurable applications.