This is the implementation companion to the SecretVault introduction post. Where that post showed you what SecretVault does, this one shows you how it's built โ every class, every design decision, and why things are structured the way they are.
Project Structure at a Glance
SecretVault/
โโโ src/
โ โโโ SecretVault.Core/ # Abstractions, caching, chaining
โ โ โโโ ISecretManager.cs
โ โ โโโ ISecretRotationManager.cs
โ โ โโโ Models.cs
โ โ โโโ CachedSecretManager.cs
โ โ โโโ ChainedSecretManager.cs
โ โโโ SecretVault.Aws/ # AWS Secrets Manager
โ โ โโโ AwsSecretManager.cs
โ โโโ SecretVault.Azure/ # Azure Key Vault
โ โ โโโ AzureKeyVaultSecretManager.cs
โ โโโ SecretVault.HashiCorp/ # HashiCorp Vault KV v2
โ โ โโโ HashiCorpVaultSecretManager.cs
โ โโโ SecretVault.Gcp/ # GCP Secret Manager
โ โ โโโ GcpSecretManager.cs
โ โโโ SecretVault.Extensions.Hosting/ # ASP.NET Core integration
โ โโโ SecretVaultServiceExtensions.cs
โ โโโ SecretVaultHealthCheck.cs
โโโ tests/
โโโ SecretVault.Core.Tests/
โโโ CachedAndChainedTests.cs
The golden rule: if a class only references ISecretManager โ it goes in Core. If it references a provider SDK (AWS, Azure, etc.) โ it goes in that provider's project.
Part 1 โ The Core Abstractions
ISecretManager โ The Unified Interface
This is the only type your application code ever depends on:
namespace SecretVault.Core;
public interface ISecretManager
{
// On-demand runtime fetching
Task<string?> GetSecretAsync(string secretName, CancellationToken ct = default);
Task<T?> GetSecretAsync<T>(string secretName, CancellationToken ct = default);
Task SetSecretAsync(string secretName, string value, CancellationToken ct = default);
Task DeleteSecretAsync(string secretName, CancellationToken ct = default);
// Bulk fetch โ useful at startup to warm secrets
Task<IReadOnlyDictionary<string, string?>> GetSecretsAsync(
IEnumerable<string> secretNames, CancellationToken ct = default);
}
Why string? and not string? Returning null instead of throwing when a secret doesn't exist is a deliberate choice โ it lets callers decide whether a missing secret is an error or an expected condition without forcing a try/catch everywhere.
Why Task<T?> GetSecretAsync<T>? Many secrets are stored as JSON objects (e.g. a database config with host, port, and password together). The generic overload deserializes directly into your type so you don't have to JsonSerializer.Deserialize manually on every call.
ISecretRotationManager โ Rotation on Top
Rotation is an optional capability โ not every use case needs it. So instead of bloating ISecretManager, we extend it:
public interface ISecretRotationManager : ISecretManager
{
Task<SecretRotationResult> RotateSecretAsync(
string secretName,
string newValue,
TimeSpan? gracePeriod = null,
CancellationToken ct = default);
Task<IReadOnlyList<SecretVersion>> ListVersionsAsync(
string secretName, CancellationToken ct = default);
Task<string?> GetSecretVersionAsync(
string secretName, string version, CancellationToken ct = default);
}
All four provider implementations (AwsSecretManager, AzureKeyVaultSecretManager, etc.) implement ISecretRotationManager โ which means they also satisfy ISecretManager. You can inject either interface depending on what you need.
The return types:
// Returned from RotateSecretAsync โ tells you what happened
public record SecretRotationResult(
string SecretName,
string NewVersion,
string? PreviousVersion,
DateTimeOffset RotatedAt,
DateTimeOffset? GracePeriodEndsAt // null if no grace period requested
);
// One entry from ListVersionsAsync
public record SecretVersion(
string Version,
DateTimeOffset CreatedAt,
bool IsCurrent,
string? Status
);
C# records are used here because these are pure data carriers โ immutable, with built-in equality and ToString(). No behaviour, no setters.
The Exception Hierarchy
Provider SDKs throw completely different exceptions โ AmazonServiceException, RequestFailedException, RpcException. Your application shouldn't need to know which one it got. We normalize them:
// Base โ all SecretVault errors derive from this
public class SecretVaultException : Exception { ... }
// Secret doesn't exist in the provider
public class SecretNotFoundException : SecretVaultException
{
public string SecretName { get; } // carry the name so callers can log it
}
// IAM / RBAC permissions failure
public class SecretAccessDeniedException : SecretVaultException { ... }
// Network outage or provider down
public class SecretProviderUnavailableException : SecretVaultException { ... }
Each provider catches its own SDK exception types and re-throws as the appropriate SecretVaultException. Your application catches one hierarchy regardless of which provider is running:
catch (SecretNotFoundException ex) { /* missing secret */ }
catch (SecretAccessDeniedException) { /* permissions issue */ }
catch (SecretProviderUnavailableException) { /* outage */ }
Part 2 โ The AWS Implementation
AwsSecretManager implements ISecretRotationManager (which includes all of ISecretManager):
public class AwsSecretManager : ISecretRotationManager
{
private readonly IAmazonSecretsManager _client;
private readonly ILogger<AwsSecretManager> _logger;
public AwsSecretManager(IAmazonSecretsManager client, ILogger<AwsSecretManager> logger)
{
_client = client;
_logger = logger;
}
IAmazonSecretsManager not AmazonSecretsManagerClient โ we take the interface, not the concrete class. This makes the provider testable via mocking without hitting real AWS.
GetSecretAsync
public async Task<string?> GetSecretAsync(string secretName, CancellationToken ct = default)
{
try
{
_logger.LogDebug("Fetching secret '{Name}' from AWS Secrets Manager", secretName);
var response = await _client.GetSecretValueAsync(new GetSecretValueRequest
{
SecretId = secretName
}, ct);
// AWS can return either a string OR binary โ handle both
return response.SecretString
?? Convert.ToBase64String(response.SecretBinary.ToArray());
}
catch (ResourceNotFoundException)
{
throw new SecretNotFoundException(secretName); // normalize
}
catch (AmazonServiceException ex) when (ex.ErrorCode == "AccessDeniedException")
{
throw new SecretAccessDeniedException(secretName, ex); // normalize
}
catch (Exception ex)
{
throw new SecretProviderUnavailableException("AWS Secrets Manager", ex); // normalize
}
}
Exception filters (when) are used to catch only the specific AWS error codes we care about, letting other AmazonServiceException subtypes fall through to the general handler.
SetSecretAsync โ Create or Update
public async Task SetSecretAsync(string secretName, string value, CancellationToken ct = default)
{
try
{
await _client.PutSecretValueAsync(new PutSecretValueRequest
{
SecretId = secretName,
SecretString = value
}, ct);
}
catch (ResourceNotFoundException)
{
// Secret doesn't exist yet โ create it first
await _client.CreateSecretAsync(new CreateSecretRequest
{
Name = secretName,
SecretString = value
}, ct);
}
}
AWS PutSecretValue requires the secret to already exist. We catch ResourceNotFoundException and create it automatically so callers don't have to think about whether a secret is new or existing.
RotateSecretAsync
public async Task<SecretRotationResult> RotateSecretAsync(
string secretName, string newValue, TimeSpan? gracePeriod = null, CancellationToken ct = default)
{
// 1. Read the current version ID before rotating
var current = await _client.DescribeSecretAsync(
new DescribeSecretRequest { SecretId = secretName }, ct);
var previousVersion = current.VersionIdsToStages?.Keys.FirstOrDefault();
// 2. Write the new value, explicitly marking it as current
await _client.PutSecretValueAsync(new PutSecretValueRequest
{
SecretId = secretName,
SecretString = newValue,
VersionStages = ["AWSCURRENT"]
}, ct);
// 3. Read back the new version ID
var describe = await _client.DescribeSecretAsync(
new DescribeSecretRequest { SecretId = secretName }, ct);
var newVersion = describe.VersionIdsToStages?.Keys.FirstOrDefault() ?? "unknown";
var rotatedAt = DateTimeOffset.UtcNow;
return new SecretRotationResult(
SecretName: secretName,
NewVersion: newVersion,
PreviousVersion: previousVersion,
RotatedAt: rotatedAt,
GracePeriodEndsAt: gracePeriod.HasValue ? rotatedAt.Add(gracePeriod.Value) : null
);
}
The AWSCURRENT stage label is how AWS tracks which version is active. The old version automatically moves to AWSPREVIOUS โ staying accessible during the grace period before it expires.
Part 3 โ CachedSecretManager (The Decorator Pattern)
CachedSecretManager is a decorator โ it wraps any ISecretManager and adds caching without modifying the wrapped class:
Your App โ CachedSecretManager โ AwsSecretManager โ AWS API
public class CachedSecretManager : ISecretManager
{
private readonly ISecretManager _inner; // the real provider
private readonly IMemoryCache _cache;
private readonly ILogger<CachedSecretManager> _logger;
private readonly CachedSecretManagerOptions _options;
public CachedSecretManager(
ISecretManager inner,
IMemoryCache cache,
ILogger<CachedSecretManager> logger,
CachedSecretManagerOptions? options = null)
{
_inner = inner;
_cache = cache;
_logger = logger;
_options = options ?? new CachedSecretManagerOptions(); // defaults to 5 min
}
GetSecretAsync โ Cache-Aside Pattern
public async Task<string?> GetSecretAsync(string secretName, CancellationToken ct = default)
{
var cacheKey = CacheKey(secretName); // "secretvault:my-secret"
// 1. Check cache first
if (_cache.TryGetValue(cacheKey, out string? cached))
{
_logger.LogDebug("Cache hit for secret '{SecretName}'", secretName);
return cached;
}
// 2. Cache miss โ fetch from the real provider
_logger.LogDebug("Cache miss for '{SecretName}', fetching from provider", secretName);
var value = await _inner.GetSecretAsync(secretName, ct);
// 3. Store in cache (only if we got a value)
if (value is not null)
{
_cache.Set(cacheKey, value, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.AbsoluteExpiration,
SlidingExpiration = _options.SlidingExpiration
});
}
return value;
}
This is the classic cache-aside (lazy loading) pattern: populate the cache on demand, not upfront.
Write-Through Cache Invalidation
public async Task SetSecretAsync(string secretName, string value, CancellationToken ct = default)
{
await _inner.SetSecretAsync(secretName, value, ct); // write to provider first
_cache.Remove(CacheKey(secretName)); // then invalidate cache
}
Why invalidate instead of update? Updating the cache after a write seems logical but introduces a race condition โ between the write and the cache update, another thread could read the old value. Invalidating forces the next read to fetch fresh from the provider, which is always safe.
Bulk Fetch โ Partial Cache Hits
public async Task<IReadOnlyDictionary<string, string?>> GetSecretsAsync(
IEnumerable<string> secretNames, CancellationToken ct = default)
{
var result = new Dictionary<string, string?>();
var missing = new List<string>(); // secrets not in cache
// 1. Check cache for each name
foreach (var name in secretNames)
{
if (_cache.TryGetValue(CacheKey(name), out string? cached))
result[name] = cached; // cache hit
else
missing.Add(name); // needs fetching
}
// 2. Fetch only the ones that weren't cached
if (missing.Count > 0)
{
var fetched = await _inner.GetSecretsAsync(missing, ct);
foreach (var (key, value) in fetched)
{
result[key] = value;
if (value is not null)
_cache.Set(CacheKey(key), value, _options.AbsoluteExpiration);
}
}
return result;
}
If you request 10 secrets and 7 are cached, only 3 API calls go to the provider. The result dictionary combines both.
The Options Class
public class CachedSecretManagerOptions
{
// Expires exactly N minutes after caching, regardless of usage
public TimeSpan AbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(5);
// Resets the timer on every access โ null means disabled
public TimeSpan? SlidingExpiration { get; set; }
}
You can combine both: sliding keeps frequently-accessed secrets warm, while absolute prevents them from living in cache indefinitely even if constantly hit.
Part 4 โ ChainedSecretManager (The Chain of Responsibility Pattern)
public class ChainedSecretManager : ISecretManager
{
private readonly IReadOnlyList<ISecretManager> _managers;
public ChainedSecretManager(
IEnumerable<ISecretManager> managers,
ILogger<ChainedSecretManager> logger)
{
_managers = managers.ToList();
if (_managers.Count == 0)
throw new ArgumentException("At least one ISecretManager must be provided.");
}
public async Task<string?> GetSecretAsync(string secretName, CancellationToken ct = default)
{
foreach (var manager in _managers)
{
try
{
var value = await manager.GetSecretAsync(secretName, ct);
if (value is not null)
{
_logger.LogDebug("Secret '{Name}' found in {Provider}",
secretName, manager.GetType().Name);
return value;
}
}
catch (SecretNotFoundException)
{
// Not found here โ try next provider
_logger.LogDebug("Secret '{Name}' not found in {Provider}, trying next",
secretName, manager.GetType().Name);
}
catch (Exception ex)
{
// Provider error โ log and try next, don't crash
_logger.LogWarning(ex, "Error fetching '{Name}' from {Provider}, trying next",
secretName, manager.GetType().Name);
}
}
// Exhausted all providers
throw new SecretNotFoundException(secretName);
}
// Writes always go to the PRIMARY (first) provider only
public Task SetSecretAsync(string secretName, string value, CancellationToken ct = default)
=> _managers[0].SetSecretAsync(secretName, value, ct);
}
Key design decisions:
-
SecretNotFoundExceptionfrom a provider means "not here, try next" โ it's expected and swallowed - Any other exception (network error, auth failure) is logged as a warning and the chain continues โ one broken provider doesn't block fallbacks
- Writes go to
_managers[0](primary) only โ writing to all providers would create sync problems
Part 5 โ ASP.NET Core Integration
AddSecretVault โ The DI Extension Method
public static IServiceCollection AddSecretVault(
this IServiceCollection services,
ISecretManager manager,
Action<CachedSecretManagerOptions>? configureCaching = null)
{
if (configureCaching is not null)
{
services.AddMemoryCache();
// Register the CachedSecretManager as a concrete singleton
services.AddSingleton(sp =>
{
var options = new CachedSecretManagerOptions();
configureCaching(options); // apply caller's configuration
var cache = sp.GetRequiredService<IMemoryCache>();
var logger = sp.GetRequiredService<ILogger<CachedSecretManager>>();
return new CachedSecretManager(manager, cache, logger, options);
});
// Map ISecretManager โ CachedSecretManager
// Any class that injects ISecretManager gets the cached version
services.AddSingleton<ISecretManager>(
sp => sp.GetRequiredService<CachedSecretManager>());
}
else
{
// No caching โ register the raw provider directly
services.AddSingleton<ISecretManager>(manager);
}
return services;
}
The key line is services.AddSingleton<ISecretManager>(sp => sp.GetRequiredService<CachedSecretManager>()). This creates an alias in the DI container โ ISecretManager resolves to CachedSecretManager, which itself wraps AwsSecretManager. Your services inject ISecretManager and silently get three layers of behavior.
IConfiguration Integration
// The source tells the config system how to build our provider
public class SecretVaultConfigurationSource : IConfigurationSource
{
private readonly ISecretManager _manager;
private readonly IEnumerable<string> _secretNames;
public IConfigurationProvider Build(IConfigurationBuilder builder)
=> new SecretVaultConfigurationProvider(_manager, _secretNames);
}
// The provider does the actual loading
public class SecretVaultConfigurationProvider : ConfigurationProvider
{
public override void Load()
{
// Fetch all secrets synchronously at startup
var secrets = _manager.GetSecretsAsync(_secretNames).GetAwaiter().GetResult();
// Store in the Data dictionary โ this is what IConfiguration reads from
Data = secrets
.Where(kvp => kvp.Value is not null)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!);
}
}
Why .GetAwaiter().GetResult() instead of await? IConfigurationProvider.Load() is synchronous โ it's called during host startup before the async pipeline is running. This is one of the few legitimate places to block on async code in .NET.
Health Check
public class SecretVaultHealthCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
try
{
await _manager.GetSecretAsync("__healthcheck__", ct);
return HealthCheckResult.Healthy("Secret provider is reachable.");
}
catch (SecretNotFoundException)
{
// Provider is reachable โ the probe secret just doesn't exist, which is fine
return HealthCheckResult.Healthy("Secret provider is reachable.");
}
catch (SecretProviderUnavailableException ex)
{
return HealthCheckResult.Unhealthy("Secret provider is unavailable.", ex);
}
catch (Exception ex)
{
return HealthCheckResult.Degraded("Unexpected error.", ex);
}
}
}
The __healthcheck__ probe secret almost certainly doesn't exist โ and that's intentional. SecretNotFoundException proves the provider is reachable and responding. Only SecretProviderUnavailableException (network issue, auth failure) marks the check as unhealthy.
Part 6 โ Tests
public class CachedSecretManagerTests
{
private readonly ISecretManager _inner = Substitute.For<ISecretManager>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private readonly CachedSecretManager _sut;
[Fact]
public async Task GetSecretAsync_SecondCall_ReturnsCachedValue()
{
_inner.GetSecretAsync("my-secret", default).Returns("super-secret");
await _sut.GetSecretAsync("my-secret"); // first call โ hits inner
await _sut.GetSecretAsync("my-secret"); // second call โ from cache
// Inner was only called ONCE despite two GetSecretAsync calls
await _inner.Received(1).GetSecretAsync("my-secret", default);
}
[Fact]
public async Task SetSecretAsync_InvalidatesCache()
{
_inner.GetSecretAsync("my-secret", default).Returns("old-value", "new-value");
await _sut.GetSecretAsync("my-secret"); // caches "old-value"
await _sut.SetSecretAsync("my-secret", "new-value"); // invalidates cache
var result = await _sut.GetSecretAsync("my-secret"); // re-fetches "new-value"
result.Should().Be("new-value");
await _inner.Received(2).GetSecretAsync("my-secret", default);
}
}
public class ChainedSecretManagerTests
{
[Fact]
public async Task GetSecretAsync_FallsBackToSecondary_WhenPrimaryThrowsNotFound()
{
_primary.GetSecretAsync("key", default)
.Throws(new SecretNotFoundException("key"));
_fallback.GetSecretAsync("key", default).Returns("from-fallback");
var result = await _sut.GetSecretAsync("key");
result.Should().Be("from-fallback");
}
}
NSubstitute is used for mocking โ Substitute.For<ISecretManager>() creates a fake implementation that returns configured values. FluentAssertions makes assertions read like English. Real MemoryCache is used instead of a mock because testing with the actual cache catches real caching bugs that a mock would hide.
Part 7 โ GitHub Actions CI/CD
CI (ci.yml) โ Every push and PR
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- run: dotnet restore SecretVault.sln
- run: dotnet build SecretVault.sln --configuration Release --no-restore
- run: dotnet test SecretVault.sln --configuration Release --no-build
Publish (publish.yml) โ Tag a version, ship everything
on:
push:
tags:
- 'v*.*.*' # v1.0.0, v1.2.3, etc.
steps:
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# v1.0.0 โ 1.0.0 (strips the "v" prefix for NuGet)
- name: Pack all packages
run: |
dotnet pack src/SecretVault.Core/SecretVault.Core.csproj \
--configuration Release \
/p:Version=${{ steps.version.outputs.VERSION }} \
--output ./nupkgs
# ... repeated for all 6 packages
- name: Publish to NuGet
run: |
dotnet nuget push ./nupkgs/*.nupkg \
--api-key ${{ secrets.NUGET_API_KEY }} \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate
${GITHUB_REF_NAME#v} is a bash parameter expansion โ it strips the v prefix from the tag name so v1.0.0 becomes 1.0.0, which is what NuGet expects for version numbers.
--skip-duplicate prevents the pipeline from failing if a package version was already published (e.g. if you re-run the workflow).
To release:
git tag v1.0.0
git push origin v1.0.0
# GitHub Actions handles the rest automatically
Summary โ Design Patterns Used
| Pattern | Where | Why |
|---|---|---|
| Interface segregation |
ISecretManager + ISecretRotationManager
|
Rotation is optional โ don't force it on basic consumers |
| Decorator | CachedSecretManager |
Add caching without modifying providers |
| Chain of responsibility | ChainedSecretManager |
Fallback across providers without coupling |
| Factory via DI |
AddSecretVault extension |
Compose the full object graph in one place |
| Normalized exceptions | Per-provider catch/rethrow | Shield callers from SDK-specific error types |
| Cache-aside | CachedSecretManager.GetSecretAsync |
Lazy populate, invalidate on write |
| Configuration provider | SecretVaultConfigurationSource |
Plug into the standard .NET config pipeline |
Top comments (0)