Building Custom Providers
If the built-in providers don't cover your data source, you can build your own by extending ConfigurationProvider.
The Provider Contract
Every provider implements two methods:
public abstract class ConfigurationProvider<TProviderConfiguration, TProviderQuery>
where TProviderConfiguration : IProviderConfiguration
{
protected TProviderConfiguration ProviderOptions { get; }
// One-time fetch — called during recompute
public abstract Task<byte[]> FetchConfigurationBytesAsync(
TProviderQuery query, CancellationToken ct = default);
// Change stream — called once, returns ongoing notifications
public abstract IObservable<byte[]> ChangesAsBytes(TProviderQuery query);
}FetchConfigurationBytesAsync— returns the current configuration as UTF-8 JSON bytesChangesAsBytes— returns an observable that emits new bytes whenever the source changes
Both methods receive a query object. The split between provider options and query options is important:
| Level | Scope | Example |
|---|---|---|
TProviderConfiguration | Shared across rules | Base URL, directory path, connection string |
TProviderQuery | Per-rule | Specific URL path, filename, key prefix |
Example: Database Provider
A provider that reads configuration from a database table:
// Provider options — shared, one instance per connection string
public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration
{
public string GenerateProviderKey() => ConnectionString;
}
// Query options — per-rule
public record DatabaseProviderQuery(string ConfigKey) : IProviderQuery;public class DatabaseConfigProvider
: ConfigurationProvider<DatabaseProviderOptions, DatabaseProviderQuery>
{
public DatabaseConfigProvider(DatabaseProviderOptions options) : base(options) { }
public override async Task<byte[]> FetchConfigurationBytesAsync(
DatabaseProviderQuery query, CancellationToken ct = default)
{
await using var conn = new SqlConnection(ProviderOptions.ConnectionString);
await conn.OpenAsync(ct);
var json = await conn.QuerySingleOrDefaultAsync<string>(
"SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key",
new { Key = query.ConfigKey });
return json is not null
? Encoding.UTF8.GetBytes(json)
: "{}"u8.ToArray();
}
public override IObservable<byte[]> ChangesAsBytes(DatabaseProviderQuery query)
{
// No change detection — static until next recompute
return ProviderObservable.Never<byte[]>();
// Or: implement SqlDependency, polling, etc.
}
}Helper utilities
ProviderObservable and ProviderDisposable (in Cocoar.Configuration.Providers.Abstractions, the same namespace as ConfigurationProvider) are public helpers for building a provider's change stream without referencing System.Reactive: Never<T>(), Empty<T>(), Create<T>() for observables and Empty / Create(Action) for disposables.
Provider Key and Instance Caching
IProviderConfiguration.GenerateProviderKey() controls provider reuse:
- Return a string — providers with the same key share one instance. Use this when the provider manages a shared resource (file watcher, database connection, HTTP client).
- Return null — each rule gets its own provider instance. Use this for providers with no shared state.
public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration
{
// Same connection string = same provider instance
public string GenerateProviderKey() => ConnectionString;
}Registering via Fluent API
Create an extension method on TypedRuleBuilder<T>:
public static class DatabaseProviderRulesExtensions
{
public static ProviderRuleBuilder<DatabaseConfigProvider,
DatabaseProviderOptions, DatabaseProviderQuery>
FromDatabase<T>(this TypedRuleBuilder<T> builder,
string connectionString, string configKey)
{
return builder.FromProvider<T, DatabaseConfigProvider,
DatabaseProviderOptions, DatabaseProviderQuery>(
_ => new DatabaseProviderOptions(connectionString),
_ => new DatabaseProviderQuery(configKey));
}
}Now it works like any built-in provider:
rule.For<AppSettings>()
.FromDatabase("Server=localhost;Database=Config", "AppSettings")
.Required()
.Named("Database Config")Service-Backed Providers (DI-aware) ADV
The FromDatabase above takes a connection string and news its own SqlConnection — so it works at registration time, before the container exists. But what if your provider should use a DI-managed resource — an IDbContextFactory<T>, a Marten IDocumentStore, an IHttpClientFactory? Those don't exist when AddCocoarConfiguration runs. That's the two-layer / service-backed model: a custom provider opts into it, and whether it does is entirely your choice as the author.
A service-backed provider is its own provider — a separate class from the no-DI one above — and carries only what the DI path needs, often just the resolved service.
The framework builds your provider as new YourProvider(options), so what you resolve has to travel on the options (the provider's only input). The natural shape: resolve the DI-managed factory or store — a singleton like IDbConnectionFactory, IHttpClientFactory, or a Marten IDocumentStore — and pass it on the options as a plain value; the provider opens a short-lived unit per read from it.
// Options carry the resolved DI singleton (a connection factory).
public sealed record DbConfigOptions(IDbConnectionFactory Connections) : IProviderConfiguration
{
public string? GenerateProviderKey() => null; // carries a DI-resolved dependency → never share this provider
}
public sealed record DbConfigQuery(string Key) : IProviderQuery;
public sealed class DbConfigProvider(DbConfigOptions options)
: ConfigurationProvider<DbConfigOptions, DbConfigQuery>(options)
{
public override async Task<byte[]> FetchConfigurationBytesAsync(
DbConfigQuery query, CancellationToken ct = default)
{
await using var conn = ProviderOptions.Connections.Create(); // short-lived unit, opened per read
await conn.OpenAsync(ct);
var json = await conn.QuerySingleOrDefaultAsync<string>(
"SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key", new { Key = query.Key });
return json is not null ? Encoding.UTF8.GetBytes(json) : "{}"u8.ToArray();
}
public override IObservable<byte[]> ChangesAsBytes(DbConfigQuery query) => ProviderObservable.Never<byte[]>();
}The fluent overload uses the ServiceBacked(...) helper. The (sp, _) => … you pass is a factory, not an eager call — ServiceBacked invokes it later, at recompute time, so nothing is resolved when you author the rule. You're describing how to build the options, not building them now:
public static ProviderRuleBuilder<DbConfigProvider, DbConfigOptions, DbConfigQuery>
FromDatabase<T>(this ServiceBackedProviderBuilder<T> builder, string configKey) where T : class
=> builder.ServiceBacked<DbConfigProvider, DbConfigOptions, DbConfigQuery>(
(sp, _) => new DbConfigOptions(sp.GetRequiredService<IDbConnectionFactory>()), // runs at recompute, not here
_ => new DbConfigQuery(configKey));Nothing is resolved in the method body
FromDatabase returns immediately at registration — it just hands ServiceBacked the (sp, _) => … factory. sp.GetRequiredService<…>() runs only when the framework calls that factory during a recompute, after the host has started. (For per-read freshness, resolve a factory/store and call it inside the provider — Connections.Create() above — rather than resolving a live connection here.)
That's the whole pattern: a Layer-1 provider built against TypedRuleBuilder<T>, and a Layer-2 provider built against ServiceBackedProviderBuilder<T> — two small providers, one per layer. (If their inputs happen to overlap you can put both overloads on one class, but you rarely need to.)
Now the provider can pull a DI-managed resource — only inside UseServiceBackedConfiguration:
services.AddCocoarConfiguration(c => c
.UseConfiguration(rules => [ /* eager, no-DI bootstrap */ ])
.UseServiceBackedConfiguration(rules =>
[
rules.For<AppSettings>().FromDatabase("AppSettings"),
]));Type-safe, not stringly-gated
The Layer-2 overload targets ServiceBackedProviderBuilder<T>. Calling it inside the Layer-1 UseConfiguration (a plain TypedRuleBuilder<T>) is a compile error — the type system keeps DI-backed loading out of the eager layer. The whole seam (ServiceBackedProviderBuilder<T>.Context, ServiceBackedRuleContext, WithActivationGate) is public, so this needs no internals.
Lifetime discipline (ADR-006 §9): the IServiceProvider is the root — resolve singletons / factories (IDbContextFactory<T>, IDocumentStore, IHttpClientFactory) and open a short-lived unit per read (as the await using var conn above does). Never resolve a scoped service from root. Combine with .TenantScoped() for DB-config-per-tenant. See Service-Backed Configuration for the full lifecycle, readiness, and failure contracts.
Change Detection
For reactive providers, return an IObservable<byte[]> that emits when the source changes:
public override IObservable<byte[]> ChangesAsBytes(DatabaseProviderQuery query)
{
return ObservableHelpers.Create<byte[]>(observer =>
{
var cts = new CancellationTokenSource();
var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
Task.Run(async () =>
{
while (await timer.WaitForNextTickAsync(cts.Token))
{
var bytes = await FetchConfigurationBytesAsync(query, cts.Token);
observer.OnNext(bytes);
}
}, cts.Token);
return ProviderDisposable.Create(() =>
{
cts.Cancel();
timer.Dispose();
});
});
}The engine compares bytes by hash — if the content hasn't changed, no recompute is triggered. So it's safe to emit on every poll even when nothing changed.
Complete Example
Here is a full custom provider as a single, self-contained block you can copy and adapt:
// Complete DatabaseConfigProvider — copy and adapt
public record DatabaseProviderOptions(string ConnectionString) : IProviderConfiguration
{
public string? GenerateProviderKey() => ConnectionString;
}
public record DatabaseProviderQuery(string ConfigKey) : IProviderQuery;
public class DatabaseConfigProvider
: ConfigurationProvider<DatabaseProviderOptions, DatabaseProviderQuery>
{
public DatabaseConfigProvider(DatabaseProviderOptions options) : base(options) { }
public override async Task<byte[]> FetchConfigurationBytesAsync(
DatabaseProviderQuery query, CancellationToken ct = default)
{
await using var conn = new SqlConnection(ProviderOptions.ConnectionString);
await conn.OpenAsync(ct);
var json = await conn.QuerySingleOrDefaultAsync<string>(
"SELECT JsonValue FROM Configuration WHERE ConfigKey = @Key",
new { Key = query.ConfigKey });
return json is not null
? Encoding.UTF8.GetBytes(json)
: "{}"u8.ToArray(); // Return empty JSON on failure, never null
}
public override IObservable<byte[]> ChangesAsBytes(DatabaseProviderQuery query)
{
return ProviderObservable.Never<byte[]>(); // Or implement polling/notifications
}
}
// Extension method for fluent API
public static class DatabaseProviderExtensions
{
public static ProviderRuleBuilder<DatabaseConfigProvider,
DatabaseProviderOptions, DatabaseProviderQuery>
FromDatabase<T>(this TypedRuleBuilder<T> builder,
string connectionString, string configKey) where T : class
{
return builder.FromProvider<T, DatabaseConfigProvider,
DatabaseProviderOptions, DatabaseProviderQuery>(
_ => new DatabaseProviderOptions(connectionString),
_ => new DatabaseProviderQuery(configKey));
}
}Usage in rules:
rule.For<AppSettings>()
.FromDatabase("Server=localhost;Database=Config", "AppSettings")
.Required()
.Named("Database Config")Guidelines
- Always return
"{}"u8.ToArray()on failure, never null — this keeps optional rules working - Use
CancellationTokenthroughout — providers are cancelled during shutdown - Keep
FetchConfigurationBytesAsyncidempotent — it may be called multiple times - Use
GenerateProviderKey()to enable instance sharing when your provider holds expensive resources - The change observable should not error — if it does, the subscription is lost
Secrets in Custom Providers ADV
When your provider delivers secrets, emit them as encrypted envelopes. The engine stores envelopes as-is and decrypts on Secret<T>.Open().
X.509 Hybrid Envelope
The provider holds only the public certificate; the app holds the private key. The provider generates a random AES-256 DEK, encrypts content with AES-GCM, and wraps the DEK with RSA-OAEP-256:
{
"type": "cocoar.secret",
"version": 1,
"kid": "prod-secrets",
"alg": "RSA-OAEP-AES256-GCM",
"walg": "RSA-OAEP-256",
"iv": "<base64 — AES-GCM nonce>",
"ct": "<base64 — AES-GCM ciphertext>",
"tag": "<base64 — AES-GCM auth tag>",
"wk": "<base64 — wrapped DEK>"
}The kid links to the certificate registered via UseSecretsSetup(). The provider never sees the private key.
Rotation
Use a new kid for each key rotation. Apps register both old and new certificates during the overlap period via WithAdditionalKeyId(). New secrets use the new kid; old secrets remain decryptable until the old certificate is removed.