Loading content...
The simplest form of configuration is a flat collection of key-value pairs—environment variables, property files, or JSON objects with primitive values. While adequate for simple applications, this approach quickly becomes problematic as systems grow. Configuration becomes a stringly-typed mess where typos cause runtime crashes, units are ambiguous, and related settings scatter across dozens of unrelated keys.
Configuration objects elevate configuration to first-class design artifacts. By applying object-oriented principles—encapsulation, type safety, validation, and immutability—we transform configuration from a source of bugs into a robust foundation for application behavior.
This page explores how experienced engineers design configuration systems that are easy to use correctly and hard to use incorrectly.
By the end of this page, you will understand how to design configuration objects using immutability, builder patterns, and hierarchical organization. You'll learn to create configuration APIs that prevent misconfiguration through compile-time guarantees and runtime validation.
Before we explore solutions, let's understand why raw configuration—simple dictionaries or property maps—creates problems in production systems.
The Raw Configuration Anti-Pattern:
12345678910111213141516171819202122232425262728293031
// ❌ BAD: Raw configuration as string dictionarytype RawConfig = Record<string, string>; function loadConfig(): RawConfig { return { "database.host": process.env.DB_HOST ?? "localhost", "database.port": process.env.DB_PORT ?? "5432", "database.maxConnections": process.env.DB_MAX_CONN ?? "10", "cache.enabled": process.env.CACHE_ENABLED ?? "true", "cache.ttlSeconds": process.env.CACHE_TTL ?? "3600", "api.timeoutMs": process.env.API_TIMEOUT ?? "30000", // ... 50 more settings };} // Problems everywhere configuration is used:function createDatabaseConnection(config: RawConfig): Connection { const host = config["database.host"]; // Typo in key = undefined const port = parseInt(config["database.port"]); // NaN if invalid // Unit confusion: is timeout in ms or seconds? const timeout = parseInt(config["database.timeout"]); // Wrong key! // Boolean parsing: "false", "0", "" all behave differently const useSsl = config["database.ssl"] === "true"; // No compile-time checking of key names // No validation that values are in expected ranges // No documentation of what each key means // No type safety whatsoever}database.timeout related to database.connection.timeout?Raw configuration errors are insidious because they often work during development and fail only in production. The typo in a key name works fine locally (falling back to default) but crashes in production where the environment variable is actually set under the correct name.
The first step toward robust configuration is type safety. Instead of string dictionaries, define explicit interfaces or classes that capture the exact shape of your configuration.
From strings to types:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ✅ GOOD: Strongly typed configurationinterface DatabaseConfig { readonly host: string; readonly port: number; readonly database: string; readonly username: string; readonly password: string; readonly maxConnections: number; readonly connectionTimeoutMs: number; readonly queryTimeoutMs: number; readonly useSsl: boolean; readonly sslCertPath?: string; // Optional} interface CacheConfig { readonly enabled: boolean; readonly host: string; readonly port: number; readonly ttlSeconds: number; readonly maxEntriesInMemory: number; readonly evictionPolicy: "LRU" | "LFU" | "FIFO";} interface ApiConfig { readonly baseUrl: string; readonly timeoutMs: number; readonly retryCount: number; readonly retryDelayMs: number; readonly maxConcurrentRequests: number;} interface AppConfig { readonly database: DatabaseConfig; readonly cache: CacheConfig; readonly api: ApiConfig; readonly features: FeatureFlagsConfig;} // Usage - completely type-safefunction createDatabaseConnection(config: DatabaseConfig): Connection { // Compiler enforces correct access return new Connection({ host: config.host, // Can't typo this port: config.port, // Already a number timeout: config.connectionTimeoutMs, // Units clear from name ssl: config.useSsl, // Already a boolean });}Benefits of typed configuration:
timeoutMs and ttlSeconds prevent unit confusion.Configuration should be immutable once loaded. If configuration can change during application runtime, debugging becomes extremely difficult—you can't reproduce issues because the state that caused them no longer exists.
Why immutability matters:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// Pattern 1: TypeScript readonly with interfaceinterface DatabaseConfig { readonly host: string; readonly port: number; readonly maxConnections: number;} // Pattern 2: Frozen object (runtime immutability)function createDatabaseConfig(input: DatabaseConfigInput): DatabaseConfig { const config: DatabaseConfig = { host: input.host, port: input.port, maxConnections: input.maxConnections ?? 10, }; return Object.freeze(config); // Runtime enforcement} // Pattern 3: Deep freeze for nested structuresfunction deepFreeze<T extends object>(obj: T): Readonly<T> { const propNames = Object.getOwnPropertyNames(obj); for (const name of propNames) { const value = (obj as any)[name]; if (value && typeof value === "object") { deepFreeze(value); } } return Object.freeze(obj);} // Pattern 4: Immutable class with private constructorclass AppConfig { private constructor( readonly database: DatabaseConfig, readonly cache: CacheConfig, readonly api: ApiConfig, ) {} static create(input: AppConfigInput): AppConfig { // Validate during creation const database = validateDatabaseConfig(input.database); const cache = validateCacheConfig(input.cache); const api = validateApiConfig(input.api); return new AppConfig(database, cache, api); } // If you need to change config, create a new instance withDatabase(database: DatabaseConfig): AppConfig { return new AppConfig(database, this.cache, this.api); }}If your application needs to support configuration hot-reload, don't mutate the config object. Instead, create a new immutable config object and swap the reference atomically. Components that need the new config request it; components holding the old reference continue using consistent old values.
Real applications have dozens or hundreds of configuration settings. A flat structure becomes unmanageable. Hierarchical configuration organizes settings by concern, creating a tree structure that mirrors application architecture.
Designing configuration hierarchies:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Level 1: Root application configurationinterface AppConfig { readonly name: string; readonly version: string; readonly environment: Environment; // Sub-configurations organized by concern readonly server: ServerConfig; readonly database: DatabaseConfig; readonly messaging: MessagingConfig; readonly observability: ObservabilityConfig; readonly security: SecurityConfig;} // Level 2: Server configuration hierarchyinterface ServerConfig { readonly http: HttpServerConfig; readonly grpc: GrpcServerConfig;} interface HttpServerConfig { readonly port: number; readonly host: string; readonly tls: TlsConfig | null; readonly cors: CorsConfig; readonly compression: CompressionConfig; readonly rateLimiting: RateLimitConfig;} // Level 3: Detailed sub-configurationinterface TlsConfig { readonly enabled: boolean; readonly certPath: string; readonly keyPath: string; readonly minVersion: "TLS1.2" | "TLS1.3"; readonly cipherSuites: readonly string[];} interface RateLimitConfig { readonly enabled: boolean; readonly requestsPerMinute: number; readonly burstSize: number; readonly keyExtractor: "ip" | "user" | "api-key";} // Level 2: Database configuration with multiple backendsinterface DatabaseConfig { readonly primary: PostgresConfig; readonly readonly: PostgresConfig[]; // Read replicas readonly migrations: MigrationConfig; readonly monitoring: DbMonitoringConfig;} interface PostgresConfig { readonly host: string; readonly port: number; readonly database: string; readonly credentials: CredentialsRef; // Reference to secrets readonly pool: ConnectionPoolConfig;} interface ConnectionPoolConfig { readonly minConnections: number; readonly maxConnections: number; readonly acquireTimeoutMs: number; readonly idleTimeoutMs: number; readonly connectionLifetimeMs: number;}Benefits of hierarchical organization:
config.database.pool.maxConnections is self-explanatory.Avoid both extremes: a flat structure with 200 top-level properties is unnavigable, but 10 levels of nesting creates verbose access patterns. A depth of 3-4 levels is usually the sweet spot for configuration hierarchies.
Complex configuration objects with many properties, defaults, and validation rules benefit from the Builder Pattern. Builders provide fluent APIs for constructing configuration, with validation at build time.
Why builders for configuration:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
// Immutable configuration classclass DatabaseConfig { private constructor( readonly host: string, readonly port: number, readonly database: string, readonly username: string, readonly password: string, readonly poolSize: number, readonly connectionTimeoutMs: number, readonly queryTimeoutMs: number, readonly useSsl: boolean, readonly sslCertPath: string | null, ) {} // Builder is the only way to create instances static builder(): DatabaseConfigBuilder { return new DatabaseConfigBuilder(); }} class DatabaseConfigBuilder { // All fields with sensible defaults private host: string = "localhost"; private port: number = 5432; private database?: string; private username?: string; private password?: string; private poolSize: number = 10; private connectionTimeoutMs: number = 30_000; private queryTimeoutMs: number = 60_000; private useSsl: boolean = false; private sslCertPath: string | null = null; // Fluent setters withHost(host: string): this { this.host = host; return this; } withPort(port: number): this { this.port = port; return this; } withDatabase(database: string): this { this.database = database; return this; } withCredentials(username: string, password: string): this { this.username = username; this.password = password; return this; } withPoolSize(size: number): this { this.poolSize = size; return this; } withTimeouts(connectionMs: number, queryMs: number): this { this.connectionTimeoutMs = connectionMs; this.queryTimeoutMs = queryMs; return this; } withSsl(certPath: string): this { this.useSsl = true; this.sslCertPath = certPath; return this; } // Validation and construction build(): DatabaseConfig { // Validate required fields if (!this.database) { throw new ConfigurationError("Database name is required"); } if (!this.username || !this.password) { throw new ConfigurationError("Database credentials are required"); } // Validate ranges if (this.port < 1 || this.port > 65535) { throw new ConfigurationError(`Invalid port: ${this.port}`); } if (this.poolSize < 1 || this.poolSize > 100) { throw new ConfigurationError(`Pool size must be 1-100: ${this.poolSize}`); } // Validate consistency if (this.useSsl && !this.sslCertPath) { throw new ConfigurationError("SSL enabled but no certificate path"); } // Create immutable config return new DatabaseConfig( this.host, this.port, this.database, this.username, this.password, this.poolSize, this.connectionTimeoutMs, this.queryTimeoutMs, this.useSsl, this.sslCertPath, ); }} // Usage - clear, fluent, validatedconst config = DatabaseConfig.builder() .withHost("db.production.internal") .withPort(5432) .withDatabase("myapp") .withCredentials("app_user", secretsManager.get("db_password")) .withPoolSize(20) .withTimeouts(10_000, 30_000) .withSsl("/etc/ssl/certs/db.pem") .build(); // Validates everything, returns immutable configSensible defaults are crucial for usable configuration. They allow applications to start with minimal configuration while providing full control for those who need it. But defaults require careful design.
Principles for configuration defaults:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Pattern 1: Layered defaults with TypeScript spreadconst DEFAULT_DATABASE_CONFIG: Partial<DatabaseConfig> = { port: 5432, poolSize: 10, connectionTimeoutMs: 30_000, queryTimeoutMs: 60_000, useSsl: true, // Safe default}; function createDatabaseConfig(input: DatabaseConfigInput): DatabaseConfig { return { ...DEFAULT_DATABASE_CONFIG, ...input, } as DatabaseConfig;} // Pattern 2: Development vs Production defaultsinterface ConfigDefaults { database: Partial<DatabaseConfig>; server: Partial<ServerConfig>;} const DEVELOPMENT_DEFAULTS: ConfigDefaults = { database: { host: "localhost", port: 5432, poolSize: 5, useSsl: false, // Simpler for local dev }, server: { port: 3000, logLevel: "debug", },}; const PRODUCTION_DEFAULTS: ConfigDefaults = { database: { port: 5432, poolSize: 20, useSsl: true, }, server: { port: 8080, logLevel: "info", },}; function getDefaults(environment: Environment): ConfigDefaults { return environment === "production" ? PRODUCTION_DEFAULTS : DEVELOPMENT_DEFAULTS;} // Pattern 3: Required vs Optional with explicit markersinterface HttpClientConfig { // Required - no default, must be provided baseUrl: string; // Optional with defaults timeoutMs?: number; // Default: 30000 retryCount?: number; // Default: 3 retryDelayMs?: number; // Default: 1000 maxConcurrent?: number; // Default: 10 // Optional, differs by environment tlsVerify?: boolean; // Default: true in prod, false in dev} function createHttpClient(input: HttpClientConfig): HttpClient { const config = { baseUrl: input.baseUrl, // Required, no default timeoutMs: input.timeoutMs ?? 30_000, retryCount: input.retryCount ?? 3, retryDelayMs: input.retryDelayMs ?? 1000, maxConcurrent: input.maxConcurrent ?? 10, tlsVerify: input.tlsVerify ?? (isProduction() ? true : false), }; return new HttpClient(config);}Never default security-sensitive settings to insecure values. Default to sslEnabled: true, not false. Default to finite timeouts, not infinite. Default to minimal permissions, not admin access. Make the secure path the easy path.
Real configuration systems compose configuration from multiple sources: defaults, configuration files, environment variables, command-line arguments, and secret managers. The order of precedence and merging strategy must be explicit and predictable.
Standard precedence (lowest to highest priority):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Configuration sources in order of precedence (lowest to highest)enum ConfigSource { Defaults = 1, // Built-in defaults ConfigFile = 2, // config.yaml, config.json EnvironmentFile = 3, // .env file (dotenv) Environment = 4, // OS environment variables CommandLine = 5, // CLI arguments Runtime = 6, // Hot-reload/API updates (if supported)} // Configuration loader that merges sourcesclass ConfigurationLoader { private sources: ConfigurationSource[] = []; addSource(source: ConfigurationSource, priority: ConfigSource): this { this.sources.push({ source, priority }); return this; } async load(): Promise<AppConfig> { // Sort by priority const sorted = this.sources.sort((a, b) => a.priority - b.priority); // Merge in order (later sources override earlier) let merged: Partial<AppConfig> = {}; for (const { source } of sorted) { const config = await source.load(); merged = this.deepMerge(merged, config); } // Validate final configuration return this.validateAndCreate(merged); } private deepMerge(target: any, source: any): any { // Arrays are replaced, not concatenated if (Array.isArray(source)) return source; // Objects are recursively merged if (typeof source === "object" && source !== null) { const result = { ...target }; for (const key of Object.keys(source)) { result[key] = this.deepMerge(target[key], source[key]); } return result; } // Primitives are replaced return source; }} // Usage exampleconst config = await new ConfigurationLoader() .addSource(new DefaultsSource(), ConfigSource.Defaults) .addSource(new YamlFileSource("config/app.yaml"), ConfigSource.ConfigFile) .addSource(new EnvFileSource(".env"), ConfigSource.EnvironmentFile) .addSource(new EnvironmentVariableSource(), ConfigSource.Environment) .addSource(new CommandLineSource(process.argv), ConfigSource.CommandLine) .load(); // Environment variables typically use naming convention:// APP_DATABASE_HOST -> config.database.host// APP_DATABASE_PORT -> config.database.port// APP_SERVER_HTTP_PORT -> config.server.http.port| Source | When to Use | Example |
|---|---|---|
| Defaults | Built-in reasonable values | Timeout = 30s, Port = 8080 |
| Config File | Complex structured configuration | Full YAML/JSON with comments |
| Env File | Local development secrets | .env with local DB password |
| Environment | Container/cloud deployment | Kubernetes ConfigMaps/Secrets |
| Command Line | Per-execution overrides | --port=9000 --log-level=debug |
Once you have a validated configuration object, how do you get it to the components that need it? The pattern you use affects testability, modularity, and coupling.
Anti-Pattern: Global Configuration
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ❌ BAD: Global singleton configurationclass Config { private static instance: AppConfig; static get(): AppConfig { if (!Config.instance) { Config.instance = loadConfig(); // Hidden side effect } return Config.instance; }} // Components access config globallyclass UserRepository { async findById(id: string): Promise<User> { // Tight coupling to global singleton const timeout = Config.get().database.queryTimeoutMs; return this.db.query("...", { timeout }); }} // ✅ GOOD: Dependency injection of configurationclass UserRepository { constructor(private readonly dbConfig: DatabaseConfig) {} async findById(id: string): Promise<User> { return this.db.query("...", { timeout: this.dbConfig.queryTimeoutMs }); }} // Wire at composition rootconst config = await loadConfig();const userRepo = new UserRepository(config.database); // ✅ BETTER: Inject only what's neededinterface UserRepositoryConfig { readonly queryTimeoutMs: number; readonly maxResults: number;} class UserRepository { constructor(private readonly config: UserRepositoryConfig) {} // ...} // Extract from larger configconst userRepo = new UserRepository({ queryTimeoutMs: config.database.queryTimeoutMs, maxResults: config.app.defaultPageSize,});Benefits of injecting specific configuration:
Well-designed configuration objects transform configuration from a source of bugs into a robust foundation. By applying object-oriented principles—types, immutability, building, and composition—we create configuration systems that are reliable and maintainable.
What's next:
Configuration often needs to vary across deployment environments—development, staging, production. The next page explores environment-based configuration strategies, from simple environment detection to sophisticated profiles and feature flags that vary by environment.
You now understand how to design robust configuration objects using types, immutability, builders, and composition. These patterns transform configuration from a liability into a strength of your system design.