7.1 KiB
NFR Design Patterns — Unit 1: master-backend
Pattern 1 — Resilience: Polly Pipeline via AddResilienceHandler
NFR: Exponential backoff (3 attempts), per-attempt timeout (HttpTimeoutSeconds)
Pattern: Single shared resilience pipeline registered on the IHttpClientBuilder for SlaveApiClient. All HTTP calls from SlaveApiClient pass through the pipeline automatically — no per-method boilerplate.
Pipeline composition (outer → inner execution order):
- Retry —
AddRetrywith exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries onHttpRequestExceptionand non-2xx responses - Timeout —
AddTimeoutwithTimeSpan.FromSeconds(MasterModuleOptions.HttpTimeoutSeconds); applied per attempt (not total)
Registration (in MasterModule.RegisterServices):
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, ctx) =>
{
var opts = ctx.ServiceProvider
.GetRequiredService<IOptions<MasterModuleOptions>>().Value;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.IsSuccessStatusCode == false))
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.HttpTimeoutSeconds));
});
Retry flow:
graph TD
Call["SlaveApiClient HTTP call"]
Attempt["Execute HTTP request\n(with per-attempt timeout)"]
Success{"Response\nsuccessful?"}
ReturnOk["Return result"]
MaxReached{"Max attempts\n(3) reached?"}
Backoff["Wait exponential delay\n1s or 2s plus jitter"]
ReturnFail["Return false\nor throw on final attempt"]
Call --> Attempt --> Success
Success -->|"yes"| ReturnOk
Success -->|"no"| MaxReached
MaxReached -->|"yes"| ReturnFail
MaxReached -->|"no"| Backoff --> Attempt
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef fail fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Success,MaxReached decision
class Call,Attempt,Backoff action
class ReturnOk terminal
class ReturnFail fail
Text alternative: HTTP call enters pipeline; per-attempt timeout applies; on failure checks if max attempts reached; if not waits exponential delay and retries; after 3 total failures returns false.
Pattern 2 — Security: IApiKeyProtector Wrapper
NFR: ApiKey encrypted at rest; decrypted only for HTTP calls; never exposed in responses
Pattern: Thin wrapper interface over ASP.NET Core Data Protection. Keeps CmsInstanceService independent of Data Protection internals and makes unit tests trivially simple (mock returns plain strings).
Interface:
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
Implementation:
public class ApiKeyProtector : IApiKeyProtector
{
private readonly IDataProtector _protector;
public ApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encrypted) => _protector.Unprotect(encrypted);
}
Registration (in MasterModule.RegisterServices):
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
Usage in tests:
var protector = new Mock<IApiKeyProtector>();
protector.Setup(p => p.Protect(It.IsAny<string>())).Returns((string s) => $"enc:{s}");
protector.Setup(p => p.Unprotect(It.IsAny<string>())).Returns((string s) => s.Replace("enc:", ""));
Pattern 3 — Constructor Aggregation: MasterServiceDependencies
Rationale: CmsInstanceService requires 6 dependencies. Wrapping them in a record removes constructor noise and groups related parameters semantically.
Record definition:
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
Registration (framework resolves all fields automatically):
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
CmsInstanceService constructor:
public CmsInstanceService(MasterServiceDependencies deps)
{
_deps = deps;
}
Test construction (explicit, no DI container needed):
var deps = new MasterServiceDependencies(
mockRepo.Object,
mockSlaveClient.Object,
mockProtector.Object,
Options.Create(new MasterModuleOptions()),
mockHttpContextAccessor.Object,
NullLogger<CmsInstanceService>.Instance
);
var svc = new CmsInstanceService(deps);
Pattern 4 — Background Service Scope Isolation
NFR: ICmsInstanceService is Scoped; IntegrityCheckBackgroundService is Singleton
Pattern: Create and dispose a dedicated IServiceScope per tick. No singleton scope leakage.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(_options.Value.IntegrityCheckIntervalMinutes));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
try
{
var svc = scope.ServiceProvider
.GetRequiredService<ICmsInstanceService>();
await svc.VerifyIntegrityAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during integrity check tick");
}
}
}
Pattern 5 — Structured Logging
Pattern: Log with structured fields; never log the raw ApiKey value.
| Scenario | Level | Fields |
|---|---|---|
| Status push failed | Error |
{InstanceId}, {SlaveUrl}, {Exception} |
| Integrity check: slave unreachable | Warning |
{InstanceId}, {SlaveUrl} |
| Integrity check: URL mismatch | Warning |
{InstanceId}, {SlaveUrl}, {ExpectedUrl}, {RegisteredUrl} |
| Integrity check: re-registration ok | Information |
{InstanceId}, {SlaveUrl} |
| Integrity check: re-registration failed | Warning |
{InstanceId}, {SlaveUrl} |
| ApiKey decryption failure | Error |
{InstanceId} — do NOT log key material |
| Tick started | Debug |
{ActiveInstanceCount} |
Example:
_logger.LogWarning(
"Slave {InstanceId} at {SlaveUrl} is unreachable during integrity check",
instance.Id, instance.Url);