Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
-assemblyfilters:"+PatternKit*;-*Tests*" \
-filefilters:"-**/*.Tests/*;-**/*Tests*/**"
echo "COVERAGE_SUMMARY<<EOF" >> $GITHUB_ENV
cat coverage-report/Summary.txt >> $GITHUB_ENV
sed -n '1,60p' coverage-report/Summary.txt >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV

- name: Upload coverage HTML report
Expand Down
10 changes: 10 additions & 0 deletions docs/examples/tenant-external-configuration-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Tenant External Configuration Store

The tenant external-configuration-store example loads feature settings from a central provider before application workflows use them. It demonstrates:

- a fluent `ExternalConfigurationStore<TenantFeatureSettings>`
- a `[GenerateExternalConfigurationStore]` source-generated factory
- typed validation and cache duration
- `IServiceCollection` registration through `AddTenantExternalConfigurationStoreDemo()`

The example is implemented in `src/PatternKit.Examples/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemo.cs` and covered by TinyBDD tests in `test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,6 @@

- name: Product Search Rate Limiting
href: product-search-rate-limiting.md

- name: Tenant External Configuration Store
href: tenant-external-configuration-store.md
17 changes: 17 additions & 0 deletions docs/generators/external-configuration-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# External Configuration Store Generator

`[GenerateExternalConfigurationStore]` creates a typed `ExternalConfigurationStore<TSettings>` factory from a static loader and ordered validators.

```csharp
[GenerateExternalConfigurationStore(typeof(TenantFeatureSettings), FactoryName = "Create")]
public static partial class GeneratedTenantConfigStore
{
[ExternalConfigurationLoader]
private static ValueTask<ExternalConfigurationSnapshot<TenantFeatureSettings>> Load(CancellationToken ct) { }

[ExternalConfigurationValidator("Tenant id is required.", 10)]
private static bool HasTenant(TenantFeatureSettings settings) => !string.IsNullOrWhiteSpace(settings.TenantId);
}
```

The loader must return `ValueTask<ExternalConfigurationSnapshot<TSettings>>` and accept a `CancellationToken`. Validators must be static `bool` methods accepting `TSettings`.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Queue Load Leveling**](queue-load-leveling.md) | Bounded worker queue policy factories | `[GenerateQueueLoadLevelingPolicy]` |
| [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` |
| [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` |
| [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` |

## Quick Reference

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@
- name: Rate Limiting
href: rate-limiting.md

- name: External Configuration Store
href: external-configuration-store.md

- name: Queue Load Leveling
href: queue-load-leveling.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Queue-Based Load Leveling | `QueueLoadLevelingPolicy<T>` | Queue Load Leveling generator |
| Cloud Architecture | Cache-Aside | `CacheAsidePolicy<T>` | Cache-Aside generator |
| Cloud Architecture | Rate Limiting | `RateLimitPolicy<T>` | Rate Limiting generator |
| Cloud Architecture | External Configuration Store | `ExternalConfigurationStore<TSettings>` | External Configuration Store generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
| Application Architecture | Repository | `IRepository<TEntity,TKey>` and `InMemoryRepository<TEntity,TKey>` | Repository generator |
Expand Down
13 changes: 13 additions & 0 deletions docs/patterns/cloud/external-configuration-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# External Configuration Store

Use `ExternalConfigurationStore<TSettings>` when application configuration is centralized in a service such as Azure App Configuration, AWS AppConfig, Consul, Vault, or a tenant settings API. PatternKit keeps the loader, validation rules, and cache policy together so applications import one typed store through DI.

```csharp
var store = ExternalConfigurationStore<TenantFeatureSettings>.Create("tenant-feature-config")
.LoadFrom(provider.LoadAsync)
.ValidateWith("Tenant id is required.", settings => !string.IsNullOrWhiteSpace(settings.TenantId))
.CacheFor(TimeSpan.FromMinutes(5))
.Build();
```

The source-generated path uses `[GenerateExternalConfigurationStore]`, one `[ExternalConfigurationLoader]`, and optional `[ExternalConfigurationValidator]` methods. Import the example through `AddTenantExternalConfigurationStoreDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@
href: cloud/cache-aside.md
- name: Rate Limiting
href: cloud/rate-limiting.md
- name: External Configuration Store
href: cloud/external-configuration-store.md
- name: Application Architecture
items:
- name: Anti-Corruption Layer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
namespace PatternKit.Cloud.ExternalConfigurationStore;

/// <summary>
/// Centralized external configuration store with typed async loading, validation, and optional caching.
/// </summary>
public sealed class ExternalConfigurationStore<TSettings>
{
/// <summary>Loads the current configuration snapshot from an external source.</summary>
public delegate ValueTask<ExternalConfigurationSnapshot<TSettings>> ConfigurationLoader(CancellationToken cancellationToken);

/// <summary>Validates a loaded settings object.</summary>
public delegate bool ConfigurationValidator(TSettings settings);

private readonly string _name;
private readonly ConfigurationLoader _loader;
private readonly ValidationRule[] _validators;
private readonly TimeSpan _cacheDuration;
private readonly object _gate = new();
private ExternalConfigurationSnapshot<TSettings>? _cached;

private ExternalConfigurationStore(
string name,
ConfigurationLoader loader,
ValidationRule[] validators,
TimeSpan cacheDuration)
=> (_name, _loader, _validators, _cacheDuration) = (name, loader, validators, cacheDuration);

/// <summary>Gets validated configuration, reusing the cached snapshot while it remains fresh.</summary>
public async ValueTask<ExternalConfigurationResult<TSettings>> GetAsync(CancellationToken cancellationToken = default)
{
var cached = GetFreshCachedSnapshot();
if (cached is not null)
Comment on lines +28 to +32
return Validate(cached);

var loaded = await _loader(cancellationToken).ConfigureAwait(false);
var result = Validate(loaded);
if (result.Succeeded)
{
lock (_gate)
_cached = loaded;
}

return result;
}

/// <summary>Creates a new external configuration store builder.</summary>
public static Builder Create(string name = "external-configuration-store") => new(name);

private ExternalConfigurationSnapshot<TSettings>? GetFreshCachedSnapshot()
{
if (_cacheDuration <= TimeSpan.Zero)
return null;

lock (_gate)
{
if (_cached is null)
return null;

return DateTimeOffset.UtcNow - _cached.LoadedAtUtc <= _cacheDuration ? _cached : null;
}
}

private ExternalConfigurationResult<TSettings> Validate(ExternalConfigurationSnapshot<TSettings> snapshot)
{
foreach (var validator in _validators)
{
if (!validator.Predicate(snapshot.Settings))
return ExternalConfigurationResult<TSettings>.Rejected(_name, snapshot, validator.RejectionReason);
}

return ExternalConfigurationResult<TSettings>.Accepted(_name, snapshot);
}

/// <summary>Fluent builder for <see cref="ExternalConfigurationStore{TSettings}"/>.</summary>
public sealed class Builder
{
private readonly string _name;
private readonly List<ValidationRule> _validators = new(4);
private ConfigurationLoader? _loader;
private TimeSpan _cacheDuration = TimeSpan.Zero;

internal Builder(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("External configuration store name cannot be null, empty, or whitespace.", nameof(name));

_name = name;
}

/// <summary>Registers the async external configuration loader.</summary>
public Builder LoadFrom(ConfigurationLoader loader)
{
_loader = loader ?? throw new ArgumentNullException(nameof(loader));
return this;
}

/// <summary>Adds a validation rule for loaded settings.</summary>
public Builder ValidateWith(string rejectionReason, ConfigurationValidator validator)
{
if (string.IsNullOrWhiteSpace(rejectionReason))
throw new ArgumentException("Validation rejection reason cannot be null, empty, or whitespace.", nameof(rejectionReason));
if (validator is null)
throw new ArgumentNullException(nameof(validator));

_validators.Add(new ValidationRule(rejectionReason, validator));
return this;
}

/// <summary>Configures how long successful snapshots should be cached.</summary>
public Builder CacheFor(TimeSpan duration)
{
if (duration < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(duration), "Cache duration cannot be negative.");

_cacheDuration = duration;
return this;
}

/// <summary>Builds an immutable external configuration store.</summary>
public ExternalConfigurationStore<TSettings> Build()
{
if (_loader is null)
throw new InvalidOperationException("External configuration store requires a loader.");

return new ExternalConfigurationStore<TSettings>(_name, _loader, _validators.ToArray(), _cacheDuration);
}
}

private sealed class ValidationRule
{
public ValidationRule(string rejectionReason, ConfigurationValidator predicate)
=> (RejectionReason, Predicate) = (rejectionReason, predicate);

public string RejectionReason { get; }

public ConfigurationValidator Predicate { get; }
}
}

/// <summary>Loaded configuration snapshot with source version metadata.</summary>
public sealed class ExternalConfigurationSnapshot<TSettings>
{
public ExternalConfigurationSnapshot(TSettings settings, string version, DateTimeOffset loadedAtUtc)
{
if (settings is null)
throw new ArgumentNullException(nameof(settings));
if (string.IsNullOrWhiteSpace(version))
throw new ArgumentException("Configuration version cannot be null, empty, or whitespace.", nameof(version));

Settings = settings;
Version = version;
LoadedAtUtc = loadedAtUtc;
}

/// <summary>The loaded typed settings.</summary>
public TSettings Settings { get; }

/// <summary>External store version, revision, or etag.</summary>
public string Version { get; }

/// <summary>UTC timestamp when the snapshot was loaded.</summary>
public DateTimeOffset LoadedAtUtc { get; }
}

/// <summary>Validated configuration result returned by <see cref="ExternalConfigurationStore{TSettings}"/>.</summary>
public sealed class ExternalConfigurationResult<TSettings>
{
private ExternalConfigurationResult(
string storeName,
ExternalConfigurationSnapshot<TSettings> snapshot,
bool succeeded,
string? rejectionReason)
=> (StoreName, Snapshot, Succeeded, RejectionReason) = (storeName, snapshot, succeeded, rejectionReason);

/// <summary>The store name.</summary>
public string StoreName { get; }

/// <summary>The loaded snapshot.</summary>
public ExternalConfigurationSnapshot<TSettings> Snapshot { get; }

/// <summary>True when all validation rules accepted the snapshot.</summary>
public bool Succeeded { get; }

/// <summary>Validation failure reason when rejected.</summary>
public string? RejectionReason { get; }

/// <summary>Creates an accepted result.</summary>
public static ExternalConfigurationResult<TSettings> Accepted(string storeName, ExternalConfigurationSnapshot<TSettings> snapshot)
=> new(storeName, snapshot, true, null);

/// <summary>Creates a rejected result.</summary>
public static ExternalConfigurationResult<TSettings> Rejected(string storeName, ExternalConfigurationSnapshot<TSettings> snapshot, string rejectionReason)
=> new(storeName, snapshot, false, rejectionReason);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
using PatternKit.Examples.DomainEventDemo;
using PatternKit.Examples.EnterpriseFeatureSlices;
using PatternKit.Examples.EventSourcingDemo;
using PatternKit.Examples.ExternalConfigurationStoreDemo;
using PatternKit.Examples.FeatureToggleDemo;
using PatternKit.Examples.FlyweightDemo;
using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo;
Expand Down Expand Up @@ -169,6 +170,7 @@ public sealed record ShippingBulkheadExample(BulkheadPolicy<ShippingAllocation>
public sealed record FulfillmentQueueLoadLevelingExample(QueueLoadLevelingPolicy<FulfillmentQueueResult> Policy, FulfillmentQueueLoadLevelingService Service);
public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy<ProductReadModel> Policy, ProductCatalogCacheAsideService Service);
public sealed record ProductSearchRateLimitingExample(RateLimitPolicy<SearchResponse> Policy, ProductSearchRateLimitService Service);
public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service);

/// <summary>
/// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection.
Expand Down Expand Up @@ -241,7 +243,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddShippingBulkheadExample()
.AddFulfillmentQueueLoadLevelingExample()
.AddProductCatalogCacheAsideExample()
.AddProductSearchRateLimitingExample();
.AddProductSearchRateLimitingExample()
.AddTenantExternalConfigurationStoreExample();

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -826,6 +829,15 @@ public static IServiceCollection AddProductSearchRateLimitingExample(this IServi
return services.RegisterExample<ProductSearchRateLimitingExample>("Product Search Rate Limiting", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddTenantExternalConfigurationStoreExample(this IServiceCollection services)
{
services.AddTenantExternalConfigurationStoreDemo();
services.AddSingleton<TenantExternalConfigurationStoreExample>(sp => new(
sp.GetRequiredService<TenantExternalConfigurationStoreDemoRunner>(),
sp.GetRequiredService<TenantExternalConfigurationService>()));
return services.RegisterExample<TenantExternalConfigurationStoreExample>("Tenant External Configuration Store", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

private static IServiceCollection RegisterExample<T>(
this IServiceCollection services,
string name,
Expand Down
Loading
Loading