Approved brainstorming design for the Config + validation normalization pass (Tier-2 candidate in upcoming.md). Scope: startup options validation only, single package ZB.MOM.WW.Configuration, Approach A (lightweight base + rule primitives + DI/startup helpers). Full pass = components/configuration/ docs + built library.
11 KiB
ZB.MOM.WW.Configuration — shared startup-options-validation library (design)
Date: 2026-06-01
Status: Design (approved) — implementation plan to follow via writing-plans
Component: Config + validation (Tier-2 normalization candidate, upcoming.md)
Deliverable: Full normalization pass — components/configuration/ docs and a built
ZB.MOM.WW.Configuration library + this design doc.
1. Problem & intent
The three sister apps (OtOpcUa, MxAccessGateway, ScadaBridge) each re-implement the same
configuration-validation plumbing and drift apart. All three use the same .NET primitives —
IOptions<T> + IValidateOptions<T> + ValidateOnStart() — and each hand-rolls a
List<string> failure accumulator that returns ValidateOptionsResult. ScadaBridge also has a
pre-host StartupValidator that validates raw IConfiguration before the Akka actor system
exists (so it cannot use IOptions), aggregates all errors, and throws one
InvalidOperationException.
This was flagged as a Tier-2 normalization candidate in
upcoming.md: "all three use IOptions + IValidateOptions +
ValidateOnStart; a shared validation base + startup-validation helper is reusable."
Goal: extract the genuinely-common plumbing into one shared library, removing the
duplicated boilerplate while leaving every app's domain-specific rules per-project — per the
components/README.md rule: "extract only what is genuinely common; leave domain-specific
logic in the projects."
Decisions locked during brainstorming
| Decision | Choice | Rationale |
|---|---|---|
| Deliverable | Full pass (normalization docs + built library) | Match Auth/Theme/Health/Telemetry exactly |
| Scope | Startup options validation only | Genuinely common across all 3; honors YAGNI |
| Package name | ZB.MOM.WW.Configuration |
Full-word style; aligns with Microsoft.Extensions.Configuration/Options |
| API style | Approach A — lightweight base + rule primitives + DI/startup helpers | Mirrors what all 3 already do; no new fleet-wide dependency; single package |
Explicitly out of scope (YAGNI)
- Runtime draft/snapshot validation — OtOpcUa's
DraftValidator/DraftSnapshot(validating admin-UI-submitted config drafts before commit) stays per-project; only ~1 of 3 apps uses it. - A FluentValidation or DataAnnotations dependency — the apps hand-roll
IValidateOptionsbecause their rules are conditional/cross-field, which DataAnnotations can't express and FluentValidation only solves by adding a new fleet-wide dependency. - Editing the three apps' code — adoption is a tracked follow-on in
GAPS.md, not part of this pass (same stance as Auth/Health/Theme).
2. Approaches considered
- A — Lightweight base + rule primitives + DI/startup helpers (chosen). Extract exactly the boilerplate the apps repeat; keep domain rules per-project. Minimal surface, idiomatic, stays inside .NET's native options pipeline, no new external dependency → single package.
- B — Fluent rule-builder DSL (
RuleFor(o => o.Port).InRange(...)). Nicer ergonomics but a much larger surface to build/test/maintain, and property-chains express the apps' real role-conditional/cross-field rules awkwardly. Rejected: over-built. - C — Wrap DataAnnotations or FluentValidation. Less code to own, but DataAnnotations is too weak for the apps' conditional rules (the reason they hand-roll today) and FluentValidation is a new fleet-wide dependency for modest gain. Rejected.
3. Architecture & repository layout
One new .NET 10 package in its own nested git repo at the workspace root, plus the normalization paper trail and this design doc:
scadaproj/
components/configuration/
README.md
spec/SPEC.md # the one normalized target
shared-contract/ZB.MOM.WW.Configuration.md # proposed public API
current-state/{otopcua,mxaccessgw,scadabridge}/CURRENT-STATE.md
GAPS.md # divergence + adoption backlog
ZB.MOM.WW.Configuration/ # the built library (nested git repo)
src/ZB.MOM.WW.Configuration/
tests/ZB.MOM.WW.Configuration.Tests/
ZB.MOM.WW.Configuration.slnx
docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md # this file
Dependencies (single package — no heavy dep to isolate):
Microsoft.Extensions.Options, Microsoft.Extensions.Options.ConfigurationExtensions,
Microsoft.Extensions.Configuration.Abstractions,
Microsoft.Extensions.DependencyInjection.Abstractions — all already referenced by all three
apps. This is why a single package is the right shape, unlike the multi-package splits Auth
(LDAP/crypto/ASP.NET) and Health (Akka/EF) needed.
4. Public API surface
Four public types in namespace ZB.MOM.WW.Configuration; everything else internal.
4.1 OptionsValidatorBase<TOptions> (abstract IValidateOptions<TOptions>)
Removes the List<string> → ValidateOptionsResult plumbing every validator repeats. The base
implements Validate(name, options), hands a fresh ValidationBuilder to one overridden method,
and returns Success only when no failures were accumulated — otherwise Fail with all
messages.
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{
protected override void Validate(ValidationBuilder v, ClusterOptions o)
{
v.MinCount(o.SeedNodes, 2, "ClusterOptions.SeedNodes");
v.OneOf(o.SplitBrainResolverStrategy, AllowedStrategies, "ClusterOptions.SplitBrainResolverStrategy");
v.RequireThat(o.MinNrOfMembers == 1, "ClusterOptions.MinNrOfMembers must be 1");
v.PositiveTimeSpan(o.StableAfter, "ClusterOptions.StableAfter");
}
}
4.2 ValidationBuilder (failure accumulator)
Carries the standard rule primitives (each checks and appends a consistently-formatted message) plus a low-level escape hatch for custom/cross-field rules.
- Primitives:
Required(string?, field),Port(int, field),HostPort(string?, field),PositiveTimeSpan(TimeSpan, field),OneOf(value, allowed, field),MinCount(collection, n, field). - Escape hatch:
RequireThat(bool ok, string message)andAdd(string message)— for the role-conditional / cross-field rules the apps genuinely need (e.g. "SiteId required only when Role=Site").
4.3 ServiceCollectionExtensions.AddValidatedOptions<TOptions, TValidator>(config, sectionPath)
One call = bind section + register the validator + ValidateOnStart(). Returns the
OptionsBuilder<TOptions> so callers can chain .PostConfigure(...). Replaces each module's
bespoke AddXxx() wiring.
services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(config, "ScadaBridge:Cluster");
4.4 ConfigPreflight (fluent aggregator over raw IConfiguration)
For checks that must run before the host/DI exists (generalizes ScadaBridge's pre-Akka
StartupValidator).
ConfigPreflight.For(configuration)
.Require("ScadaBridge:Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
.RequirePort("ScadaBridge:Node:RemotingPort")
.When(role == "Site", p => p.Require("ScadaBridge:Node:SiteId", NotEmpty, "required for Site nodes"))
.ThrowIfInvalid(); // throws one InvalidOperationException listing ALL errors
DRY: ValidationBuilder and ConfigPreflight share one internal Checks helper so rule
logic and message wording are identical whether validating a bound options object or raw config
keys.
5. Error-handling contract
- Accumulate, never fail-fast-on-first. Both front-ends collect all failures and surface them together — one boot shows every config problem, not just the first.
- Two surfacing paths: options validators flow through
ValidateOnStart()→ host throwsOptionsValidationExceptionat startup;ConfigPreflight.ThrowIfInvalid()throws a singleInvalidOperationExceptionwith a newline-bulleted list — byte-compatible with ScadaBridge's currentStartupValidatormessage shape, so its swap is a drop-in. - Consistent message format:
"<Section:Key> <reason>", one per failure.
6. Testing
xUnit, matching the sibling repos; target ~40–60 tests (cf. Health 58 / Telemetry 19).
- Each primitive at its boundaries —
Portat 0/1/65535/65536,Requirednull/empty/whitespace,HostPortparse success/failure,PositiveTimeSpanzero/negative,OneOfcase-insensitivity,MinCount. - Base aggregation: multiple failures all reported; clean options →
Success. AddValidatedOptions: bad config makes the host throw at start; good config resolves;ValidateOnStartactually fires.ConfigPreflight: aggregation,When(...)conditional branches,ThrowIfInvalidmessage format.
7. Versioning, packaging & adoption
- Version
0.1.0,dotnet pack→ 1 nupkg. Build/test fromZB.MOM.WW.Configuration/viadotnet test— identical conventions to the other four libraries. - Not adopted in-pass (same stance as Auth/Health/Theme). The
current-state/docs map each app's validators withfile:linerefs, andGAPS.mdbecomes the per-project delete/replace backlog. - Consumer matrix: all three apps consume the single package. ScadaBridge is the heaviest
adopter (most
*OptionsValidator+ theStartupValidator→ConfigPreflightswap); OtOpcUa and MxGateway adopt the base +AddValidatedOptionsfor their options validators.
Current-state anchors (to verify against live code during the pass)
- OtOpcUa:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs(draft validation stays per-project); options validators elsewhere in…Configuration. - MxAccessGateway:
src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator, GatewayConfigurationServiceCollectionExtensions}.cs. - ScadaBridge: per-module
*OptionsValidator.cs(ClusterInfrastructure,Security,HealthMonitoring,AuditLog) +src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs+ each module'sServiceCollectionExtensions.cs.
8. Index/registry housekeeping (part of this pass)
- New row in
components/README.mdregistry. - Component-normalization table + a normalization paragraph in the root
CLAUDE.md. - Tick the "Config + validation" Tier-2 item in
upcoming.md.
9. Implementation task outline
Tracked in the session task list; dependency order:
- Write
components/configuration/normalization docs (spec, shared-contract, 3× current-state withfile:line, GAPS, README). - Implement
ZB.MOM.WW.Configuration(the four public types + internalChecks). - Add
ZB.MOM.WW.Configuration.Tests(~40–60 xUnit tests). - Update registry/index + package (
dotnet pack→ 1 nupkg @ 0.1.0; init nested git repo).
A detailed, bite-sized implementation plan follows via the writing-plans skill.