Configuration is everywhere in production systems, yet many teams still scatter settings across environment variables, hardcoded defaults, and config files without structure or validation. Options and configuration patterns solve this by binding configuration to typed classes, validating at startup, and injecting options into components—turning loose strings into strongly-typed, testable contracts that catch errors before your service even starts.
Over my years building e-commerce platforms and SaaS services, I've watched teams burn hours chasing "why is this feature disabled in production?" only to discover a typo in an env var or a missing default. This article walks you through the patterns I use now to make configuration reliable, debuggable, and team-friendly.
Typed options — Bind config sections to classes or schemas
The first principle is simple: stop treating configuration as a bag of strings. Instead, define a class or schema that represents your configuration shape, then bind environment variables or config files to that type. This gives you compile-time safety and IDE autocomplete.
In a .NET project, this looks like the Options pattern with IOptions<T>:
// Define your configuration shape
public class DatabaseOptions
{
public string ConnectionString { get; set; }
public int CommandTimeout { get; set; }
public int MaxPoolSize { get; set; }
}
// In Startup or Program.cs
services
.Configure<DatabaseOptions>(configuration.GetSection("Database"))
.AddScoped<IUserRepository>(sp =>
new UserRepository(sp.GetRequiredService<IOptions<DatabaseOptions>>().Value)
);
In Node.js with NestJS, I use class-validator and plain TypeScript classes:
import { IsString, IsNumber, Min, Max } from 'class-validator';
import { plainToClass } from 'class-transformer';
export class DatabaseConfig {
@IsString()
connectionString: string;
@IsNumber()
@Min(1)
@Max(300)
commandTimeout: number;
@IsNumber()
@Min(1)
@Max(100)
maxPoolSize: number;
}
export function loadDatabaseConfig(): DatabaseConfig {
const raw = {
connectionString: process.env.DATABASE_URL,
commandTimeout: parseInt(process.env.DB_COMMAND_TIMEOUT || '30', 10),
maxPoolSize: parseInt(process.env.DB_MAX_POOL_SIZE || '20', 10),
};
return plainToClass(DatabaseConfig, raw);
}
Why this matters: When you bind config to a type, you're documenting what your service actually needs. A new team member reads the class and knows exactly which environment variables matter. No hunting through ten files for "what was that cache key again?"
Validate at startup — Fail fast on missing values
The worst configuration bugs are the silent ones. A service starts up, but a feature silently fails because a required setting is missing. Catch this during boot, not at 3 AM when a customer reports a broken flow.
I always validate the entire configuration object before the application accepts traffic. In .NET, I use data annotations:
public class CacheOptions
{
[Required(ErrorMessage = "Redis connection string is required")]
public string RedisConnectionString { get; set; }
[Range(1, 3600, ErrorMessage = "TTL must be between 1 and 3600 seconds")]
public int DefaultTtlSeconds { get; set; }
[RegularExpression(@"^[a-z0-9:_-]+$", ErrorMessage = "Cache key prefix must be lowercase alphanumeric")]
public string KeyPrefix { get; set; }
}
// Register with validation
services
.Configure<CacheOptions>(configuration.GetSection("Cache"))
.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<CacheOptions>>().Value;
var context = new ValidationContext(opts);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(opts, context, results, validateAllProperties: true))
{
var errors = string.Join("; ", results.Select(r => r.ErrorMessage));
throw new InvalidOperationException($"Invalid cache configuration: {errors}");
}
return opts;
});
In NestJS, I validate early in the app bootstrap:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Load and validate all config at startup
const dbConfig = loadDatabaseConfig();
const cacheConfig = loadCacheConfig();
const authConfig = loadAuthConfig();
const errors = [];
errors.push(...(await validate(dbConfig)));
errors.push(...(await validate(cacheConfig)));
errors.push(...(await validate(authConfig)));
if (errors.length > 0) {
const errorMessages = errors
.map(e => `${e.property}: ${Object.values(e.constraints || {}).join(', ')}`)
.join('\n');
console.error(`Configuration validation failed:\n${errorMessages}`);
process.exit(1);
}
await app.listen(3000);
}
This approach means your logs immediately show what went wrong and why, instead of a cryptic null reference error three requests in.
Inject, don't read globals — Components receive their options
Once your configuration is typed and validated, inject it into the components that need it. Avoid static helpers or singletons that read global config. Dependency injection makes your code testable, flexible, and explicit about dependencies.
Bad approach—components reach for global config:
export class PaymentService {
processPayment(orderId: string, amount: number) {
const apiKey = process.env.STRIPE_API_KEY; // ❌ Hidden dependency
const timeoutMs = process.env.PAYMENT_TIMEOUT_MS;
// ... use stripe
}
}
Good approach—inject typed options:
export class PaymentConfig {
@IsString()
stripeApiKey: string;
@IsNumber()
@Min(1000)
paymentTimeoutMs: number;
}
@Injectable()
export class PaymentService {
constructor(private readonly config: PaymentConfig) {}
processPayment(orderId: string, amount: number) {
const stripe = new Stripe(this.config.stripeApiKey);
// ... use config.paymentTimeoutMs
}
}
// In module setup
@Module({
providers: [
{
provide: PaymentConfig,
useValue: loadPaymentConfig(),
},
PaymentService,
],
})
export class PaymentModule {}
Now when you test PaymentService, you pass a mock PaymentConfig. You can test with different timeout values or API keys without touching environment variables. Your code is clear: "this service needs these three settings."
Defaults in one place — Document defaults next to the options type
Defaults matter. Whether a value comes from an environment variable or a hardcoded default, it should live in one place and be documented. I keep defaults as part of the class definition or in a constants file tied to that class.
Pattern 1: Defaults in the class with JSDoc:
export class LoggingConfig {
/**
* Log level for the application.
* @default "info"
*/
@IsEnum(['debug', 'info', 'warn', 'error'])
level: string = 'info';
/**
* Maximum number of log entries before rotation.
* @default 10000
*/
@IsNumber()
@Min(100)
maxEntriesPerFile: number = 10000;
/**
* Whether to include stack traces in error logs.
* @default true
*/
@IsBoolean()
includeStackTrace: boolean = true;
}
export function loadLoggingConfig(): LoggingConfig {
const raw = {
level: process.env.LOG_LEVEL || 'info',
maxEntriesPerFile: parseInt(process.env.LOG_MAX_ENTRIES || '10000', 10),
includeStackTrace: process.env.LOG_INCLUDE_STACK !== 'false',
};
return plainToClass(LoggingConfig, raw);
}
Pattern 2: Separate constants file for complex defaults:
export const CACHE_DEFAULTS = {
ttlSeconds: 3600,
maxSize: 1000,
evictionPolicy: 'lru' as const,
keyPrefix: 'app:',
} as const;
export class CacheConfig {
@IsNumber()
@Min(1)
ttlSeconds: number = CACHE_DEFAULTS.ttlSeconds;
@IsNumber()
@Min(1)
maxSize: number = CACHE_DEFAULTS.maxSize;
@IsEnum(['lru', 'lfu', 'fifo'])
evictionPolicy: 'lru' | 'lfu' | 'fifo' = CACHE_DEFAULTS.evictionPolicy;
@IsString()
keyPrefix: string = CACHE_DEFAULTS.keyPrefix;
}
This pattern makes it trivial to see "what's the actual default for this setting?" and makes it easy to tweak defaults globally for all environments.
Common mistakes
Validating only in one environment: Many teams validate strictly in production but skip it in local or staging. This means broken config ships to production anyway. Validate everywhere, every time.
Storing secrets in typed config classes: If your CacheConfig class exposes a Redis password, that password gets logged or shown in error messages. For secrets, use a separate SecretConfig that's never logged, or use a dedicated secrets manager (AWS Secrets Manager, HashiCorp Vault). Keep your typed options for non-sensitive configuration.
Circular dependencies through global config: If module A imports config, then exports a service that module B uses, then module B tries to import the same config, you can deadlock the DI container. Keep config at module boundaries and pass it through dependency injection, not through shared imports.
Forgetting to document which env vars map to which options: A README that says "set DATABASE_URL" is better than nothing, but a typed config class with JSDoc comments is bulletproof. Make it discoverable.
Key takeaways
- Define a typed class or schema for each major configuration section (database, cache, auth, logging). This makes configuration self-documenting.
- Validate all configuration at startup before the service accepts requests. Fail fast with clear error messages that show exactly what's wrong and why.
- Inject configuration into components via dependency injection, never read global config. This makes tests simpler and dependencies explicit.
- Keep defaults in one place alongside your options type, documented with JSDoc or a constants file. This prevents defaults from drifting across codebases.
- Separate secrets from configuration types. Typed options are great for logging and documentation; secrets belong in a secrets manager.
- Test configuration loading independently. Write tests that load config from a fake environment, validate it, and verify the output matches your expectations.






Comments
Loading comments…