Guided Project 2: A Robust Configuration Management System with Injection-JS

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:

  1. Open your package.json and 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"
    },
    
  2. Now, compile and run:
    npm run config-project
    
    Observe how APP_SETTINGS changes 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:

  1. Add a JsonFileConfigSource:

    • Create a new class JsonFileConfigSource that implements PartialAppSettings.
    • Its constructor should take an InjectionToken<string> for CONFIG_FILE_PATH.
    • The JsonFileConfigSource should read and parse a JSON file from the provided path to return PartialAppSettings.
    • Modify main.ts to include this source, demonstrating how to load configuration from a file. (Remember to create a sample config.json file).
  2. Separate Feature Flag Management:

    • Currently, FeatureFlagConfig is a static class. Imagine feature flags are managed by a remote service.
    • Create an IFeatureFlagApi interface and an InjectionToken for it.
    • Implement RemoteFeatureFlagApi (simulating HTTP calls) and provide it.
    • Modify FeatureFlagConfig (or create a new source) to inject IFeatureFlagApi and use its results to populate feature flags. This adds a dependency to the config source itself.
  3. Validate Final Configuration:

    • Create a ConfigValidatorService.
    • After APP_SETTINGS is created by the factory, you might want to validate that all critical settings are present (e.g., databaseUrl is not empty in production).
    • Modify the appSettingsFactory to also inject ConfigValidatorService and run a validation check on the finalSettings before returning it. Throw an error if validation fails. This ensures a robust system start.

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.