In Part 1, we introduced Cocoar.Configuration's core benefits: zero-ceremony DI, atomic multi-config updates, and reactive configuration. We saw how it eliminates the IOptions<T>
wrapper pattern and makes configuration a first-class, strongly-typed subsystem.
Today we're diving deep into one of its most powerful capabilities: config-aware conditional rules. This feature enables sophisticated multi-tenant patterns, dynamic configuration loading, and environment-specific behavior—all with type-safe, compile-time checked configuration dependencies.
🧩 Cocoar.Configuration’s dynamic behavior rests on a few key foundations — how it handles failures safely and how it performs atomic recompute of configuration changes.
Let’s look at these building blocks first.
Understanding Error Handling: Required vs Optional
Before exploring config-aware rules, it's important to understand how Cocoar.Configuration handles failures—this is the foundation for building resilient dynamic configurations.
Configuration errors are handled based on whether a rule is required or optional.
Required Rules: Fail-Fast and Transactional Rollback
During initialization (first recompute), required rules that fail will crash the app immediately:
rule.For<DatabaseConfig>()
.FromFile("database.json")
.Required() // ⚠️ App won't start if this fails
This is intentional—better to fail during deployment than start in an undefined state and risk data corruption or security issues.
At runtime, if a required rule fails during reload:
- Recompute transaction fails, error is logged
- App keeps using last known good configuration
- Health status → Unhealthy
- No reactive emissions (i.e.,
IReactiveConfig<T>
change notifications) occur (entire transaction rolled back) - Other rules don't commit (even if they "succeeded")
- Auto-recovers when source is fixed
Optional Rules: Resilient and Auto-Recovering
In contrast, optional rules favor uptime over strict consistency.
Optional rules never crash the app, whether at startup or runtime:
rule.For<CacheSettings>()
.FromFile("cache.json") // Optional by default
When an optional rule fails:
- Rule is skipped for that recompute
- App uses last known good value for that type (if none exists, that config type is unavailable)
- Health status → Degraded
- Rule status shows
Down
in health monitoring - Other rules in the same recompute can still succeed
- Reactive emissions occur for types that changed (skipped type doesn't emit)
- Auto-recovers when source is fixed
Example: If cache.json
becomes malformed at runtime:
-
CacheSettings
rule skipped - Previous
CacheSettings
still active - Health → Degraded
- App continues serving traffic
- Fix
cache.json
→ auto-reloads → Health → Healthy
No try/catch blocks or special error handling needed—resilience is automatic.
Understanding Atomic Recompute
Before diving into config-aware rules, let's understand how Cocoar.Configuration processes configuration changes and what this means for dynamic rules.
The Recompute Pipeline
When any configuration source changes (file modified, environment variable updated, HTTP poll returns new data), Cocoar.Configuration triggers an atomic recompute. The recomputation itself is global—even a single source change recomputes the entire configuration pipeline.
- Rules execute sequentially in the order you defined them
- Each rule sees a merged snapshot of all previous rules' results
- Last write wins - later rules override earlier ones for the same config type
- All changes commit atomically - ensuring consumers never observe partial updates
What The Accessor Sees
When using config-aware rules, the IConfigurationAccessor
provides access to the merged configuration snapshot from all earlier rules:
builder.Services.AddCocoarConfiguration(rule => [
rule.For<TenantSettings>().FromFile("tenant.json"), // Rule 1
rule.For<TenantSettings>().FromEnvironment("TENANT_"), // Rule 2 (overrides Rule 1)
rule.For<ApiSettings>().FromFile("api.json"), // Rule 3
rule.For<PremiumFeatures>() // Rule 4
.FromFile("premium.json")
.When(accessor =>
{
// Accessor sees the MERGED result of Rules 1-3
// TenantSettings = Rule 1 + Rule 2 overrides
// ApiSettings = Rule 3
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return tenant.Tier == "Premium";
}),
rule.For<BetaConfig>() // Rule 5
.FromHttpPolling(accessor =>
{
// Accessor sees the MERGED result of Rules 1-4
// Includes PremiumFeatures IF Rule 4 executed
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return new HttpPollingRuleOptions($"https://beta.example.com/{tenant.Id}");
})
]);
What each rule sees:
- Rule 1: Nothing (first rule)
-
Rule 2:
TenantSettings
from Rule 1 -
Rule 3:
TenantSettings
(merged Rules 1+2), noApiSettings
yet -
Rule 4:
TenantSettings
(merged Rules 1+2),ApiSettings
(Rule 3) -
Rule 5: Everything from Rules 1-4, including
PremiumFeatures
if Rule 4 executed
Key Implications
Layering works naturally:
rule.For<AppSettings>().FromFile("base.json"), // Base values
rule.For<AppSettings>().FromFile("prod.json"), // Production overrides
rule.For<AppSettings>().FromEnvironment("APP_"), // Final overrides
// Later rules see the fully merged AppSettings
rule.For<ApiClient>()
.FromHttpPolling(accessor => {
var app = accessor.GetRequiredConfig<AppSettings>(); // Merged result!
return new HttpPollingRuleOptions(app.ConfigServiceUrl);
})
Order matters:
// ❌ Wrong - ApiSettings not available yet
rule.For<DerivedConfig>()
.FromFile("derived.json")
.When(accessor => accessor.GetRequiredConfig<ApiSettings>().IsEnabled),
rule.For<ApiSettings>().FromFile("api.json"), // Too late!
// ✅ Correct - ApiSettings available to dependent rule
rule.For<ApiSettings>().FromFile("api.json"),
rule.For<DerivedConfig>()
.FromFile("derived.json")
.When(accessor => accessor.GetRequiredConfig<ApiSettings>().IsEnabled)
Atomic guarantee:
Recompute operates as a transaction: all rules execute and build a candidate configuration snapshot. Only when the entire pipeline succeeds does this candidate become the new active configuration.
If a required rule fails during recompute:
rule.For<TenantSettings>().FromFile("tenant.json").Required(), // Rule 1
rule.For<ApiSettings>().FromFile("api.json").Required(), // Rule 2
rule.For<CacheSettings>().FromFile("cache.json").Required(), // Rule 3 - FAILS!
- Rules 1-2 complete successfully
- Rule 3 fails (required)
- Entire transaction rolls back - candidate snapshot is discarded
- Consumers continue using the previous good configuration
- Health status becomes Unhealthy
-
No emissions are triggered through any
IReactiveConfig<T>
- even for types whose rules succeeded
The transaction failed, so nothing commits.
If an optional rule fails during recompute:
rule.For<TenantSettings>().FromFile("tenant.json").Required(), // Rule 1
rule.For<ApiSettings>().FromFile("api.json").Required(), // Rule 2
rule.For<CacheSettings>().FromFile("cache.json"), // Rule 3 - FAILS (optional)
rule.For<NotifySettings>().FromFile("notify.json"), // Rule 4
- Rules 1-2 complete successfully
- Rule 3 fails but is skipped (optional) - uses last good value
- Rule 4 continues and completes
- Transaction succeeds - candidate snapshot becomes active (Rules 1, 2, 4 applied)
- Consumers see the new configuration
- Health status becomes Degraded
-
Per-type emissions:
-
IReactiveConfig<TenantSettings>
emits if TenantSettings changed from old to new -IReactiveConfig<ApiSettings>
emits if ApiSettings changed -IReactiveConfig<NotifySettings>
emits if NotifySettings changed -IReactiveConfig<CacheSettings>
does not emit (still has last good value, no change)
The transaction succeeded, so types that changed emit.
Key Insight: Transactional Emissions
Emissions are:
- Per-type - only types that actually changed between old and new snapshots
- All-or-nothing at recompute level - if the transaction fails, zero emissions for any type
Subscribers never see partial updates or inconsistent states across related configurations. Either the entire recompute succeeds and relevant types emit, or nothing emits and everything stays at the last good state.
Config-Aware Rules: The Power Feature
One of Cocoar.Configuration's most powerful features is config-aware rules. These allow each rule to access configuration produced by earlier rules—enabling multi-tenant systems, environment-specific behavior, and feature flags without a single hardcoded if-statement.
This unlocks sophisticated scenarios for dynamic, context-aware configuration. Now that you understand how atomic recompute works, let's explore what you can do with it.
Two Complementary Capabilities
Config-aware rules expose two complementary mechanisms: control whether a rule runs, and customize how it runs.
1. Conditional Execution with .When()
Control whether a rule executes at all based on earlier configuration:
rule.For<PremiumFeatures>()
.FromFile("premium.json")
.When(accessor => {
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return tenant.Tier == "Premium";
})
If the condition returns false
, the rule is skipped entirely—the provider never runs, and the config type retains its last known value (or remains unavailable if never loaded).
2. Dynamic Provider Configuration
Configure how a provider behaves (URLs, paths, intervals) based on earlier configuration:
rule.For<RegionalApiConfig>()
.FromHttpPolling(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return new HttpPollingRuleOptions(
$"https://{tenant.Region}.api.example.com/config",
pollInterval: TimeSpan.FromMinutes(5)
);
})
The provider always executes, but its behavior is dynamically configured based on earlier config.
Combining Both
You can use both capabilities together for maximum flexibility:
rule.For<BetaFeatures>()
.FromHttpPolling(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
// Dynamic URL based on tenant
return new HttpPollingRuleOptions(
$"https://beta-config.example.com/{tenant.TenantId}",
pollInterval: TimeSpan.FromMinutes(1)
);
})
.When(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
// Only execute if tenant has beta access
return tenant.BetaAccess;
})
This rule:
- Only executes if
TenantSettings.BetaAccess
is true (.When()
) - If it executes, polls a tenant-specific URL (dynamic provider config)
The Accessor Pattern
Both techniques rely on IConfigurationAccessor
—a type-safe snapshot of all configuration built by earlier rules.
Two methods available:
// Get config or throw if not available
var tenant = accessor.GetRequiredConfig<TenantSettings>();
// Try get config, returns false if not available
if (accessor.TryGetConfig<TenantSettings>(out var tenant))
{
// Use tenant
}
Example usage in rules:
rule.For<PremiumFeatures>()
.FromFile("premium.json")
.When(accessor => {
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return tenant.Tier == "Premium";
})
⚠️ Common Pitfall: Calling
GetRequiredConfig<T>()
for a config type that hasn't been loaded yet will throw an exception.
✅ Always declare dependency rules before the rules that depend on them.
Full Example: Putting It All Together
public record TenantSettings(
string TenantId,
string Tier, // "Free", "Pro", "Enterprise"
string Region, // "us-east", "eu-west", etc.
bool BetaAccess);
public record RegionalApiConfig(
string Endpoint,
string ApiKey,
int TimeoutSeconds);
public record ProFeatures(
int MaxConcurrentUsers,
bool CustomBranding,
bool PrioritySupport);
public record EnterpriseFeatures(
bool DedicatedInfrastructure,
bool AdvancedAnalytics,
bool SLA99_9,
string DedicatedSupportEmail);
builder.Services.AddCocoarConfiguration(rule => [
// Base: Tenant identification (required)
// ⚠️ Must be first - all other rules depend on this
rule.For<TenantSettings>()
.FromFile("tenant.json")
.Required(),
// Regional API config based on tenant's region (dynamic provider config)
rule.For<RegionalApiConfig>()
.FromHttpPolling(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return new HttpPollingRuleOptions(
$"https://{tenant.Region}.config.example.com/api-config",
pollInterval: TimeSpan.FromMinutes(5)
);
})
.Required(),
// Pro features only for Pro+ tiers (conditional execution)
rule.For<ProFeatures>()
.FromFile("pro-features.json")
.When(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return tenant.Tier is "Pro" or "Enterprise";
}),
// Enterprise features only for Enterprise tier (conditional execution)
rule.For<EnterpriseFeatures>()
.FromFile("enterprise-features.json")
.When(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return tenant.Tier == "Enterprise";
}),
// Beta features (both dynamic config AND conditional execution)
rule.For<BetaFeatures>()
.FromHttpPolling(accessor =>
{
var tenant = accessor.GetRequiredConfig<TenantSettings>();
return new HttpPollingRuleOptions(
$"https://beta-config.example.com/{tenant.TenantId}",
pollInterval: TimeSpan.FromMinutes(1)
);
})
.When(accessor =>
accessor.GetRequiredConfig<TenantSettings>().BetaAccess)
]);
What happens:
- Free tier: Only
TenantSettings
andRegionalApiConfig
load - Pro tier: Additionally loads
ProFeatures
(conditionally) - Enterprise tier: Loads
ProFeatures
+EnterpriseFeatures
(conditionally) - Beta-enrolled tenants: Additionally poll beta config with tenant-specific URL (both dynamic config + conditional)
Key techniques demonstrated:
-
RegionalApiConfig
: Dynamic provider configuration (URL changes per region) -
ProFeatures
&EnterpriseFeatures
: Conditional execution (skip if tier doesn't match) -
BetaFeatures
: Both techniques combined (dynamic URL + conditional execution)
The configuration system automatically adapts per tenant—loading only what's needed, when it's needed, using tenant-specific endpoints.
What's Next
In Part 3, we'll explore production-ready patterns:
- Health monitoring - Complete observability for your configuration system
- Testing strategies - How to test complex configuration scenarios
- Performance tuning - Optimization tips for large-scale deployments
- Custom providers - Building your own configuration sources
- Integration patterns - Working with existing systems
Try It Today
dotnet add package Cocoar.Configuration
dotnet add package Cocoar.Configuration.AspNetCore
Explore the examples and full documentation.
Conclusion
Config-aware conditional rules transform configuration from static declarations into a dynamic, context-aware system. Combined with proper error handling, you get:
- Type-safe dependencies between configuration sources
- Dynamic adaptation based on tenant, environment, or module state
- Reduced resource usage by loading only what's needed
- Compile-time safety for configuration dependencies
This deep integration of configuration logic and business requirements—while maintaining type safety—is what makes Cocoar.Configuration uniquely powerful for complex, real-world applications.
How are you planning to use config-aware rules in your projects? Share in the comments!
Resources:
Top comments (0)