DEV Community

Cover image for Config-Aware Rules in .NET — The Power Feature of Cocoar.Configuration (Part 2)
bwi
bwi

Posted on

Config-Aware Rules in .NET — The Power Feature of Cocoar.Configuration (Part 2)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. CacheSettings rule skipped
  2. Previous CacheSettings still active
  3. Health → Degraded
  4. App continues serving traffic
  5. 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.

  1. Rules execute sequentially in the order you defined them
  2. Each rule sees a merged snapshot of all previous rules' results
  3. Last write wins - later rules override earlier ones for the same config type
  4. 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}");
        })
]);
Enter fullscreen mode Exit fullscreen mode

What each rule sees:

  • Rule 1: Nothing (first rule)
  • Rule 2: TenantSettings from Rule 1
  • Rule 3: TenantSettings (merged Rules 1+2), no ApiSettings 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);
    })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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";
    })
Enter fullscreen mode Exit fullscreen mode

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)
        );
    })
Enter fullscreen mode Exit fullscreen mode

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;
    })
Enter fullscreen mode Exit fullscreen mode

This rule:

  1. Only executes if TenantSettings.BetaAccess is true (.When())
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

Example usage in rules:

rule.For<PremiumFeatures>()
    .FromFile("premium.json")
    .When(accessor => {
        var tenant = accessor.GetRequiredConfig<TenantSettings>();
        return tenant.Tier == "Premium";
    })
Enter fullscreen mode Exit fullscreen mode

⚠️ 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)
]);
Enter fullscreen mode Exit fullscreen mode

What happens:

  • Free tier: Only TenantSettings and RegionalApiConfig 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
Enter fullscreen mode Exit fullscreen mode

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)