Files
slp-modular-cms/aidlc-docs/features/master-cms-module/construction/master-backend/nfr-design/nfr-design-patterns.md
T

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):

  1. RetryAddRetry with exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries on HttpRequestException and non-2xx responses
  2. TimeoutAddTimeout with TimeSpan.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);