Architecture Decision Record
ADR-070: Fail-Fast Configuration Contract
Status
Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the consumer-repo facade claim narrowed to production code, with the controller-test exception recorded).
Context
Every host in the workspace reads a dozen or more configuration sections: connection strings, SMTP, JWT key material, outbox tuning, message-bus provider, module enablement, page-size limits. A settings value can go wrong in two places. It can be discovered at first use, where a missing SMTP host becomes a 500 on the first password-reset mail and an unparseable outbox interval becomes a background loop that never drains, both on a replica that already passed its readiness probe and is taking live traffic. Or it can be discovered at boot, where the host refuses to start and the platform never routes to it.
Nothing in the record decided which. ADR-025 gates a replica out of rotation until warm-up has had its chance, but warm-up runs only on a host that started. ADR-031 decides that feature flags are read from configuration without saying what happens when the section is absent. ADR-061 decides where a secret value comes from at runtime, not what a host does when the value never arrives.
A second, related question had no recorded answer: how code below the host reaches a bound setting. The
options pipeline is a Microsoft.Extensions.Options concern, and a layer that is supposed to be free of
hosting concerns taking an IOptions<T> constructor parameter drags that pipeline into it.
Decision
Bind every settings section through a validating chain that runs at startup, and expose a settings type through a read-only interface when it must be read above Infrastructure.
- One binding shape, used everywhere.
AddOptions<T>().Bind(configuration.GetSection(T.SectionName)).ValidateDataAnnotations().ValidateOnStart()is the required form (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:67-70)..BindConfiguration(T.SectionName)is the accepted shorthand for theBind(GetSection(...))step and is what Store's Sales module uses (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/SalesModule.cs:47-50,:52-55).ValidateOnStart()is the load-bearing link: without it the annotations are evaluated lazily on first resolution, which is exactly the first-use failure the contract exists to prevent. - The framework owns the base set. Eight sections are bound this way in the Infrastructure package's
DependencyInjection.csalone: seven insideAddInfrastructure(ConnectionStringSettings:67-70,SmtpSettings:81-84,PersistenceSettings:123-126,OutboxSettings:128-131,LoginProtectionSettings:133-136,MessageBusSettings:139-142,JwksSettings:144-147) and an eighth in the opt-inAddPushNotifications(PushNotificationSettings:530-533). Three more are bound by the Presentation packages:IdempotencySettings(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:70-73),JwtSettings, bound insideAddCommonAuthentication(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:502-505), andApiSettings(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:32-35). - Each service host adds exactly two of its own,
ApplicationSettingsandModulesSettings, and all eight hosts across the three application repos do it identically: ADC's four services (for exampleMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:170-173and:313-316), Store's three (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:123-126and:226-229), and the Helpdesk monolith seed (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:17-20and:75-78). Modules may add their own sections on the same chain, as Store's Sales module does for Stripe. - Validation is data annotations, extended by
IValidatableObjectwhere a rule spans fields.JwtSettingsmarksIssuerandAudience[Required](MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/JwtSettings.cs:34-39) and implementsIValidatableObject(:16) so key material is checked conditionally on the selected algorithm: HS256 demands aSecretForKeyof at least 32 characters, RS256 demandsRsaPrivateKeyPem(:51-66).ValidateDataAnnotations()invokes that method, so a host configured for RS256 with no private key fails to boot rather than failing to sign its first token. - Read-only interface facades over
IOptions. Five settings types are additionally registered as singletons that resolveIOptions<T>.Valueand hand back an interface:IApplicationSettings(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:31),IJwtSettings(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:65),IConnectionStringSettings(:71),ISmtpSettings(:85), andIPushNotificationSettings(:534-535). The interfaces declareget/initmembers only (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/ISmtpSettings.cs:6-28), so a consumer can read a setting and cannot rebind it. The interface forIJwtSettingslives in a file namedIJwSettings.cs(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/IJwSettings.cs:10); the type name, not the filename, is the contract. - Three recorded exceptions to the chain, all in the framework.
CacheKeyPrefixOptionsis bound with a bareservices.Configureand no validation, because an absent section must leave cache keys exactly as callers write them (a single call insideAddCaching,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:172, the method starting at:164).LayoutSettingsis bound without validation for the same reason: it is optional footer copy (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:38-39).DataSourcesSettingsis constructed directly from the section rather than throughAddOptions, because a root-level dictionary section does not bind through the options pipeline (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:75-77).
Adoption of the facade half is partial, and this ADR settles the direction rather than claiming the
state. Only five of the framework's settings types have a facade; the rest are consumed as IOptions<T>
inside Infrastructure and API, which is where they belong: OutboxProcessor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxProcessor.cs:57),
BrokerEventBus (.../Services/BrokerEventBus.cs:34), LoginProtectionService
(.../Auth/LoginProtectionService.cs:21), RsaJwksProvider (.../Auth/RsaJwksProvider.cs:15),
SQLServerDbContext (.../Persistence/DbContexts/SQLServerDbContext.cs:37), and IdempotencyFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:432). The facades are
consumed only inside the framework: EntityControllerBase resolves IApplicationSettings per request at
two sites, its MaxPageSize and MaxExportRows accessors
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:60 and :80),
RepositoryFactory takes it in its constructor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:14),
TokenService takes IJwtSettings (.../Services/TokenService.cs:47), and SmtpEmailSender takes
ISmtpSettings (.../Services/SmtpEmailSender.cs:12). No consumer repo's production code resolves a
facade: nothing under Source/ in ADC, Store, or Helpdesk references IApplicationSettings,
IJwtSettings, or ISmtpSettings. The only consumer references are in controller tests, and they exist
because of EntityControllerBase: Store registers a stand-in IApplicationSettings in six API test
fixtures so MaxPageSize resolves (for example
MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.API.Tests/Controllers/ShoppingCartsControllerTests.cs:44),
and six ADC Conference controller tests name it only in a comment recording that an empty provider yields
the default (for example
MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/SponsorsControllerTests.cs:41);
Helpdesk has no reference of either kind.
Where consumer code reads a setting it injects IOptions<T> directly (Store's Sales Infrastructure
in StripeClientFactory.cs:21, StripePaymentService.cs:62, StripeWebhookRegistrationService.cs:29,
PaymentReconciliationService.cs:35-36; ADC's web host in its /client-config minimal-API handler,
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:145, and its calendar component in
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Components/AddToCalendarButton.razor:7).
The Application-layer invariant nevertheless holds today: no Application-layer file in any of the four
repos takes an IOptions dependency, because settings are read at the Infrastructure, API, and host
edges. The settled direction is therefore narrower than "never use IOptions": the fail-fast chain is
mandatory for every section other than the three recorded exceptions, and the facade is the required
form only when a settings type must be read above Infrastructure. Below that boundary IOptions<T> at
the point of consumption stays correct, and the direct injections listed above are conformant, not debt,
with the exception that new framework settings crossing into Application or controller code get a facade.
Rationale
- A boot failure is cheaper than a first-use failure. A host that will not start is caught by the
deployment, by a local
dotnet run, or by CI. A host that starts and fails on the first mail send is caught by a user, on a replica the platform already considers healthy. ValidateOnStartis the whole point of the chain.ValidateDataAnnotations()alone defers evaluation to first resolution, which for a section only a background service reads can be minutes after the replica takes traffic. Pairing the two is what converts "configured wrong" into "did not start".- Validation belongs on the settings type. Annotations plus
IValidatableObjectkeep the rule next to the property it constrains, soJwtSettingscan express "RS256 requires a private key" once instead of every host re-checking it. - A facade keeps the options pipeline out of layers that should not name it.
EntityControllerBaseandRepositoryFactoryneed one number each; takingIApplicationSettingsrather thanIOptions<ApplicationSettings>keepsMicrosoft.Extensions.Optionsout of their signatures, and theinit-only members make the setting unwritable at the point of use. - One shape makes the contract auditable. Because the chain is textually identical in all eleven
framework registrations and all sixteen host registrations, a grep for
ValidateOnStartis a complete inventory of what a host validates at boot.
Trade-offs
- Nothing enforces it. There is no architecture fitness test asserting that a new
AddOptions<T>call carriesValidateDataAnnotations().ValidateOnStart(). The uniformity above is convention held by review, not a gate, which is weaker than the invariant-over-discipline posture ADR-015 applies elsewhere. A section added without the chain fails silently, which is to say it fails later. - Bad configuration becomes a crash loop, not a degraded start. A deployed replica with a missing required value never reaches the warm-up and readiness machinery of ADR-025: it terminates at host build. That is the intended trade (no half-configured replica serves traffic), but it means a configuration mistake takes the whole rollout rather than one code path.
- Two ways to read a setting coexist. Five types have a facade and the rest do not, and no consumer repo's production code uses a facade at all, so a reader encounters both forms. The boundary above sets which is correct where, but it does not make the codebase look uniform today.
- Data annotations are the vocabulary. Anything richer needs
IValidatableObjector a customIValidateOptions<T>, and onlyJwtSettingsreaches for the former today. - A facade is a snapshot. Each facade is a singleton resolved from
IOptions<T>.Value, so it captures the bound instance once and never observes a configuration reload.IOptions<T>itself has the same property; a section that genuinely needs reload would have to move toIOptionsMonitor<T>, and none does today.
Related
ADR-025 (startup warm-up and readiness gating: this contract decides what happens before a host reaches that machinery), ADR-031 (feature flags read from configuration, whose section this contract governs the binding of), ADR-061 (where a secret value comes from at runtime; this decides what the host does when it does not arrive), ADR-015 (architecture fitness functions, the enforcement mechanism this contract currently lacks).