This document describes how the Modular framework's enhanced configuration system handles environment variables and manages feeder precedence.
The Modular framework uses a unified Environment Catalog system that combines environment variables from multiple sources:
- Operating System environment variables
.envfile variables- Dynamically set variables
This allows all environment-based feeders (EnvFeeder, AffixedEnvFeeder, InstanceAwareEnvFeeder, TenantAffixedEnvFeeder) to access variables from both OS environment and .env files with proper precedence handling.
The EnvCatalog provides:
- Unified Access: Single interface for all environment variables
- Source Tracking: Tracks whether variables come from OS env or .env files
- Precedence Management: OS environment always takes precedence over .env files
- Thread Safety: Concurrent access safe with mutex protection
- OS Environment Variables (highest precedence)
- .env File Variables (lower precedence)
When the same variable exists in both sources, the OS environment value is used.
- Single global catalog instance shared across all env-based feeders
- Initialized once and reused for performance
- Can be reset for testing scenarios
These feeders read from configuration files and populate structs directly:
- YamlFeeder: Reads YAML files, supports nested structures
- JSONFeeder: Reads JSON files, handles complex object hierarchies
- TomlFeeder: Reads TOML files, supports all TOML data types
- DotEnvFeeder: Special hybrid - loads .env into catalog AND populates structs
These feeders read from the unified Environment Catalog:
- EnvFeeder: Basic env var lookup using struct field
envtags - AffixedEnvFeeder: Adds prefix/suffix to env variable names
- InstanceAwareEnvFeeder: Handles instance-specific configurations
- TenantAffixedEnvFeeder: Combines tenant-aware and affixed behavior
The DotEnvFeeder has dual behavior:
- Catalog Population: Loads .env variables into the global catalog for other env feeders
- Direct Population: Populates config structs using catalog (respects OS env precedence)
This allows other env-based feeders to access .env variables while maintaining proper precedence.
All feeders support comprehensive field-level tracking that records:
- Field Path: Complete field path (e.g., "Database.Connections.primary.DSN")
- Field Type: Data type of the field
- Feeder Type: Which feeder populated the field
- Source Type: Source category (env, yaml, json, toml, dotenv)
- Source Key: The actual key used (e.g., "DB_PRIMARY_DSN")
- Value: The value that was set
- Search Keys: All keys that were searched
- Found Key: The key that was actually found
- Instance Info: For instance-aware feeders
tracker := NewDefaultFieldTracker()
feeder.SetFieldTracker(tracker)
// After feeding
populations := tracker.GetFieldPopulations()
for _, pop := range populations {
fmt.Printf("Field %s set to %v from %s\n",
pop.FieldPath, pop.Value, pop.SourceKey)
}All feeders now support explicit priority control via the WithPriority(n) method. This allows you to precisely control which configuration sources override others, solving common issues like test isolation.
Key Concepts:
- Higher priority values mean the feeder is applied later
- Later application means the feeder can override earlier feeders
- Default priority is 0 if not specified
- When priorities are equal, original order is preserved (stable sort)
Use Cases:
- Test Isolation: Set YAML test configs to higher priority than environment variables
- Production Overrides: Set environment variables to higher priority than config files
- Layered Configuration: Use priority levels (e.g., 10, 50, 100) to create clear precedence layers
When using multiple feeders, the typical order is:
- File-based feeders (YAML/JSON/TOML) - set base configuration
- DotEnvFeeder - load .env variables into catalog
- Environment-based feeders - override with env-specific values
With Priority Control:
- Use lower priorities (e.g., 50) for base/default configuration
- Use higher priorities (e.g., 100) for overrides
- Explicit priority values make configuration behavior predictable
With Priority Control (Recommended): Use .WithPriority(n) to explicitly control precedence
- Higher priority values are applied later, allowing them to override lower priority feeders
- Default priority is 0 if not specified
- When priorities are equal, original order is preserved
Without Priority (Legacy Behavior): Order of execution determines precedence
- Last feeder wins (overwrites previous values)
For environment variables: OS environment always beats .env files within the catalog system
config := modular.NewConfig()
// Base configuration from YAML (lower priority)
config.AddFeeder(feeders.NewYamlFeeder("config.yaml").WithPriority(50))
// Load .env into catalog for other env feeders (medium priority)
config.AddFeeder(feeders.NewDotEnvFeeder(".env").WithPriority(75))
// Environment-based overrides (highest priority)
config.AddFeeder(feeders.NewEnvFeeder().WithPriority(100))
config.AddFeeder(feeders.NewAffixedEnvFeeder("APP_", "_PROD").WithPriority(100))
// Feed the configuration
err := config.Feed(&appConfig)config := modular.NewConfig()
// Environment variables (lower priority - won't override test config)
config.AddFeeder(feeders.NewEnvFeeder().WithPriority(50))
// Test YAML configuration (higher priority - overrides environment)
config.AddFeeder(feeders.NewYamlFeeder("test-config.yaml").WithPriority(100))
// Feed the configuration
err := config.Feed(&appConfig)With Priority Control:
Lower Priority Values → Higher Priority Values
(e.g., priority 50) → (e.g., priority 100)
Without Priority (Legacy):
YAML values → DotEnv values → OS Env values → Affixed Env values
(base) → (if not in OS) → (override) → (final override)
Uses env tags directly: env:"DATABASE_URL"
Constructs: PREFIX + ENVTAG + SUFFIX
- Example with prefix
"PROD_", tag"HOST", suffix"_ENV":PROD_HOST_ENV - Users must include separators (like underscores) in their prefix/suffix
- Framework no longer automatically adds underscores between components
Constructs: MODULE_INSTANCE_FIELD
- Example:
DB_PRIMARY_DSN,DB_SECONDARY_DSN
Combines tenant ID with affixed pattern:
- Example with prefix function
tenantId + "_"and tag"CONFIG":TENANT123_CONFIG - Prefix/suffix functions must include any desired separators
- Preserves pre-configured prefix/suffix when used with tenant config loader
The system uses static error definitions to comply with linting rules:
var (
ErrDotEnvInvalidStructureType = errors.New("expected pointer to struct")
ErrJSONCannotConvert = errors.New("cannot convert value to field type")
// ... more specific errors
)Errors are wrapped with context using fmt.Errorf("%w: %s", baseError, context).
All feeders support verbose debug logging for troubleshooting:
feeder.SetVerboseDebug(true, logger)Debug output includes:
- Environment variable lookups and results
- Field processing steps
- Type conversion attempts
- Source tracking information
- Error details with context
- Use explicit priorities for predictable configuration behavior
- Use file-based feeders for base configuration (lower priority: 50)
- Use DotEnvFeeder to load .env files for local development (medium priority: 75)
- Use env-based feeders for deployment-specific overrides (higher priority: 100)
- Set up field tracking for debugging and audit trails
- Reserve priority ranges for different purposes:
- 0-50: Base/default configuration (files, defaults)
- 51-99: Environment-specific configuration (.env files)
- 100+: Runtime overrides (OS environment variables, command-line flags)
- For tests: Use higher priority for test configs to override host environment
- For production: Use higher priority for environment variables to override defaults
- Document your priority scheme in application documentation
- Use consistent naming patterns
- Document env var precedence in your application
- Test with both OS env and .env file scenarios
- Use verbose debugging during development
- Use priority control to make precedence explicit and testable
- Always check feeder errors during configuration loading
- Use field tracking to identify configuration sources
- Validate required fields after feeding
- Provide clear error messages for missing configuration
Problem: Environment variables from the host system can interfere with test configuration.
Solution: Use priority control to ensure test configs override environment variables.
func TestWithIsolation(t *testing.T) {
// Host environment may have SDK_KEY set
t.Setenv("SDK_KEY", "host-value")
// Create test YAML with explicit config
yamlPath := createTestYAML(t, `sdkKey: "test-value"`)
// Use higher priority for test config to override environment
config := modular.NewConfig()
config.AddFeeder(feeders.NewEnvFeeder().WithPriority(50)) // Lower priority
config.AddFeeder(feeders.NewYamlFeeder(yamlPath).WithPriority(100)) // Higher priority
config.AddStructKey("_main", &cfg)
config.Feed()
// Test gets explicit YAML value, not environment variable
assert.Equal(t, "test-value", cfg.SDKKey)
}// Reset catalog between tests if needed
feeders.ResetGlobalEnvCatalog()
// Use t.Setenv for test environment variables
t.Setenv("TEST_VAR", "test_value")Test various combinations of feeders to ensure proper precedence handling:
- Test with and without priority specified
- Test priority ordering with multiple feeders
- Test backward compatibility (no priority = original order)
Verify that field tracking correctly reports source information for debugging and audit purposes.