to navigate Enter to open "…" all these words ANDOR to combine

Onboarding guide

12. API Hosting, Middleware, Idempotency & DTO/Contract Mapping

What this group covers. This is the ASP.NET Core edge of the framework: the layer that turns an HTTP request into a domain call and turns a Result back into an HTTP response. Almost everything here lives in MMCA.Common.API (the presentation layer that sits above Infrastructure in the dependency flow, see primer §1), with a handful of transport-agnostic collaborators in MMCA.Common.Application (ICorrelationContext, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>), MMCA.Common.Infrastructure (CorrelationContext, JwtForwardingDelegatingHandler), and MMCA.Common.Shared (the DTO vocabulary and SupportedCultures). The group has seven interlocking concerns: the composition root that registers the whole edge; the middleware pipeline every request flows through, itself expressed as ordered data rather than a hard-coded call sequence; the error translation that keeps every failure shaped like RFC 9457 Problem Details; the controller hierarchy that hands a module ready-made CRUD, export, auth, recovery, and service-discovery endpoints; the write-safety controls (idempotency keys and conditional writes) that make a retried or racing write predictable; the contract surface (DTO/request mapping, JSON conversion, model binding, correlation, tenancy, feature gating, output caching); and the well-known endpoints that make an extracted service self-describing. Read the group as the reusable ASP.NET host a downstream service (Store, ADC, Helpdesk, or an extracted microservice) drops into place so its own code is nothing but modules. Its central rubric column is [Rubric §9, API & Contract Design] (consistent, versioned, standardized contracts and error shapes), with heavy supporting roles for [Rubric §12, Performance & Scalability], [Rubric §11, Security], [Rubric §13, Observability & Operability], [Rubric §7, Microservices Readiness], and (since ADR-027) [Rubric §27, Internationalization].

The composition root: AddAPI, the builder extensions, and the module-host pair. A host wires the edge through two static extension classes. DependencyInjection's AddAPI (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:44) registers MVC controllers with the global UnhandledResultFailureFilter (DependencyInjection.cs:49) and ReturnHttpNotAcceptable = false (:48), wires two JSON converters into the serializer options, the CurrencyJsonConverter (DependencyInjection.cs:53) and the EnumerationJsonConverterFactory (DependencyInjection.cs:58, registered here because a concrete enumeration does not inherit the base class's [JsonConverter] attribute), adds the XML formatters (DependencyInjection.cs:60), optionally installs the ModuleControllerFeatureProvider when a ModulesSettings instance is supplied (DependencyInjection.cs:62-66), binds IdempotencySettings with ValidateDataAnnotations().ValidateOnStart() when configuration is supplied (DependencyInjection.cs:68-74, ADR-070), registers the two scoped action filters IdempotencyFilter and OwnerOrAdminFilter (DependencyInjection.cs:77-78, scoped because they depend on scoped services), turns on feature management with targeting through CurrentUserTargetingContextAccessor and the DisabledFeatureHandler (DependencyInjection.cs:91-93), and registers the edge error-localization boundary (AddErrorLocalization, DependencyInjection.cs:96 and :107, with AddErrorResources<TResource> at :122 for each module's own .resx set). Three sibling methods on the same class complete the picture: AddCommonExceptionHandlers (DependencyInjection.cs:135) registers the Problem Details service and the five exception handlers in most-specific-first order (:140-144), AddServerAuthSessionCookie (DependencyInjection.cs:160) registers the Blazor Server host's SSR cookie reader (CookieTokenReader, :166) plus the singleton CookieSessionRefresher (:172, singleton so its in-flight map is shared across requests), and AddModuleHealthChecks (DependencyInjection.cs:188) turns ModuleLoader discovery results into module-{Name} health checks, tagged module so /health?tag=module filters them (Healthy for enabled modules at :192-198, Degraded for disabled ones at :200-207). WebApplicationBuilderExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:33) carries the identical builder-side setup every service shares: header-based API versioning through the api-version header (AddCommonApiVersioning, line 244, reader at line 253, v1.0 assumed when the header is absent at line 251, ADR-046), rate limiting (AddCommonRateLimiting, three overloads at lines 296, 314, and 332), Brotli and Gzip compression at CompressionLevel.Fastest (AddCommonResponseCompression, line 374, both providers pinned to Fastest at lines 382-383 and 387-388 because these are dynamic per-request payloads on fractional vCPUs), OpenAPI (line 403), CORS (line 581, ADR-082, with the two policy names as constants at lines 35 and 38 and the allow-any-origin policy reachable only in Development, lines 593-597), and the two JWT bearer registrations: in-process AddCommonAuthentication (line 538) for the Identity host and AddForwardedJwtBearer (line 445) for extracted services that validate against a remote JWKS. Two smaller helpers keep a service host's Program.cs honest. ModuleHostExtensions's AddModuleHost (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:51) collapses the settings-bind plus loader construction every module-hosting service repeated verbatim, and answers with a ModuleHostContext (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:21) carrying the bound settings, the loader, and RegisterModules (ModuleHostContext.cs:66) as a step the host hands to its own application pipeline: discovery is deliberately not run inside AddModuleHost, because it has to sit between AddApplication() and AddApplicationDecorators() at a host-chosen position (ModuleHostContext.cs:13-19, ADR-014). JwtAuthorityExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAuthorityExtensions.cs:15) reads the authority every non-issuer service validates against and throws when the AppHost never injected it (GetRequiredJwtAuthority, :35-43), because a host that boots without one answers every authenticated request with a 401 that looks like a token problem instead of a wiring problem. Only one DI ordering rule is load-bearing in the whole host, and it belongs to the CQRS pipeline group, not here: AddApplicationDecorators (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:91) must run last so Scrutor can decorate handlers that are already registered. The API registrations themselves are order-independent. This is the [Rubric §9, API & Contract Design] and [Rubric §12, Performance & Scalability] story: versioning, compression, rate limiting, and CORS are configured once and inherited by every service instead of copy-pasted per host.

The request pipeline is data, not prose. Middleware order is behavior in ASP.NET Core, so the framework does not leave it to each host's Program.cs, and it no longer even leaves it as a fixed sequence of Use... calls. WebApplicationExtensions's UseCommonMiddlewarePipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:48) and its Action<MiddlewarePipelineBuilder> overload (WebApplicationExtensions.cs:60) both route through one private ApplyPipeline that seeds the defaults, lets the host adjust them, validates the result, and only then applies each step (WebApplicationExtensions.cs:140-147). The steps themselves are MiddlewarePipelineStep records (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStep.cs:21), each a stable Name plus an Action<WebApplication> Configure delegate, both null-validated on construction (MiddlewarePipelineStep.cs:27 and :30); because a step is inert data until someone runs it, the whole order is assertable without building a host. MiddlewarePipelineBuilder (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:16) owns the list: CreateDefault (MiddlewarePipelineBuilder.cs:32) seeds the eighteen framework steps in order (:34-156) and InsertBefore, InsertAfter, Replace, and Remove (:166, :183, :203, :224) let a host address any of them by name, which is why MiddlewarePipelineStepNames (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:14) is public contract rather than an implementation detail: renaming a constant there is a breaking change, and the declaration order of those constants (:17-74) is the runtime order. Read it top to bottom and you have the edge: exception handler, correlation id, request localization, pre-forwarded capture, forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant resolution, rate limiting, the soft-deleted-user filter, authorization, output cache, the JWKS and OIDC discovery endpoints, and finally the controllers. Four of those adjacencies are load-bearing, and Build (MiddlewarePipelineBuilder.cs:258) re-checks them before a single step is applied (:259-277): the pre-forwarded capture must run immediately before UseForwardedHeaders or the captured scheme and host are no longer the ones the connection saw; authentication must run immediately before tenant resolution because the claim strategy reads HttpContext.User; authentication must precede the rate limiter (ADR-019) because GlobalRateLimitPartition keys on the authenticated principal and an unauthenticated pipeline would see every request as anonymous; and forwarded headers must precede the HTTPS redirect so the redirect decision reads the proxy-reported scheme. A violation throws InvalidOperationException at startup with the offending order printed (:325-326 and :340-341), and an invariant binds only when both of its steps are still present (:320 and :335), so dropping a whole capability stays legal while reordering a pair does not. Two more decisions are worth internalizing from the default step list: the HTTPS redirect is wrapped in a UseWhen that skips any request whose content type starts with application/grpc (MiddlewarePipelineBuilder.cs:91-93), because extracted gRPC services speak HTTP/2 cleartext (h2c) and a 307 would break the call; and the forwarded-headers step clears the known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs (:76-77), which is safe only because the pre-forward scheme and host were already stashed in HttpContext.Items under the two keys declared at WebApplicationExtensions.cs:24 and :33. Hosts freeze their own resulting order with the opt-in fitness function MiddlewarePipelineOrderTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:29), whose default expectation is the framework list verbatim (:40-57), so a reorder fails a fast unit test instead of surfacing as an unreachable jwks_uri or a rate cap that never engages (ADR-079). Alongside the pipeline, UseCommonRequestLocalization (WebApplicationExtensions.cs:73) builds the culture options from SupportedCultures (MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:9: en-US as the default at line 12 and the full en-US plus es list at line 18, with the qps-Ploc pseudo locale at line 28 added in Development only, WebApplicationExtensions.cs:80-83) so edge error localization runs under the caller's culture, and the companion MapCultureEndpoint (WebApplicationExtensions.cs:102) serves the GET /culture/set switch that Blazor UI hosts map (ADR-027). Turning order into inspectable, validated data is [Rubric §12, Performance & Scalability], [Rubric §14, Testability] and [Rubric §34, Architecture Governance] in one move.

Rate limiting: one always-on partition, one named policy, and an optional shared counter. The global limiter is active on every request and rejects with 429 above RateLimitingSettings.GlobalPermitLimit (default 300 requests per minute, MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:40) per authenticated user: GlobalRateLimitPartition (WebApplicationBuilderExtensions.cs:80) routes anonymous traffic down a no-limiter branch (lines 86-89), and health, liveness, /.well-known/*, and application/grpc traffic bypass the limiter outright (IsRateLimitBypassed, lines 61-65). Because it deliberately no-ops for anonymous callers and account lockout is per-email, a password spray (one password, many email addresses) from a single source would otherwise be unthrottled. The framework closes that gap with the named auth-ip policy (RateLimitPolicyAuthIp, WebApplicationBuilderExtensions.cs:48), whose partition selector AuthIpRateLimitPartition (line 221) is a per-client-IP window defaulting to 30 requests per minute (RateLimitingSettings.cs:47) and fails open on an unattributable IP (lines 225-226) rather than collapsing every such request into one shared bucket, which would throttle the in-process test server to a standstill. Unlike the other named limiters, this one is not left for each app to attach: AuthControllerBase carries [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] on both LoginAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:72) and RegisterAsync (AuthControllerBase.cs:96), and PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> carries it on both recovery endpoints (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:78 and :102), so every consumer inherits spray protection by construction, while RefreshAsync (AuthControllerBase.cs:117-119) is deliberately left unthrottled because refresh is periodic and automatic and Blazor Server circuits issue it server-side from one shared host IP. A consumer that inherits the base without calling AddCommonRateLimiting fails at startup on an unregistered policy, which is the loud failure rather than the silent one. Two knobs sit on top of that baseline, both reachable only through the IConfiguration overload (WebApplicationBuilderExtensions.cs:315) that binds the RateLimiting section. Algorithm (RateLimitingSettings.cs:53) selects the RateLimitAlgorithm enum (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8): FixedWindow is the default and cheapest but lets a caller spend an allowance twice across a boundary (RateLimitAlgorithm.cs:10-15), while SlidingWindow divides the same one-minute window into SegmentsPerWindow segments (default 4, RateLimitingSettings.cs:62) and smooths that burst away at the cost of one counter per segment. And Distributed (RateLimitingSettings.cs:72) swaps the in-memory counter for RedisFixedWindowRateLimiter (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37), so a limit means the same thing behind a load balancer as it does on one node. All three choices funnel through one private factory, CreateLimitedPartition (WebApplicationBuilderExtensions.cs:149), which takes the Redis path only when an IConnectionMultiplexer is actually registered and otherwise falls through to the in-memory limiters rather than failing startup (lines 157-178). The Redis limiter is worth reading for its three deliberate compromises: it stores one INCR counter per partition per window under rl:{partitionKey}:{unixMinute} (line 130) and gives it a 65 second TTL on the increment that creates it (line 142), so keys expire themselves and clock skew between instances cannot hand a partition a fresh allowance mid-window; the increment is not transactional with the permit decision, so a simultaneous burst can overshoot slightly, accepted in exchange for one round trip per request (RedisFixedWindowRateLimiter.cs:28); and any Redis fault permits the request and logs at most one warning per window across the process (lines 147-154), because rate limiting protects capacity and must never itself become the reason a healthy request is rejected. RedisRateLimitLease (RedisFixedWindowRateLimiter.cs:169) is the two-instance lease type it hands out, Acquired and Rejected as shared statics (lines 172 and 175) so a permitted request allocates nothing. The auth-ip policy stays per-instance whatever Distributed says (allowDistributed: false, WebApplicationBuilderExtensions.cs:235): per-account lockout already backs it, and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (ADR-019 for the layering, and ADR-029 for the brute-force half). This is [Rubric §11, Security] and [Rubric §12, Performance & Scalability] handled once at the edge.

Correlation, tenancy, and the soft-deleted-user gate: three ambient facts established once. CorrelationIdMiddleware (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:15) reads the X-Correlation-ID request header (the constant lives at CorrelationIdMiddleware.cs:18), falling back to the current W3C trace id then ASP.NET's TraceIdentifier (lines 32-34), writes it onto the scoped ICorrelationContext (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8, implemented by CorrelationContext, which self-seeds a GUID when no middleware ever sets one, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CorrelationContext.cs:12), and echoes it back through Response.OnStarting (CorrelationIdMiddleware.cs:37-41). That single id is what the CQRS logging decorators read (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:16 and :24) and stamp onto every log scope through the Command {CommandName} [Module: {ModuleName}] [CorrelationId: {CorrelationId}] scope definition (LoggingCommandDecorator.cs:69-70), so one request is traceable end to end, and by module. TenantResolutionMiddleware (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:36) is the HTTP half of ADR-073: it tries each configured strategy in order (claim, then header; the TenantResolutionStrategy.Host option is defined but deliberately returns null rather than being guessed at, lines 105-121) and publishes the winner on the scoped ITenantContext that the persistence query filter, save interceptor, and per-tenant database routing all read (line 70). Two decisions mirror the soft-deleted-user middleware below. It is wired unconditionally but inert by default, because TenancySettings resolves to defaults with Enabled false in a host that never called AddMultiTenancy (line 62). And it fails closed: with Tenancy:RequireTenant on, a request that resolves no tenant is rejected at line 83 and answered 400 with an RFC 9457 body naming the claim and header it looked at (lines 133-146), because an unscoped request would read across every tenant, which is the exact outcome tenancy exists to prevent; an explicit RequireTenant opt-out lets the request run as a system caller instead (lines 75-81), and health, liveness, and discovery paths are excluded so probes still answer before any tenant exists (lines 89-94). SoftDeletedUserMiddleware (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31) enforces business rule BR-133: an authenticated caller whose account was soft-deleted is rejected with a bare 401 (lines 104 and 145), checked first against a marker cached for 30 seconds (SoftDeletedUserCache.MarkerDuration, MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29, read at SoftDeletedUserMiddleware.cs:91 and written at :132) to keep the per-request lookup cheap (ADR-047). It resolves ISoftDeletedUserValidator lazily from RequestServices (line 75) instead of as an InvokeAsync parameter, so a service that does not host Identity passes the request through rather than 500-ing on every call: an explicit nod to the [Rubric §7, Microservices Readiness] extraction path. And unlike tenancy it fails open: a cache read that throws falls back to the validator query (lines 93-99), and a validator query that throws lets the request continue (lines 118-124), because failing closed would turn any cache or database blip into a total outage for every authenticated request, while the exposure it buys back is bounded by the access-token lifetime. All three middlewares are [Rubric §13, Observability & Operability] (correlation), [Rubric §11, Security] (deleted-account lockout), and [Rubric §8, Data Architecture] (tenant scoping) concerns handled once at the edge instead of in every controller.

Errors become Problem Details, through two channels and one table. Failures reach the client two ways. Thrown exceptions are caught by the handler chain registered in AddCommonExceptionHandlers (DependencyInjection.cs:137-146), evaluated most-specific-first: OperationCanceledExceptionHandler (499 Client Closed Request, OperationCanceledExceptionHandler.cs:32), DomainExceptionHandler (400, DomainExceptionHandler.cs:32), DbUpdateExceptionHandler (409 at line 33 with a deliberately generic detail so database schema names never leak, DbUpdateExceptionHandler.cs:35-37), ValidationExceptionHandler (400 at line 33 with FluentValidation errors grouped by property name into the errors extension, ValidationExceptionHandler.cs:46-54), and finally GlobalExceptionHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:23), which is the 500 catch-all (line 69) plus one special case: a CrossTenantWriteException is answered 400 with a fixed title and detail that name nothing the exception carries (constants at :29 and :37-39, handled at :47-65), because echoing a tenant id back tells an unauthorized caller which tenant owns the row it just tried to write, and it is logged as a warning rather than an error because a tenant-scoped API refusing an untenanted write is routine. Business failures that travel as Result.Failure rather than as exceptions are mapped by ApiControllerBase's HandleFailure (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:35), which falls back to a 500 when the error list is empty (lines 39-45) and otherwise derives the status from the most severe error present rather than the first (line 48), so an aggregate built by Result.Combine cannot be downgraded by error ordering: a 403 or a 500 travelling alongside a validation error still answers 403 or 500, and equal ranks keep the earliest error (the ranking is documented at ApiControllerBase.cs:22-30 and lives in ErrorTypeSeverity in MMCA.Common.Shared, so the gRPC edge classifies the same aggregate identically). The safety net UnhandledResultFailureFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:22, an IAlwaysRunResultFilter) catches any action that accidentally returned a failed Result as a 200 body, logs a warning, and rewrites it as the correct error (lines 28-49). All of those paths converge on ErrorHttpMapping (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14), whose FrozenDictionary<ErrorType, int> (lines 20-31) is the single source of truth mapping each ErrorType (Validation, Invariant and Failure to 400, NotFound to 404, Conflict to 409, Unauthorized to 401, Forbidden to 403, UnprocessableEntity to 422, Unexpected to 500) to a status code, with 400 as the fallback for anything unmapped (lines 37-38) and the list-taking overload at lines 50-51 applying the severity ranking. Its BuildErrorsExtension (line 61) localizes each Error's human message at the edge through IErrorLocalizer (MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/IErrorLocalizer.cs:9), keyed by the stable Code, leaving Code, Type, Source, and Target verbatim so clients can still branch on them, and leaving the original English message untouched when no localizer is registered (line 65). ErrorLocalizer (MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorLocalizer.cs:11) walks the registered ErrorResourceSource (MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorResourceSource.cs:12) list in registration order (lines 23-30: the framework's own ErrorResources (MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9) anchor first, then each module's resources added through AddErrorResources, DependencyInjection.cs:124) and falls back to the caller's message when no source knows the code (line 32). This is [Rubric §9, API & Contract Design] (one consistent RFC 9457 shape) meeting the [Rubric §1, SOLID] discipline of never duplicating the mapping, and [Rubric §27, Internationalization] at the one boundary where a machine-readable code becomes human prose.

The controller hierarchy: generic CRUD earned by inheritance. A module gets working endpoints by subclassing one generic base and supplying its type parameters (ADR-034). ApiControllerBase is the root: [ApiController], HandleFailure, nothing else. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:36) adds the read surface (GetAllAsync at line 106, paged at line 153, lookup at line 371, GetByIdAsync at line 410) over an IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, with field projection through a fields query parameter, X-Pagination header metadata (line 187), and a page size clamped to MaxPageSize (resolved per request from IOptions<ApplicationSettings>, defaulting to 500, lines 58-64). AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:28) extends it with an [Idempotent] POST create (lines 58-59) that returns 201 through CreatedAtRoute("Get{EntityName}ById", ...) (line 72) and a DELETE that dispatches DeleteEntityCommand<TEntity, TIdentifierType> and returns 204 (lines 84-97), and CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:54) closes the set with the conditional PUT {id} (attributes at lines 87-89, the six documented response codes at :90-96, the token read at :102, and a fresh ETag on the way out at :111). The interfaces IEntityControllerBase<TEntityDTO, TIdentifierType> (IEntityControllerBase.cs:14) and IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest> (IAggregateRootEntityControllerBase.cs:15) describe those shapes for testing and documentation. The generic constraints tie the tower together: TEntity derives from AuditableBaseEntity<TIdentifierType> for reads and from AuditableAggregateRootEntity<TIdentifierType> for writes, TEntityDTO implements IBaseDTO<TIdentifierType>, and TCreateRequest implements ICreateRequest (IEntityControllerBase.cs:17-18, IAggregateRootEntityControllerBase.cs:20-22, AggregateRootEntityControllerBase.cs:41-44). Alongside the CRUD tower sit six special-purpose bases: AuthControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:43, anonymous login, register, and refresh over IAuthenticationService, lines 67-117, plus the [Authorize] revoke at line 142 and the multi-device pair GET my-sessions (line 173) and POST revoke/{sessionId:guid} (line 205), whose current-device flag comes from the access token's own sid claim so no client state is involved, ADR-097); PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43), a sibling of that base rather than an addition to it because each app's own AuthController already occupies the single-inheritance chain (it derives straight from ApiControllerBase, PasswordResetAuthControllerBase.cs:45), serving POST forgot-password (line 75) and POST reset-password (line 99); both are anonymous by necessity since the caller has lost the credential, forgot-password always answers 202 whether or not the address exists (line 92) so the response never reveals which addresses hold accounts, and each app supplies its own command record through a one-line factory (:61 and :69, ADR-091); UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:41), which subclasses AuthControllerBase purely additively (line 46) and adds the self-service account endpoints PUT password (line 86), PUT preferences (line 112), and GET preferences (line 138), taking the two mutation commands as type parameters because each app owns its own command record while the preferences query is shared (UserAccountAuthControllerBase.cs:48-49); OAuthControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:35, the Google, GitHub, and Apple external-provider flow at lines 51, 59, and 70, whose single-use exchange code, cached for two minutes at OAuthControllerBase.cs:46, keeps tokens out of the redirect URL; ADR-036 and ADR-043); DataExportControllerBase<TQuery> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59), the data-subject access and portability endpoint (ADR-076), which serves GET {userId}/export (line 77) as a real file download rather than an inline body (returned through File(...) at line 109, with an invariant user-data-{userId}-{yyyyMMdd}.json name derived from the package's own GeneratedOn at lines 133-134), gated by both an [Authorize] and a [FeatureGate] on PrivacyFeatures.DataExport (lines 57-58) while the handler independently enforces owner-or-privileged-role, and abstract only because each app owns its own query record (CreateQuery, line 119); and ServiceInfoControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30), whose dual-version /ServiceInfo returns ServiceInfoResponse for the deprecated v1.0 (line 51) and ServiceInfoV2Response for v2.0 (line 54, a superset adding the supported and deprecated version lists held at lines 32-33), proving the versioning machinery works across versions ([MapToApiVersion] at lines 40 and 46). All of these carry the same note: class-level routing and versioning attributes are not reliably inherited, so the per-service sealed subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §15, Best Practices & Code Quality] payoff in the presentation layer: a module writes a DTO, a mapper, and a short sealed subclass, and inherits a fully paged, filterable, exportable, error-mapped REST resource, with [Rubric §30, Compliance & Data Governance] covered by the DSAR base.

Export is a route, not a content negotiation. EntityControllerBase.ExportAsync (EntityControllerBase.cs:247-251) streams the same filtered collection the paged endpoint serves as an RFC 4180 CSV download, page-looping the query service server-side (ADR-078). It is a distinct path rather than an Accept: text/csv variant for two reasons stated in the source: the public output-cache policy varies by query string but not by Accept, so a cached JSON body could be replayed to a CSV request, and AddAPI sets ReturnHttpNotAcceptable = false, so a negotiation miss would fall back to JSON silently instead of returning 406 (EntityControllerBase.cs:198-203). The writing half is CsvWriter (MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:34), a deliberately hand-written internal helper: it quotes only when RFC 4180 requires it (WriteField, line 145, with the trigger set as a SearchValues<char> at line 61), terminates records with CRLF regardless of host OS (line 48), formats cells invariantly so the same row produces the same bytes on every machine (FormatCell, line 128), and writes a UTF-8 BOM explicitly (lines 45 and 69, paired with the preamble-free encoding at line 55) because Excel reads a BOM-less UTF-8 CSV in the machine's ANSI code page. Two guards run before any byte is written. Columns a CSV cannot represent faithfully, binary concurrency tokens and every non-string collection property, are computed once per closed controller type and dropped (UnexportablePropertyNames at EntityControllerBase.cs:639, the type test at :667-670); value objects and other class-typed properties are deliberately kept, since their invariant ToString is exactly the cell a reader expects (:635-637). And a caller who names one of those dropped properties in fields= gets an Error.InvalidEntityField validation failure rather than a quietly missing column (ValidateExportFields, :685, called at :259). Then the controller opens a StreamWriter over Response.Body without committing the response (line 277), which is what keeps the "a failure on page one still returns Problem Details" path honest (line 298), and the row ceiling is announced up front through the X-Export-Row-Limit header (constant at line 535, default 100,000 at line 526, overridable per host through MaxExportRows at lines 78-84, written alongside the Content-Disposition attachment name in BeginExportResponse at lines 713-714) with the truncation notice written as a final body line (line 349), because headers are frozen the moment the first byte flushes. Row scoping is a shared hook rather than an export-only one: GetReadSpecificationAsync (line 597) is what both the list endpoints and the export read, and it returns GetExportSpecification(), itself null by default (line 626), so an export can no longer drift wider than the list it mirrors but is unscoped until a controller overrides one of the two. That is a [Rubric §12, Performance & Scalability] and [Rubric §11, Security] pairing worth reading closely.

Idempotency for safe retries. Write endpoints are made replay-safe by IdempotentAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotentAttribute.cs:16), a one-line ServiceFilterAttribute that resolves the scoped IdempotencyFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:67) from DI (ADR-017). The filter runs at two MVC stages. As an IAsyncResourceFilter it runs before model binding, the last point at which the body can be made replayable, and calls EnableBuffering only when an Idempotency-Key header is actually present (lines 119-125), so ordinary traffic pays nothing. As an IAsyncActionFilter it does the real work (line 128): with no key the action simply runs (lines 132-136); with one, it derives the cache key from the caller's user id claim (or anon: plus the remote IP), the HTTP method, the route template, and the client-supplied key, joined on newlines and SHA-256 hashed so the key length stays bounded (BuildCacheKey, lines 485-498). Scoping to the caller stops one user's cached response from being replayed to another; scoping to method plus route stops one key from colliding across endpoints that share a cache instance. It also hashes the buffered request body (ComputeRequestBodyHashAsync, line 182, rewinding the stream on both sides so model binding still sees it) and binds that hash to the record. The flow is then a lock-free fast path (TryReplayAsync called at line 143), then the guarded section. The guard is an IDistributedLock resolved from RequestServices when the host registers one (lines 148-156), because a per-process lock only serializes duplicates that land on the same replica and both deployed apps run more than one; a host with no distributed lock falls back to the striped KeyedSemaphoreStripe (field at line 90, used from line 201) rather than a per-key semaphore table that would grow unbounded or race on removal. Under the distributed lock the filter waits LockWait (5 seconds, line 104) for a lease living LockTimeToLive (30 seconds, line 97), double-checks the cache, and runs the action. Four outcomes are worth memorizing. A hit replays the stored IdempotencyRecord (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyRecord.cs:14, a status code plus JSON body plus the request-body hash) with an X-Idempotent-Replay: true header (line 383, through IdempotencyHeaders), as a bare StatusCodeResult when the stored body is empty so a replayed 204 does not acquire a content type (lines 384-391). A key reused with a different payload is answered 422 Unprocessable Entity rather than replayed (line 376 and BodyMismatchResult at 322-333), because replaying would tell the client a genuinely new write succeeded when nothing ran. A duplicate that cannot take the lock within the wait and finds nothing cached gets 409 Conflict (lines 263-269 and InFlightDuplicateResult at 301-312), which is retryable and honest rather than a second execution. And a cache or lock that faults is swallowed: the request runs without the guarantee and the degradation is counted (lines 255, 364, and 439), because deduplication is an optimization over an at-least-once client retry and must not become an outage of every write endpoint. Only 2xx results are stored, and only the two shapes the record can represent: an ObjectResult or a body-less StatusCodeResult such as NoContent() (BuildRecord, line 448), for IdempotencySettings.CacheExpirationHours (default 24, constrained to the range 1 to 168, MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencySettings.cs:15-16) through ICacheService (line 430). Replaying a transient 500 for a whole retention window would defeat the retry the header exists to enable. All three behaviors are observable: IdempotencyMetrics (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16) publishes idempotency.replayed (line 36), idempotency.conflict tagged kind=body_mismatch or in_flight (lines 24, 29, and 41), and idempotency.degraded (line 46) on the MMCA.Common.Idempotency meter (line 19), so a sustained degraded rate says out loud that deduplication is effectively off. The one thing the filter cannot do is notice an endpoint that forgot to opt in, which is what NonIdempotentAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23) exists for: it attaches no pipeline stage and changes no behavior, it only records a required Justification string (line 28), and its sole consumer is the PostActionsDeclareIdempotencyIntent fitness function (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.Idempotency.cs:44, matching at :72-73), which fails the build unless every POST action carries either [Idempotent] or this attribute. That is why AuthControllerBase reads the way it does: RegisterAsync is [Idempotent] (AuthControllerBase.cs:94) while login, refresh, and the two revoke actions each carry a written reason for staying outside the contract (AuthControllerBase.cs:70, :116, :143, :206), since replaying a stored token pair would hand a retrying client credentials the rotation has already invalidated, and replaying a cached 204 would report success for a revoke that never ran. This is a [Rubric §7, Microservices Readiness], [Rubric §13, Observability & Operability], [Rubric §34, Architecture Governance] and [Rubric §29, Resilience & Business Continuity] control: at-least-once retry from a gateway or a flaky client cannot create duplicate resources, and "no idempotency here" is always a recorded decision rather than an omission nobody noticed.

Conditional writes: the ETag round trip, and only the header. Idempotency keys make a retried write safe; the concurrency token makes a racing write safe, and the HTTP way to state that precondition is If-Match (ADR-035). The token travels in exactly one place. IConcurrencyAware (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:15) is the read-side contract: a byte[] RowVersion declared init (line 19), rendered as the response ETag and echoed back by the client in its next write, and its own doc states the rule (lines 9-14): update requests carry no token, a persisted aggregate always has one, and a write that states no precondition is refused rather than falling back to last-write-wins. ConcurrencyETag (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:24, taught with the auth and HTTP primitives in Group 08) is the pure translation layer between the EF Core rowversion byte array and the wire: Format renders it as the weak tag W/"<base64>" (line 44) and TryParse reads one back (line 62), with the header names and the * wildcard as constants (:27, :30, :33). Weak is the honest strength, because a strong tag promises byte-for-byte equality of the representation, which this cannot promise when the same row version renders differently under a fields= projection (ConcurrencyETag.cs:13). The read side emits it automatically: EntityControllerBase.GetByIdAsync calls SetConcurrencyETag (EntityControllerBase.cs:436), which resolves the DTO's RowVersion property once per closed controller type (line 445, so a DTO with no token costs no per-request reflection) and writes the header at line 479; the method is protected rather than private (line 471) so a controller serving a row from a custom read emits the same header instead of re-implementing the format, which is where the two halves drift apart and a precondition quietly stops working. The write side is SupportsIfMatchAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:49), which, unlike IdempotentAttribute, needs no scoped service and therefore implements IAsyncActionFilter directly and needs no DI registration (lines 44-48). Before the action it decodes the header into HttpContext.Items under a public key (TokenItemKey, line 57, written at line 122), which the action reads back through the static RequiredToken (line 68); after the action it rewrites a conflict outcome to 412 Precondition Failed, covering both an ObjectResult 409 and a bare StatusCodeResult 409 (lines 130-155). Three responses make the contract unambiguous. A missing or wildcard If-Match is 428 Precondition Required and the action never runs (lines 109-114 and 162-165), so last-write-wins is not reachable by omission. A malformed tag is a 400 rather than being ignored (lines 116-120 and 174-177), because silently dropping a precondition the client believed it had set is the one outcome worse than rejecting it. And 412 is used rather than 409 because RFC 9110 reserves it for a precondition the client stated in a header (SupportsIfMatchAttribute.cs:34-39). CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> is where all of it lands: [SupportsIfMatch] on the PUT (CrudEntityControllerBase.cs:90), the token read at :102, and the whole set of outcomes declared as ProducesResponseType at :90-96. [Rubric §9, API & Contract Design] and [Rubric §8, Data Architecture].

Output caching at the edge, and evicting it across services. Read endpoints that are user-independent by contract can be cached at the HTTP edge even when the UI attaches a bearer token to every request, which the built-in ASP.NET policy refuses to do. PublicEndpointOutputCachePolicy (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35), registered by name through OutputCacheOptionsExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6, both overloads at lines 20-21 and 34-35), drops that identity bail-out (lines 68-72), varies by every query-string key (QueryKeys = "*" at line 81) so search, paging, filtering and field projection each get their own entry, refuses to store responses that set cookies or are not plain 200s (lines 100-103), and offers a bypassRoles escape hatch so a privileged caller who receives an elevated payload always reads fresh, skipping both lookup and storage (lines 71-72 and 112-113, ADR-040). The store behind it defaults to per-replica memory, so a tag eviction on one instance leaves every other instance serving stale bytes. OutputCacheEvictionHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32) closes that gap by consuming the OutputCacheEvictionRequested integration event and calling EvictByTagAsync for each tag against this host's store (lines 38-64); no MassTransit type appears in it, so the same handler is reachable from the in-process dispatcher and from the broker (ADR-026). Its two behaviors are both deliberate: eviction is per-tag best effort, so a store that throws on one tag is logged and counted rather than rethrown (lines 56-62), because rethrowing would redeliver the message, re-evict every tag that already succeeded, and eventually dead-letter a message whose only consequence is a cache entry that expires on its own TTL anyway; and an OperationCanceledException is explicitly not swallowed (line 56), so host shutdown looks like shutdown rather than an acked message. The failures are visible through OutputCacheMetrics (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheMetrics.cs:16), which publishes cache.eviction.failed tagged by cache tag on the MMCA.Common.OutputCache meter (lines 19 and 29-30), the alert target for cross-service cache coherence: a non-zero rate means this host is serving responses it was told to drop. OutputCacheEvictionExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:27) is the DI half plus the in-process shortcut: AddOutputCacheEvictionHandler (line 111) registers the handler as a singleton through TryAddEnumerable (lines 115-117) so a host and a module that both ask for the behavior get one handler rather than a double eviction, while EvictTagsAsync (line 49) and the log-and-continue TryEvictTagsAsync (line 78) let code already running in this host evict its own tags without publishing an event to itself. [Rubric §12, Performance & Scalability] with a [Rubric §13, Observability & Operability] backstop.

The contract surface: mapping, JSON, and query filters. The framework maps between the wire and the domain by hand, not through a runtime reflection mapper (ADR-001). Two interfaces in the Application layer define the shape, both in MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (line 14) turns an entity into its DTO and supplies a default interface implementation for the collection overload (line 27), and IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (line 42) turns an incoming request into a domain entity through its factory, returning a Result so mapping-time validation (a uniqueness check, for example) is a first-class failure rather than an exception (line 54). Their write-side twin, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> (same file, line 79), belongs to the CQRS group because it is what lets one generic update handler own loading, the concurrency token, and the save while the aggregate keeps owning its invariants (IEntityDTOMapper.cs:62-74). All three are auto-registered by module assembly scanning. Two edge helpers finish the contract surface: CurrencyJsonConverter (MMCA.Common/Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:12) serializes the Currency value object as its bare ISO 4217 code (lines 29-30) and throws JsonException on a non-string token or an unknown code (lines 17-23), which the framework surfaces as a 400; and QueryFilterModelBinder (MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24) parses the filters[Prop].operator= and filters[Prop].value= query-string convention into the (operator, value) dictionary the paged read and export endpoints hand to the specification layer, capping one request at MaxFilters = 50 distinct properties (line 34, enforced at lines 61-62, bounding the per-request reflection work a caller can demand from QueryFilterService) and discarding entries missing an operator or a value (line 75). The small shared DTO vocabulary those generics rely on lives in MMCA.Common.Shared: IBaseDTO<TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9, an Id declared with an init accessor at line 13), BaseLookup<TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/BaseLookup.cs:8, a required id at line 12 plus a required display name at line 15, for dropdowns and autocomplete), and the concurrency contract IConcurrencyAware described above. Manual mapping keeps the DTO contract explicit and reviewable: the [Rubric §9, API & Contract Design] and [Rubric §15, Best Practices] position this codebase takes deliberately.

Feature gating and per-module controller visibility. Three mechanisms let an operator turn surface area on and off without a code change. DisabledFeatureHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13) renders a consistent Problem Details 404 when a FeatureGate-protected action is reached while its flag is off (lines 18-26), so a disabled feature looks like a nonexistent endpoint rather than an error (ADR-031). A percentage rollout needs to be sticky per user rather than random per request, which is what CurrentUserTargetingContextAccessor (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:54) supplies: registered by AddAPI through WithTargeting<...> (DependencyInjection.cs:94), it builds the targeting context from the current request's principal, taking the user id from the user_id claim that TokenService emits and falling back to the principal's name (line 86), and taking the groups from the caller's role claims under all three types the JWT middleware may produce (ClaimTypes.Role, role, roles, lines 76-82) because ICurrentUserService is scoped while this accessor is a singleton (DependencyInjection.cs:86-91). An anonymous request yields an empty context (lines 67-74), so a targeted feature is simply off for anonymous callers, and the accessor can never fail a request. At a coarser grain, ModuleControllerFeatureProvider (MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:28) removes a disabled module's controllers from MVC discovery entirely, snapshotting the disabled names once (lines 36-39), returning immediately when none are disabled (lines 41-44), and matching a .{ModuleName}. token against the controller's assembly name or namespace (lines 60-81, the dots on both sides preventing a Catalogue false positive on Catalog), so a module switched off in configuration cannot have its routes mapped (they would otherwise 500, since the module's DI services were never registered). Together these are the feature-flag story extended to the HTTP edge and part of the [Rubric §7, Microservices Readiness] "one codebase, many deployment shapes" design.

Well-known endpoints, database initialization, and the extraction edge. Several types here exist only so a module can be lifted out of the monolith into its own service (ADRs 004, 007, 008). JwksEndpointExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/JwksEndpointExtensions.cs:15) serves /.well-known/jwks.json (path constant at line 20, mapped at line 33) from IJwksProvider so extracted services validate tokens against the issuer's public keys instead of a shared secret, and OidcDiscoveryEndpointExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OidcDiscoveryEndpointExtensions.cs:22) serves the minimal discovery document the JWT middleware fetches when AddForwardedJwtBearer sets an authority: it returns 404 when Jwt:Issuer is not configured (lines 62-66), derives jwks_uri from that configured issuer rather than from the inbound request (line 76) so issuer and JWKS URI stay origin-aligned, and disables the camelCase naming policy (line 45) because RFC 8414 field names are snake_case and jwksUri would not be recognized. AddForwardedJwtBearer itself resolves RequireHttpsMetadata in three steps, explicit argument, then the Authentication:JwtBearer:RequireHttpsMetadata key (constant at WebApplicationBuilderExtensions.cs:56), then "true outside Development" (WebApplicationBuilderExtensions.cs:458-460); a resolved false outside Development is honored, because an internal-ingress h2c authority genuinely has no HTTPS metadata, but it is never silent: InsecureJwtMetadataWarningStartupFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/InsecureJwtMetadataWarningStartupFilter.cs:15) is registered exactly then (WebApplicationBuilderExtensions.cs:462-466) and logs one startup warning naming the key (InsecureJwtMetadataWarningStartupFilter.cs:21 with the message at :26-28). It is an IStartupFilter rather than a log line at registration time for a reason worth remembering: while the service collection is being built the logging providers are not configured yet, so a warning written there is dropped (InsecureJwtMetadataWarningStartupFilter.cs:7-13). JwtForwardingDelegatingHandler (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17) copies the caller's inbound Authorization header onto outgoing HTTP calls, unless one was already set (lines 27-30) and no-oping when there is no ambient HttpContext (lines 32-36), so distributed authorization flows through a service-to-service hop without any handler threading the token by hand: the HTTP twin of JwtForwardingClientInterceptor. DatabaseInitializationExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21) initializes every physical data source the host owns: InitializeDatabaseAsync (line 35) validates the strategy before touching anything (EnsureKnownStrategy, line 47, which accepts only Migrate or None and otherwise throws naming the valid values, lines 184 and 195), warms the IEntityDataSourceRegistry so entity-to-database routing is deterministic before the first repository call (lines 54-56), runs EnsureCreated for the migration-less Cosmos and SQLite sources up front (lines 70-87, skipping a SQLite source that does configure a migrations assembly, because creating its tables without an __EFMigrationsHistory row leaves every migration both pending and un-appliable), then applies the configured strategy per migrated source (lines 92-102), where None is the production guard that throws with a per-source breakdown of pending migrations (line 98 into ThrowIfPendingMigrationsAsync at line 241; ADR-030, ADR-006), repeats the same strategy per tenant that keeps its own copy of a source, each in a fresh scope with its tenant set (line 104 into InitializeTenantDatabasesAsync at line 125, ADR-073), and finishes by running the enabled modules' seeders on the default scope only (line 111). Five smaller startup helpers round out the host: OpenApiEndpointExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:22) maps the per-version OpenAPI document (MapCommonOpenApi, lines 34-38) and the optional Scalar reference UI (MapCommonScalarUi, lines 52-56), both outside Production only, ApiParameterDescriptorBackfillProvider (MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:43) fills in the placeholder descriptor MVC leaves null on an unbound route token (lines 65-71) so a URL-segment-versioned or {tenant}-templated route cannot turn document generation into a 500, running last by ordering itself at int.MinValue (line 46) and registered exactly once through TryAddEnumerable however many helpers a host calls (WebApplicationBuilderExtensions.cs:262, :406, and the helper itself at :418-420), SignalRExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12) maps NotificationHub at its configured path when push notifications are enabled (lines 22-27), MiniProfilerExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9) registers MiniProfiler with Entity Framework profiling when ApplicationSettings.UseMiniProfiler is set (lines 16-25), and AppAssociationEndpointExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:15) with AppAssociationOptions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationOptions.cs:9) serve the Android Digital Asset Links and Apple App Site Association documents (paths at lines 18 and 24, mapped at lines 42 and 46) that let a mobile OS hand this host's https links to the installed native app (ADR-043). ExternalAuthExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:22) completes the OAuth half of authentication, staying entirely inert when no provider is configured (lines 47-52), each of Google, GitHub, and Apple gated on its OAuth:{Provider}:ClientId being present (lines 60-73) and throwing at startup when the matching client secret is missing (lines 98-100 and 110-112). Taken together these are the [Rubric §7, Microservices Readiness], [Rubric §11, Security], and [Rubric §12, Performance & Scalability] concerns that let the same controller code run identically in a monolith and in a fleet of extracted services behind a YARP gateway.

Where this group sits. Everything above is the outermost ring. It depends downward on the CQRS pipeline and query services (Groups 03 and 05), the auth and caching infrastructure (Groups 08 and 09), the persistence factories (Group 07), the integration-event contracts it consumes (Group 04), and the module system that discovers controllers and drives health checks (Group 14 via ModuleLoader), and it rests on the Result and domain primitives (Groups 01 and 02). Nothing inside the framework depends on it: the app hosts and the gRPC transport group (Group 13) call into it. AssemblyReference and ClassReference (MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:8 and :20) are the scanning anchors that let those callers point at this assembly without naming an incidental type. Read the group as the framework's HTTP grammar: the reusable edge every downstream service inherits so its own code stays modules and domain logic, never plumbing.

AssemblyReference

MMCA.Common.API · MMCA.Common.API · MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:8 · Level 0 · class (static)

  • What it is: a tiny static class that exposes the MMCA.Common.API assembly handle and its simple name, so convention-based scanners have a stable, refactor-safe anchor into this layer.
  • Depends on: System.Reflection (BCL) only (AssemblyReference.cs:1).
  • Concept introduced (assembly-marker types for convention scanning). Scrutor-based DI registration and the NetArchTest architecture rules both need a stable "anchor" type to say scan the assembly that contains this. Rather than reaching for typeof(SomeIncidentalClass).Assembly, a dedicated AssemblyReference makes the intent explicit and survives type moves. [Rubric §2, Design Patterns] assesses whether recurring structural problems are solved with named, reusable patterns; here the same two-type marker shape repeats in every layer and module assembly, which is exactly this pattern applied uniformly. [Rubric §33, Developer Experience] assesses how easy the framework is to build on; centralizing one assembly handle per package means every scan call references one obvious token.
  • Walkthrough: two public static readonly fields. Assembly (AssemblyReference.cs:11) is typeof(AssemblyReference).Assembly, resolved once at type initialization. AssemblyName (AssemblyReference.cs:14) is Assembly.GetName().Name with a ?? string.Empty fallback for logging and diagnostics.
  • Why it's built this way: a purpose-built anchor decouples scanning from any incidental type. The pattern is duplicated in every layer so each assembly is self-describing without a cross-layer reference back to a single "well-known" class.
  • Where it's used: module handler/validator/mapper scanning (see ModuleLoader) and the architecture tests' package-assembly pinning.

ClassReference

MMCA.Common.API · MMCA.Common.API · MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:20 · Level 0 · class

  • What it is: an empty, instantiable class in the MMCA.Common.API assembly, used where a generic constraint or a typeof(...) needs a concrete reference type from this layer rather than an Assembly instance.
  • Depends on: nothing. See AssemblyReference for the full concept; ClassReference is its type-shaped sibling.
  • Concept: covered under AssemblyReference. Where AssemblyReference.Assembly answers "which assembly", ClassReference answers "give me a class token from that assembly" for APIs whose generic parameter is constrained to a reference type (where T : class).
  • Walkthrough: the whole type is public class ClassReference; (AssemblyReference.cs:20), a body-less class declaration whose doc comment (:17-19) states the intent. It carries no members; its identity is the entire point.
  • Why it's built this way: some registration and scanning helpers take a marker type parameter instead of an Assembly; a dedicated empty class keeps those call sites from accidentally binding to a real domain or controller type.
  • Where it's used: generic registration helpers that need a per-assembly type anchor from the API layer.

ExternalAuthExtensions

MMCA.Common.API · MMCA.Common.API.Authentication · MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:22 · Level 0 · class (static)

  • What it is: a static class that registers the external OAuth provider schemes (Google, GitHub, Apple) plus the short-lived cookie scheme that carries the external principal from the provider callback to the app's OAuth controller. It is the counterpart wiring that AddCommonAuthentication (JWT-only) deliberately leaves out.
  • Depends on: the ASP.NET Core authentication, HTTP, configuration, and DI surfaces (ExternalAuthExtensions.cs:1-4), plus the provider handler packages reached through AddGoogle, AddGitHub, and AddApple (:95, :107, :122). First-party, it partners with the app's OAuth controller subclassing OAuthControllerBase, whose ExtractClaims consumes the schemes registered here (ExternalAuthExtensions.cs:9-11, :116), and it names the same signal the UI's ConfigurationOAuthUISettings uses to decide which buttons to render (:16-18).
  • Concept introduced (config-gated, additive auth registration). The single public const string ExternalLoginScheme = "ExternalLogin" (ExternalAuthExtensions.cs:28) is shared with the OAuth controller so the sign-in scheme name can never drift between the two halves of the flow. The important property of this class is that it is additive and inert: AddCommonAuthentication registers only the JWT bearer scheme, so without this call the OAuth controller would Challenge and read schemes that are not in the authentication pipeline and the flow would fail at runtime (:12-15), yet a host with no OAuth configuration section keeps the JWT-only default completely untouched. [Rubric §11, Security] assesses how authentication, secrets, and trust boundaries are handled; each provider is gated on its OAuth:<Provider>:ClientId being present, and a missing companion secret throws at startup (:98-100, :110-112, :131-139) rather than silently half-configuring an auth scheme. [Rubric §9, API & Contract Design] is relevant because the opt-in posture mirrors AddPermissions (ADR-020), and the class doc says so explicitly (:16-19).
  • Walkthrough
    • The public surface is one C# extension(IServiceCollection services) block (ExternalAuthExtensions.cs:30), matching the DI convention described in the primer.
    • AddExternalAuthProviders(IConfiguration configuration) (:38) reads the OAuth section (:40) and pulls Google:ClientId, GitHub:ClientId, and Apple:ClientId (:41-43). When all three are empty it returns the collection untouched (:47-52), which is what keeps environments without OAuth secrets (most tests, local dev) exactly as AddCommonAuthentication left them (:45-46).
    • When at least one is configured it calls services.AddAuthentication() with no argument (:56). The comment above it is the load-bearing detail (:54-55): the parameterless overload does not reset the default scheme set by AddCommonAuthentication, it just yields a builder to append schemes onto.
    • It then adds the ExternalLogin cookie (:58) and each configured provider in turn, each behind its own non-empty check (:60-73), before returning services for chaining (:75).
    • AddExternalLoginCookie (:83-92) configures the bridge cookie: name mmca_external_login (:86), HttpOnly (:87), SameSite=Lax (:90, sufficient because the OAuth round trip returns as a top-level GET navigation, which avoids the Secure plus cross-site cost that SameSite=None imposes, :88-89), and a 10-minute expiry (:91).
    • AddGoogleProvider (:94-104) and AddGitHubProvider (:106-119) each set the client id, require the matching ClientSecret or throw (:98-100, :110-112), set SignInScheme = ExternalLoginScheme, pin a fixed CallbackPath (/auth/callback/google at :102, /auth/callback/github at :113), and set SaveTokens = true. GitHub additionally requests the user:email scope (:117) because it does not return the email on the default scope and the controller's ClaimTypes.Email lookup would otherwise fail (:115-116).
    • AddAppleProvider (:121-145) is the one that differs structurally. Its ClientId is the Apple Services ID, not the app bundle id (:124-126), and Apple has no static client secret at all: the handler mints a short-lived ES256 JWT from the developer's private key, so GenerateClientSecret = true (:130) and TeamId (:131), KeyId (:134), and PrivateKeyPem (:137) are each required-or-throw. The PEM is handed over as a callback returning a ReadOnlyMemory<char> (:140). Scheme, callback path (/auth/callback/apple, :143), and SaveTokens match the other two.
  • Why it's built this way: the cookie is intentionally short-lived and single-purpose. It exists only to bridge the provider callback to the controller's CompleteAsync, which signs it out the moment the local JWT pair is minted (:80-81). Splitting the OAuth scheme registration from AddCommonAuthentication keeps the JWT-only default free of provider secrets, and gating each provider independently means adding Apple did not disturb hosts that only configure Google. See ADR-036 for the external-login decision.
  • Where it's used: called from the host composition of a service that exposes social login; pairs with the app's OAuthController (subclass of OAuthControllerBase).

OutputCacheMetrics

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheMetrics.cs:16 · Level 0 · class (internal static)

  • What it is: the OpenTelemetry instrument set for the output-cache eviction consumer. Today it is exactly one counter, cache.eviction.failed, plus the helper that records it.
  • Depends on: System.Diagnostics.Metrics (Meter, Counter<long>, OutputCacheMetrics.cs:1) only. Its single recording site is OutputCacheEvictionHandler.
  • Concept: the same "meter a degradable mechanism" idea introduced at IdempotencyMetrics, applied to a different failure. Cross-service cache eviction (OutputCacheEvictionHandler) is deliberately best-effort: an eviction that throws is logged and swallowed rather than rethrown, because rethrowing would redeliver the message and eventually dead-letter it. The consequence is that a failing eviction is invisible from the outside, since every request still succeeds and the stale entry simply serves until its TTL. [Rubric §13, Observability & Operability] assesses whether an operator can see that: the counter doc names itself the alert target for cross-service cache coherence, because a non-zero rate means this host is serving output-cached responses it was told to drop (OutputCacheMetrics.cs:23-27). It also records the cardinality argument, which is the discipline that keeps a tagged counter affordable: the cache_tag tag is bounded by the host's own tag vocabulary, a small fixed set declared in its output-cache policies.
  • Walkthrough
    • MeterName = "MMCA.Common.OutputCache" (OutputCacheMetrics.cs:19) and a single static Meter built from it (:21). The type doc carries a warning worth reading once and remembering (:10-14): never create a second Meter with this name, because a duplicate instance publishes a parallel set of instruments under the same meter name and a listener enabling one of them silently misses measurements recorded on the other.
    • EvictionFailed (:29-32) is a Counter<long> named cache.eviction.failed with unit {tag}.
    • RecordEvictionFailure(string cacheTag) (:36-37) adds one with a cache_tag tag, so the instrument name and the tag name are spelled in exactly one place.
  • Why it's built this way: internal static keeps the instruments out of the package's public surface while the handler in the same assembly records them. As with IdempotencyMetrics, the meter name is duplicated as a literal in MMCA.Common.Aspire because that package has no reference to MMCA.Common.API (:8-9), and a host exports the instruments by registering the meter, which the Aspire service defaults (ConfigureOpenTelemetry) already do (:6-8).
  • Where it's used: exactly once, in OutputCacheEvictionHandler's catch block (OutputCacheEvictionHandler.cs:60), paired with a Warning log naming the same tag.

PublicEndpointOutputCachePolicy

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35 · Level 0 · class (sealed)

  • What it is: a custom ASP.NET Core IOutputCachePolicy for public, user-independent GET/HEAD endpoints that must stay cacheable even when the request carries an Authorization header. It replaces the built-in default policy, which refuses to serve or store a cached response for any authenticated request.
  • Depends on: Microsoft.AspNetCore.OutputCaching (IOutputCachePolicy, OutputCacheContext), System.Security.Claims, and Microsoft.Extensions.Primitives (StringValues), all at PublicEndpointOutputCachePolicy.cs:1-4. No first-party dependencies; it is registered by OutputCacheOptionsExtensions.
  • Concept introduced (auth-header-tolerant output caching). The framework UI attaches a Bearer token to every outgoing API request, including reads of [AllowAnonymous] endpoints whose payload is identical for every caller. Under the default policy those reads bypass the output cache for any signed-in user and land on the database each time (PublicEndpointOutputCachePolicy.cs:12-17). [Rubric §12, Performance & Scalability] assesses whether hot read paths avoid redundant work; this policy is a direct performance lever, letting public reads share one cached entry across authenticated and anonymous callers. [Rubric §11, Security] assesses trust boundaries; the class docs (:24-33) are explicit that a cached response is served verbatim to every subsequent caller, so it must be applied only to identity-independent payloads, and the bypassRoles mechanism exists precisely so a privileged role that receives an elevated payload (for example organizers seeing unpublished rows) is never served or stored from the shared cache. This is the edge tier of the two-tier caching model (ADR-026); the authenticated-read decision itself is ADR-040.
  • Walkthrough: three fields hold the config, _expiration, _bypassRoles, _tags (PublicEndpointOutputCachePolicy.cs:37-39). Two constructors: the params string[] tags overload (:44) delegates to the full one with an empty bypass-roles array (:45), and the primary constructor (:54) guards its inputs (ThrowIfLessThanOrEqual(expiration, TimeSpan.Zero), null checks on both arrays, :56-58). All three interface methods are explicitly implemented, so they are reachable only through IOutputCachePolicy. CacheRequestAsync (:66) computes attemptOutputCaching as "is a GET/HEAD request" AND "is not a bypassed caller" (:71-72), enables output caching, sets AllowCacheLookup/AllowCacheStorage to that flag, allows locking, sets the expiration (:73-77), and (matching the built-in default) varies the cache key by every query-string parameter via CacheVaryByRules.QueryKeys = "*" (:81), then copies the eviction tags in (:83-84). ServeFromCacheAsync (:90) is a no-op returning ValueTask.CompletedTask. ServeResponseAsync (:94) refuses to store any response that set a cookie or returned a non-200 status (:100-104), the same guard the built-in default applies. Two private helpers close it out: IsCacheableRequest (:109, GET or HEAD) and IsBypassedCaller (:112, Array.Exists(_bypassRoles, user.IsInRole)).
  • Why it's built this way: it mirrors the built-in default policy minus exactly one behavior, the authenticated-request bail-out (:68-70), so its caching, query-key variance, and cookie/status guards stay identical to what developers already expect. Bypass roles get the default behavior back (no lookup, no storage), which keeps elevated payloads out of the shared cache without disabling caching for everyone. A raw IOutputCachePolicy implementation inherits none of the default policy's behavior, so every guard is re-implemented here.
  • Where it's used: registered as a named policy by OutputCacheOptionsExtensions and referenced from controller actions via [OutputCache(PolicyName = ...)]. Store's Catalog service registers four such policies with no bypass roles (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:149-154); ADC's Conference service registers ten, most of them with an admin bypass-roles array (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:216-244), including two short 60-second policies for the fast-moving NowNextCache (:231) and BookmarkCountsCache (:243). The tags passed here are the same strings OutputCacheEvictionHandler evicts by.

OutputCacheOptionsExtensions

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6 · Level 1 · class (static)

  • What it is: registration helpers that add named output-cache policies backed by PublicEndpointOutputCachePolicy onto ASP.NET Core's OutputCacheOptions.
  • Depends on: Microsoft.AspNetCore.OutputCaching (OutputCacheOptions, OutputCacheOptionsExtensions.cs:1) and PublicEndpointOutputCachePolicy.
  • Concept: this is a thin fluent facade over OutputCacheOptions.AddPolicy, using a C# extension(OutputCacheOptions options) block (OutputCacheOptionsExtensions.cs:8) so the policy registration reads as a first-class option on the options object. See the DI registration extension(T) convention. [Rubric §9, API & Contract Design] is relevant: the helper gives callers a self-documenting, named entry point instead of hand-constructing the policy at each call site.
  • Walkthrough: two overloads of AddPublicEndpointPolicy. The first (OutputCacheOptionsExtensions.cs:20-21) takes name, expiration, and params string[] tags and registers new PublicEndpointOutputCachePolicy(expiration, tags). The second (:34-35) adds a string[] bypassRoles parameter before the params string[] tags and forwards to the three-argument policy constructor, for endpoints whose payload is identical for every caller except one privileged role. Both are expression-bodied and return void, mutating the options in place.
  • Why it's built this way: keeping the policy construction behind a named helper means the "apply only to [AllowAnonymous], identity-independent endpoints" guidance travels with the API surface (see the doc comments at :10-16 and :23-29) instead of being re-derived at each registration.
  • Where it's used: called during host composition where the app configures AddOutputCache(...); the registered name is then referenced by [OutputCache(PolicyName = ...)] on controller actions. Store's Catalog service uses the two-argument form (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:149-154); ADC's Conference service mostly uses the bypass-roles form (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:216-244).

ModuleControllerFeatureProvider

MMCA.Common.API · MMCA.Common.API · MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:28 · Level 2 · class (sealed)

  • What it is: an IApplicationFeatureProvider<ControllerFeature> that removes controllers belonging to disabled modules from MVC's controller discovery, so a module turned off via configuration exposes no routes.
  • Depends on: ModulesSettings (the config-bound enabled/disabled map, ModuleControllerFeatureProvider.cs:4) and the MVC application-parts surface (IApplicationFeatureProvider<ControllerFeature>, ControllerFeature, ApplicationPart, :2-3), plus System.Reflection for the TypeInfo it inspects (:1).
  • Concept introduced (module-aware controller discovery). MVC discovers controllers by scanning referenced assemblies. When a host references a module's API assembly transitively but an operator has disabled that module (Modules:{Name}:Enabled=false), MVC would still map its controllers, and every request to them would 500 because the module's DI services were never registered (ModuleControllerFeatureProvider.cs:19-25). [Rubric §7, Microservices Readiness] assesses whether modules can be composed and decomposed cleanly; this provider is one boundary that lets a module be switched off without deleting code or breaking the host, complementing the disabled-module stub registrations in the module system. [Rubric §29, Resilience, Reliability & Business Continuity] is relevant: the enable/disable decision is enforced once at the edge rather than checked inside each controller.
  • Walkthrough: the primary-constructor parameter is ModulesSettings modulesSettings (ModuleControllerFeatureProvider.cs:28-29). PopulateFeature (:33) first snapshots the disabled module names once (:36-39) so it does not re-scan the settings dictionary per controller, returns early if none are disabled (:41-44), then materializes the matches and removes every controller matched by IsDisabledModuleController (:46-53). The private static matcher (:60) reads the controller's assembly simple name and namespace, each with a ?? string.Empty fallback (:64-65), and, for each disabled module, tests whether either contains the token .{ModuleName}. (:72). Wrapping the module name in dots is deliberate: it matches .Catalog. inside MMCA.Store.Catalog.API or its .Controllers namespace while avoiding false positives from substrings like "Catalogue" (:69-71). The comparison is OrdinalIgnoreCase on both the assembly name and the namespace (:74-75), and the loop falls through to false when nothing matches (:81).
  • Why it's built this way: matching on the dotted token handles both the MMCA.{Repo}.{Module}.API convention and the legacy {Prefix}.Modules.{Module}.* convention without maintaining a registry of controller types (:14-17). Removing controllers at feature-provider time is earlier than routing, so a disabled module is invisible rather than returning a runtime error.
  • Where it's used: registered by DependencyInjection's AddAPI(modulesSettings) via ConfigureApplicationPartManager (DependencyInjection.cs:64-65), but only when a non-null ModulesSettings is supplied (DependencyInjection.cs:62); pairs with the module system's disabled-stub registrations so cross-module interfaces stay resolvable.

OutputCacheEvictionHandler

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32 · Level 4 · class (sealed, partial)

  • What it is: the integration-event handler that drops named tags from this host's output cache when another service says the underlying data changed. It is the consumer half of cross-service output-cache coherence.
  • Depends on: IIntegrationEventHandler<in TIntegrationEvent> closed over OutputCacheEvictionRequested (OutputCacheEvictionHandler.cs:35), ASP.NET Core's IOutputCacheStore, ILogger, and OutputCacheMetrics for the failure counter (:1-4).
  • Concept introduced (cache coherence across a service boundary, as a best-effort hint). PublicEndpointOutputCachePolicy makes a public read cheap by caching it for minutes at a time. The cost of that is staleness when the data changes, and in a multi-service topology the change usually happens somewhere else: a write in one service invalidates a cached read in another. The framework's answer is an ordinary integration event, so nothing about this handler is broker-specific. [Rubric §6, CQRS & Event-Driven] assesses whether cross-service reactions travel as events rather than as direct calls; the class doc is explicit that no MassTransit type appears here, so the handler is equally reachable from the in-process dispatcher (:8-15). Two failure decisions carry the design.
    • Per-tag best effort (:18-24): each tag is evicted independently and a failure is logged and counted rather than rethrown. Rethrowing would hand the message back to the retry policy and redeliver it, re-evicting every tag that already succeeded, and would eventually dead-letter a message whose only consequence is a cache entry that expires on its own TTL anyway. The doc gives the one-line principle worth remembering: a failed eviction is a staleness window, not a lost fact. [Rubric §29, Resilience & Business Continuity] assesses whether degraded dependencies degrade the system gracefully instead of failing it; this is the contract ADR-096 generalizes.
    • Cancellation is not swallowed (:25-28): an OperationCanceledException from host shutdown propagates, so MassTransit sees the shutdown rather than an acked message. This is the same when (ex is not OperationCanceledException) discipline used across the framework's cache-touching code.
  • Walkthrough: the primary constructor takes IOutputCacheStore outputCacheStore and ILogger<OutputCacheEvictionHandler> logger (:32-34). HandleAsync(OutputCacheEvictionRequested integrationEvent, CancellationToken) (:38) null-guards the event (:42), then loops the tags (:44), skipping blank entries (:46-49). Each tag goes through outputCacheStore.EvictByTagAsync(tag, cancellationToken) inside its own try (:51-53) followed by a Debug log (:54). The catch (:56-62) is deliberately broad, with the comment noting that CA1031/S2221 are suggestions here because an eviction store that throws must not turn a coherence hint into a dead-lettered message; it records the failure on OutputCacheMetrics (:60) and logs a Warning naming the tag and stating that responses carrying it stay cached until their own TTL expires (:61, template at :71-74).
  • Why it's built this way: partial plus two [LoggerMessage] declarations (:66-74) gives source-generated, allocation-free logging at Debug for the success path and Warning for the failure path. Keeping the handler free of any broker type is what lets the same class serve both the in-process dispatcher and the MassTransit consumer, which is the extraction property the module system is built around (ADR-007, ADR-008).
  • Where it's used: registered by OutputCacheEvictionExtensions.AddOutputCacheEvictionHandler(). The broker half is RegisterOutputCacheEvictionConsumer() inside the host's AddBrokerMessaging configuration, which wires the generic IntegrationEventConsumer<OutputCacheEvictionRequested> onto this handler; ADC's Conference service calls both (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250 and :352). The tags it evicts are the ones passed to OutputCacheOptionsExtensions.AddPublicEndpointPolicy. Framework coverage is OutputCacheEvictionHandlerTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Caching/OutputCacheEvictionHandlerTests.cs).

OutputCacheEvictionExtensions

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:27 · Level 5 · class (static, two extension blocks)

  • What it is: two things that travel together. It is the one-method DI registration for OutputCacheEvictionHandler (the container half of the cross-service eviction path), and it is the multi-tag eviction helper pair a mutating controller reaches for right after a write.
  • Depends on: OutputCacheEvictionHandler, IIntegrationEventHandler<in TIntegrationEvent>, OutputCacheEvictionRequested, BestEffort, and ServiceCollectionDescriptorExtensions.TryAddEnumerable (OutputCacheEvictionExtensions.cs:1-8).
  • Concept introduced (two eviction verbs for two call sites). ASP.NET Core's IOutputCacheStore evicts one tag per call, but a single write usually invalidates several (a speaker edit drops both conference:speakers and conference). Rather than a private per-controller helper wrapping a run of EvictByTagAsync calls (:17-21), the framework offers one call that names every tag, in two flavors that differ only in what they do when the cache store is unreachable.
    • EvictTagsAsync propagates the failure. A caller that considers a failed eviction part of its own success uses this one.
    • TryEvictTagsAsync is the best-effort sibling: a Redis outage or a transient network fault becomes one Warning plus one BestEffort metric increment instead of an exception (:60-66). The reasoning is worth internalizing: the mutation this follows has already committed, so surfacing the failure would turn a successful write into a client-visible error while leaving the write in place, and an entry that could not be evicted expires on its own TTL anyway. [Rubric §29, Resilience & Business Continuity] covers exactly this post-commit-follow-up contract (ADR-096).
    • A second detail in TryEvictTagsAsync is easy to miss and load-bearing: eviction runs under CancellationToken.None, not the request token (:67-70, :90). The write has committed, so a client that disconnected mid-response must not abandon the cleanup.
  • Concept (an idempotent registration for a handler that must not run twice). Two lifetime decisions are documented on AddOutputCacheEvictionHandler and both matter. The handler is registered as a singleton, matching the lifetime the module scanner gives every other integration-event handler, so a host that wires it by hand and a module that wires it through the scan agree. And it goes in through TryAddEnumerable rather than AddSingleton, so calling it twice (a host plus a module that both want the behavior) registers one handler rather than evicting every tag twice (:102-108). [Rubric §12, Performance & Scalability] assesses whether a shared concern can be opted into safely from more than one place; a plain Add here would double every eviction, harmless in effect but doubling the store round trips and the failure counter.
  • Walkthrough
    • A class-level [SuppressMessage] for CA1708 (:23-26) records a known analyzer trap: with multiple extension(T) blocks in one static class, CA1708 flags the compiler-generated grouping members as case-colliding, and no user-visible identifier differs only by case.
    • EvictOperationPrefix = "output-cache-evict:" (:34) is the prefix of the best-effort operation name. The tag is appended so a failure is attributable to the cache it could not clear, which is also why the doc insists call-site tags stay low-cardinality literals: the name becomes a metric tag (:29-33).
    • extension(IOutputCacheStore store) (:36) holds the two verbs. EvictTagsAsync(CancellationToken cancellationToken, params string[] tags) (:49) null-guards store and tags (:51-52) and awaits EvictByTagAsync for each tag in the order given (:54-57). TryEvictTagsAsync(ILogger logger, params string[] tags) (:78) adds a logger guard (:80-82) and routes each eviction through BestEffort.ExecuteAsync with the prefixed operation name, the caller's logger, the eviction as the action, and CancellationToken.None (:86-90).
    • extension(IServiceCollection services) (:95) holds AddOutputCacheEvictionHandler() (:111). It null-guards services (:113), calls services.TryAddEnumerable(ServiceDescriptor.Singleton<IIntegrationEventHandler<OutputCacheEvictionRequested>, OutputCacheEvictionHandler>()) (:115-117), and returns the collection for chaining (:119). The doc records the one prerequisite (:100-101): AddOutputCache() must have been called, since it supplies the singleton IOutputCacheStore the handler evicts through.
  • Why it's built this way: the class doc states the pairing rule plainly (:12-16): this is the DI half, RegisterOutputCacheEvictionConsumer() on the MassTransit bus configurator is the broker half, and a host that wants the behavior calls both. Splitting them is what keeps the handler broker-agnostic, since the DI registration has no messaging dependency at all. Housing the eviction verbs in the same class keeps the write-side helper and the read-side coherence path in one file.
  • Where it's used: ADC's Conference service calls services.AddOutputCacheEvictionHandler() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250) alongside the broker registration (:352). ADC's Conference controllers use the throwing form after a write, for example SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:315) and SpeakerCategoryItemsController, which evicts three tags in one call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:178); Store's Catalog controllers use the best-effort form, for example ProductsController (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ProductsController.cs:242) and CategoriesController (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/CategoriesController.cs:168). Framework coverage: OutputCacheEvictTagsTests pins both verbs, including the CancellationToken.None rule and the swallow-versus-throw split (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Caching/OutputCacheEvictTagsTests.cs:59-113), and OutputCacheEvictionHandlerTests pins the double-registration behavior (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Caching/OutputCacheEvictionHandlerTests.cs:107-114).

DependencyInjection

MMCA.Common.API · MMCA.Common.API · MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:25 · Level 11 · class (static)

  • What it is: the primary DI entry point for the MMCA.Common.API layer. Using a C# extension(IServiceCollection services) block (DependencyInjection.cs:27) it adds six methods to IServiceCollection: AddAPI, AddErrorLocalization, AddErrorResources<TResource>, AddCommonExceptionHandlers, AddServerAuthSessionCookie, and AddModuleHealthChecks.
  • Depends on: a broad slice of the API layer plus feature management and localization (DependencyInjection.cs:1-18). Notable first-party types wired here: CurrencyJsonConverter and the Shared-layer EnumerationJsonConverterFactory (aliased at :18 because the Shared and API namespaces both surface converters), UnhandledResultFailureFilter, IdempotencyFilter and IdempotencySettings, OwnerOrAdminFilter, ModuleControllerFeatureProvider, CurrentUserTargetingContextAccessor and DisabledFeatureHandler, IErrorLocalizer/ErrorLocalizer, ErrorResources/ErrorResourceSource, the exception handlers (OperationCanceledExceptionHandler, DomainExceptionHandler, DbUpdateExceptionHandler, ValidationExceptionHandler, GlobalExceptionHandler), CookieTokenReader and ICookieSessionRefresher/CookieSessionRefresher, and ModuleLoader/ModulesSettings. Externals: Microsoft.FeatureManagement (including its IDisabledFeaturesHandler contract), Microsoft.Extensions.Localization, ASP.NET Core MVC/ProblemDetails/HealthChecks.
  • Concept introduced (layered DI wiring at the API edge). [Rubric §3, Clean Architecture] assesses whether each layer registers only its own concerns; this class wires controllers, JSON/XML formatters, filters, feature management, exception handlers, and health checks, all API-layer edges, and reaches down to Application only for ModulesSettings/ModuleLoader (:16-17). [Rubric §13, Observability & Operability] and [Rubric §17, DevOps] both apply through AddModuleHealthChecks (:188), which projects module state into /health checks tagged module so /health?tag=module reports each module's status (:180-181). [Rubric §9, API & Contract Design] is relevant: every parameter is optional and defaulted so a host wires only what it needs.
  • Walkthrough
    • AddAPI(ModulesSettings? modulesSettings = null, IConfiguration? configuration = null) (DependencyInjection.cs:44) registers controllers with ReturnHttpNotAcceptable = false and the UnhandledResultFailureFilter global filter (:46-50), adds two JSON converters (:51-59) and the XML DataContract formatters (:60). The second converter is the load-bearing one: concrete enumerations do not inherit the base class's [JsonConverter] attribute because System.Text.Json resolves it with inherit: false, so the factory is registered once here and every Enumeration<T> serializes by Name across the whole API surface (:55-58). It then conditionally registers ModuleControllerFeatureProvider when modulesSettings is non-null (:62-66), conditionally binds IdempotencySettings from the config section with data-annotation validation on start (:68-74), and registers the scoped IdempotencyFilter and OwnerOrAdminFilter (scoped because they depend on scoped services such as ICacheService and ICurrentUserService, :76-78). Feature management comes next and is worth reading closely (:80-93): AddHttpContextAccessor() (:90) is called first because it is TryAdd-based and therefore safe to repeat, then AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() (:91-92) registers the feature manager plus the built-in Percentage/TimeWindow/Targeting filters and supplies the targeting audience from the current request's principal, which is what makes a percentage rollout sticky per user instead of random per request; the accessor is a singleton, which is exactly why it reads IHttpContextAccessor rather than the scoped ICurrentUserService (comment at :84-89). Finally the singleton DisabledFeatureHandler is bound to IDisabledFeaturesHandler (:93) and AddErrorLocalization() is called (:95-96).
    • AddErrorLocalization() (:107) registers ASP.NET localization (:109), the singleton IErrorLocalizer via TryAddSingleton so a host can substitute its own (:110), and the framework's own ErrorResources source (:111); AddErrorResources<TResource>() (:122) adds a module's resource anchor as another ErrorResourceSource built from an IStringLocalizerFactory (:124-125). This is the ADR-027 edge error-localization extension point, keyed by Error.Code, and modules add their translations additively (:115-121).
    • AddCommonExceptionHandlers() (:135) registers ProblemDetails (adding a requestId extension from TraceIdentifier, :137-139) then five IExceptionHandlers in specificity order (:140-144): OperationCanceled, DomainException, DbUpdate, Validation, and GlobalExceptionHandler as the catch-all. ASP.NET Core invokes them in registration order and stops at the first that handles the exception, hence most-specific first and the 500 fallback last (:131-132).
    • AddServerAuthSessionCookie(string apiBaseAddress) (:160) wires the SSR-prerender auth path: it guards the address (:162), then adds HttpContextAccessor, memory cache, the scoped CookieTokenReader (:164-166), a named HttpClient pointed at the internal API base address (:168-169), and the CookieSessionRefresher as a singleton (:172). The singleton is load-bearing: its in-flight map must be shared across requests for single-flight refresh to work (:171). The doc comment is explicit that the address is the internal endpoint, not the browser-facing one (:155-158).
    • AddModuleHealthChecks(ModuleLoader moduleLoader) (:188) adds one health check per module, Healthy for each enabled module (:192-198) and Degraded for each disabled one (:200-207), named module-{Name} and tagged module. It must run after ModuleLoader's DiscoverAndRegister (:183-186).
  • Why it's built this way: bundling the API-edge concerns behind small, defaulted extension methods lets each host opt into exactly the surface it needs (a JWT-only test host skips AddServerAuthSessionCookie; a monolith with no disabled modules passes a null modulesSettings; a host with no idempotency configuration passes a null configuration and keeps the defaults). The exception-handler ordering, the enumeration converter factory, the targeting accessor's singleton lifetime, and the refresher's singleton lifetime are the four non-obvious, correctness-critical choices, and each carries an inline comment. Error localization is registered automatically by AddAPI so modules only add their own resources additively (ADR-027).
  • Where it's used: called from every service host's composition (Program.cs of the ADC/Store/Helpdesk API hosts and the integration-test hosts) to wire the shared API layer; AddApplicationDecorators() still runs last in the overall sequence (see MMCA.Common/CLAUDE.md DI ordering note).
  • Caveats / not-in-source: the relative ordering of AddAPI against AddInfrastructure/AddApplication in a given host is not fixed by this file; only AddApplicationDecorators() last is load-bearing.

CsvWriter

MMCA.Common.API · MMCA.Common.API.Export · MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:34 · Level 0 · class (internal static)

  • What it is: a hand-written, minimal RFC 4180 CSV writer. It writes into a caller-supplied TextWriter one record at a time, so the generic /export endpoint can stream a file straight to the response body without ever holding the whole result set in memory.
  • Depends on: nothing first-party. From the BCL: TextWriter, SearchValues<char> (System.Buffers), UTF8Encoding (System.Text), and CultureInfo.InvariantCulture (System.Globalization).
  • Concept introduced: a framework declines dependencies its consumers cannot decline. [Rubric §32, Dependency & Supply-Chain] assesses what a package drags into every downstream application; because MMCA.Common ships under lockstep versioning, a CsvHelper reference here would become a pin in ADC, Store, and Helpdesk that none of them chose. The type doc makes the trade explicit (CsvWriter.cs:12-17): the framework needs exactly three behaviors (quote when required, escape embedded quotes, terminate with CRLF), and that is a page of code with full in-repo test coverage. [Rubric §15, Best Practices & Code Quality]: every formatting decision is invariant, so the same row produces the same bytes on every machine (CsvWriter.cs:18-26). See ADR-078.
  • Walkthrough
    • Utf8ByteOrderMark (CsvWriter.cs:45) and LineEnding (CsvWriter.cs:48, the literal "\r\n" regardless of host OS) are the two format constants. Utf8NoPreamble (CsvWriter.cs:55) is a UTF8Encoding constructed with encoderShouldEmitUTF8Identifier: false, so the StreamWriter an export runs through emits no preamble of its own and the BOM decision lives in exactly one place.
    • MustQuote (CsvWriter.cs:61) is a SearchValues<char> over ,"\r\n: the four characters RFC 4180 section 2 says force quoting. SearchValues is the vectorized-lookup type, so the per-field scan is a span search rather than four IndexOf passes ([Rubric §12, Performance & Scalability]).
    • WriteByteOrderMark(writer) (CsvWriter.cs:69) writes the BOM unconditionally. The remarks say why (CsvWriter.cs:39-44): Excel reads a BOM-less UTF-8 CSV in the machine's ANSI code page, turning every accented character into mojibake, and three bytes is cheaper than a setting nobody finds in time.
    • WriteHeader(columns, writer) (CsvWriter.cs:81) and WriteRow(cells, writer) (CsvWriter.cs:105) are the same loop: a comma between fields (CsvWriter.cs:88-91 and CsvWriter.cs:112-115), then LineEnding (CsvWriter.cs:96 and CsvWriter.cs:120). The header escapes a column name exactly like a data field (CsvWriter.cs:93), so a column named full,name cannot break the record.
    • FormatCell(value) (CsvWriter.cs:128-137) is the type switch that fixes the value contract: null writes empty, string writes verbatim, bool writes lowercase true/false to match the JSON the sibling endpoints emit rather than .NET's capitalized ToString, DateTime and DateTimeOffset write ISO 8601 round-trip "O" (chosen over the sortable "s" because "O" keeps sub-second precision and the offset, so a parsed value equals the exported one), anything else IFormattable formats invariantly, and the fallback is Convert.ToString.
    • WriteField(field, writer) (CsvWriter.cs:145) is the only private member: a clean field is written as-is (CsvWriter.cs:147-151), and a field containing any MustQuote character is wrapped in quotes with embedded quotes doubled (CsvWriter.cs:153-155).
  • Why it's built this way: the writer takes a TextWriter rather than returning a string, because the whole point of the export endpoint is that no full result set exists in memory at any moment. Keeping the type internal static means it is a framework implementation detail, not a public surface a consumer can bind to and then be broken by.
  • Caveats / not-in-source: the writer deliberately does not neutralize spreadsheet formula injection. A cell whose value opens with =, +, -, or @ is written verbatim, and the type doc records the reasoning (CsvWriter.cs:27-32): CSV is treated as a data-faithful format here and prefixing would corrupt legitimate negative numbers, so a host that opens untrusted exports in a spreadsheet is expected to import them as text. ADR-078 records the same decision in the same direction, and names the price (a known spreadsheet risk carried by every consumer).
  • Where it's used: only by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.ExportAsync, which takes the encoding at EntityControllerBase.cs:277, writes the BOM and header at EntityControllerBase.cs:315-316, each data row at EntityControllerBase.cs:753, and the trailing markers at EntityControllerBase.cs:304 and EntityControllerBase.cs:349.

DisabledFeatureHandler

MMCA.Common.API · MMCA.Common.API.FeatureManagement · MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13 · Level 0 · class (sealed)

  • What it is: the one-method handler that decides what a [FeatureGate]-protected controller action returns when its feature flag is off. Instead of ASP.NET Core's default (a bare 404 with no body), it emits an RFC 9457 Problem Details payload so a disabled feature reads the same as any other framework error.
  • Depends on: IDisabledFeaturesHandler and FeatureGateAttribute from Microsoft.FeatureManagement.Mvc (NuGet); ProblemDetails, ObjectResult, and StatusCodes from ASP.NET Core. No first-party dependencies.
  • Concept introduced: feature gating at the HTTP edge. [Rubric §9, API & Contract Design] assesses whether every response, success or refusal, follows one uniform contract; this handler makes the disabled-feature path match the ApiControllerBase.HandleFailure shape rather than leaking a framework default. [Rubric §6, CQRS & Event-Driven Design] covers concerns applied uniformly across endpoints, and feature flags are exactly that. Note the split: this class gates controller actions decorated with [FeatureGate], while FeatureGateCommandDecorator<TCommand, TResult> gates CQRS handlers one layer deeper. The two surfaces cover the two entry points into a gated capability, which is precisely the dual-surface enforcement ADR-031 records.
  • Walkthrough: HandleDisabledFeatures(features, context) (DisabledFeatureHandler.cs:16) sets context.Result to an ObjectResult wrapping a ProblemDetails with Status = 404 and a fixed title/detail ("Feature not available", DisabledFeatureHandler.cs:18-23), and also sets the outer StatusCode = 404 (DisabledFeatureHandler.cs:25) so the response code and the body agree. It returns Task.CompletedTask (DisabledFeatureHandler.cs:28): the work is synchronous, there is nothing to await.
  • Why it's built this way: the payload deliberately does not name the disabled feature. An anonymous caller learns only that the endpoint is unavailable, not which flag is off, so the flag set is not enumerable from outside. The features parameter is available but unused for that reason.
  • Where it's used: registered as the app's IDisabledFeaturesHandler singleton inside AddAPI (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:93, immediately after services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() on DependencyInjection.cs:91-92); invoked by Microsoft.FeatureManagement.Mvc whenever a [FeatureGate] action is hit with its flag disabled. In the framework itself that covers DataExportControllerBase<TQuery> while PrivacyFeatures.DataExport is off (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:58) and the three notification controllers gated on NotificationFeatures.PushNotifications (Controllers/Notifications/DevicesController.cs:23, NotificationInboxController.cs:27, NotificationsController.cs:28).

ServiceInfoResponse

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:51 · Level 0 · record (sealed, nested)

  • What it is: the v1.0 (minimal) payload returned by the service-info discovery endpoint: just the service name and the API version.
  • Depends on: nothing beyond the BCL; a nested positional record inside ServiceInfoControllerBase.
  • Concept: the deprecated shape in a versioned-contract pair. [Rubric §9, API & Contract Design] assesses whether an API can evolve without breaking callers; this record is the "before" shape that v1.0 clients keep receiving unchanged while v2.0 clients get the superset (ADR-046).
  • Walkthrough: ServiceInfoResponse(string Service, string ApiVersion) (ServiceInfoControllerBase.cs:51), returned by GetV1() populated with the concrete service name and the literal "1.0" (ServiceInfoControllerBase.cs:42).
  • Where it's used: produced by ServiceInfoControllerBase.GetV1(); superseded by ServiceInfoV2Response.

ServiceInfoV2Response

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:54 · Level 0 · record (sealed, nested)

  • What it is: the v2.0 (evolved) service-info payload, a strict superset of ServiceInfoResponse that additionally advertises the supported and deprecated version lists.
  • Depends on: nothing beyond the BCL; a nested positional record inside ServiceInfoControllerBase.
  • Concept: the additive-evolution half of the versioned pair. [Rubric §9, API & Contract Design]: adding fields (not renaming or removing them) is the backward-compatible way to grow a contract, so a v1.0 caller who never sees the new fields is unaffected.
  • Walkthrough: ServiceInfoV2Response(string Service, string ApiVersion, IReadOnlyList<string> SupportedVersions, IReadOnlyList<string> DeprecatedVersions) (ServiceInfoControllerBase.cs:54-58). The two extra members surface the Supported/Deprecated arrays the controller holds (ServiceInfoControllerBase.cs:32-33), so the body itself documents the version landscape, the same facts the api-supported-versions / api-deprecated-versions headers carry.
  • Where it's used: produced by ServiceInfoControllerBase.GetV2() (ServiceInfoControllerBase.cs:47-48).

ServiceInfoControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30 · Level 1 · class (abstract)

  • What it is: an anonymous, read-only discovery controller that proves the API-versioning machinery works across more than one version. The same /ServiceInfo route is served by v1.0 (deprecated) and v2.0, selected via the api-version header.
  • Depends on: Asp.Versioning (MapToApiVersion) and ASP.NET Core MVC (ControllerBase); returns ServiceInfoResponse and ServiceInfoV2Response, both nested in this file.
  • Concept introduced: header-based API versioning as a first-class contract. [Rubric §9, API & Contract Design] assesses whether an API can carry multiple versions concurrently and signal deprecation; this controller demonstrates the whole loop: two versions on one route, one marked deprecated, and ReportApiVersions = true (set in AddCommonApiVersioning on WebApplicationBuilderExtensions, WebApplicationBuilderExtensions.cs:253, inside the extension member declared at WebApplicationBuilderExtensions.cs:245) so responses carry api-supported-versions / api-deprecated-versions headers (class doc, ServiceInfoControllerBase.cs:6-14). ADR-046 makes the point that a versioning claim which only ever ships v1.0 is untestable; this endpoint is what makes it testable.
  • Walkthrough
    • Supported = ["1.0", "2.0"] and Deprecated = ["1.0"] (ServiceInfoControllerBase.cs:32-33) are the static version lists the v2 payload echoes.
    • ServiceName (ServiceInfoControllerBase.cs:36) is an abstract property the sealed per-service subclass supplies, because class-level routing/versioning attributes are not reliably inherited (remarks, ServiceInfoControllerBase.cs:15-29): the subclass carries [ApiController], [Route("[controller]")], [AllowAnonymous], and the two [ApiVersion] attributes.
    • GetV1() (ServiceInfoControllerBase.cs:41) is [HttpGet] plus [MapToApiVersion("1.0")] (ServiceInfoControllerBase.cs:39-40) and returns the minimal ServiceInfoResponse.
    • GetV2() (ServiceInfoControllerBase.cs:47) is [MapToApiVersion("2.0")] (ServiceInfoControllerBase.cs:46) and returns the superset ServiceInfoV2Response with the supported/deprecated lists.
  • Why it's built this way: the type is abstract with an abstract ServiceName so each extracted service reuses the identical versioning surface while stamping its own identity, keeping the "build the monolith now, extract a service later" path uniform ([Rubric §7, Microservices Readiness]). The endpoint is anonymous and reached on the service host directly; gateways do not route it (class doc, ServiceInfoControllerBase.cs:12-13).
  • Where it's used: subclassed by each service's sealed ServiceInfoController, for example ADC's Conference service (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20) and Store's Catalog service (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:20). Because the controller ships in the framework, the fitness contract that exercises it is shared too: ServiceInfoVersioningContractTestsBase<TFixture> (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/ServiceInfoVersioningContractTestsBase.cs:20), and a repo subclasses it supplying only its fixture.

IEntityControllerBase<TEntityDTO, TIdentifierType>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IEntityControllerBase.cs:14 · Level 2 · interface

  • What it is: the contract every read-only entity controller implements, four GET-shaped methods for all-entities, paged, lookup, and by-id retrieval.
  • Depends on: IBaseDTO<TIdentifierType> (constraint), CollectionResult<T>, PagedCollectionResult<T>, BaseLookup<TIdentifierType>, and QueryFilterModelBinder for the filter parameter.
  • Concept introduced: the generic entity-controller contract. [Rubric §9, API & Contract Design] assesses uniform endpoint conventions across every entity, and this interface is the guarantee that all read controllers expose the same four GET shapes. [Rubric §1, SOLID]: it is deliberately the read-only slice, kept separate from the create/delete slice (IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest>) so a child-collection controller can implement reads without inheriting mutation endpoints (Interface Segregation).
  • Walkthrough: the type constrains TEntityDTO : IBaseDTO<TIdentifierType> and TIdentifierType : notnull (IEntityControllerBase.cs:17-18). The members:
    • GetAllAsync unpaged (IEntityControllerBase.cs:26), with fields projection ([FromQuery]) and the two eager-load flags (IEntityControllerBase.cs:27-30).
    • the paged GetAllAsync overload (IEntityControllerBase.cs:43), adding sortColumn/sortDirection, [Range(1, int.MaxValue)]-guarded pageNumber/pageSize (IEntityControllerBase.cs:49-50), and a Dictionary<string, (string Operator, string Value)> of filters bound by QueryFilterModelBinder (IEntityControllerBase.cs:51).
    • GetAllForLookupAsync for id/label dropdown data (IEntityControllerBase.cs:58).
    • GetByIdAsync (IEntityControllerBase.cs:69), whose includeFKs defaults to true for the single-entity case (IEntityControllerBase.cs:71) while the collection endpoints default it to false (IEntityControllerBase.cs:28 and IEntityControllerBase.cs:44).
  • Why it's built this way: expressing the surface as an interface lets architecture tests and OpenAPI tooling reason about the contract independently of the concrete generic base, and lets the two-level controller hierarchy layer capabilities without collapsing reads and writes into one type. Note what is deliberately absent: the CSV ExportAsync action added to the generic base is a base-class method only, and this interface still declares exactly four members (IEntityControllerBase.cs:26, :43, :58, :69). Adding a fifth would be a breaking change for any consumer implementing the interface explicitly instead of inheriting the base, and a default interface member would hide that break behind a runtime surprise (ADR-078).
  • Where it's used: implemented by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> and extended by IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest>.

IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:15 · Level 3 · interface

ApiControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:16 · Level 4 · class (abstract)

  • What it is: the base class every API controller inherits. It carries the [ApiController] behavior and one shared method, HandleFailure, that turns domain errors into RFC 9457 Problem Details responses.
  • Depends on: Error, ErrorType, ErrorHttpMapping (and through it ErrorTypeSeverity), and IErrorLocalizer (resolved optionally from RequestServices).
  • Concept introduced: centralized error-to-HTTP mapping. [Rubric §9, API & Contract Design] assesses whether every endpoint fails the same way; [Rubric §3, Clean Architecture] covers keeping the HTTP-translation concern in the presentation layer rather than the domain. This is the boundary where a Result failure from the Application/Domain layers becomes an HTTP status: the domain never knows about status codes, this base owns that mapping. The [ApiController] attribute (ApiControllerBase.cs:15) enables automatic model-state validation, binding-source inference, and ProblemDetails serialization.
  • Concept introduced: the status code is ranked, not positional. [Rubric §11, Security] and [Rubric §9, API & Contract Design] both bear on this. An aggregate failure built by Result.Combine carries several errors at once, and if the response status came from errors[0] then the order in which handlers appended errors would decide whether a caller saw 403 or 400. HandleFailure therefore hands the whole list to ErrorHttpMapping.GetStatusCode(IReadOnlyList<Error>) (ApiControllerBase.cs:48, mapping at MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:50-51), which resolves the most severe ErrorType present through ErrorTypeSeverity. The ranking is spelled out on the method doc (ApiControllerBase.cs:21-31): Unexpected (500), then Unauthorized (401), Forbidden (403), Conflict (409), NotFound (404), UnprocessableEntity (422), and finally Invariant/Validation/Failure (400). Equal ranks keep the earliest error, so a same-rank list behaves exactly as a positional selection would. The ranking itself lives in MMCA.Common.Shared on purpose (ErrorHttpMapping.cs:45-46), so the gRPC edge classifies the same aggregate identically. Every error still travels in the response; only the status is ranked.
  • Walkthrough: HandleFailure(IEnumerable<Error> errors) (ApiControllerBase.cs:35) is protected virtual:
    • Null/empty guard (ApiControllerBase.cs:37-45): with no errors it returns a 500 "Unknown error", treating an empty failure as a programming mistake rather than a domain outcome.
    • Ranked status (ApiControllerBase.cs:48, with the intent stated in the comment just above it at ApiControllerBase.cs:47).
    • Builds a ProblemDetails with that status and a fixed title/detail (ApiControllerBase.cs:50-55), attaches Extensions["errors"] via ErrorHttpMapping.BuildErrorsExtension (ApiControllerBase.cs:58), optionally localized through an IErrorLocalizer resolved with GetService (ApiControllerBase.cs:57, so a host without localization simply passes null), then returns StatusCode(statusCode, problemDetails) (ApiControllerBase.cs:60).
  • Why it's built this way: one virtual method instead of a switch in every action removes duplication and makes the response shape uniform (ADR-013 for why failures are values rather than exceptions in the first place); keeping it virtual lets a subclass (EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>) wrap it with logging without reimplementing the mapping. The ErrorHttpMapping members are internal static (ErrorHttpMapping.cs:37, :50, :61), which is what lets UnhandledResultFailureFilter reuse the same ranked status mapping and the same errors extension array for a failed Result that an action returned without calling HandleFailure (UnhandledResultFailureFilter.cs:36 and :47). The two bodies are deliberately not identical: the filter labels its ProblemDetails Title/Detail "Unhandled result failure" / "The action returned a Result.Failure that was not mapped to an HTTP error response." (UnhandledResultFailureFilter.cs:42-43) against the base's "Operation failed" / "One or more errors occurred." (ApiControllerBase.cs:53-54), so a response that fell through the filter is distinguishable from one the controller mapped on purpose. Localization is the ADR-027 extension point, keyed by Error.Code and leaving Code/Type/Source/Target verbatim so clients can still branch on them (ErrorHttpMapping.cs:56-59 for the contract, ErrorHttpMapping.cs:61-69 for the projection that honours it).
  • Where it's used: the root of the controller hierarchy. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, AuthControllerBase, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, DataExportControllerBase<TQuery>, and every module controller derive from it directly or transitively. Framework coverage is ApiControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/ApiControllerBaseTests.cs).

EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:36 · Level 6 · class (abstract)

  • What it is: the generic read-only controller that gives any entity five working REST endpoints (GET /, GET /paged, GET /export, GET /lookup, GET /{id}) with filtering, sorting, pagination, field projection, one shared row-scoping hook, and an ETag on the by-id read, by delegating to the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> pipeline.
  • Depends on: ApiControllerBase (base), IEntityControllerBase<TEntityDTO, TIdentifierType> (implements), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, AuditableBaseEntity<TIdentifierType> (constraint), ApplicationSettings (through IOptions<T>), CollectionResult<T>, PagedCollectionResult<T>, BaseLookup<TIdentifierType>, QueryFilterModelBinder, Error, CsvWriter, ConcurrencyETag, QueryFieldService, and Specification<TEntity, TIdentifierType>; ASP.NET Core MVC, Asp.Versioning, System.Text.Json, System.Reflection, TimeProvider, and ILogger.
  • Concept introduced: generic controller bases that eliminate CRUD boilerplate. [Rubric §9, API & Contract Design] covers uniform endpoint conventions; [Rubric §1, SOLID] covers the Open/Closed side, since a new entity controller extends this base rather than re-writing the endpoints. The class-level [ApiController], [Route("[controller]")], [ApiVersion("1.0")] (EntityControllerBase.cs:33-35) plus the three generic constraints (EntityControllerBase.cs:43-45) turn the type parameters into the contract: name the entity, the DTO, and the identifier alias, and the routes, the versioning, and the query behavior follow (ADR-034).
  • Walkthrough
    • Primary constructor (EntityControllerBase.cs:36-41) takes the query service and an ILogger, both null-guarded into the QueryService (:47) and Logger (:52) protected properties.
    • MaxPageSize (:58-65) resolves IOptions<ApplicationSettings> per-request from HttpContext.RequestServices, falling back to 500 (:63). Per-request resolution means a settings change takes effect without a restart. MaxExportRows (:78-86) does the same for the export ceiling, with the extra rule that a configured value of zero or less is treated as unconfigured (:83-84), because a cap of zero would silently serve every caller a header-only file (:73-77). EntityName (:91) is typeof(TEntity).Name, used in log messages.
    • GetAllAsync unpaged (:109, [HttpGet] at :106): resolves the read specification (:115), then delegates to the query service with pageNumber: 1 and pageSize: MaxPageSize (:122-123), so even the "all" endpoint is capped, then either HandleFailure or Ok.
    • GetAllAsync paged (:157, [HttpGet("paged")] at :153): clamps with Math.Min(pageSize, MaxPageSize) (:168), and on success serializes PaginationMetadata into the X-Pagination response header (:187) rather than mixing it into the body, [Rubric §9] again, and [Rubric §12, Performance & Scalability] for the clamp.
    • ExportAsync (:251, [HttpGet("export")] at :247) is covered as its own concept below.
    • GetAllForLookupAsync (:374, [HttpGet("lookup")] at :371): returns CollectionResult<BaseLookup<TIdentifierType>>, a lightweight id/label pair, with a [Required] nameProperty (:375) choosing the label.
    • GetByIdAsync (:415, [HttpGet("{id}")] at :410): includeFKs defaults to true for the single-entity case (:417), and on success it calls SetConcurrencyETag(result.Value) before returning (:436-437).
    • HandleFailure override (:505-519): logs the first error at Warning, guarded by Logger.IsEnabled (:508), before delegating to ApiControllerBase.HandleFailure, so the read path gets observability ([Rubric §13, Observability & Operability]) without changing the response mapping. Note it logs the first error while the base ranks the status by the most severe one: the log line is a breadcrumb, not the status decision.
    • Every read action passes asTracking: false to the query service (for example :124), because a read endpoint never mutates what it loaded.
  • Concept introduced: one row-scoping hook for every read the controller serves. [Rubric §11, Security] assesses whether an authorization decision can be reproduced consistently across every path that returns the same rows; [Rubric §15, Best Practices & Code Quality] assesses whether that decision lives in one place. GetReadSpecificationAsync(cancellationToken) (:597-599) returns the Specification<TEntity, TIdentifierType> applied by all five read actions: both GetAllAsync overloads, GetAllForLookupAsync, GetByIdAsync, and ExportAsync (each resolves it at :115, :170, :378, :422, and :264). Four properties are worth internalizing, all stated on the hook's own doc (:562-596).
    • It is asynchronous because row scoping usually is (:572-579). Scope is rarely a pure function of the current principal: it comes from a query handler, a claim lookup that hits a store, a tenancy read. A synchronous hook forced such a controller to override all five actions by hand purely to get an await in before the query. An override with nothing to await returns ValueTask.FromResult(...) and allocates nothing.
    • It narrows, it never replaces (:580-586). The query service ANDs the specification with the caller's filters dictionary and fields projection, so a caller can only narrow what the specification already allows; a filter naming excluded rows yields an empty page rather than leaking them (:137-142).
    • A rejected row is a 404, not a 403 (:587-591, restated on GetByIdAsync at :404-409). The specification narrows the query, so the row is simply absent. Answering "forbidden" would confirm the id exists and turn a scoped read into an existence oracle a caller could walk.
    • The lookup endpoint carries the scope as a predicate (:364-370). GetAllForLookupAsync has no specification parameter, so the scope travels as specification?.Criteria (:382); a null specification passes a null predicate, the unscoped query this endpoint always issued. A dropdown that lists what the list endpoint hides would be an oracle of its own. GetExportSpecification() (:626) is the synchronous half: it returns null by default and is what GetReadSpecificationAsync returns by default (:599). A controller that can build its scope without awaiting overrides this one and gets all five actions scoped; a controller that needs an await overrides the async hook instead, and then this one is no longer consulted (:606-613). The remarks are blunt about the consequence of overriding neither on a row-scoped controller (:614-619): it hands every caller the whole table in one request. See ADR-033 for the ownership model this hook reproduces in the query.
  • Concept introduced: handing the client a precondition token on the way out. [Rubric §8, Data Architecture] and [Rubric §9, API & Contract Design]. SetConcurrencyETag(object? dto) (:471) emits the read's concurrency token as a weak ETag so a client can hand it straight back as an If-Match precondition on the next write (see SupportsIfMatchAttribute) instead of round-tripping it through the request body. Three details make it cheap and honest.
    • RowVersionProperty (:445-449) is resolved once per closed controller type because a DTO's shape cannot change at runtime: the first public instance byte[] property named exactly RowVersion. For a DTO with no concurrency token it is null, which makes the whole method a no-op with no per-request reflection at all (:473-474).
    • ReadRowVersion (:488) handles both shapes a served row can take: a typed DTO, read through the cached PropertyInfo (:490-491), or a shaped dictionary keyed by JSON names when the caller asked for a field projection, probed under both rowVersion and RowVersion (:493-496).
    • A DTO without a token gets no header at all (:476-477). The remark says why (:458-463): absent is the correct answer for a resource that has no version to condition on, and a fabricated tag would invite preconditions the write side cannot honour. The method is protected rather than private (:464-469) so a derived controller serving a row from a custom read action emits the identical header instead of re-implementing the reflection and the base64 format.
  • Concept introduced: streaming a bulk extract from a paged read. [Rubric §12, Performance & Scalability] assesses whether a large response is bounded and whether memory grows with the result set; [Rubric §11, Security] covers who may pull a whole table in one request. ExportAsync answers the "export what you filtered" request without any new query path (ADR-078):
    • A dedicated route, not content negotiation ([HttpGet("export")], :247). The remarks name the two behaviors that make an Accept: text/csv formatter wrong here (:197-204): the public output-cache policy varies by query string but not by Accept, so a cached JSON body could be replayed to a CSV request, and AddAPI sets ReturnHttpNotAcceptable = false (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:48), so a negotiation miss falls back to JSON silently instead of returning 406. A distinct path has neither failure mode.
    • Field validation before a byte moves (:259-261): ValidateExportFields (:685) rejects a fields= request naming a property the CSV cannot render, as an Error.InvalidEntityField failure (:694-698), rather than quietly dropping the column.
    • Row scoping through the shared hook: the specification is resolved once, before the first page (:263-264), so the same instance filters every page, and it is the same hook the list endpoints read, so an export can no longer drift wider than the list it mirrors (:229-235).
    • The page loop (:280-345) calls the same QueryService.GetAllAsync the paged route uses, at pageSize = Math.Max(1, MaxPageSize) (:267), writing each page out as it materializes. Three termination conditions are distinguished on purpose: a short page is the last page (:333-334), stopping mid-page means rows were left behind (:326-330), and a cap landing exactly on a page boundary consults PaginationMetadata.TotalItemCount rather than issuing a wasted extra query (:338-342).
    • Headers first, then body (BeginExportResponse, :708): content type text/csv; charset=utf-8 (:529, set at :712), a Content-Disposition attachment whose file name is {controller}-{yyyyMMdd'T'HHmmss'Z'}.csv built invariantly from an injected TimeProvider (:710, :713, BuildExportFileName at :724-727, prefix at :547-560), and X-Export-Row-Limit (:535, appended at :714) so a client can tell "exactly at the limit" from "coincidentally that many rows".
    • Truncation rides in the body. Headers freeze the moment the first body byte flushes, so a truncated export ends with a # export truncated at N rows record (:347-350, marker at :837-838) and a mid-stream query failure ends with # export incomplete after N rows (:303-304, marker at :843-844) plus a Warning log (LogExportPageFailure, :852). A failure on the FIRST page, before anything was written, still returns Problem Details through HandleFailure (:297-298), which is exactly what the "constructing the writer sends nothing" comment protects (:274-277).
    • Column resolution is derived, not invented: ResolveExportColumns (:774) takes the shaped keys of the first row (:776-778), so the CSV columns for a given fields= request are the same camelCase JSON names the JSON endpoints emit; an empty result still gets a header row built from the DTO's own properties in declaration order (:780-792). UnexportablePropertyNames (:639-645) drops the properties that cannot render a faithful scalar cell, byte[]/ReadOnlyMemory<byte> concurrency tokens and every collection type except string (IsExportableType, :667-670), computed once per closed controller type since a DTO's shape cannot change at runtime. Value objects and other class-typed properties are deliberately NOT dropped, because a record or value object has a meaningful invariant ToString (:634-638).
  • Why it's built this way: the controller stays thin. All filtering/sorting/paging lives in IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, and manual DTO mapping (ADR-001) keeps entities off the wire. The controller only translates HTTP concerns: query strings, headers, status codes. The export reuses the paged read wholesale precisely so it cannot disagree with the grid it was launched from, and the row ceiling (DefaultMaxExportRows = 100_000, :526, matching ApplicationSettings.MaxExportRows's own default) is a number an operator can reason about rather than an unbounded connection hold.
  • Caveats / not-in-source: the export carries no attributes of its own. It inherits whatever [Authorize] or [FeatureGate] the derived controller declares, so a controller that exposes paged anonymously exposes export anonymously; the symmetry is deliberate and documented (:205-211). The export is also not output-cached.
  • Where it's used: the base for every read-only module controller, for example ADC's child-collection controllers SessionSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:56) and CategoryItemsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:70). Extended by AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> for entities that also create and delete. The scoping hooks are widely overridden today: the async GetReadSpecificationAsync by ADC's SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:105), SessionsController (SessionsController.cs:77), RoomsController (RoomsController.cs:120) and their child controllers, and by Store's owner-scoped ShoppingCartsController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:206); the synchronous GetExportSpecification by ADC's EventsController (EventsController.cs:75) and SessionQuestionAnswersController (SessionQuestionAnswersController.cs:96) and by Store's OrdersController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:244). Store's CustomersController deliberately overrides neither and records why (its list endpoints are Admin-only rather than row-scoped, so there is no ownership specification to reproduce, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:86-100). Framework coverage lives in EntityControllerBaseTests, EntityControllerBaseExportTests, EntityControllerBaseETagTests, and EntityControllerBaseReadSpecificationTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/), the last of which drives both hooks through purpose-built doubles (EntityControllerBaseReadSpecificationTests.cs:356 and :377).

OAuthControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:35 · Level 6 · class (abstract)

  • What it is: the base controller for external OAuth2 sign-in (Google, GitHub, Apple). It runs the challenge/callback/complete/exchange dance so a browser or native head can log in through a provider and receive a local JWT pair without ever exposing tokens in a redirect URL.
  • Depends on: IAuthenticationService (ExternalLoginAsync), ICacheService, IConfiguration, ExternalAuthExtensions (the scheme constant, OAuthControllerBase.cs:40), NonIdempotentAttribute, AuthenticationResponse, OAuthCodeExchangeRequest, and Error; the Google, GitHub, and Apple OAuth packages (OAuthControllerBase.cs:3-6) and System.Security.Cryptography.
  • Concept introduced: the code-exchange OAuth completion pattern. [Rubric §11, Security] assesses how credentials move through the system; the design's whole point is that the redirect after a successful provider login carries only a single-use opaque code, never the access/refresh tokens, so tokens never land in the address bar, browser history, the Referer header, or upstream access logs (OAuthControllerBase.cs:127-129). [Rubric §7, Microservices Readiness]: the base is hoisted from the app hosts so every service reuses the identical flow, with the sealed subclass supplying only [Route("auth/oauth")] and versioning (class doc, OAuthControllerBase.cs:30-33). See ADR-036.
  • Walkthrough
    • OAuthExchangeCodePrefix and a 2-minute OAuthExchangeCodeLifetime (OAuthControllerBase.cs:45-46) namespace and time-box the server-side token stash; the short TTL matches the single redirect-then-POST round trip (:41-43).
    • GoogleLogin (:52), GitHubLogin (:60), and AppleLogin (:71) all call ChallengeProvider (:272), which stashes returnUrl in AuthenticationProperties.Items and sets RedirectUri = "/auth/oauth/complete" (:276-277). Apple is the newest of the three and needs no special action here: its callback arrives as a cross-site form POST (response_mode=form_post is forced by the name/email scopes) that the middleware handles at /auth/callback/apple like any other provider (:63-68).
    • CompleteAsync (:88): after the middleware handles the provider callback, this reads the external cookie (:91), redirects to /login?error=oauth_failed when the ticket did not survive (:93-98), reads the stashed returnUrl with a GetString fallback to "/" rather than the throwing Items indexer (:100-103), extracts provider claims (ExtractClaims, :185), calls ExternalLoginAsync to find or create the local user and mint tokens (:113-114), signs out the temporary external cookie (:124), then mints a 32-byte hex exchangeCode (:129), stashes the token pair in the cache under it (:130-131), and redirects with only the code (:133, URL built by BuildSuccessRedirectUrl at :136-139).
    • Name handling is defensive: ExtractName (:195) prefers GivenName/Surname claims and otherwise splits the Name claim, falling back to ("User", "") when there is no usable space-separated name (:209-223), so a provider that returns only a display name still yields a creatable local account. That fallback matters most for Apple, which returns name claims on the first authorization only.
    • Native heads (ADR-043): GetAllowedMobileReturnUrl (:247) returns the stashed returnUrl as the redirect target only when it is an absolute URI whose custom scheme is listed in OAuth:AllowedReturnUrlSchemes; http/https never match (:249-251), so the allowlist cannot become an open redirect, and a missing or empty section (or a test double returning null from GetSection) means "no allowlist", the exact pre-ADR-043 behavior (:256-259). Failures route to the same surface through RedirectError (:235-238), so a native window closes on an error instead of stranding on a web login page.
    • ExchangeAsync (:152): [HttpPost("exchange")] with [NonIdempotent(...)] and [AllowAnonymous] (:148-151); the UI swaps the code for the real AuthenticationResponse out-of-band. Because that response is a struct, a cache miss yields a default value rather than null, so the miss is detected via an empty AccessToken (:163-169). The code is then removed (:172), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (InvalidCode, :177-183).
  • Why it's built this way: carrying tokens in a redirect is the classic OAuth token-leak vector; the single-use code plus a short-lived server-side stash closes it while keeping the client flow a plain redirect and one POST. The [NonIdempotent] justification on ExchangeAsync (:149) records why this one endpoint must stay outside the replay contract: replaying the stored response would defeat the burn, letting a leaked code mint the same tokens again for the whole retention window. The AppendQuery helper (:263) deliberately uses OriginalString rather than ToString(), because Uri normalization appends a trailing slash to authority-only URIs (atldevcon://oauth-complete) and native authenticator callback matching can be exact (:265-267).
  • Caveats / not-in-source: the provider scheme registration and the concrete ExternalLoginAsync implementation live outside this base (ExternalAuthExtensions and the app's IAuthenticationService); this file assumes both are wired. The challenge and complete actions are hidden from OpenAPI with [ApiExplorerSettings(IgnoreApi = true)] (:87, :151) because they are browser redirects, not a documented client contract.
  • Where it's used: subclassed by each app's sealed OAuth controller, today ADC's OAuthController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20), which adds only the class-level routing and versioning attributes. ExchangeAsync is one of the endpoints the framework's anonymous-endpoint architecture gate lists by name (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:31), so its [AllowAnonymous] is an approved exception rather than an oversight. Framework coverage is OAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs).

AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:28 · Level 7 · class (abstract)

  • What it is: the read-plus-create-plus-delete tier of the controller hierarchy. It extends EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (the read endpoints) by adding a CreateAsync (POST) and a DeleteAsync (DELETE) for aggregate-root entities.
  • Depends on: EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (base), IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest> (implements), ICommandHandler<in TCommand, TResult> (create and delete handlers), DeleteEntityCommand<TEntity, TIdentifierType>, AuditableAggregateRootEntity<TIdentifierType> (constraint), ICreateRequest (constraint), IdempotentAttribute; ASP.NET Core MVC and Asp.Versioning.
  • Concept introduced: idempotent creation guarded at the endpoint. [Rubric §9, API & Contract Design] assesses safe mutation; CreateAsync carries [Idempotent] (AggregateRootEntityControllerBase.cs:60), which wires IdempotencyFilter so a retried POST carrying the same Idempotency-Key replays the original 201 (flagged X-Idempotent-Replay: true, IdempotencyFilter.cs:40) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-second LockWait (IdempotencyFilter.cs:104, awaited at IdempotencyFilter.cs:250) is answered with 409 Conflict rather than a replay (IdempotencyFilter.cs:304-309). Because the fitness rule reads attributes with inherit: true, every concrete controller that inherits this action satisfies its POST idempotency-intent obligation through this base (see NonIdempotentAttribute). [Rubric §1, SOLID]: the four constraints (AggregateRootEntityControllerBase.cs:41-44, notably TEntity : AuditableAggregateRootEntity<TIdentifierType>) enforce at compile time that only aggregate roots reach this create/delete surface.
  • Walkthrough
    • Primary constructor (AggregateRootEntityControllerBase.cs:28-39): four parameters, where queryService and logger are forwarded to the EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> base (:38), plus createHandler and deleteHandler. The logger is typed ILogger<EntityControllerBase<...>>, not of this class, because ILogger<T> is not covariant and the base ctor requires that exact type; the #pragma warning disable S6672 (:35-37) is a justified, narrowly-scoped suppression documenting exactly that ([Rubric §15, Best Practices]).
    • CreateHandler property (:48): protected, so a derived controller that overrides CreateAsync to build a more specific command can still reach the handler. deleteHandler stays a captured constructor parameter, used directly at :93, because nothing overrides delete today.
    • CreateAsync (:63-76): [HttpPost] plus [Idempotent] (:58-59), body bound [FromBody, Required] (:64); it dispatches the create command and on success returns CreatedAtRoute($"Get{typeof(TEntity).Name}ById", new { id = result.Value!.Id }, result.Value) (:72-75), following the "Get{Entity}ById" route-name convention derived controllers establish (:69). On failure it maps errors via HandleFailure.
    • DeleteAsync (:89-98): [HttpDelete("{id}")] (:84); builds a DeleteEntityCommand<TEntity, TIdentifierType>, dispatches it, and returns NoContent() on success. Delete here means soft-delete: the handler loads the aggregate and calls its Delete() method, so the domain, not the controller, decides whether the removal is allowed.
  • Why it's built this way: splitting the read-only base from the aggregate-root base means a child-collection controller (add/remove associations, not create whole aggregates) can extend the read base without inheriting create/delete it should not expose ([Rubric §1, SOLID], Interface Segregation), while the actual work stays in injected Application-layer handlers ([Rubric §3, Clean Architecture]) that the CQRS decorator pipeline already wraps with validation, transactions, and cache invalidation (ADR-014). Update is deliberately NOT here: it needs a fifth type parameter, so it lives on CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> instead of changing this type's generic arity.
  • Where it's used: concrete aggregate controllers in the modules extend this, for example ADC's EventsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:60) and SessionsController (SessionsController.cs:57), and Store's CustomersController (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:43); child-only controllers deliberately extend the read-only base instead. Framework coverage lives in AggregateRootEntityControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AggregateRootEntityControllerBaseTests.cs).

CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:54 · Level 8 · class (abstract)

  • What it is: the top rung of the generic controller chain. It adds the one endpoint AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> leaves out, a conditional PUT /{id}, completing read, create, update, and delete with no per-entity action bodies at all.
  • Depends on: AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (base), ICommandHandler<in TCommand, TResult> with UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>, SupportsIfMatchAttribute, IdempotentAttribute, AuditableAggregateRootEntity<TIdentifierType> and ICreateRequest (constraints, CrudEntityControllerBase.cs:69-72); ASP.NET Core MVC and Asp.Versioning.
  • Concept introduced: adding an endpoint without changing a shipped generic arity. [Rubric §15, Best Practices & Code Quality] assesses whether a capability can be added without disturbing what already works, and [Rubric §32, Dependency & Supply-Chain] covers what a version bump costs a consumer. The update endpoint needs a fifth type, the update request. Adding that parameter to the shipped four-parameter base would change that type's arity, and every controller in every consuming app would stop compiling on the next version bump (CrudEntityControllerBase.cs:23-30). So this base is purely additive: a controller offering only create and delete keeps inheriting the four-parameter base; one that also offers update inherits this and gains the action. Note also what did not change: IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest> gained no UpdateAsync member, for the same reason.
  • Concept introduced: a write that requires the client to state a precondition. [Rubric §8, Data Architecture]. The PUT is conditional (ADR-035): [SupportsIfMatch] (:89) decodes the caller's If-Match header into the command's RowVersion, refuses a request that states no precondition with 428 Precondition Required, and answers a failed one with 412 Precondition Failed (:30-37, and the full status contract declared for OpenAPI at :90-96). The request body carries no token at all, so there is exactly one route a concurrency token travels: out as an ETag on the read, back as If-Match on the write. On success the refreshed token is emitted through the inherited SetConcurrencyETag (:111), so the client can condition its next write without re-reading the resource.
  • Walkthrough
    • Class attributes (:50-52) repeat [ApiController], [Route("[controller]")], [ApiVersion("1.0")], since class-level routing attributes are not reliably inherited.
    • Primary constructor (:53-67): five handlers and services, of which queryService, createHandler, deleteHandler, and logger are forwarded straight to the aggregate-root base (:66-67); only updateHandler is new. The same narrowly-scoped #pragma warning disable S6672 covers the non-covariant ILogger<T> category (:63-65).
    • UpdateHandler property (:77): protected, for a derived controller that overrides UpdateAsync entirely rather than wrapping it.
    • UpdateAsync (:97-113): [HttpPut("{id}")], [Idempotent], [SupportsIfMatch] (:87-89), body bound [FromBody, Required] (:99). It reads the decoded token with SupportsIfMatchAttribute.RequiredToken(HttpContext) (:102), dispatches an UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> carrying id, request, and row version (:104-106), and on success sets the fresh ETag and returns Ok(result.Value) (:111-112). Returning 200 with the refreshed DTO rather than 204 is deliberate (:80-81): the caller re-renders from the response instead of issuing a follow-up read.
  • Why it's built this way: the same Clean Architecture split as the rungs below it. No loading, no mapping, no concurrency comparison happens in this file; the command handler behind the CQRS decorator pipeline does all of it (ADR-014), and cache eviction rides on the command's default CachePrefix through the caching decorator (:38-43). An app that also uses ASP.NET output caching overrides UpdateAsync, awaits base.UpdateAsync, and evicts its own output-cache tags on success, exactly the pattern it already follows for the inherited create and delete actions.
  • Where it's used: today the framework's own coverage is the only consumer: CrudEntityControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/CrudEntityControllerBaseTests.cs:20) drives it through a TestCrudController double (:145, closing the five type parameters at :151). No ADC or Store controller extends it yet; they inherit the four-parameter base and write their own PUT actions, which is exactly the case this base exists to absorb.

CurrentUserTargetingContextAccessor

MMCA.Common.API · MMCA.Common.API.FeatureManagement · MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:54 · Level 8 · class (sealed)

  • What it is: the ITargetingContextAccessor that supplies the audience (a user id plus that user's groups) which the feature-management Targeting filter evaluates, read from the current HTTP request's principal.
  • Depends on: Microsoft.FeatureManagement.FeatureFilters.ITargetingContextAccessor and TargetingContext, Microsoft.AspNetCore.Http.IHttpContextAccessor, System.Security.Claims, and ClaimsPrincipalExtensions for the user-id read (CurrentUserTargetingContextAccessor.cs:1-4). Registered by AddAPI; a sibling of DisabledFeatureHandler in the same folder.
  • Concept introduced: a percentage rollout that is sticky per user rather than random per request. A Percentage feature filter with no targeting rolls a die on every evaluation, so the same user sees the feature on one request and off the next: unusable for a UI. The Targeting filter fixes that by hashing the audience's user id, which makes the answer deterministic for a given user across requests and across instances, and this accessor is what supplies that id (:10-14). [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether a cross-cutting toggle is applied uniformly; [Rubric §11, Security] and [Rubric §17, DevOps] both bear on the rollout being an operational lever rather than a deploy. See ADR-031. Two source decisions are worth carrying. First, the user id is the standard sub claim TokenService emits, read through ClaimsPrincipalExtensions so the ClaimTypes.NameIdentifier form the bearer handler maps it to resolves identically, the same read CurrentUserService and IdempotencyFilter perform (:15-19, and IdempotencyFilter.cs:487); the principal's name is the fallback for a token carrying neither. Second, the accessor is a singleton (that is the lifetime WithTargeting gives it), so it cannot take the scoped ICurrentUserService; it reads IHttpContextAccessor instead and re-derives the roles itself, which the doc calls out explicitly (:22-24).
  • Walkthrough: GetContextAsync() (:63) reads httpContextAccessor.HttpContext?.User (:65). An unauthenticated or absent principal yields an empty context, UserId = null and Groups = [] (:67-74), so a targeted feature is simply off for anonymous callers unless the audience opts everyone in (:26-29); the method never returns null, because a feature filter must not be able to fail a request (:57-61). For an authenticated caller it collects the role claims, accepting each claim type the JWT middleware may produce: the standard ClaimTypes.Role URI when inbound claim mapping is on, or the raw role / roles claim when it is off (:76-82). Finally it builds the TargetingContext with user.FindUserIdValue() ?? user.Identity.Name (:86) and those groups (:87).
  • Why it's built this way: accepting three role claim types is not defensive padding, it is the concrete consequence of ASP.NET Core's inbound claim mapping being configurable; matching only ClaimTypes.Role would silently drop every group when a host turns mapping off, and a group-targeted rollout would then behave as an ungrouped one. The class doc carries a worked FeatureManagement configuration example (:30-51) showing a rollout that always includes the Organizer role, includes 25 percent of everyone else, and pins two named users, which is the fastest way to see what the context is actually feeding.
  • Where it's used: registered inside AddAPI as services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92), immediately after AddHttpContextAccessor() (:90, which is TryAdd-based and therefore safe to call here as well as elsewhere, the reasoning recorded at :84-89). From there it is consumed only by the Targeting feature filter, whose verdicts reach the HTTP edge through [FeatureGate] and DisabledFeatureHandler, and the CQRS layer through FeatureGateCommandDecorator<TCommand, TResult>.

AuthControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:43 · Level 15 · class (abstract)

  • What it is: the abstract base for password-based authentication and session management: login, register, refresh, revoke-everywhere, list-my-devices, and revoke-one-device. A downstream module (Identity) inherits it and adds the route prefix, version attribute, and any module-specific endpoints.
  • Depends on: ApiControllerBase (base), IAuthenticationService and ICurrentUserService (injected), LoginRequest, RegisterRequest, RefreshTokenRequest, AuthenticationResponse, RefreshSessionSummaryResponse, ClaimsPrincipalExtensions (FindSessionId), IdempotentAttribute and NonIdempotentAttribute, and the RateLimitPolicyAuthIp constant on WebApplicationBuilderExtensions; Microsoft.AspNetCore.RateLimiting for [EnableRateLimiting].
  • Concept introduced: a secure-by-default base, not just a shared one. [Rubric §9, API & Contract Design] assesses uniform endpoint conventions and [Rubric §1, SOLID] the Open/Closed angle (the base provides virtual endpoints, a derived controller overrides only what differs), but the load-bearing idea here is [Rubric §11, Security]: anti-spray throttling is on by default. LoginAsync and RegisterAsync carry [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (AuthControllerBase.cs:72 and :94), so any consumer inheriting this base gets per-IP protection without opting in. The class doc (AuthControllerBase.cs:20-29) records why: the earlier arrangement shipped the policy in the framework and left each app to attach it, and an app that simply inherited these actions silently had no spray protection at all, because the global limiter deliberately no-ops for anonymous traffic and account lockout is per-email (ADR-019, ADR-029). Every action otherwise shares the same Result-to-ActionResult shape: call the service, check result.IsFailure, return HandleFailure(result.Errors) or the success result. None carries business logic; they are thin HTTP adapters over IAuthenticationService.
  • Concept introduced: every POST states its idempotency intent. This base is the framework's worked example of the NonIdempotentAttribute rule, and reading its actions together is the fastest way to internalize the distinction. RegisterAsync is [Idempotent] (:92): a retried registration should replay the original 201 rather than fail on a duplicate email. The others carry [NonIdempotent("...")] with a written harm: login issues a token pair, so a replayed response would hand a retrying client the tokens minted for an earlier call and extend the lifetime of credentials the caller may already have discarded (:68); refresh rotates the refresh token, so a replay would return a token the rotation has already invalidated and hand the client dead credentials (:116); and revocation must reach the store on every call, since a replayed 204 would report success for a revoke that never ran and leave a refresh token live (:143) or a device signed in (:206) after the user asked otherwise.
  • Walkthrough
    • Primary constructor (:41-43) exposes AuthenticationService and CurrentUserService as protected properties (:46 and :49) so derived controllers can reach them for extra endpoints.
    • ClientIpAddress (:56) and ClientUserAgent (:62) are the two request facts every write action forwards to the service. The IP is recorded on the refresh session and drives the registration rate limit, and behind a proxy it is the forwarded value only when the host configured UseForwardedHeaders (:51-55). The user agent is purely informational, so a device list can name the session a user is looking at; nothing validates against it (:58-61).
    • LoginAsync (:74): [HttpPost("login")], [NonIdempotent], [AllowAnonymous], throttled per IP (:67-70); calls LoginAsync(request, ClientIpAddress, ClientUserAgent, ...) (:78-80) and returns Ok or HandleFailure. The [ProducesResponseType] attributes (:71-73) feed the OpenAPI contract and include the 429 the limiter can produce.
    • RegisterAsync (:99): [HttpPost("register")], [Idempotent], anonymous and throttled (:91-94); returns StatusCode(StatusCodes.Status201Created, ...) (:109), correctly 201 Created for a new account rather than 200. It is virtual so a module can override it to inject extra context (:88-89).
    • RefreshAsync (:120): [AllowAnonymous] (:117), since exchanging an expired token pair is pre-authentication, and deliberately not throttled (:28-34): refresh is automatic and periodic rather than user-initiated, Blazor Server circuits issue it server-side so every Server-circuit user shares the UI host's IP, and refresh tokens are high-entropy, so brute force is not the threat password spraying is.
    • RevokeAsync (:147): [Authorize] (:144); reads CurrentUserService.UserId, returns Unauthorized() if null (:149-151) as a defensive guard even though [Authorize] should already prevent a null id, then calls RevokeAllSessionsAsync and returns NoContent() (:153-159). The endpoint carries no body, so it cannot name the device it is called from: it signs the user out everywhere, which is what a caller with no way to identify its own session should get (:135-140).
    • GetMySessionsAsync (:177): [HttpGet("my-sessions")] plus [Authorize] (:173-174), one row per live refresh session, newest first. The "this is the device you are on" flag comes from the access token's own sid claim, read with User.FindSessionId() (:185), so no client state is involved and nothing has to send a refresh token to a read endpoint; a token minted before sid shipped simply flags no row (:166-170).
    • RevokeSessionAsync (:211): [HttpPost("revoke/{sessionId:guid}")] (:205), the per-device counterpart. The session is named in the route rather than by its refresh token, so a client can sign out a device it does not hold the token for, which is the whole point of a device list. Ownership is enforced in the store query, so another account's session id answers 404 exactly as a nonexistent one does, and revoking an already-revoked session answers 204 because a device list is where duplicate clicks come from (:196-202).
  • Why it's built this way: [Rubric §15, Best Practices & Code Quality]: adding a new token flow means changing one base, not N module controllers; keeping the methods virtual (rather than the class open-ended) keeps the override surface intentional. The rate-limit default is deliberately a loud dependency: a consumer that inherits this base without calling AddCommonRateLimiting() (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:297, which registers the "auth-ip" policy at :366-367, constant defined at :47, with an authIpPermitLimit default of 30 requests per minute per IP at :296) fails at startup on an unregistered policy rather than silently serving unthrottled logins (AuthControllerBase.cs:37-41).
  • Caveats / not-in-source: the per-IP policy partitions on Connection.RemoteIpAddress (WebApplicationBuilderExtensions.cs:224) and deliberately does not limit when that address is null (in-process TestServer, integration tests): AuthIpRateLimitPartition returns RateLimitPartition.GetNoLimiter("__unknown-ip") in that case (WebApplicationBuilderExtensions.cs:226-227), a fail-open posture matching the global limiter and documented at WebApplicationBuilderExtensions.cs:206-212.
  • Where it's used: the base of every app's Identity AuthController, reached today through UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which both ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:30) and Store (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27) extend. Password recovery is deliberately NOT on this chain: it ships as the sibling PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>. The framework's own coverage drives the base through a minimal test double, TestAuthController (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/AuthControllerBaseTests.cs:339), with the throttling asserted separately in AuthControllerBaseRateLimitTests.

PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43 · Level 15 · class (abstract)

  • What it is: the two anonymous password-recovery endpoints, POST forgot-password and POST reset-password, for a user who cannot sign in at all. It is a sibling of AuthControllerBase, not an addition to it.
  • Depends on: ApiControllerBase (base), two ICommandHandler<in TCommand, TResult> instances returning Result (PasswordResetAuthControllerBase.cs:44-45), ICommandWithRequest<out TRequest> as the constraint on both type parameters (:46-47), ForgotPasswordRequest and ResetPasswordRequest, IdempotentAttribute, and the RateLimitPolicyAuthIp constant on WebApplicationBuilderExtensions; ASP.NET Core MVC, Microsoft.AspNetCore.Authorization, and Microsoft.AspNetCore.RateLimiting.
  • Concept introduced: a recovery surface that leaks nothing, on a chain single inheritance already owns. Two separate ideas meet in this one type.
    • Why a sibling controller and not two more actions on the auth base. C# gives a class one base, and each app's AuthController already spends it on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>. Recovery therefore ships as its own base that the app routes to the same Auth prefix ([Route("Auth")] on the concrete controller), so POST /Auth/forgot-password rides the gateway's existing /Auth route with no gateway change (class doc, PasswordResetAuthControllerBase.cs:13-18; the ADC subclass records the same reasoning at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:19-24). [Rubric §15, Best Practices & Code Quality] assesses whether a capability can be added without disturbing what already works: nothing on the authentication chain changed to make room for this.
    • Response shapes chosen so the endpoint is not an account oracle. [Rubric §11, Security] assesses what an unauthenticated caller can learn by probing. Forgot-password always answers 202 on a well-formed request: an unknown address, a throttled request and a failed send are all treated as success by the handler, so the response never reveals which addresses hold accounts, and only a malformed payload reaches 400 through the request validator (remarks, :27-31). Reset-password collapses every rejection to one 401 for the same reason (:95-98). Both actions must be anonymous by necessity, because the caller has lost the credential that authentication would demand, so requiring one would be circular (:20-26); the framework's anonymous-endpoint architecture gate therefore lists both by name rather than letting them pass silently (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:37-38). See ADR-091, which chose a cache-backed single-use token over user-row columns or a self-contained signed payload.
  • Walkthrough
    • Type parameters and constraints (:43-47): TForgotPasswordCommand : ICommandWithRequest<ForgotPasswordRequest> and TResetPasswordCommand : ICommandWithRequest<ResetPasswordRequest>. That is the entire contract the base needs, a command that carries a request payload. Note the difference from the account base next door, whose commands are IUserScopedCommand<TRequest>: recovery has no authenticated user to scope to.
    • The two handlers become protected properties (:50 and :53), the same convention the other auth bases follow, so a derived controller can dispatch them itself for an extra endpoint.
    • CreateForgotPasswordCommand(request) (:61) and CreateResetPasswordCommand(request) (:69) are the two abstract factories. The doc comments state the expected implementation verbatim, => new(request);, and both consumers do exactly that (MMCA.ADC/.../PasswordResetController.cs:36 and :39).
    • ForgotPasswordAsync (:82): [HttpPost("forgot-password")], [Idempotent], [AllowAnonymous], [EnableRateLimiting(RateLimitPolicyAuthIp)] (:75-78), with the 202/400/429 contract declared for OpenAPI (:79-81). The body dispatches the app command and returns Accepted() on success or HandleFailure (:86-92).
    • ResetPasswordAsync (:107) mirrors it at [HttpPost("reset-password")] (:99-102), declaring 204/400/401/429 (:103-106) and returning NoContent() on success (:111-117).
  • Why it's built this way: the commands stay app-side for the same reason they do on the account base (remarks, :32-39): ADC marks its ResetPasswordCommand ICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:16) while Store's implements only ICommandWithRequest<ResetPasswordRequest> (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:13), so one shared record could not preserve both behaviors. [Rubric §2, Design Patterns]: this is Template Method again, the base owning the HTTP shape and deferring construction to two primitive operations. Both actions carry [Idempotent] rather than a [NonIdempotent] justification, which fits their contract: a retried forgot-password should replay the same 202 instead of mailing a second token, and a retried reset should replay the same 204 rather than fail against a token the first call already burned. [Rubric §3, Clean Architecture]: no token generation, hashing, or mail send appears here at all; the controller dispatches and maps, and everything else lives behind the CQRS decorator pipeline (ADR-014).
  • Where it's used: subclassed by each app's sealed PasswordResetController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/PasswordResetController.cs:25), each supplying only the two one-line factory overrides plus [ApiController], [Route("Auth")], and [ApiVersion("1.0")]. Framework coverage is PasswordResetAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/PasswordResetAuthControllerBaseTests.cs:23), which drives a TestPasswordResetController double (:165) and additionally asserts, action by action, that the anonymous, rate-limited and idempotent attributes are still attached (:115-141), so the security posture cannot be removed silently.
  • Caveats / not-in-source: the "always 202" and "every rejection collapses to one 401" guarantees are properties of the app's command handlers, stated in this base's remarks (:27-31, :95-98) but enforced one layer down; nothing in this file forces them.

UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:41 · Level 16 · class (abstract)

  • What it is: AuthControllerBase plus the three self-service account endpoints every app needs once a user is signed in: PUT password, PUT preferences, and GET preferences. The app Identity modules previously carried line-identical copies of all three actions, and the only real difference between them was the command record each one constructed (class doc, UserAccountAuthControllerBase.cs:15-20).
  • Depends on: AuthControllerBase (base, constructed with the same IAuthenticationService and ICurrentUserService it forwards, UserAccountAuthControllerBase.cs:47), two ICommandHandler<in TCommand, TResult> instances and one IQueryHandler<in TQuery, TResult> (:43-45), IUserScopedCommand<out TRequest> as the constraint on both command type parameters (:47-48), ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, UserPreferencesResponse, and Result; ASP.NET Core MVC and Microsoft.AspNetCore.Authorization.
  • Concept introduced: generic-over-the-command deduplication. Two apps wanted the same HTTP surface but not the same command record: ADC's ChangePasswordCommand also implements ICacheInvalidating with a cache prefix built from its own User type (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:16), while Store's does not, so one shared record could not preserve both behaviors (remarks, UserAccountAuthControllerBase.cs:22-31). The resolution is the classic Template Method: the base owns the HTTP shape and the dispatch, and defers construction of the app command to two abstract factory methods. [Rubric §15, Best Practices & Code Quality] assesses whether a change lands in one place; [Rubric §1, SOLID] covers both the Open/Closed extension point and the Dependency Inversion angle, since the base depends only on the IUserScopedCommand<TRequest> abstraction and never on either app's concrete record. [Rubric §2, Design Patterns]: the two Create*Command overrides are the pattern's primitive operations, and their implementations really are one line each.
  • Walkthrough
    • Type parameters and constraints (:40-48): TChangePasswordCommand : IUserScopedCommand<ChangePasswordRequest> and TChangePreferencesCommand : IUserScopedCommand<ChangePreferencesRequest>. That constraint is the whole contract the base needs: a command that carries a user id and a request payload.
    • The three handlers become protected properties (:51, :54, :57), matching the base's convention so a derived controller can dispatch them itself for an extra endpoint.
    • CreateChangePasswordCommand(userId, request) (:66-68) and CreateChangePreferencesCommand(userId, request) (:77-79) are the two abstract factories. Both take a UserIdentifierType, the solution-wide identifier alias, so the base never has to know whether an app's user key is an int or a Guid.
    • ChangePasswordAsync (:91): [HttpPut("password")] plus [Authorize] (:86-87). It reads CurrentUserService.UserId, returns Unauthorized() when null (:95-97), then dispatches CreateChangePasswordCommand(userId.Value, request) through the handler (:99-101) and returns NoContent() or HandleFailure. Note what is absent: no password verification, no hashing, no user lookup. Those live in the app's command handler, behind the CQRS decorator pipeline, so validation and the transaction wrap them (ADR-014). The doc comment is explicit that this dispatches the handler directly rather than brokering through the authentication service (:82-84).
    • ChangePreferencesAsync (:117) mirrors it at [HttpPut("preferences")] (:112): the stored UI culture and theme (ADR-027, ADR-028) follow the user across devices, and a null field leaves that preference unchanged (:109-110).
    • GetPreferencesAsync (:142): [HttpGet("preferences")] (:138). This one constructs its query inline, new GetUserPreferencesQuery(userId.Value) (:150), because the read side has no per-app detail to preserve; the remarks call that asymmetry out deliberately (:28-29).
    • All three actions repeat the same "UserId is null yields Unauthorized()" guard rather than trusting [Authorize] alone, the same defensive posture AuthControllerBase.RevokeAsync takes.
  • Why it's built this way: inheriting this base instead of AuthControllerBase is purely additive (remarks, :31-36): every inherited login/register/refresh/revoke action, including the default per-IP throttling, the idempotency attributes, and the ability to override RegisterAsync or attach another [EnableRateLimiting] policy app-side, behaves exactly as before. That is what made the consolidation safe to do at all. The alternative (pushing the command records into the framework) would have forced ADC's cache-invalidation behavior onto Store or dropped it from ADC. [Rubric §14, Testability]: because the extension point is two abstract methods rather than a service lookup, the framework can exercise the whole base with a test double supplying trivial commands.
  • Where it's used: extended by each app's Identity AuthController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:30) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27), each supplying the two one-line factory overrides. Covered in the framework by UserAccountAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/UserAccountAuthControllerBaseTests.cs:17), which drives the base through a TestUserAccountAuthController double (:252).

ErrorResourceSource

MMCA.Common.API · MMCA.Common.API.Localization · MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorResourceSource.cs:12 · Level 0 · class (sealed)

  • What it is: one registered resource set that IErrorLocalizer consults when translating an error code. It is a thin wrapper around a single IStringLocalizer, which is itself backed by one .resx family. Common registers one for its own ErrorResources anchor; each module registers its own additively.
  • Depends on: Microsoft.Extensions.Localization.IStringLocalizer; produced from a resource anchor type such as ErrorResources (MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9, an empty sealed class whose only job is to name the .resx family).
  • Concept introduced: an ordered, additive localization registry. The alternative design is one global resource file that every module has to edit. Instead, the framework registers a set of ErrorResourceSource instances into DI, Common's first and each module's after it, and the localizer walks that set returning the first match (type doc, ErrorResourceSource.cs:5-9). The wrapper type exists purely so DI can hold several IStringLocalizers as a distinguishable IEnumerable<ErrorResourceSource> (a bare IEnumerable<IStringLocalizer> would collide with every other localizer in the container). [Rubric §27, i18n] assesses whether user-facing text is externalized and extensible per feature; the additive set means adding a module never touches Common's resources. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out intact; because the module owns its own source registration, an extracted service carries its own translations with it. See ADR-027.
  • Walkthrough: the whole type is a primary-constructor class taking IStringLocalizer localizer (ErrorResourceSource.cs:12) and exposing it as a single get-only property, public IStringLocalizer Localizer { get; } = localizer (ErrorResourceSource.cs:15). There is no behavior; the enumeration and first-match logic live in ErrorLocalizer.
  • Why it's built this way: registration order is the priority order, and a distinct wrapper type is what makes that order expressible in the container. AddErrorResources<TResource>() registers each one as a singleton whose factory asks IStringLocalizerFactory.Create(typeof(TResource)) (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:124-125), so the anchor type is the only thing a module has to supply.
  • Where it's used: injected as IEnumerable<ErrorResourceSource> into ErrorLocalizer (ErrorLocalizer.cs:11). Common's own source is registered inside AddErrorLocalization() (DependencyInjection.cs:111), which DependencyInjection's AddAPI calls automatically (DependencyInjection.cs:96); modules add theirs by calling AddErrorResources<TResource>() (DependencyInjection.cs:122).

IdempotencyMetrics

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16 · Level 0 · class (internal static)

  • What it is: the three OpenTelemetry counters that make the idempotency filter observable: how often a stored response was replayed, how often a duplicate was refused, and how often the filter ran without its guarantee because the cache or the lock was unhealthy.
  • Depends on: System.Diagnostics.Metrics (Meter, Counter<long>) from the BCL only. Consumed by IdempotencyFilter; the meter is exported by the Aspire service defaults.
  • Concept introduced: metering a degradable cross-cutting filter. [Rubric §13, Observability & Operability] assesses whether an operator can tell that a mechanism is still doing its job. That matters here more than for most filters because IdempotencyFilter is deliberately best-effort: when its cache or its lock faults, it swallows the fault and lets the request run unguarded rather than failing the write. Without a counter that failure mode is invisible, since every request still succeeds, so a Redis outage would silently turn deduplication off across the fleet. idempotency.degraded is the signal that turns that silence into an alertable number, and the doc says so directly: a sustained non-zero rate means deduplication is effectively off (IdempotencyMetrics.cs:64-67). [Rubric §12, Performance & Scalability] applies because instrument names and tag names are centralized in one type rather than restated at each call site (remarks, IdempotencyMetrics.cs:12-15).
  • Walkthrough
    • MeterName = "MMCA.Common.Idempotency" (IdempotencyMetrics.cs:19) names the meter; Meter is a single static instance built from it (IdempotencyMetrics.cs:34).
    • Three counters, all Counter<long> with unit {request}: idempotency.replayed (IdempotencyMetrics.cs:36-39), idempotency.conflict (IdempotencyMetrics.cs:41-44), and idempotency.degraded (IdempotencyMetrics.cs:46-49).
    • The two conflict shapes are separated by a tag rather than by separate counters: ConflictKindTag = "kind" (IdempotencyMetrics.cs:32) carries either ConflictKindBodyMismatch = "body_mismatch" (IdempotencyMetrics.cs:24) or ConflictKindInFlight = "in_flight" (IdempotencyMetrics.cs:29), so one time series can be split or summed.
    • The three helpers are the only recording surface: RecordReplayed() (IdempotencyMetrics.cs:52), RecordConflict(string kind), which attaches the tag as a KeyValuePair (IdempotencyMetrics.cs:61-62), and RecordDegraded() (IdempotencyMetrics.cs:68).
  • Why it's built this way: internal static keeps the instruments off the package's public API while still letting the filter in the same assembly record them. A host exports these by registering the meter, which the Aspire service defaults do with the name as a literal, because MMCA.Common.Aspire has no reference to MMCA.Common.API; the duplication is called out in the type doc (IdempotencyMetrics.cs:8-10). OutputCacheMetrics uses the identical pattern for the eviction consumer.
  • Where it's used: every recording site is in IdempotencyFilter: RecordDegraded on a faulted lock acquisition (IdempotencyFilter.cs:255), a failed cache read (IdempotencyFilter.cs:364) and a failed cache write (IdempotencyFilter.cs:439); RecordConflict for an in-flight duplicate (IdempotencyFilter.cs:267) and a body mismatch (IdempotencyFilter.cs:374); RecordReplayed on every served replay (IdempotencyFilter.cs:380).

IdempotencyRecord

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyRecord.cs:14 · Level 0 · record (sealed, positional)

  • What it is: the cached snapshot of an idempotent action's response: the HTTP status code, the JSON-serialized body, and the hash of the request body that produced it. It is what IdempotencyFilter writes after the first successful execution and replays for every duplicate carrying the same key and the same payload.
  • Depends on: nothing beyond the BCL; a three-parameter positional record, all three parameters required.
  • Concept: introduced fully by IdempotencyFilter; this is the value it persists. Two properties of the shape are load-bearing. First, only status and body are captured, no headers, which is why the replay path re-adds X-Idempotent-Replay itself (IdempotencyFilter.cs:383) and why a replayed 201 does not carry the original Location header. Second, RequestBodyHash is non-nullable, so there is no "no hash stored" state to fall through: every record carries one and a key replayed with a different payload is always rejected rather than served someone else's response (parameter doc, IdempotencyRecord.cs:9-13). A body-less request hashes the empty payload rather than storing nothing. [Rubric §9, API & Contract Design] assesses whether a persisted contract states its invariants in its shape rather than in a comment; making the hash required is what removes the unchecked path.
  • Walkthrough: IdempotencyRecord(int StatusCode, string ResponseBody, string RequestBodyHash) (IdempotencyRecord.cs:14). StatusCode is the original response's code, defaulting to 200 when an ObjectResult carried none (IdempotencyFilter.cs:453). ResponseBody is non-nullable: an ObjectResult stores objectResult.Value serialized with JsonSerializerOptions.Web (IdempotencyFilter.cs:456-459), while a body-less 2xx such as the 204 from NoContent() stores string.Empty (IdempotencyFilter.cs:468), which is the signal the replay path uses to answer with a bare status code instead of a JSON content result. RequestBodyHash is the lowercase hex SHA-256 of the request body (IdempotencyFilter.cs:188-192).
  • Why it's built this way: keeping the record to three primitives makes it provider-agnostic, so the same artifact round-trips through the in-memory cache or Redis with no serializer coupling. Distinguishing "empty body" from "no body" via the empty string (rather than a nullable) keeps the JSON shape stable across both cases, and the comment at IdempotencyFilter.cs:463-465 names that convention at the one place a body-less result is stored.
  • Where it's used: read by IdempotencyFilter through ICacheService (IdempotencyFilter.cs:360), built by its BuildRecord (IdempotencyFilter.cs:448), and written back with the configured expiration (IdempotencyFilter.cs:435).

IdempotencySettings

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencySettings.cs:9 · Level 0 · class (sealed)

  • What it is: the options object bound from the Idempotency configuration section. It carries exactly one knob: how long a cached idempotent response is retained.
  • Depends on: System.ComponentModel.DataAnnotations for the [Range] attribute; bound through Microsoft.Extensions.Options.
  • Concept: the standard options pattern (see the primer). [Rubric §17, DevOps & Deployment] assesses whether a cross-cutting concern is configurable without becoming mandatory; the whole section is optional because the single property has a default, so a host that never mentions idempotency still behaves correctly (type doc, IdempotencySettings.cs:5-8).
  • Walkthrough: SectionName is a public static readonly string equal to "Idempotency" (IdempotencySettings.cs:12), so the binding key is a symbol rather than a magic string at the call site. CacheExpirationHours defaults to 24 (IdempotencySettings.cs:16) and is constrained by [Range(1, 168)] (IdempotencySettings.cs:15), one hour to one week. The property is init-only, so the value is fixed once bound.
  • Why it's built this way: AddAPI binds the section only when the caller passes an IConfiguration, and when it does it chains .ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:70-73), so an out-of-range CacheExpirationHours fails the host at startup rather than at the first idempotent POST. [Rubric §15, Best Practices & Code Quality]: fail fast, loudly, at composition time. RateLimitingSettings follows the same shape one directory over.
  • Where it's used: resolved as IOptions<IdempotencySettings> inside IdempotencyFilter at store time (IdempotencyFilter.cs:427-431). Note the deliberate GetService, not GetRequiredService: when the options are absent (the AddAPI overload called with no configuration) the filter falls back to its hard-coded 24-hour DefaultExpiration (IdempotencyFilter.cs:80) instead of throwing.

IErrorLocalizer

MMCA.Common.API · MMCA.Common.API.Localization · MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/IErrorLocalizer.cs:9 · Level 0 · interface

  • What it is: the contract for localizing a domain Error's human-readable message at the HTTP edge, keyed by its stable machine Code. Domain, handler, and Result code stays culture-agnostic; only the edge speaks a culture.
  • Depends on: nothing beyond the BCL. Implemented by ErrorLocalizer; consumed by ErrorHttpMapping.
  • Concept introduced: edge localization keyed by a stable code. The domain raises errors carrying a machine Code such as "PhoneNumber.Empty" plus an English message (IErrorLocalizer.cs:15). Translating on the Code rather than on the English prose is what keeps culture out of the Error type: the same code resolves to any registered culture, and rewording the English text never invalidates a translation. The contract also pins the failure mode: when the code is empty or no registered resource has a key, the caller's fallbackMessage is returned unchanged (IErrorLocalizer.cs:11-14), so an untranslated code degrades to English rather than throwing or emitting a raw resource key. [Rubric §27, i18n] assesses whether text is translatable without leaking locale into the core, and the split (code in the domain, culture at the edge) is the clean version of that. [Rubric §9, API & Contract Design] applies to the graceful-degradation clause: an error response never becomes an error itself because a translation is missing.
  • Walkthrough: a single method, string Localize(string code, string fallbackMessage) (IErrorLocalizer.cs:17). The XML doc is the specification: resolve code against the current UI culture, otherwise return fallbackMessage (IErrorLocalizer.cs:11-16).
  • Why it's built this way: consumers depend on the abstraction while the additive resource-source enumeration stays an implementation detail (see ErrorLocalizer). Registration uses TryAddSingleton (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:110), so an app that wants a different localization strategy registers its own first and the framework's default steps aside. See ADR-027.
  • Where it's used: resolved optionally at three edge call sites, via GetService rather than GetRequiredService: ApiControllerBase.HandleFailure (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:57), UnhandledResultFailureFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:46), and the precondition failure path of SupportsIfMatchAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:217). All three hand it to ErrorHttpMapping.BuildErrorsExtension (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:61), which accepts a null localizer and leaves messages in English (ErrorHttpMapping.cs:65) while leaving the machine Code verbatim so clients can still branch on it (ErrorHttpMapping.cs:56-58).

NonIdempotentAttribute

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23 · Level 0 · class (sealed)

  • What it is: a documentation-only marker declaring that a POST action is deliberately outside the Idempotency-Key contract, and why. It attaches no filter and changes no runtime behavior; its single member is the justification string.
  • Depends on: nothing but System.Attribute. Its counterpart is IdempotentAttribute, and its only consumer is the PostActionsDeclareIdempotencyIntent fitness function on ArchitectureRules (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.Idempotency.cs:44, where the attribute is matched by type NAME through the NonIdempotentAttributeName constant at ArchitectureRules.Idempotency.cs:9 so the rules package needs no ASP.NET reference).
  • Concept introduced (an attribute whose only job is to make an omission impossible). IdempotentAttribute is opt-in, which leaves one failure mode: an action that should deduplicate but is missing the attribute gets no protection, and nothing in the type system flags it. This attribute is the answer. POST is the one verb HTTP does not define as idempotent, so a client retrying a timed-out POST cannot know whether the first attempt landed, and the filter costs an existing client nothing because it no-ops for a request with no key header. That makes the only failure mode worth gating "an omission nobody noticed", so the fitness rule requires every POST action to carry one of the two attributes (rule remarks, ArchitectureRules.Idempotency.cs:20-29; the failure message spells out both remedies at :56-60), and this one carries a written reason (NonIdempotentAttribute.cs:3-9). [Rubric §34, Architecture Governance & Documentation] assesses whether decisions are recorded where they can be checked rather than remembered: the justification lives on the method and the build fails without it. [Rubric §15, Best Practices & Code Quality] covers [AttributeUsage(AttributeTargets.Method)] (NonIdempotentAttribute.cs:22), which makes a class-level application a compile error rather than a silent no-op.
  • Walkthrough: the whole type is a primary-constructor attribute, NonIdempotentAttribute(string justification) (NonIdempotentAttribute.cs:23), exposing public string Justification { get; } = justification; (NonIdempotentAttribute.cs:28). The parameter doc says the reason is required and is meant to be read by the next person who wonders whether the omission was intentional (NonIdempotentAttribute.cs:18-21).
  • Why it's built this way: the remarks (NonIdempotentAttribute.cs:11-17) narrow the legitimate cases sharply: this attribute is for actions where replaying a stored response would be actively wrong rather than merely unhelpful (token issuance and revocation, single-use code exchange, anything whose response is only valid for the call that produced it), and IdempotentAttribute is the default everywhere else. Making it a marker rather than a filter is what keeps it free: no pipeline stage, no DI registration, no runtime cost.
  • Where it's used: in the framework, on the four token-lifecycle actions of AuthControllerBase (login at AuthControllerBase.cs:70, refresh at :116, revoke at :143, per-session revoke at :206) and on OAuthControllerBase.ExchangeAsync (OAuthControllerBase.cs:150). Each justification names the concrete harm: a replayed login hands a retrying client tokens minted for an earlier call, a replayed refresh returns a token the rotation already invalidated, a replayed revoke reports success for a revocation that never ran, and a replayed exchange defeats the single-use burn on the OAuth code. Downstream the same discipline holds: ADC's session-selection enqueue is opted out because the queue's own pending set already deduplicates and a cached 202 would hide both an already-running refusal and a queue-full rejection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:107), and Store opts its payments action out (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/PaymentsController.cs:58). Consumer repos inherit the rule through IdempotencyConventionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IdempotencyConventionTestsBase.cs).

ErrorLocalizer

MMCA.Common.API · MMCA.Common.API.Localization · MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorLocalizer.cs:11 · Level 1 · class (sealed, internal)

  • What it is: the default IErrorLocalizer. It resolves an error code against an ordered set of registered ErrorResourceSources (Common first, then modules) using the current UI culture, and falls back to the caller's English message when the code is empty or unknown to every source.
  • Depends on: IErrorLocalizer (implements), ErrorResourceSource (injected as a collection); Microsoft.Extensions.Localization.LocalizedString.
  • Concept introduced: first-match-wins over an ordered source list. The interesting mechanism is how "not found" is detected. IStringLocalizer's indexer never returns null: on a miss it hands back a LocalizedString whose Value is the key itself and whose ResourceNotFound flag is true. Testing that flag (ErrorLocalizer.cs:26) rather than comparing strings is what lets the loop distinguish a genuine translation from an echoed code, and therefore what lets it keep walking to the next source instead of returning "PhoneNumber.Empty" to a user. [Rubric §27, i18n] assesses translation coverage and layering; first-match ordering lets Common ship base translations while a module extends the set additively. See ADR-027.
  • Walkthrough: the primary constructor takes IEnumerable<ErrorResourceSource> sources (ErrorLocalizer.cs:11), materialized once into _sources with a collection expression (ErrorLocalizer.cs:13). Localize (ErrorLocalizer.cs:16): returns fallbackMessage immediately when code is null or empty (ErrorLocalizer.cs:18-21); otherwise walks _sources in registration order, reading source.Localizer[code] and returning localized.Value on the first entry where !localized.ResourceNotFound (ErrorLocalizer.cs:23-30); if no source matches it returns fallbackMessage (ErrorLocalizer.cs:32). The current UI culture is never read explicitly: IStringLocalizer resolves it per call, which is why one singleton can serve requests in different cultures concurrently.
  • Why it's built this way: internal sealed keeps the implementation behind the IErrorLocalizer abstraction, so nothing downstream can bind to the enumeration strategy. Snapshotting the DI collection once (rather than enumerating it per lookup) matters because this runs on every failing request. Registering it with TryAddSingleton (DependencyInjection.cs:110) is safe precisely because the type holds no per-request state.
  • Where it's used: registered by AddErrorLocalization() (DependencyInjection.cs:107-113, which also calls services.AddLocalization() at :109), and that method is called by AddAPI (DependencyInjection.cs:96); reached at runtime only through the IErrorLocalizer handle that ApiControllerBase, UnhandledResultFailureFilter and SupportsIfMatchAttribute pass into ErrorHttpMapping.

IdempotencyFilter

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:67 · Level 4 · class (sealed, partial)

  • What it is: the ASP.NET Core filter that gives write operations client-driven idempotency. A client attaches an Idempotency-Key header; the first successful response for that key is cached and every subsequent request carrying the same key gets the stored response back, without re-running the action. It implements both IAsyncActionFilter and IAsyncResourceFilter (IdempotencyFilter.cs:68), which is the detail that makes body-aware deduplication possible.
  • Depends on: ICacheService and IDistributedLock, both resolved per request from RequestServices (IdempotencyFilter.cs:140 and IdempotencyFilter.cs:148); IdempotencyRecord; IdempotencySettings via IOptions<>; IdempotencyMetrics; IdempotencyHeaders for the two header names; KeyedSemaphoreStripe as the no-distributed-lock fallback; ClaimsPrincipalExtensions for the caller's identity. Externals: SHA256, Encoding, System.Text.Json, and ILogger with source-generated [LoggerMessage] partials.
  • Concept introduced: idempotent mutation as a lock plus a caller-scoped key plus a body binding. [Rubric §29, Resilience & Business Continuity] assesses surviving client retries without duplicating side effects; [Rubric §9, API & Contract Design] covers safe retry semantics on non-safe verbs; [Rubric §11, Security] applies because the key derivation is a trust boundary, not just a cache detail; [Rubric §13, Observability & Operability] applies because every degraded path is counted rather than silent. Three ideas compose here.
    1. Double-check locking (doc comment, IdempotencyFilter.cs:22-32): read the cache with no lock (the common fast path); on a miss take the lock for this key; re-read, because a concurrent duplicate may have finished while this one waited; only then execute and store. Without the lock, two near-simultaneous retries of a slow create both miss and both execute.
    2. The lock must be visible to every replica. A per-process stripe only serializes duplicates that land on the same instance, and both deployed apps run more than one (IdempotencyFilter.cs:33-38, restated at IdempotencyFilter.cs:219-223), so the primary path uses an IDistributedLock and the stripe is only the fallback for a host that registers none.
    3. The key alone is not enough. A stored response is bound to the request body that produced it, so a key reused with a different payload is refused (422) rather than replayed, which would otherwise silently swallow a genuinely new write (IdempotencyFilter.cs:43-51).
  • Walkthrough
    • Constants and shared state: IdempotencyKeyHeader delegates to IdempotencyHeaders.IdempotencyKey (IdempotencyFilter.cs:73, defined at MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19 as "Idempotency-Key"); CacheKeyPrefix returns "idempotency:" (IdempotencyFilter.cs:75); DefaultExpiration of 24 hours (IdempotencyFilter.cs:80); LockTimeToLive of 30 seconds and LockWait of 5 seconds (IdempotencyFilter.cs:97 and IdempotencyFilter.cs:104), sized so a slow action finishes under its own lock while a dead replica does not block a retry for long; and EmptyBodyHash, the SHA-256 of zero bytes (IdempotencyFilter.cs:110-111).
    • KeyLocks (IdempotencyFilter.cs:90) is a static KeyedSemaphoreStripe. The comment above it (IdempotencyFilter.cs:82-89) gives the reason for striping over one-semaphore-per-key: the key embeds a caller-supplied value, so a per-key table either grows without bound or needs an eager removal that races (a removal between another request's lookup and its wait lets a third request create a fresh semaphore, and both then execute concurrently).
    • Resource stage. OnResourceExecutionAsync (IdempotencyFilter.cs:119) runs before model binding, the last point at which the body can still be made replayable. It calls Request.EnableBuffering() only when the header is present (IdempotencyFilter.cs:121-122), so ordinary traffic on an [Idempotent] action pays nothing, then awaits next() (IdempotencyFilter.cs:124).
    • Action stage. OnActionExecutionAsync (IdempotencyFilter.cs:128): no header means straight through to next() with nothing else resolved (IdempotencyFilter.cs:131-136), so idempotency is opt-in per request as well as per action. Otherwise it derives the cache key (IdempotencyFilter.cs:138), hashes the body (:139), resolves ICacheService (:140), and tries the lock-free replay (:143). On a miss it picks a lock strategy: GetService<IDistributedLock>() returning null routes to the process-stripe path (:148-153), otherwise the distributed path (:155). The comment at :146-147 states the invariant that shapes both paths: the lock has to span execute-and-store, so a duplicate cannot slip in between the action finishing and its response reaching the cache.
    • ReadIdempotencyKey (IdempotencyFilter.cs:163-170) treats a missing or blank header as absent, and both stages call it so they agree on what "has a key" means.
    • ComputeRequestBodyHashAsync (IdempotencyFilter.cs:182-193): a stream that cannot seek was never buffered, so it takes EmptyBodyHash rather than throwing (:185-186), which leaves such a request deduplicated on its key alone. Otherwise it rewinds to 0, hashes with SHA256.HashDataAsync, and rewinds again (:188-190) because model binding still has to read the whole body.
    • ExecuteUnderProcessLockAsync (IdempotencyFilter.cs:199): acquires the stripe honoring RequestAborted inside a using so release survives a throw (:206), re-checks the cache (:209), then executes and stores (:212).
    • ExecuteUnderDistributedLockAsync (IdempotencyFilter.cs:238) is where the interesting policy lives, and it separates three outcomes that a naive implementation conflates (rationale in the remarks, :219-236).
      • The lock backend faults: counted on idempotency.degraded, logged, and the action runs unguarded (:253-259). There is no holder to wait for, so refusing would turn a cache blip into a write outage.
      • The wait expires with the lock still held elsewhere: the holder is executing this key right now, so it logs the timeout and re-checks the cache first, and only if still nothing is stored does it record an in_flight conflict and answer with InFlightDuplicateResult() (:261-273). That helper (:301-310) is a 409 ProblemDetails titled "Request in progress" telling the client to retry with the same key: retryable and honest, where executing would be the duplicate write.
      • The lock is acquired: await using on the handle (:275), double-check (:278), then execute and store (:281).
    • ExecuteAndStoreAsync (IdempotencyFilter.cs:286-295) is the two-line shared tail of all three paths: await next(), then TryStoreAsync the executed result.
    • TryReplayAsync (IdempotencyFilter.cs:351) serves both the fast path and every double-check, returning whether it short-circuited. A cache read that throws is reported as "nothing stored" after counting a degradation (:358-367). A stored record whose RequestBodyHash differs from this request's hash (an ordinal comparison, :372) yields a body_mismatch conflict and BodyMismatchResult() (:372-378), a 422 whose remarks explain the status choice: 409 is already spent on "still in flight", which is retry-with-the-same-key, while key reuse with a different body is not retryable until the client picks a new key (:317-331). On a genuine hit it counts the replay (:380), appends X-Idempotent-Replay: true (:383, name from IdempotencyHeaders.cs:25), and short-circuits with a bare StatusCodeResult when the stored body is empty or a ContentResult with application/json otherwise (:384-391). That split is what makes a replayed 204 look like the original 204 instead of a 204 carrying a content type.
    • TryStoreAsync (IdempotencyFilter.cs:416) builds the record and returns early when it is not cacheable (:423-425), reads the expiration from IdempotencySettings when registered and DefaultExpiration otherwise (:427-431), and swallows a failing SetAsync after counting it (:433-441): the action already ran, so failing here would push the client into the very retry the filter exists to deduplicate (:410-414).
    • BuildRecord (IdempotencyFilter.cs:448) decides what is cacheable. An ObjectResult with a 2xx status stores its value serialized with JsonSerializerOptions.Web (:452-461; the VSTHRD103 suppression at :454 documents that serializing to a string is correctly synchronous). A StatusCodeResult with a 2xx status, which is what NoContent(), OkResult and StatusCode(int) produce, stores the empty string (:466-469). Everything else, including every non-2xx and every redirect or file result, returns null and is not stored (:471-472); IsSuccess is the >= 200 and < 300 test (:476).
    • BuildCacheKey (IdempotencyFilter.cs:485-499) is the security-relevant part: the subject is the caller's user-id claim via FindUserIdValue() (:487, MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:26, which reads sub and falls back to the mapped NameIdentifier) or, unauthenticated, anon:{remote address} (:488); the route is the attribute route template falling back to the request path (:490-492); those plus the HTTP method and the client key are joined with a newline (:495, a character valid in none of the components, so the tuple cannot be forged), SHA-256 hashed (:496), and emitted as idempotency:{lowercase hex} (:498).
    • Logging is entirely source-generated [LoggerMessage] partials with instance forwarders (IdempotencyFilter.cs:501-552): one Information message for a served replay (:501-507) and five Warnings covering body mismatch (:509-514), in-flight duplicate (:516-521), lock-wait timeout (:523-528), cache-read failure (:530-536) and cache-store failure (:538-544), plus lock unavailability (:546-552). [Rubric §13, Observability & Operability]: each degraded path has both a counter and a log line naming the cache key.
  • Why it's built this way: keying on the bare client value would make the key space global, so two callers who happened to pick the same value would share an entry and one user's serialized body could be replayed to another; with services sharing one cache instance that collision reaches across endpoints and services (SECURITY note, IdempotencyFilter.cs:59-65; ADR-017). Hashing also bounds the stored key length regardless of what a client sends (:478-484). Non-2xx results are deliberately not stored (IdempotencyFilter.cs:400-403) because replaying a failure for the whole retention window would mean a client retrying after a transient 500 keeps receiving that 500 for 24 hours instead of the retry actually executing. The 204 case is explicitly covered rather than skipped (:405-409): skipping it left every command answering NoContent() with nothing stored, so the body-less writes, the ones most likely to be retried, were the ones idempotency did not actually cover. And the whole cache-and-lock layer is treated as best-effort infrastructure (IdempotencyFilter.cs:52-58): deduplication is an optimization over an at-least-once client retry, so a Redis outage must degrade dedup, not every write endpoint carrying the attribute.
  • Caveats / not-in-source: the replay restores only the status code and the JSON body. Response headers the original action set (a 201's Location, for example) are not captured by IdempotencyRecord and are therefore not reproduced; only X-Idempotent-Replay is added.
  • Where it's used: registered scoped in AddAPI because it depends on scoped services (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:77, comment at :76), and attached to actions through IdempotentAttribute. In the framework itself that is the create endpoint on AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (AggregateRootEntityControllerBase.cs:60), the update endpoint on CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> (CrudEntityControllerBase.cs:89, alongside [SupportsIfMatch]), the register endpoint on AuthControllerBase (AuthControllerBase.cs:94), both actions of PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (PasswordResetAuthControllerBase.cs:76 and :100), and the send endpoint of NotificationsController (NotificationsController.cs:44). Downstream, ADC marks its Engagement writes (check-ins, bookmarks, live polls, session questions) and its Conference collection writes, and Store marks its cart, order and catalog writes.

IdempotentAttribute

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotentAttribute.cs:16 · Level 5 · class (sealed)

  • What it is: the method-level marker that attaches IdempotencyFilter to a controller action. Putting [Idempotent] on an action opts it into the Idempotency-Key replay behavior; leaving it off means the action is never deduplicated.
  • Depends on: ServiceFilterAttribute from ASP.NET Core MVC; resolves IdempotencyFilter from DI.
  • Concept introduced: service filters (DI-resolved action filters). [Rubric §2, Design Patterns] covers the filter idiom and how the instance is obtained. A plain [TypeFilter] constructs the filter itself, which would confine it to constructor arguments MVC can supply; ServiceFilterAttribute (IdempotentAttribute.cs:16) resolves it from the container instead, which is what lets the filter be registered AddScoped and reach scoped services such as ICacheService (remarks, IdempotentAttribute.cs:10-14). [Rubric §15, Best Practices & Code Quality]: [AttributeUsage(AttributeTargets.Method)] (IdempotentAttribute.cs:15) makes misapplication at class level a compile error rather than a silent no-op.
  • Walkthrough: the entire type is one line, public sealed class IdempotentAttribute() : ServiceFilterAttribute(typeof(IdempotencyFilter)) (IdempotentAttribute.cs:16); the primary constructor forwards the filter type to the base. The doc notes the filter must be registered in DI, which AddAPI does (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:77); without that registration, resolution fails at request time rather than at startup. It also states the no-op contract: an action carrying the attribute but reached by a request with no Idempotency-Key header executes normally (IdempotentAttribute.cs:5-9).
  • Why it's built this way: opt-in per action is the ADR-017 decision. Nothing is deduplicated unless the action declares it, so adding the attribute is additive and safe, and the client still decides per request whether to send a key at all (a keyless request short-circuits at IdempotencyFilter.cs:131-136). The flip side, that a POST which should be idempotent but is missing the attribute gets no protection, is closed by a fitness function rather than by inventory discipline: see NonIdempotentAttribute, whose PostActionsDeclareIdempotencyIntent rule requires every POST action to carry one of the two attributes (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.Idempotency.cs:44). Because that rule reads attributes with inherit: true, a concrete controller deriving from one of the framework bases already satisfies it through the base's method (ArchitectureRules.Idempotency.cs:30-36).
  • Where it's used: applied in the framework to the create endpoint of AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (AggregateRootEntityControllerBase.cs:60), the update endpoint of CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> (CrudEntityControllerBase.cs:89), AuthControllerBase.RegisterAsync (AuthControllerBase.cs:94), the forgot-password and reset-password actions of PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (PasswordResetAuthControllerBase.cs:76, :100) and NotificationsController.SendAsync (NotificationsController.cs:44); and in the apps to the ADC Conference/Engagement/Identity and Store Sales/Catalog write endpoints listed under IdempotencyFilter.

ApiParameterDescriptorBackfillProvider

MMCA.Common.API · MMCA.Common.API.OpenApi · MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:43 · Level 0 · class (internal sealed)

  • What it is: a defensive IApiDescriptionProvider that walks every API description MVC produced and fills in a placeholder ParameterDescriptor wherever the API explorer left one null, so an OpenAPI transformer that dereferences the property cannot turn document generation into a 500.
  • Depends on: Microsoft.AspNetCore.Mvc.Abstractions.ParameterDescriptor and Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider (ASP.NET Core), nothing first-party. Registered by AddCommonApiVersioning() and AddCommonOpenApi() on WebApplicationBuilderExtensions.
  • Concept introduced: the API-explorer provider chain as a repair point. MVC builds its OpenAPI input (the ApiDescription list) by running a chain of IApiDescriptionProvider instances; OnProvidersExecuting runs in ascending Order, OnProvidersExecuted in descending order, so the lowest order gets the last word (MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:34-41). That makes the chain a legitimate place to normalize data other components will read. [Rubric §9, API & Contract Design] assesses whether the machine-readable contract is dependable: here the whole point is that GET /openapi/{documentName}.json must not fail for a consumer that adopts URL-segment versioning. [Rubric §32, Dependency & Supply-Chain] assesses what a framework does about defects in packages it forces on its consumers: this is a local guard around a third-party bug, written so it can be deleted without a behavior change once upstream adds the null check.
  • Walkthrough
    • Order => int.MinValue (ApiParameterDescriptorBackfillProvider.cs:46): last to observe the results, so it sees the parameters VersionedApiDescriptionProvider contributed as well as MVC's own.
    • OnProvidersExecuting (ApiParameterDescriptorBackfillProvider.cs:49-53) is intentionally empty, and the comment says why (:51-52): the descriptions this guard repairs do not exist yet at that point.
    • OnProvidersExecuted (ApiParameterDescriptorBackfillProvider.cs:56) null-guards the context (:58), flattens context.Results.SelectMany(description => description.ParameterDescriptions) (:60) and applies parameter.ParameterDescriptor ??= new ParameterDescriptor { ... } (:65-71). The ??= is the load-bearing operator: an existing descriptor (a ControllerParameterDescriptor, which carries the ParameterInfo that XML-comment lookups prefer) is never replaced, and the comment states that rule explicitly (:62-64). The synthesized replacement takes parameter.Name ?? string.Empty (:67), and the type falls back through parameter.Type, then ModelMetadata?.ModelType, then typeof(string) (:68-70).
  • Why it's built this way: the class remarks (ApiParameterDescriptorBackfillProvider.cs:12-42) document the exact failure it guards. MVC leaves ParameterDescriptor null for a route-template token with no matching action parameter (normal for [Route("api/v{version:apiVersion}/orders")] or [Route("api/{tenant}/orders")]), while Asp.Versioning.OpenApi 10.2.1 reads arg.ParameterDescriptor.Name without a null check (:21-27). The fix is deliberately general (any unbound route token, not just the version token) so a {tenant} or {region} segment is covered by the same guard (:28-33).
  • Where it's used: registered once through the private AddApiParameterDescriptorBackfill() helper (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:419-421), which uses TryAddEnumerable so calling both AddCommonApiVersioning() (WebApplicationBuilderExtensions.cs:245, which calls the helper at :261) and AddCommonOpenApi() (:403, calling at :406) still yields exactly one instance. The de-duplication rationale is on the helper's own doc (:411-417).

DbUpdateExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DbUpdateExceptionHandler.cs:17 · Level 0 · class (sealed)

  • What it is: the IExceptionHandler that turns an EF Core DbUpdateException into an HTTP 409 Conflict RFC 9457 response with a deliberately generic body, while the full exception is logged server-side.
  • Depends on: Microsoft.AspNetCore.Diagnostics.IExceptionHandler, Microsoft.AspNetCore.Http.IProblemDetailsService, Microsoft.EntityFrameworkCore.DbUpdateException. Siblings in the same chain: OperationCanceledExceptionHandler, DomainExceptionHandler, ValidationExceptionHandler, and the catch-all GlobalExceptionHandler.
  • Concept introduced: the IExceptionHandler chain. ASP.NET Core resolves registered IExceptionHandler implementations in registration order and calls TryHandleAsync on each: returning true claims the exception and stops the chain, returning false passes it on. The framework registers the chain in AddCommonExceptionHandlers() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:135-146) as OperationCanceled (:140), then Domain (:141), then DbUpdate (:142), then Validation (:143), then Global (:144), and that same method calls AddProblemDetails with a customization that stamps a requestId extension from HttpContext.TraceIdentifier onto every problem document (DependencyInjection.cs:137-139). [Rubric §9, API & Contract Design] assesses whether errors have one uniform, standards-based shape: every handler here writes RFC 9457 application/problem+json through the same IProblemDetailsService, so controllers never need a try/catch. [Rubric §11, Security] applies to this handler specifically, see below.
  • Walkthrough: TryHandleAsync (DbUpdateExceptionHandler.cs:23):
    • Type-tests with is not DbUpdateException dbUpdateException and returns false (DbUpdateExceptionHandler.cs:28-29) so the rest of the chain still gets its turn.
    • Logs the exception at LogError (:31), then sets StatusCodes.Status409Conflict (:33).
    • Builds the client-facing detail as the fixed string "A data conflict occurred. Please retry or contact support." (:37), with the comment stating why (:35-36): leaking the EF message would expose table, column and constraint names. The full exception is already in the log.
    • Assembles a ProblemDetailsContext titled "Database Update Exception" (:39-49) and returns await problemDetailsService.TryWriteAsync(context) (:51), so the response body is whatever the configured ProblemDetailsService writes.
  • Why it's built this way: 409 is the honest status for a write rejected by a constraint or a concurrency token, and the split between a rich server-side log and a generic client body is the standard information-disclosure posture.
  • Where it's used: registered third in AddCommonExceptionHandlers() (DependencyInjection.cs:142), ahead of GlobalExceptionHandler. Note that a DbUpdateConcurrencyException on an action carrying SupportsIfMatchAttribute never reaches this handler: that filter converts it to 412 and sets ExceptionHandled = true (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:132-137).
  • Caveats / not-in-source: the class doc comment (DbUpdateExceptionHandler.cs:12-13) still says "the inner exception message is included in the detail because it typically contains the database-level constraint name". The code does not do that: it writes the generic string at :37. Trust the code; the comment is stale.

OperationCanceledExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/OperationCanceledExceptionHandler.cs:16 · Level 0 · class (sealed)

  • What it is: the handler that maps OperationCanceledException (a client that hung up mid-request) to HTTP 499 Client Closed Request instead of letting it inflate the 500 rate.
  • Depends on: IProblemDetailsService, ILogger<OperationCanceledExceptionHandler>; chain concept at DbUpdateExceptionHandler.
  • Concept: [Rubric §13, Observability & Operability] assesses whether the signals operators alert on distinguish real faults from normal client behavior. 499 is a non-standard nginx-origin code (class comment, OperationCanceledExceptionHandler.cs:8-13) that monitoring stacks read as "the caller gave up", so cancellations stop looking like server errors on a dashboard.
  • Walkthrough: TryHandleAsync (OperationCanceledExceptionHandler.cs:22) type-tests and returns false for anything else (:27-28), logs at LogWarning with a client-disconnected message (:30), sets StatusCodes.Status499ClientClosedRequest (:32), and still builds a problem document titled "Operation Canceled Exception" (:33-43) which it writes through TryWriteAsync (:45). Note that Status499ClientClosedRequest is a real constant on ASP.NET Core's StatusCodes, so the non-standard code is spelled symbolically, not as a magic number.
  • Why it's built this way: it is registered first in the chain (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:140). That position matters: a cancellation propagating out of a CancellationToken would otherwise be claimed by nothing until GlobalExceptionHandler turned it into a 500. It is also why every catch in the cache-touching middleware and limiters filters OperationCanceledException out explicitly rather than swallowing it (for example SoftDeletedUserMiddleware.cs:93 and RedisFixedWindowRateLimiter.cs:147).
  • Where it's used: AddCommonExceptionHandlers() (DependencyInjection.cs:140); it fires for request-abort propagation from any handler that honors its CancellationToken.
  • Caveats / not-in-source: whether the 499 body actually reaches a disconnected client is not determinable from this file; the write is attempted unconditionally at :45.

QueryFilterModelBinder

MMCA.Common.API · MMCA.Common.API.ModelBinders · MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24 · Level 0 · class (sealed)

  • What it is: a custom IModelBinder that parses the structured filter query string (?filters[Name].operator=contains&filters[Name].value=shirt) into a Dictionary<string, (string Operator, string Value)> that list endpoints hand to the query layer.
  • Depends on: Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder only. Its output is consumed by QueryFilterService via the list actions on EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.
  • Concept introduced: model binding as the parsing boundary for a bespoke query grammar. ASP.NET's default binders cannot express "a bag of per-property operator/value pairs", so the grammar is defined once here rather than re-parsed in each controller. [Rubric §9, API & Contract Design] assesses whether the request contract is explicit and uniform: every list endpoint in both apps accepts the identical filter syntax because they share this binder, and the expected format is documented on the class itself (QueryFilterModelBinder.cs:9-23). [Rubric §12, Performance & Scalability] applies through MaxFilters, which bounds attacker-controlled work.
  • Walkthrough
    • MaxFilters = 50 (QueryFilterModelBinder.cs:34): the cap on distinct filter properties per request. The comment (:26-33) explains the choice: it bounds the per-request reflection QueryFilterService does for unknown names (it resolves each miss by reflection rather than memoizing), and surplus entries are dropped rather than rejected because a 400 would break clients that send junk alongside real filters.
    • BindModelAsync (QueryFilterModelBinder.cs:37): null-guards the binding context (:39), then builds the dictionary with StringComparer.OrdinalIgnoreCase (:42) so client capitalization does not matter.
    • It iterates Request.Query.Keys (:46), skipping anything that fails IsFilterKey (:91-94, prefix filters[ plus suffix ].operator or ].value), extracts the bracketed property name with GetFilterPropertyName (:101-109) and the suffix with GetFilterSuffix (:116-125).
    • The two halves of one filter can arrive in either order, so it accumulates into a tuple and merges (:59-70, with the ordering comment at :44-45). The MaxFilters check is applied only when a new property key would be added (:61-62), so the cap counts distinct properties, not query-string keys.
    • A second pass removes any entry still missing an operator or a value (:74-80): incomplete filters are silently discarded, never a 400.
    • Finally ModelBindingResult.Success(filters) (:82) and Task.CompletedTask (:83): the binder is synchronous work behind an async signature, so it allocates no task machinery.
  • Why it's built this way: silent discard plus case-insensitive matching makes the grammar forgiving for hand-built UI query strings, while the hard cap keeps a malicious caller from turning the query string into a reflection amplifier.
  • Where it's used: applied per parameter with [ModelBinder(typeof(QueryFilterModelBinder))] on the list actions of IEntityControllerBase<TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IEntityControllerBase.cs:51) and EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:165 for the paged read and :256 for the CSV export).

RateLimitAlgorithm

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8 · Level 0 · enum

  • What it is: a two-value enum selecting the limiting algorithm used by the in-memory rate-limit partitions that AddCommonRateLimiting registers.
  • Depends on: nothing. Read by RateLimitingSettings.Algorithm and switched on inside WebApplicationBuilderExtensions.CreateLimitedPartition.
  • Concept (naming the window-replenishment trade). Both values use the same one-minute window and the same partition keys, so switching between them changes only how permits are replenished (RateLimitAlgorithm.cs:3-7). [Rubric §12, Performance & Scalability] assesses whether a throttle is understood rather than guessed: the two doc comments state the trade in one sentence each, which is what makes the setting safe to move in production.
  • Walkthrough
    • FixedWindow (RateLimitAlgorithm.cs:15) is the default and the cheapest to run: the whole allowance becomes available again at the window boundary, which lets a caller spend the allowance twice across a boundary, once at the end of one window and once at the start of the next (:10-14).
    • SlidingWindow (:22) divides the one-minute window into SegmentsPerWindow segments and returns each segment's permits as it ages out, smoothing that boundary burst away at the cost of tracking one counter per segment (:17-21).
  • Why it's built this way: an enum rather than a boolean keeps the configuration self-describing in appsettings.json ("Algorithm": "SlidingWindow") and leaves room for a third algorithm without a breaking rename. The distributed Redis path is deliberately not an algorithm value: it is the separate Distributed flag on RateLimitingSettings, because it changes where the counter lives rather than how it replenishes, and RedisFixedWindowRateLimiter implements only the fixed window.
  • Where it's used: RateLimitingSettings.Algorithm defaults it to FixedWindow (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:53), and CreateLimitedPartition branches on it (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:181) to build either a SlidingWindowRateLimiterOptions partition (:182-189) or a FixedWindowRateLimiterOptions one (:192-198).

RedisRateLimitLease

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:169 · Level 0 · class (internal sealed)

  • What it is: the two-instance lease type RedisFixedWindowRateLimiter hands back: one shared Acquired lease and one shared Rejected lease.
  • Depends on: System.Threading.RateLimiting.RateLimitLease (BCL abstract base). It lives in the same file as the limiter that produces it.
  • Concept (a stateless lease can be a singleton). ASP.NET Core's rate-limiting middleware asks a limiter for a RateLimitLease on every request and disposes it afterwards. A lease that carries metadata (a retry-after value, a permit count) has to be allocated per call. This one carries none, so the type declares exactly two immutable instances and returns whichever the decision calls for. [Rubric §12, Performance & Scalability] assesses allocation on a per-request hot path; the doc states the reasoning directly (RedisFixedWindowRateLimiter.cs:164-168): both leases are stateless and carry no metadata, so one shared instance of each serves every request rather than allocating a lease per call.
  • Walkthrough: Acquired = new(isAcquired: true) (RedisFixedWindowRateLimiter.cs:172) and Rejected = new(isAcquired: false) (:175) are the only two instances, built through a private expression-bodied constructor that sets the single property (:177). IsAcquired is an override with a get-only auto property (:180). MetadataNames returns an empty collection expression (:183), and TryGetMetadata always nulls its out parameter and returns false (:186-190), which is the honest answer for a lease that has none.
  • Why it's built this way: internal sealed (:169) keeps it an implementation detail of the limiter. Because the type is immutable and metadata-free, sharing instances across concurrent requests is safe by construction, and there is no dispose-time state to reset.
  • Where it's used: returned by RedisFixedWindowRateLimiter from AttemptAcquireCore (:114, always Acquired) and from AcquireAsyncCore on all three of its outcomes: the oversized request (:126, Rejected), the counted decision (:145, either one), and the fail-open path after a Redis fault (:154, Acquired).

ValidationExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ValidationExceptionHandler.cs:17 · Level 0 · class (sealed)

  • What it is: the handler that converts FluentValidation's ValidationException into a 400 whose problem document carries an errors extension shaped as { "PropertyName": ["message", ...] }.
  • Depends on: FluentValidation.ValidationException, IProblemDetailsService, ILogger<ValidationExceptionHandler>; chain concept at DbUpdateExceptionHandler.
  • Concept: [Rubric §9, API & Contract Design] assesses whether one failure family always serializes the same way, and [Rubric §24, Forms/Validation/UX Safety] assesses whether a client can bind failures back to the fields that caused them. The grouped shape is chosen to match what ASP.NET Core's own model-state validation emits (comment, ValidationExceptionHandler.cs:46-47), so a client form component can render field errors with one code path regardless of whether the failure came from model binding or from a validator running in the CQRS validating decorator.
  • Walkthrough: TryHandleAsync (ValidationExceptionHandler.cs:23) type-tests and passes on non-validation exceptions (:28-29), logs at LogWarning (:31, not LogError: an invalid payload is a client mistake, not a system fault), sets 400 (:33), builds the problem document titled "Validation Exception" (:34-44), then groups validationException.Errors by PropertyName into a Dictionary<string, string[]> (:48-53) and adds it under the "errors" extension key (:54) before writing (:56).
  • Why it's built this way: the grouping consolidates several failures for one field into one array, which is exactly the ModelStateDictionary serialization front ends already understand. Note the key is the raw PropertyName from FluentValidation, not a camelCased alias, and that Extensions.Add (:54) would throw on a duplicate key, which cannot happen here because the document was just constructed.
  • Where it's used: registered fourth (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:143), just before the catch-all; it is the exception path that complements the Result-based validation failures returned by ApiControllerBase.

DomainExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DomainExceptionHandler.cs:16 · Level 1 · class (sealed)

  • What it is: the handler that translates a DomainException into a 400 Bad Request problem document whose detail is the domain message itself.
  • Depends on: DomainException (MMCA.Common.Shared.Exceptions, imported at DomainExceptionHandler.cs:5), IProblemDetailsService, ILogger<DomainExceptionHandler>; chain concept at DbUpdateExceptionHandler.
  • Concept: [Rubric §4, DDD] assesses whether the domain's vocabulary survives the trip to the edge, and [Rubric §9, API & Contract Design] whether the status code tells the truth about who is at fault. A domain exception is a broken business rule, not a system fault, so two things differ from the other handlers: it logs at LogWarning (DomainExceptionHandler.cs:30) rather than LogError, and it is the one handler that puts the raw exception message into the client-facing Detail (:41). That is safe precisely because a DomainException message is authored by the domain layer for humans, unlike an EF or infrastructure message.
  • Walkthrough: TryHandleAsync (DomainExceptionHandler.cs:22) type-tests and returns false otherwise (:27-28), logs the warning (:30), sets StatusCodes.Status400BadRequest (:32), and writes a problem document titled "Domain Exception" with the domain message as its detail (:33-45).
  • Why it's built this way: it sits second in the chain (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:141), ahead of the infrastructure handlers, so a business-rule violation never gets mislabeled as a database conflict or an internal error. Remember that the framework's primary path for business failures is the Result pattern; this handler covers the code that still throws.
  • Where it's used: AddCommonExceptionHandlers() (DependencyInjection.cs:141).

GlobalExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:23 · Level 1 · class (sealed)

  • What it is: the last handler in the IExceptionHandler chain. It claims anything the specific handlers declined and answers HTTP 500 as RFC 9457 problem details, with one exception mapped by type on the way: a CrossTenantWriteException is answered 400 instead.
  • Depends on: IProblemDetailsService and ILogger<GlobalExceptionHandler> (primary-constructor injected, GlobalExceptionHandler.cs:23-26), plus CrossTenantWriteException from MMCA.Common.Infrastructure.Persistence.Interceptors (imported at :5). Chain concept introduced at DbUpdateExceptionHandler.
  • Concept: [Rubric §13, Observability & Operability] assesses whether error handling is factored out of business code, and [Rubric §13, Observability & Operability] whether failures are always recorded. This handler is the guarantee for both: no exception can escape the pipeline as a raw stack trace or an unlogged 500, because it never returns false on a type test. [Rubric §11, Security] covers the second half: the cross-tenant detail is deliberately free of everything the exception carries, because echoing a tenant id back tells an unauthorized caller which tenant owns the row it just tried to write (GlobalExceptionHandler.cs:31-36).
  • Walkthrough: TryHandleAsync (GlobalExceptionHandler.cs:42) has two paths.
    • CrossTenantWriteTitle (:29) and CrossTenantWriteDetail (:37-39) are internal const strings, so the tests assert against the same literals the handler writes.
    • The tenant-boundary path (:47-65): a CrossTenantWriteException is logged at LogWarning (:51), because the request was refused exactly as designed and a tenant-scoped API answering 400 to an untenanted write is routine rather than a server fault (:49-50). It sets Status400BadRequest (:53) and returns TryWriteAsync with the two constants (:54-64).
    • The catch-all path (:67-80): logs "Unhandled exception occurred" with the exception at LogError (:67), sets StatusCodes.Status500InternalServerError (:69), and returns the result of problemDetailsService.TryWriteAsync(...) (:70-80) with the generic title "Internal Server Error" and a "please try again" detail (:77-78). The exception object is attached to the ProblemDetailsContext (:73) so a configured customization can enrich the document (in development, for example) without this class deciding what to expose.
  • Why it's built this way: the class doc explains why the cross-tenant mapping lives here rather than in a handler of its own (GlobalExceptionHandler.cs:13-19): CrossTenantWriteException derives from InvalidOperationException, so nothing ahead of this handler claims it, while every other save-time invariant failure of that family still ends at the 500. Returning TryWriteAsync's own boolean rather than a hard-coded true keeps the handler honest: if no problem-details writer can serve the request, the framework is told the exception was not fully handled instead of silently swallowing it. Registration order is the only thing that makes it the fallback, so it is registered last (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:144); its own doc says so (DependencyInjection.cs:131-132).
  • Where it's used: AddCommonExceptionHandlers() (DependencyInjection.cs:144); every host that calls it inherits the same 500 contract and the same tenant-boundary 400.

RateLimitingSettings

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:21 · Level 1 · class (sealed)

  • What it is: the options object bound from the RateLimiting configuration section. It carries the four permit counts, the queue depth, the algorithm choice, the sliding-window segment count, and the flag that moves the counter into Redis.
  • Depends on: RateLimitAlgorithm for the Algorithm property; System.ComponentModel.DataAnnotations for the [Range] attributes (RateLimitingSettings.cs:1). Consumed by WebApplicationBuilderExtensions.AddCommonRateLimiting and, through the permit limit it is handed, by RedisFixedWindowRateLimiter.
  • Concept (settings that cannot change behavior by existing). The load-bearing property of this class is stated in its own doc comment (RateLimitingSettings.cs:5-10): every property defaults to the value the parameterized AddCommonRateLimiting overload has always used, so the section is optional in appsettings.json and a host that omits it keeps the previous behavior exactly. That is what made the settings-driven overload safe to add to a framework whose consumers upgrade in lockstep (ADR-016 for the sweep rule, ADR-019 for the limiter itself). [Rubric §17, DevOps & Deployment] assesses whether a cross-cutting concern is configurable without becoming mandatory; [Rubric §17, DevOps] applies because moving a limit is a config change rather than a release. The [Range] attributes make an impossible value a startup failure rather than a runtime surprise, the same discipline IdempotencySettings uses.
  • Walkthrough
    • SectionName = "RateLimiting" (RateLimitingSettings.cs:24) is a public static readonly string, the binding key as a symbol rather than a literal repeated at the call site.
    • Five bounded, init-only knobs: PermitLimit 100 for the opt-in "FixedPolicy" limiter (:27-28, [Range(1, 1_000_000)]), QueueLimit 2 for requests queued once "FixedPolicy" or "UserPolicy" saturate (:31-32, [Range(0, 10_000)]), PerUserPermitLimit 30 for the opt-in "UserPolicy" limiter (:35-36), GlobalPermitLimit 300 per authenticated user for the always-on global limiter (:39-40), and AuthIpPermitLimit 30 per client IP for the auth-ip policy that throttles anonymous authentication attempts (:46-47).
    • Algorithm (:53) defaults to RateLimitAlgorithm.FixedWindow, which is what the framework has always used; SegmentsPerWindow (:62, [Range(1, 60)], default 4) is the number of segments the one-minute window is divided into when the algorithm is SlidingWindow, and is ignored otherwise (:55-60).
    • Distributed (:72, default false) is the interesting one. Its doc (:64-71) states the whole contract: it makes the global limiter and the "UserPolicy" limiter count against a shared Redis counter instead of per-instance memory, so a limit means the same thing behind a load balancer as it does on one node; it requires an IConnectionMultiplexer in the container, and when none is registered the limiters silently fall back to the in-memory behavior rather than failing startup; and the auth-ip policy stays in memory either way, because per-account login protection already backs it.
  • Why it's built this way: bundling the knobs into one bound object is what let AddCommonRateLimiting grow a configuration overload without changing the five-parameter one, and the "same defaults" rule means the two overloads are observationally identical for a host that configures nothing (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:297-298 builds an instance from the old parameters). The fall-back-rather-than-fail choice on Distributed is deliberate: a rate limiter that refuses to start is a worse outage than one that limits per instance. The AuthIpPermitLimit default of 30 is itself argued in the overload's parameter doc (WebApplicationBuilderExtensions.cs:291-295): Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP and a tighter cap would throttle real logins.
  • Where it's used: bound in AddCommonRateLimiting(IConfiguration) with configuration.GetSection(RateLimitingSettings.SectionName).Get<RateLimitingSettings>() ?? new RateLimitingSettings() (WebApplicationBuilderExtensions.cs:319-320), then threaded through every partition selector (GlobalRateLimitPartition at :79, UserPolicyRateLimitPartition at :114, AuthIpRateLimitPartition at :221) and into CreateLimitedPartition (:148-199), which reads Distributed (:157), Algorithm (:180), and SegmentsPerWindow (:185).

RedisFixedWindowRateLimiter

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37 · Level 1 · class (sealed, partial)

  • What it is: a RateLimiter that counts a fixed one-minute window in Redis, so every instance behind a load balancer shares one allowance per partition instead of each getting its own.
  • Depends on: StackExchange.Redis.IConnectionMultiplexer, System.Threading.RateLimiting.RateLimiter, TimeProvider, and ILogger with a source-generated [LoggerMessage] partial (RedisFixedWindowRateLimiter.cs:1-5). It hands back RedisRateLimitLease instances and is selected by RateLimitingSettings.Distributed.
  • Concept introduced (a distributed counter, and the three honest compromises it makes). A per-process limiter means "300 requests a minute" actually means 300 times the replica count, which is not the number an operator configured. Moving the counter to Redis fixes that, and the type doc records the three trades it accepts to do so.
    1. Layering (RedisFixedWindowRateLimiter.cs:15-22): the limiter lives in MMCA.Common.API rather than behind an abstraction implemented in Infrastructure, because API already references Infrastructure (which owns the StackExchange.Redis dependency), so using the client here adds no new dependency edge and breaks no rule in MMCA.Common.LayerEnforcement.targets. An extra interface plus an Infrastructure implementation would buy nothing but indirection, since the limiter is a presentation concern only the rate-limiting middleware constructs. [Rubric §3, Clean Architecture] assesses whether a layer boundary is respected and whether an abstraction is added where it earns its keep; this is the second half.
    2. Storage and precision (:23-30): one key per partition per window, rl:{partitionKey}:{unixMinute}, incremented with INCR and given a TTL slightly longer than the window on the increment that creates it. Keys expire on their own so nothing has to sweep them, and a window rollover is a new key rather than a reset. The counter is not transactional with the permit decision (INCR then compare), which can let a burst arriving in the same instant overshoot the limit slightly; that is the accepted trade for one round trip per request. [Rubric §12, Performance & Scalability].
    3. Fail open (:31-35): any Redis fault permits the request and logs at warning level, at most once per window across the process. Rate limiting protects capacity, so it must never become the reason an otherwise healthy request is rejected. [Rubric §29, Resilience & Business Continuity].
  • Walkthrough
    • _lastLoggedFailureWindow (RedisFixedWindowRateLimiter.cs:44) is static and initialized to -1: a dead Redis produces one warning a minute for the whole process rather than one per request, and the doc explains that a fault is a property of the connection, which every partition shares (:39-43).
    • Instance fields hold the connection, partition key, permit limit, logger and clock (:46-50), plus _lastUsedTimestamp seeded from Stopwatch.GetTimestamp() (:52).
    • The constructor (:68) guards all four required inputs (:75-78, including ThrowIfLessThan(permitLimit, 1)) and defaults timeProvider to TimeProvider.System (:84), which is the substitution point tests use to pin a window (:64-67).
    • IdleDuration (:93-94) reports Stopwatch.GetElapsedTime(Volatile.Read(ref _lastUsedTimestamp)). The doc (:87-92) explains why it is reported rather than left null: partition keys embed user identity, so never reporting idleness would grow the owning PartitionedRateLimiter table with one entry per user seen since start-up.
    • GetStatistics() (:101) returns null on purpose: the counter lives in Redis and reporting it would cost a round trip per call for a diagnostic the middleware does not require (:96-100).
    • AttemptAcquireCore(int) (:111) stamps the last-used timestamp and always permits (:113-114). The doc (:103-108) is explicit that the ASP.NET Core middleware uses the asynchronous path exclusively, so this exists only to satisfy the base contract, and blocking a request thread on a Redis round trip would be strictly worse than the fail-open posture the limiter already takes.
    • AcquireAsyncCore (:118) is the real path: stamp the timestamp (:122), reject outright when permitCount > _permitLimit (:124-127), compute the window as GetUtcNow().ToUnixTimeSeconds() / 60 (:129) and build the invariant-culture key (:130), StringIncrementAsync (:135), and, when count <= permitCount (meaning this increment created the key), set a 65-second TTL (:142). That skew past the window length covers clock drift between instances, since an early-expiring key would hand the partition a fresh allowance inside the same window (:139-141). The decision is the final compare (:145). The catch (:147-155) excludes OperationCanceledException, logs at most once per window via Interlocked.Exchange on the static field (:149-152), and returns Acquired.
  • Why it's built this way: partial plus [LoggerMessage] (:158-161) gives a source-generated, allocation-free warning that names the partition and states plainly that requests are permitted uncounted until Redis recovers. The partition key arrives already scoped by the caller (for example global:alice, :58-61) so two policies limiting the same user never share one counter; that scoping is applied in CreateLimitedPartition as $"{redisScope}:{key}" (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:173).
  • Where it's used: constructed only inside CreateLimitedPartition (WebApplicationBuilderExtensions.cs:171-173), and only when allowDistributed && settings.Distributed is true (:157) and an IConnectionMultiplexer resolves (:163-165); the logger falls back to NullLogger when the request has no service provider (:167-168, which is also why RequestServices is read through a nullable local, :159-162). When no multiplexer is registered the code falls through to the in-memory limiters rather than failing startup (:175-177). The auth-ip policy passes allowDistributed: false (WebApplicationBuilderExtensions.cs:235), so login throttling never depends on Redis.

ErrorHttpMapping

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14 · Level 3 · class (internal static)

  • What it is: the single table that maps domain ErrorType values to HTTP status codes, the ranking rule that picks one status for a whole failure, and the builder for the errors extension array that problem documents carry.
  • Depends on: Error, ErrorType, ErrorTypeSeverity, IErrorLocalizer; System.Collections.Frozen and ASP.NET StatusCodes (ErrorHttpMapping.cs:1-4).
  • Concept: the Result-to-HTTP translation table. This is where the Result pattern meets the HTTP contract. [Rubric §9, API & Contract Design] assesses whether error responses are consistent across the whole surface: because both ApiControllerBase and UnhandledResultFailureFilter call into this one class, they cannot drift (the class doc says exactly that, ErrorHttpMapping.cs:8-13). [Rubric §7, Microservices Readiness] applies to the ranking: the severity order itself lives in MMCA.Common.Shared (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:30) so the gRPC edge classifies the same aggregate identically (ErrorHttpMapping.cs:45-46, and see MMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:117). [Rubric §27, i18n] applies through the localizer parameter.
  • Walkthrough
    • ErrorTypeToStatusCode (ErrorHttpMapping.cs:20-31): a FrozenDictionary<ErrorType, int> with Validation to 400, Invariant to 400, NotFound to 404, Conflict to 409, Unauthorized to 401, Forbidden to 403, UnprocessableEntity to 422, Failure to 400, and Unexpected to 500. FrozenDictionary is the right structure for a table built once at startup and read on every failed request: it trades slower construction for the fastest lookups (:16-19).
    • GetStatusCode(ErrorType) (:37-38) uses GetValueOrDefault(..., Status400BadRequest), so a future error type falls back to 400 rather than throwing inside an error path.
    • GetStatusCode(IReadOnlyList<Error>) (:50-51) is the overload callers actually use for a failed result: it delegates to ErrorTypeSeverity.MostSevere(errors).Type, so the status reflects the most severe error present rather than whichever one happened to be first. Ties keep the earliest error, so a list of same-rank errors behaves exactly as a positional selection would, and every error still travels in the errors array either way (:40-49).
    • BuildErrorsExtension(IReadOnlyList<Error>, IErrorLocalizer?) (:61-69) projects each error into an anonymous object with Code, Message, Type (stringified), Source and Target. Only Message is localized, and only when a localizer was supplied (:65); a null localizer leaves the original English text. Code, Type, Source and Target stay verbatim so clients can branch on them regardless of culture (:53-60).
  • Why it's built this way: internal visibility (:14) keeps the table an implementation detail of the API package. Localizing at this exact point is ADR-027: the domain produces culture-free codes, and the edge does the translation, keyed by the stable Code.
  • Where it's used: ApiControllerBase.HandleFailure when converting a failed Result into an ObjectResult (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:48 and :58), and UnhandledResultFailureFilter as the backstop (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:36 and :47).

TenantResolutionMiddleware

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:36 · Level 3 · class (sealed)

  • What it is: middleware that resolves the current request's tenant (from a claim or a header), publishes it on the scoped ITenantContext, and rejects a request that has none when tenancy is required.
  • Depends on: ITenantContext, TenancySettings (via IOptions<T>), and IProblemDetailsService (TenantResolutionMiddleware.cs:50-54). The persistence layer's query filter, save interceptor and per-tenant database routing all read the context this middleware sets (:10-12).
  • Concept introduced: request-scoped tenant resolution that fails closed. [Rubric §11, Security] assesses whether data isolation holds by default: an unscoped request in a multi-tenant database reads across every tenant, which is the exact outcome tenancy exists to prevent, so with Tenancy:RequireTenant (the default) an unresolved tenant is answered 400 Bad Request rather than allowed through (class remarks, TenantResolutionMiddleware.cs:27-33). [Rubric §8, Data Architecture] applies because the value published here is what scopes the EF global filter. [Rubric §17, DevOps & Deployment]: hosts that never opted in pay nothing, since IOptions<TenancySettings> resolves to defaults with Enabled false and the middleware passes every request straight through (:15-20).
  • Walkthrough
    • UnresolvedTenantTitle (TenantResolutionMiddleware.cs:39): the internal const problem title, shared with the tests.
    • InvokeAsync(HttpContext, ITenantContext, IOptions<TenancySettings>, IProblemDetailsService) (:50-54): per-invoke injection, null-guarding its first three arguments (:56-58).
    • Fast exit (:62-66): if !settings.Enabled or IsExcluded(...), call next and return. IsExcluded (:89-94) matches settings.EffectiveExcludedPathPrefixes with StartsWithSegments under OrdinalIgnoreCase, which is how health, liveness and discovery endpoints keep answering before any tenant exists.
    • Resolution (:68-73): Resolve(...) returns the first non-blank candidate, and on success the tenant is published with tenantContext.SetTenant(tenantId) (:70) before the pipeline continues.
    • Resolve (:105-122) walks settings.EffectiveResolutionOrder and switches on the strategy: Claim reads context.User?.FindFirst(settings.ClaimType)?.Value (:111), Header reads settings.HeaderName (:112), and Host deliberately yields null (:113) because it is declared but not implemented and options validation refuses to start a host that selected it (:100-104). Candidates are trimmed (:118).
    • Opt-out (:75-81): with RequireTenant false an unresolved request runs as a system caller and sees every tenant's rows, which the comment marks as only correct behind an internal boundary (:77-78).
    • RejectAsync (:128-151) sets 400 (:133) and writes a problem document that names the exact claim and header that were inspected (:142-146), so the caller can fix the request without reading the source.
  • Why it's built this way: see ADR-073. The registration position is load-bearing: it runs immediately after UseAuthentication() because the claim strategy reads HttpContext.User, which has no token claims until authentication has run (:21-26). Registering it earlier would silently demote every request to the header strategy, so the adjacency is not left to convention: MiddlewarePipelineBuilder.Build() enforces it with RequireImmediatelyBefore(Authentication, TenantResolution, ...) (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:265-268), so a host that reorders the pipeline fails at startup rather than misrouting tenants.
  • Where it's used: the TenantResolution step of the default pipeline (MiddlewarePipelineBuilder.cs:113-119), which WebApplicationExtensions.UseCommonMiddlewarePipeline() applies. It is wired unconditionally and inert by default.

UnhandledResultFailureFilter

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:22 · Level 4 · class (sealed, partial)

  • What it is: a globally registered IAlwaysRunResultFilter that catches an action which returned a failed Result inside an Ok(...) (or any ObjectResult) and rewrites the response as a proper problem document at the right status code.
  • Depends on: Result, Error, ErrorHttpMapping, IErrorLocalizer; Microsoft.AspNetCore.Mvc.Filters.IAlwaysRunResultFilter (UnhandledResultFailureFilter.cs:1-7).
  • Concept introduced: the always-run result filter as a safety net. A regular result filter can be bypassed on short-circuit paths; IAlwaysRunResultFilter runs for every result MVC is about to execute. [Rubric §9, API & Contract Design] assesses whether a domain failure can leak as 200 OK with error JSON in the body (a client would treat it as success), which the class doc names as the exact defect it prevents (UnhandledResultFailureFilter.cs:11-21). [Rubric §15, Best Practices & Code Quality] assesses defense in depth: this filter converts a class of controller mistakes into a correct response plus a warning log instead of a silent wrong answer.
  • Walkthrough: OnResultExecuting (UnhandledResultFailureFilter.cs:26):
    • The guard context.Result is not ObjectResult { Value: Result result } || result.IsSuccess (:28-31) makes the filter a no-op for everything except an ObjectResult carrying a failed Result. Note the pattern matches the base Result, so Result<T> values are caught too.
    • Logs at Warning with the action's display name and the errors (:33, message template at :57-59), which is how an operator learns which action leaked.
    • Derives the status through ErrorHttpMapping.GetStatusCode(result.Errors) when there is at least one error (:35-37), so the code reflects the most severe error present, matching ApiControllerBase.HandleFailure exactly (:17-19); with no errors at all it falls back to 500, since a failure with nothing to report is a framework-level anomaly rather than a client error.
    • Builds a ProblemDetails titled "Unhandled result failure" whose detail names the defect in plain words (:39-44).
    • Resolves IErrorLocalizer from HttpContext.RequestServices with GetService (:46, so a host without localization simply passes null) and attaches the localized errors extension (:47).
    • Replaces context.Result with an ObjectResult(problemDetails) { StatusCode = statusCode } (:49).
    • OnResultExecuted (:53-55) is intentionally empty: there is nothing to do after the response ran.
  • Why it's built this way: the logging uses the [LoggerMessage] source generator (:57-63) with a small private instance forwarder (:65-66) so the filter's primary-constructor logger is used without an allocation per call. The class is partial for that generator.
  • Where it's used: added as a global MVC filter inside AddAPI(...) (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:49), so it applies to every controller action in every host.

CorrelationIdMiddleware

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:15 · Level 9 · class (sealed)

  • What it is: convention-based middleware that establishes one correlation ID per request, publishes it on the scoped ICorrelationContext, and echoes it back to the caller in the X-Correlation-ID response header.
  • Depends on: ICorrelationContext (injected per invoke, so it is resolved from the request scope) and System.Diagnostics.Activity (CorrelationIdMiddleware.cs:1-3).
  • Concept: distributed trace correlation is introduced at ICorrelationContext; this is the type that populates it. [Rubric §13, Observability & Operability] assesses whether one logical operation can be reconstructed across log entries and services: with this middleware second in the pipeline, every later log line, outbox row and downstream call can carry the same ID.
  • Walkthrough
    • HeaderName = "X-Correlation-ID" (CorrelationIdMiddleware.cs:18) is a public const, so clients, tests and downstream code all name the header from one place.
    • InvokeAsync(HttpContext, ICorrelationContext) (:27): the second parameter is per-invoke injected, which is how convention-based middleware consumes a scoped service from a singleton middleware instance. Both arguments are null-guarded (:29-30).
    • The resolution waterfall (:32-34): the inbound X-Correlation-ID header wins, else Activity.Current?.TraceId (the W3C trace ID that OpenTelemetry propagates), else ASP.NET's HttpContext.TraceIdentifier. A caller-supplied ID is therefore honored, and the ID always exists.
    • correlationContext.SetCorrelationId(correlationId) (:36) publishes it, then context.Response.OnStarting(...) (:37-41) registers a callback that writes the response header. Writing it in OnStarting rather than after await next(...) is the only safe way: once the response has begun, header writes throw.
    • await next(context) (:43) continues the pipeline.
  • Why it's built this way: accepting the client's ID makes cross-system correlation possible when the caller already has a trace, and falling back to Activity.Current means the correlation ID and the OpenTelemetry trace ID are the same value in a normally instrumented host.
  • Where it's used: it is the second step of the default pipeline, right after the exception handler (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:39-41, following ExceptionHandler at :34-36), so everything downstream (including the exception handlers above) logs under a known ID. The step is named MiddlewarePipelineStepNames.CorrelationId (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:19), which is the handle a host uses to insert its own middleware around it. The CQRS logging decorators read the same context.

SoftDeletedUserMiddleware

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31 · Level 9 · class (sealed, partial)

  • What it is: middleware that rejects an authenticated request with 401 when the calling user has been soft-deleted (BR-133), using a 30-second cached marker so the check does not cost a database round trip on every request.
  • Depends on: ICurrentUserService, ICacheService, ISoftDeletedUserValidator (resolved lazily), and SoftDeletedUserCache for the key and the marker duration (SoftDeletedUserMiddleware.cs:57-61).
  • Concept introduced: a fail-open security check, and why. [Rubric §11, Security] assesses whether revoked principals stop being served, and [Rubric §29, Resilience & Business Continuity] assesses how a dependency outage degrades. The class remarks (SoftDeletedUserMiddleware.cs:16-30) document the trade-off explicitly: this check runs on the hot path of every authenticated request, so failing closed would turn any cache or database blip into a total outage. Failing open is bounded instead, because access tokens live 15 minutes and the deletion already revoked the refresh token, so no new access token can be minted. The residual exposure is one already-issued token's remaining lifetime, and only while a dependency is unhealthy. [Rubric §12, Performance & Scalability] covers the cache: the marker lives 30 seconds (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29), long enough to absorb bursts and short enough that the validator query is authoritative again quickly.
  • Walkthrough: InvokeAsync(HttpContext, ICurrentUserService, ICacheService, ILogger<SoftDeletedUserMiddleware>) (SoftDeletedUserMiddleware.cs:57-61), per-invoke injected including the logger (the reason is stated at :52-55):
    • Anonymous fast path (:65-73): no UserId, call next and return. The comment notes this is the common case in extracted services, which see internal gRPC/HTTP traffic with no user.
    • Validator resolution (:75-83): context.RequestServices.GetService<ISoftDeletedUserValidator>(). The validator is implemented by the Identity module, so a service that does not host Identity has none registered; lazy resolution lets the middleware no-op there instead of 500-ing every request (remarks at :42-51). This is the "wired unconditionally, inert when not applicable" convention that TenantResolutionMiddleware later copied by name (TenantResolutionMiddleware.cs:16).
    • Cache read (:85-100): key from SoftDeletedUserCache.KeyFor(userId.Value) (:85). A cache exception that is not an OperationCanceledException is logged and treated as a miss (:93-99), and cacheReachable goes false so the write is skipped too.
    • cachedResult is true (:102-106): the user is known deleted, respond 401 and stop the pipeline.
    • Cache miss (:108-148): query IsUserSoftDeletedAsync (:114-116). If that throws (again excluding cancellation) the request is allowed through with a warning (:118-125), which is the fail-open path. Otherwise the answer is written back to the cache when the cache is reachable, using SoftDeletedUserCache.MarkerDuration (:127-141, a failed write only costs the next request a lookup), and a deleted user gets 401 (:143-147).
    • Otherwise next runs (:150).
    • Three [LoggerMessage] warnings (:153-175) name the failure precisely: cache read, cache write, validator, each stating what the request did next.
  • Why it's built this way: every catch filters out OperationCanceledException (:93, :118, :135), so a client disconnect is never misread as a dependency outage and is left to OperationCanceledExceptionHandler. Returning a bare 401 status with no body (:104, :145) is deliberate: a revoked principal gets no detail to work with.
  • Where it's used: the SoftDeletedUserFilter step of the default pipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:129-131), placed after RateLimiting (:120-126) and before Authorization (:132-134), so it runs only once a principal exists and before any policy grants access. The step name is MiddlewarePipelineStepNames.SoftDeletedUserFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:58).

ErrorResources

MMCA.Common.API · MMCA.Common.API.Resources · MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9 · Level 0 · class (sealed)

  • What it is: an empty marker class whose only job is to be a typeof(...) anchor for the framework's own translation files, ErrorResources.resx and ErrorResources.es.resx, which sit beside it in Resources/. It has no members: public sealed class ErrorResources; at MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9 is the entire declaration, written in the body-less semicolon form.
  • Depends on: nothing, first-party or otherwise. It has no using directives and no base type. Dependencies point the other way: ErrorResourceSource wraps the IStringLocalizer built from this type, and ErrorLocalizer (the default IErrorLocalizer) enumerates those sources.
  • Concept introduced (the resource anchor type). .NET's IStringLocalizerFactory.Create(Type) locates a resource set from a type's assembly plus its namespace-relative name, so a .resx needs a co-located type to point at even when that type has no behavior of its own. Keeping the anchor a real, public, empty class makes the resource set addressable in a refactor-safe way (rename the class and the resx alongside it, and no magic base-name string goes stale) and gives every module a one-line pattern to copy. Tag [Rubric §27, i18n], which assesses whether user-facing text is externalized and culture-resolved rather than hard-coded: the fifteen entries in each of the two resx files are keyed by the stable machine error Code ("PhoneNumber.Empty", "Money.CurrencyMismatch", "DateRange.Invalid", and so on), never by the English message, so a translation survives any rewording of the domain's fallback text. Tag [Rubric §9, API & Contract Design], which assesses whether the API's error shape is stable across clients: the localized text moves while the Code a client branches on stays invariant. Tag [Rubric §3, Clean Architecture], which assesses whether concerns sit in the right layer: this anchor and its resx live in the Presentation assembly MMCA.Common.API, so the Domain and Application layers that produce Error values never take a localization dependency.
  • Walkthrough: there is nothing to trace inside the type; all of its meaning is in the doc comment and in the two resx siblings. What is worth walking is the three-step path the anchor sits on.
    1. Registration. AddErrorLocalization() calls services.AddLocalization(), registers ErrorLocalizer behind IErrorLocalizer with TryAddSingleton, and then contributes the framework's own set with services.AddErrorResources<ErrorResources>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:109-111). AddErrorLocalization() is called automatically from AddAPI (DependencyInjection.cs:96), so no host has to opt in.
    2. Source construction. The generic AddErrorResources<TResource>() registers a singleton factory that resolves IStringLocalizerFactory, calls Create(typeof(TResource)), and wraps the result in an ErrorResourceSource (DependencyInjection.cs:122-126). ErrorResources is just the first TResource; each module passes its own anchor, which is why translations are additive rather than an edit to a framework file.
    3. Lookup. ErrorLocalizer snapshots every registered source in registration order (MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorLocalizer.cs:13, so Common's set is consulted first), returns the caller's fallback immediately for an empty code (ErrorLocalizer.cs:18-21), then walks the sources and returns the first hit whose ResourceNotFound is false (:23-30). A code no source knows falls through to the original English message (:32), so an untranslated code degrades instead of throwing or rendering a key.
  • Why it's built this way: see ADR-027 (multi-locale i18n). Localizing at the HTTP edge rather than inside the domain keeps Error.Code and the fallback message culture-free all the way through the Application layer, and one anchor per resource set turns "add Spanish for my module's errors" into a new resx plus one AddErrorResources<T>() call instead of a merge into shared framework resources.
  • Where it's used: registered as the framework's own source at MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111. The text it supplies reaches clients through three call sites that resolve IErrorLocalizer from the request container: ApiControllerBase when it turns a failed Result into a response (Controllers/ApiControllerBase.cs:57), UnhandledResultFailureFilter (Middleware/UnhandledResultFailureFilter.cs:46), and SupportsIfMatchAttribute when it shapes a precondition failure (Concurrency/SupportsIfMatchAttribute.cs:217); all three hand the localizer to ErrorHttpMapping.BuildErrorsExtension (Middleware/ErrorHttpMapping.cs:61), which is where the code-to-text substitution happens. Module anchors follow the same shape: ConferenceErrorResources, EngagementErrorResources, and IdentityErrorResources.
  • Caveats / not-in-source: the class file tells you nothing about which codes are covered. The key set lives in the two resx assets beside it (fifteen entries each today, matched one-for-one between English and Spanish), and nothing in this type enforces that the two files stay in sync or that every framework Error.Code has an entry.

AppAssociationOptions

MMCA.Common.API · MMCA.Common.API.Startup.Endpoints · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationOptions.cs:9 · Level 0 · class (sealed)

  • What it is: the strongly-typed options bag carrying the identifiers a mobile OS needs in order to verify that an installed native app may claim this host's https links. It feeds AppAssociationEndpointExtensions, which serializes it into the two well-known documents (Android Digital Asset Links and the Apple App Site Association).
  • Depends on: nothing first-party. BCL only (IReadOnlyList<string>).
  • Concept introduced (deep-link / universal-link association). For a Blazor Hybrid app to open a shared web URL directly in the installed native app rather than the browser, the operating system fetches an association document from the URL's host and checks that the installed app's signing identity matches what the document names. This options type is the single source of those identities, so a certificate rotation is a config change and not a code change (the doc comment says exactly that at AppAssociationOptions.cs:3-8). [Rubric §9, API & Contract Design] assesses whether public contracts are typed and pinned rather than hand-built inline; here the "public contract" is the exact JSON payload Google and Apple parse, and binding its inputs from an AppAssociation configuration section is that discipline. [Rubric §11, Security] also applies: the fingerprints in this bag are what stop an unrelated app from claiming the host's links.
  • Walkthrough: four members, all init-only.
    • AndroidPackageName (AppAssociationOptions.cs:12, required): the Android application id declared in assetlinks.json.
    • AndroidCertFingerprints (AppAssociationOptions.cs:18, defaults to []): the SHA-256 signing-certificate fingerprints; the doc comment (:14-17) warns that for Play-distributed builds this is the Play App Signing certificate, not the local upload keystore.
    • AppleAppId (AppAssociationOptions.cs:21, required): the TeamID.BundleID value used by both the webcredentials and the applinks sections.
    • AppleAppLinkComponents (AppAssociationOptions.cs:28, defaults to []): the URL patterns (for example "/conference/*") that each become a { "/": pattern } component; the comment (:23-27) notes these should mirror the app's shared Blazor routes, because identical URLs on web and device is the Blazor Hybrid payoff (no route-translation table).
  • Why it's built this way: required init gives compile-checked construction plus immutability once bound (see the primer on required/init immutability), which matches the lifetime: a host builds one instance at startup and the endpoint reads it for the process lifetime. Defaulting the two collections to [] means a host that ships only one platform still constructs a valid document for the other. See ADR-043 for the deep-link decision this serves.
  • Where it's used: constructed inline by the ADC Blazor web host and passed straight to the mapper, with the Android and Apple identifiers read from the AppAssociation configuration section (with in-code fallbacks) and the applinks patterns hard-coded to the app's routes (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:180-191). The comment there (:180-183) records a trap worth reading: the Release Android head overrides ApplicationId to ivanball.AtlDevCon, so that is the package Digital Asset Links must name, not the Debug-only id.
  • Caveats / not-in-source: the type performs no validation. Whether a fingerprint or bundle id is the correct one for the shipped app is only observable at install time on the device.

JwtAuthorityExtensions

MMCA.Common.API · MMCA.Common.API.Startup.Auth · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAuthorityExtensions.cs:15 · Level 0 · class (static, extension block)

  • What it is: a two-member helper that names the one configuration key carrying a service host's JWT/JWKS authority, and reads it with a fail-fast guard. Every extracted service that is not the token issuer calls it immediately before AddForwardedJwtBearer.
  • Depends on: Microsoft.Extensions.Configuration.IConfiguration only. Its consumer is AddForwardedJwtBearer on WebApplicationBuilderExtensions.
  • Concept introduced (fail fast on missing wiring, and hoist the key so it cannot drift). The failure this exists to prevent is diagnostically nasty: a host that boots with no authority configured answers every authenticated request with a 401, which looks like a token problem to whoever is debugging it rather than the wiring problem it actually is (JwtAuthorityExtensions.cs:8-13). Converting that into a startup exception with an actionable message is the whole point. [Rubric §13, Observability & Operability] assesses whether an operator can tell what actually went wrong; the thrown message names both the key and the AppHost call that sets it. [Rubric §34, Architecture Governance & Documentation] also applies: the key string lived inline in five host Program.cs files before it was hoisted here, and the constant is what lets the compiler (and a test) find every consumer if it ever changes.
  • Walkthrough: two members.
    • JwtAuthorityConfigKey (JwtAuthorityExtensions.cs:21, value "Authentication:JwtBearer:Authority"): the key the AppHost's WithJwksDiscovery(identityService) sets, and that the Azure deployment template sets in production (:17-20).
    • GetRequiredJwtAuthority() (:35), an extension(IConfiguration configuration) member. It null-guards the configuration itself (:37), then reads the key and throws an InvalidOperationException naming the key and the fix ("Wire .WithJwksDiscovery(identityService) in the AppHost.") when it is absent (:39-42).
  • Why it's built this way: the guard is deliberately a null check, not a blank check. A configured-but-empty authority passes through unchanged and is rejected one line later by AddForwardedJwtBearer's own ArgumentException.ThrowIfNullOrWhiteSpace (WebApplicationBuilderExtensions.cs:453), so the hoist stayed behavior-identical to the five inline guards it replaced; the test that pins this says so directly (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/Auth/JwtAuthorityExtensionsTests.cs:40-45). The key itself is pinned by a second test whose failure message spells out the coupling: WithJwksDiscovery sets Authentication__JwtBearer__Authority, so renaming the key silently breaks every service host (JwtAuthorityExtensionsTests.cs:48-51).
  • Where it's used: every non-issuer service host, immediately at the AddForwardedJwtBearer call site. ADC: Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:275), Engagement (:175 of its own Program.cs) and Notification (:161). Store: Sales (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:176) and Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:181). The Identity services do not call it: they issue the tokens and validate in process through AddCommonAuthentication instead (ADR-004).
  • Caveats / not-in-source: what value the AppHost or the Azure template actually injects is deployment configuration, not code. This type only guarantees that something was injected.

MiddlewarePipelineStep

MMCA.Common.API · MMCA.Common.API.Startup.Pipeline · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStep.cs:21 · Level 0 · record (sealed, positional)

  • What it is: one named step of the shared HTTP edge pipeline: a stable string identifier plus the delegate that registers that step's middleware on a WebApplication. It is the atom that makes the edge order data rather than a hand-written sequence of app.UseX() calls.
  • Depends on: ASP.NET Core's WebApplication (through the Action<WebApplication> payload) and nothing else first-party. Its names normally come from MiddlewarePipelineStepNames; its container is MiddlewarePipelineBuilder.
  • Concept introduced (the pipeline as an inspectable list, not an imperative script). A conventional ASP.NET composition root is its own ordering: the order exists only as the sequence of statements in Program.cs, so nothing can read it, assert on it, or edit it. Modelling each step as a value with a name means the whole order can be enumerated (StepNames on the builder), rewritten by name, validated, and frozen by a unit test with no host running at all. The doc comment states the payoff directly: steps are pure data until UseCommonMiddlewarePipeline runs them in order, which is what makes the pipeline order testable without a running host (MiddlewarePipelineStep.cs:5-10). [Rubric §2, Design Patterns] assesses whether a recognizable pattern is applied where it earns its keep; this is the classic "reify the plan, then execute it" split, and it is what unlocks the fitness function. [Rubric §14, Testability] assesses whether behavior can be asserted cheaply: because a step never touches a host until Configure is invoked, the entire order runs in the fast unit tier.
  • Walkthrough: a positional record with two components, each re-declared as a validated property.
    • Name (MiddlewarePipelineStep.cs:27) shadows the positional parameter with Validated(Name), which calls ArgumentException.ThrowIfNullOrWhiteSpace (:32-36). Null, empty, and whitespace names are rejected at construction, so an anchor lookup can never match a meaningless key.
    • Configure (:30) is validated the same way through the second Validated overload, which null-guards the delegate (:38-42).
    • The init accessors keep both immutable after construction, so a step handed to the builder cannot be mutated behind the builder's back.
    • The parameter doc records the runtime contract: Configure is invoked exactly once, in pipeline order, at the point UseCommonMiddlewarePipeline is called, so anything the delegate reads from the host (configuration, environment) is evaluated at configure time and not per request (:16-20).
  • Why it's built this way: the validation-in-the-property-initializer idiom is how a positional record enforces invariants without giving up the concise declaration or the value semantics. Value equality also matters here: two steps with the same name and delegate compare equal, which keeps assertions in MiddlewarePipelineBuilder tests simple. See ADR-079, which records the move from an inline sequence to named steps.
  • Where it's used: MiddlewarePipelineBuilder.CreateDefault() constructs eighteen of them (MiddlewarePipelineBuilder.cs:32-157), and a host customizing the pipeline constructs its own to pass to InsertBefore / InsertAfter / Replace.
  • Caveats / not-in-source: name uniqueness is not enforced here; it is enforced by the builder's RequireUniqueName at insertion time (MiddlewarePipelineBuilder.cs:305-313). Constructing two steps with the same name in isolation is legal.

MiddlewarePipelineStepNames

MMCA.Common.API · MMCA.Common.API.Startup.Pipeline · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:14 · Level 0 · class (static, constants)

  • What it is: the eighteen well-known step names of the default edge pipeline, as const string fields declared in runtime order. A host customizing the pipeline addresses steps by these constants.
  • Depends on: nothing. It is referenced by MiddlewarePipelineBuilder (which seeds the defaults with these names and re-checks adjacencies by them) and by MiddlewarePipelineOrderTestsBase.
  • Concept (names as a published contract). Because a host inserts, replaces, and removes steps by name, the names are part of the framework's public API surface: the doc comment says so outright and adds that renaming one is a breaking change (MiddlewarePipelineStepNames.cs:3-7). [Rubric §9, API & Contract Design] assesses whether the surface a consumer binds to is explicit and stable; hoisting each name into a const is what turns a magic string into that surface, and what lets the compiler find every caller when the list changes. [Rubric §34, Architecture Governance & Documentation] also applies: the declaration order below the summary is the documented runtime order, so the code and the documentation cannot drift apart.
  • Walkthrough: the constants in declaration order, which is application order (outermost first).
    • ExceptionHandler (:17), CorrelationId (:20), RequestLocalization (:23).
    • PreForwardedCapture (:29) and ForwardedHeaders (:32). The comment on the first (:25-28) states the adjacency: it must run immediately before ForwardedHeaders, because it captures the transport scheme and host as the connection saw them, before the forwarded headers rewrite them.
    • HttpsRedirection (:35), ResponseCompression (:38), Routing (:41), Cors (:44), Authentication (:47).
    • TenantResolution (:53), whose comment (:49-52) records that it must run immediately after Authentication because the claim strategy reads HttpContext.User.
    • RateLimiting (:56), documented as needing to run after Authentication per ADR-019.
    • SoftDeletedUserFilter (:59), Authorization (:62), OutputCache (:65).
    • JwksEndpoint (:68), OidcDiscoveryEndpoint (:71), and Controllers (:74), the innermost step.
    • The class summary (:8-12) flags that several of these adjacencies are load-bearing and are re-checked by MiddlewarePipelineBuilder.Build.
  • Why it's built this way: const rather than static readonly so the values are usable in attribute arguments and switch patterns, and one file rather than a nested enum so the XML doc on each field can carry the ordering rationale next to the name it explains. See ADR-079.
  • Where it's used: MiddlewarePipelineBuilder.CreateDefault() names every seeded step with these constants; Build() names them again in its four invariant checks; and MiddlewarePipelineOrderTestsBase.ExpectedStepNames (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:38-58) lists all eighteen as the frozen expected order, which each app's MiddlewarePipelineOrderTests subclasses.

OpenApiEndpointExtensions

MMCA.Common.API · MMCA.Common.API.Startup.Endpoints · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:22 · Level 0 · class (static, extension block)

  • What it is: two extension(WebApplication app) mapping helpers that expose the generated OpenAPI document and an optional interactive reference UI, both outside Production only.
  • Depends on: Scalar.AspNetCore (NuGet) for the reference UI and Asp.Versioning's WithDocumentPerVersion() convention. It pairs with AddCommonOpenApi() on WebApplicationBuilderExtensions, which registers the generator.
  • Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface). [Rubric §9, API & Contract Design] assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (OpenApiEndpointExtensions.cs:7-21) is explicit that the guarding happens at two levels: the framework-owned part of the generated document (the versioned naming convention, the unbound-route-token backfill, the generated ProblemDetails error schema) is diffed against a committed baseline in-repo by OpenApiBaselineTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs), which fails on any change until the baseline is regenerated deliberately in the same pull request, while each consumer's concrete API surface stays the concern of the contract-snapshot tests in that host's integration tier. Mapping outside Production is the security posture [Rubric §11, Security]: these are internal services reached through the Gateway, which does not route the endpoint.
  • Walkthrough
    • MapCommonOpenApi() (OpenApiEndpointExtensions.cs:34) calls the built-in MapOpenApi() only when !app.Environment.IsProduction() (:36-39) and chains .WithDocumentPerVersion(), which applies the API-versioning convention so the route resolves one document per discovered API version (/openapi/v1.json for v1.0, doc comment :26-33). It is a no-op in Production and returns app for chaining (:41).
    • MapCommonScalarUi() (OpenApiEndpointExtensions.cs:52) is the opt-in developer convenience: it calls MapScalarApiReference() outside Production (:54-57), rendering /scalar/{documentName}. Assets ship inside the Scalar.AspNetCore package rather than a CDN (:49-50), so it works offline and in CI.
  • Why it's built this way: one shared pair of helpers keeps every service's OpenAPI story identical and enforces the "internal spec, not public surface" convention in one place instead of per host. The version-aware document mapping is what keeps the route stable as versions accumulate (ADR-046).
  • Where it's used: inside this workspace the only caller is the framework's own probe host, OpenApiProbeHost (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:40 registers, :59 maps), which is what OpenApiBaselineTests and the ApiParameterDescriptorBackfillProvider tests boot. The ADC and Store service hosts today call the stock ASP.NET pair directly instead. This is the framework offering a convention ahead of the consumers adopting it; OpenApiContractTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/OpenApiContractTestsBase.cs:22) is the base a consumer subclasses once it does.

IBaseDTO<TIdentifierType>

MMCA.Common.Shared · MMCA.Common.Shared.DTOs · MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9 · Level 0 · interface

  • What it is: a one-property marker interface. Every DTO that carries an entity identifier exposes TIdentifierType Id { get; init; } (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:13).
  • Depends on: nothing first-party, and nothing external beyond the generic constraint. It lives in MMCA.Common.Shared, the bottom layer, so a Blazor WebAssembly client and an EF-backed service can both reference it.
  • Concept introduced (the DTO and the marker/role interface). [Rubric §9, API & Contract Design] assesses DTOs decoupled from domain entities and stable wire contracts: a DTO (Data Transfer Object) is the shape that crosses the wire, deliberately separate from the domain entity. IBaseDTO lets generic machinery treat any DTO uniformly through its Id, which is what makes a single generic read service, a single generic controller base, and a single generic UI service possible instead of one hand-written trio per entity. This is also [Rubric §1, SOLID]: a textbook Interface Segregation interface, with one member (the only thing a generic consumer needs), so clients never depend on more than they use.
  • Walkthrough: generic over TIdentifierType with a where TIdentifierType : notnull constraint (IBaseDTO.cs:10); the single Id is get; init; (IBaseDTO.cs:13), settable at construction and immutable after. The init-not-set choice recurs across these contracts (see the primer on immutability with required/init). The doc comment (IBaseDTO.cs:3-6) names the two consumers it exists for: the generic query service and the controller bases.
  • Why it's built this way: making the identifier type a generic parameter, rather than hard-coding int, lets a DTO match its entity's strongly-typed id alias (see identifier aliases), so a Guid-keyed aggregate and an int-keyed one share the same generic pipeline; the notnull constraint forbids Id being a nullable type, which keeps the generic code free of null checks on the one value it always needs.
  • Where it's used: it is the constraint on every generic read/write pipeline in the framework, each declaring the same where TEntityDTO : IBaseDTO<TIdentifierType> line: IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityQueryService.cs:21), IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:16), the generic create handler CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:50), the controller hierarchy (EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> at MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:44), and the UI's EntityServiceBase<TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:48). Implemented directly by BaseLookup<TIdentifierType> and by every module DTO in ADC, Store, and Helpdesk (for example MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/Tickets/TicketDTO.cs:10).

IConcurrencyAware

MMCA.Common.Shared · MMCA.Common.Shared.DTOs · MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:15 · Level 0 · interface

  • What it is: the contract for read DTOs that expose the current optimistic-concurrency token (byte[] RowVersion). The API renders that token as the response ETag, and the client echoes it back in the If-Match header of its next write.
  • Depends on: nothing first-party. It is the wire-side half of a pair whose persistence-side half is IWriteRepository<TEntity, TIdentifierType>.SetOriginalRowVersion; the header encoding of the same bytes is ConcurrencyETag, which names this interface as the byte-array side of the translation (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:7).
  • Concept introduced (optimistic concurrency, header-only). [Rubric §8, Data Architecture] assesses deliberate persistence: transactions, migrations, soft-delete, audit, and concurrency control. SQL Server's rowversion is a token the database changes on every update of a row. A read DTO carries the current RowVersion so the client can state a precondition on the next write, and the persistence layer can then refuse a conflicting concurrent edit instead of silently overwriting it. The doc comment (IConcurrencyAware.cs:3-7) spells out the full round trip: DTO to ETag to If-Match to SetOriginalRowVersion. [Rubric §9, API & Contract Design] applies too, because the precondition is expressed in HTTP's own vocabulary rather than in a body field.
  • Walkthrough: one property, byte[] RowVersion { get; init; } (IConcurrencyAware.cs:19), and it is not nullable. The remark (IConcurrencyAware.cs:9-13) is the reason: the token is the If-Match header's whole content, so it is never optional. A DTO read from a persisted aggregate always has one (AuditableBaseEntity.RowVersion is non-null), and a write that states no precondition is refused with 428 Precondition Required rather than falling back to last-write-wins. The same remark states the other half of the current design: update requests carry no token, because the precondition travels in the header alone. Note the [SuppressMessage("Performance", "CA1819")] on IConcurrencyAware.cs:18: exposing a byte[] property normally trips the "properties should not return arrays" analyzer rule, but it is required to round-trip the EF token, and the suppression is justified inline ([Rubric §15, Best Practices]: suppressions are tracked and explained, not blanket-disabled).
  • Why it's built this way: ADR-035 puts the precondition on the resource read rather than on each request model, so a module adds conditional writes by making its read DTO concurrency-aware plus tagging the action, and no request record has to loosen its immutability to receive a token. Keeping the contract in Shared means the same interface is visible to the Blazor client that must echo the tag and to the Infrastructure repository that consumes the bytes.
  • Where it's used: implemented by the read DTOs across the apps: ADC's SessionDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:15) and LivePollDTO (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollDTO.cs:8), Store's OrderDTO (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Orders/OrderDTO.cs:9) and InventoryItemDTO (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Inventory/InventoryItemDTO.cs:9), and Helpdesk's TicketDTO (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/Tickets/TicketDTO.cs:10). On the server, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETag tests a returned DTO for this interface and writes the ETag response header (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:471-479, called from :436). On the client, EntityServiceBase<TEntityDTO, TIdentifierType>.ConcurrencyTagOf pattern-matches the same interface to build the outgoing If-Match value, treating an empty array as no token (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:197-201).
  • Caveats / not-in-source: the "update requests carry no token" half is a convention this interface cannot enforce by itself; it is pinned by the shipped fitness rule UpdateRequests_ShouldNotImplement_IConcurrencyAware (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/ConcurrencyConventionTestsBase.cs:14), which each consumer app subclasses.

ICorrelationContext

MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8 · Level 0 · interface

  • What it is: the Application-layer abstraction for "the correlation ID of the work currently in flight": one reader and one writer, resolved per scope.
  • Depends on: nothing first-party, BCL string only. Implemented by CorrelationContext in Infrastructure.
  • Concept introduced (request correlation as an injected ambient value). [Rubric §13, Observability & Operability] assesses whether one logical request can be reassembled from disjoint logs; a correlation ID is the value that makes that possible. [Rubric §3, Clean Architecture] is the reason this interface exists at all: handlers, decorators and interceptors need the ID, but they must not reach for HttpContext, so the contract sits in Application and the holder sits in Infrastructure, keeping the dependency arrow pointing inward. [Rubric §13, Observability & Operability]: nothing in the business path sets or threads the ID, it is populated once at the edge and read wherever it is needed.
  • Walkthrough: two members. string CorrelationId { get; } (ICorrelationContext.cs:11) is read-only to consumers. void SetCorrelationId(string correlationId) (ICorrelationContext.cs:15) is the single write path, on the same interface, so the edge component that populates the value can do it through the DI-resolved instance without a second, internal interface. The doc comment (ICorrelationContext.cs:3-6) states the intended lifecycle: set by middleware from the X-Correlation-ID header or generated, then carried through the handler pipeline in structured-logging scopes.
  • Why it's built this way: a two-member interface is small enough that a test or a background host can supply its own holder, and putting the setter here (rather than on the concrete class) keeps middleware depending on the abstraction. ITenantContext is deliberately modelled on it, and says so: one scoped instance per request, populated once at the edge (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:11).
  • Where it's used: registered as TryAddScoped<ICorrelationContext, CorrelationContext>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:547). Populated by CorrelationIdMiddleware, which takes it as a method-injected parameter of InvokeAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:27). Consumed by LoggingCommandDecorator<TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:18) and LoggingQueryDecorator<TQuery, TResult> (.../LoggingQueryDecorator.cs:16), which wrap the ID into their log scope for the duration of the pipeline.
  • Caveats / not-in-source: not every component can use it. The audit-trail interceptor is a singleton and records the ambient Activity trace id instead of this scoped value, and says so in its own comment (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:89).

JwtForwardingDelegatingHandler

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Http · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17 · Level 0 · class (sealed)

  • What it is: an HTTP DelegatingHandler that copies the inbound Authorization header from the current HttpContext onto every outgoing request of the client it is attached to, so a service-to-service HTTP call travels on the caller's own bearer token.
  • Depends on: Microsoft.AspNetCore.Http.IHttpContextAccessor (constructor-injected, JwtForwardingDelegatingHandler.cs:17), System.Net.Http.Headers.AuthenticationHeaderValue. Its gRPC twin is JwtForwardingClientInterceptor, which the doc comment names explicitly (:12).
  • Concept introduced (token propagation at the transport boundary). [Rubric §11, Security] assesses whether authorization survives a hop: the downstream service authorizes the user, not the calling service, which means the caller's JWT has to arrive with the request. [Rubric §7, Microservices Readiness] is the other half: because the forwarding lives in a message handler, application code that calls a typed client is identical whether the module is in-process or extracted, and no handler has to know a token exists. [Rubric §29, Resilience, Reliability & Business Continuity]: this is the classic chain-of-responsibility shape of HttpClient, one concern per handler.
  • Walkthrough
    • SendAsync (:22) guards the request (:24), then returns straight to base.SendAsync in two cases. First, when request.Headers.Authorization is already set (:27-30), because a previous handler or the caller stated its own credentials and must win. Second, when there is no inbound header to copy (:32-36): httpContextAccessor.HttpContext?.Request?.Headers.Authorization.ToString() is null-conditional the whole way, so a background processor or an outbox dispatch with no ambient request simply passes through untouched.
    • When there is a value, it normalizes the scheme (:40-43): an inbound "Bearer <token>" has the prefix sliced off case-insensitively, and anything else is treated as the parameter itself. The result is assigned as a structured AuthenticationHeaderValue(BearerScheme, token) (:45) rather than as a raw string, so the outgoing header is always well-formed Bearer <token> with the constant scheme from :19.
  • Why it's built this way: the no-op-without-HttpContext behavior (:13-14) is what makes the handler safe to attach unconditionally: background work uses its own credentials and is not accidentally given the last request's token. Slicing and re-forming the scheme instead of copying the string verbatim means a malformed or scheme-less inbound value still leaves the client with a valid header.
  • Where it's used: wired by the framework's typed-service-client helper, AddTypedServiceClient<TInterface, TImplementation> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:833), which calls AddHttpContextAccessor() and TryAddTransient<JwtForwardingDelegatingHandler>() (:825-826) and then chains .AddHttpMessageHandler<JwtForwardingDelegatingHandler>() onto the typed HttpClient (:829-831) alongside Aspire service discovery and the standard resilience handler (:834).
  • Caveats / not-in-source: no shipped app calls AddTypedServiceClient today. The helper's own doc scopes it to HTTP contracts that do not warrant a gRPC binding (webhook receivers, public REST endpoints, third-party wrappers) and points at MMCA.Common.Grpc.AddTypedGrpcClient as the preferred service-to-service path (DependencyInjection.cs:822-826), which is the path the cross-service calls in ADC and Store actually take. This handler is therefore live framework code on a currently unused registration path.

AppAssociationEndpointExtensions

MMCA.Common.API · MMCA.Common.API.Startup.Endpoints · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:15 · Level 1 · class (static, extension block)

  • What it is: a mapping helper that serves the two well-known app-association documents from an AppAssociationOptions: Android Digital Asset Links at /.well-known/assetlinks.json and the Apple App Site Association at /.well-known/apple-app-site-association.
  • Depends on: AppAssociationOptions (Level 0) for every value; ASP.NET IEndpointRouteBuilder and Results.Json.
  • Concept (anonymous, machine-verified association documents). Both endpoints are anonymous by design because the OS and Apple's CDN fetch them without credentials, which the doc comment states (AppAssociationEndpointExtensions.cs:12-13). [Rubric §9, API & Contract Design]: the exact JSON shape is a contract a third party parses, so the code builds it structurally out of dictionaries rather than formatting strings by hand.
  • Walkthrough
    • Two path constants: AssetLinksPath (AppAssociationEndpointExtensions.cs:18) and AppleAppSiteAssociationPath (:24). The comment on the Apple constant (:20-23) records that the path deliberately has no file extension because Apple requires that exact path, while the content type must still be JSON.
    • MapAppAssociationEndpoints(AppAssociationOptions options) (:35) null-guards the options (:37), builds both documents once at map time because they are static for the process lifetime (:39-40), then maps two GETs that each return Results.Json(...), are .AllowAnonymous() and are .ExcludeFromDescription() so they never leak into the OpenAPI document (:42-48).
    • BuildAssetLinks (:54) emits the delegate_permission/common.handle_all_urls relation with the Android package name and the fingerprint list (:56-65).
    • BuildAppleAppSiteAssociation (:68) emits the applinks details block, projecting each configured URL pattern into a { "/": pattern } component (:78-80), plus the webcredentials apps list naming the same app id (:84-87).
  • Why it's built this way: building the payload once at map time avoids a per-request allocation for a document that never changes [Rubric §12, Performance & Scalability], and holding the RFC 8615 well-known paths as public constants keeps them from drifting between hosts or between the endpoint and any gateway forwarding rule.
  • Where it's used: the ADC Blazor web host maps them once at startup (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:181), which is the host that ships a companion MAUI Hybrid app. Both documents' exact shapes are asserted by AppAssociationEndpointTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/AppAssociationEndpointTests.cs).

JwksEndpointExtensions

MMCA.Common.API · MMCA.Common.API.Startup.Endpoints · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/JwksEndpointExtensions.cs:15 · Level 1 · class (static, extension block)

  • What it is: maps /.well-known/jwks.json, serializing the active JsonWebKeySet of the Identity service so other services can validate its RS256 tokens.
  • Depends on: IJwksProvider, resolved from DI per request, whose implementation is RsaJwksProvider; plus Microsoft.IdentityModel.Tokens.JsonWebKeySet and System.Text.Json.
  • Concept (the public-key distribution endpoint of cross-service auth). [Rubric §11, Security] and [Rubric §7, Microservices Readiness]: with RS256 only the Identity service holds the private key, and every other service fetches the public keys from here, so no shared secret ever crosses a service boundary (ADR-004). The endpoint is .AllowAnonymous() (JwksEndpointExtensions.cs:39) because clients fetch it before they have a token, which is what JWKS means (RFC 7517; the doc comment says so at :27-28).
  • Walkthrough: the DefaultJwksPath constant (JwksEndpointExtensions.cs:20) pins the RFC 8615 path. MapJwksEndpoint() (:31) maps a single GET whose handler takes HttpContext and IJwksProvider as parameters (:33), calls GetJsonWebKeySet() (:35), serializes with JsonSerializer (:36), sets application/json; charset=utf-8 explicitly (:37), and writes the body (:38). The whole endpoint is under ten lines because the key material and its rotation live behind the provider.
  • Why it's built this way: non-Identity hosts still map it (the JwksEndpoint step is unconditional in the default pipeline, MiddlewarePipelineBuilder.cs:141-148); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for /.well-known/* covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (WebApplicationBuilderExtensions.cs:65).
  • Where it's used: applied as the JwksEndpoint step of the default pipeline seeded by MiddlewarePipelineBuilder (MiddlewarePipelineBuilder.cs:148), so every host that adopts the shared pipeline serves it; the path it owns is the jwks_uri value that OidcDiscoveryEndpointExtensions advertises, which is in turn what AddForwardedJwtBearer (on WebApplicationBuilderExtensions) reaches through OIDC discovery. Its behavior is covered by JwksEndpointTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/JwksEndpointTests.cs).

BaseLookup<TIdentifierType>

MMCA.Common.Shared · MMCA.Common.Shared.DTOs · MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/BaseLookup.cs:8 · Level 1 · record class

  • What it is: a minimal DTO for dropdown and autocomplete lookups: just Id and Name.
  • Depends on: IBaseDTO<TIdentifierType> (Level 0), which it implements (BaseLookup.cs:8).
  • Concept (right-sized response shapes). [Rubric §9, API & Contract Design] assesses whether responses are shaped to their consumer rather than dumping full entities. Instead of returning a full entity DTO to populate a <select> element, the system returns BaseLookup<T>, carrying only the id and the display name; this cuts wire size and avoids coupling the UI to full entity shapes it does not need. Both Id (BaseLookup.cs:12) and Name (BaseLookup.cs:15) are required, so hand-written construction is compile-checked, and record equality gives value semantics for free.
  • Walkthrough: the type itself is four lines, but its interesting half lives in the repository that projects into it. EFReadRepository<TEntity, TIdentifierType>.GetAllForLookupAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:217) takes the name of the display property and lets the database do the shaping. The selector is built once per (entity type, property name) pair into a cache (:247-248), binding Id and the named property into a BaseLookup<TIdentifierType> via Expression.MemberInit (:262-268). Note the consequence for required: an expression-tree MemberInit constructs the record without the compiler's required-member check, which is legal because required is a compile-time contract, not a runtime one. Above the repository the shape travels as a CollectionResult<T> through EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.GetAllForLookupAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:374-389).
  • Why it's built this way: one shared lookup shape means the UI's generic select components bind to a single type regardless of which entity fills them, and projecting inside the SQL query (rather than materializing entities and mapping) keeps a lookup list cheap [Rubric §12, Performance & Scalability]; caching the compiled expression per property name keeps the reflection cost to the first call.
  • Where it's used: returned by the lookup path at every layer: the read repository (EFReadRepository.cs:217), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, the controller base (EntityControllerBase.cs:374), and the UI's IEntityService<TEntityDTO, TIdentifierType>.

InsecureJwtMetadataWarningStartupFilter

MMCA.Common.API · MMCA.Common.API.Startup.Auth · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/InsecureJwtMetadataWarningStartupFilter.cs:15 · Level 2 · class (internal, sealed, partial)

  • What it is: a one-purpose IStartupFilter that writes a single warning at host startup when AddForwardedJwtBearer resolved RequireHttpsMetadata to false outside Development. It changes no behavior; it only makes a deliberate weakening visible in the logs.
  • Depends on: Microsoft.AspNetCore.Hosting.IStartupFilter, ILogger<T>, the source-generated [LoggerMessage] attribute, and WebApplicationBuilderExtensions.RequireHttpsMetadataConfigKey for the key name it echoes.
  • Concept introduced (IStartupFilter as a "log after the logging providers exist" hook). Registration-time code runs while the IServiceCollection is still being built, so the logging providers are not configured yet and anything written there is dropped. IStartupFilter.Configure runs later, once the provider is built and the application pipeline is being assembled, which is the first moment a warning is guaranteed to reach a sink. The doc comment states exactly that reasoning (InsecureJwtMetadataWarningStartupFilter.cs:7-13). [Rubric §11, Security] assesses whether a security-relevant deviation is deliberate, narrow, and visible; the code permits the deviation (an internal-ingress cleartext authority is a real deployment) but refuses to let it be silent. [Rubric §13, Observability & Operability] assesses whether an operator can see the posture a deployment is actually running: this warning is the only place the resolved value surfaces at runtime.
  • Walkthrough: two members.
    • Configure(Action<IApplicationBuilder> next) (:19) logs once (:21) and returns next unchanged (:23). It inserts nothing into the request pipeline, so the filter costs nothing per request; the whole type is a startup-time side effect wearing a pipeline interface.
    • LogInsecureJwtMetadata (:29) is a [LoggerMessage] source-generated partial at LogLevel.Warning (:26-28). The message names the config key that produced the value and tells the operator what makes it safe (an internal-ingress cleartext h2c authority) and what to do (record that justification beside the setting in the deployment template).
  • Why it's built this way: the filter is registered through TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, ...>) (WebApplicationBuilderExtensions.cs:464-465), which de-duplicates on implementation type, so a host that calls AddForwardedJwtBearer more than once still gets exactly one warning. Registration is itself conditional (:461): the filter is only added when the resolved value is false and the environment is not Development, so a developer's normal loop stays quiet. Source-generated logging avoids boxing and string formatting on a path that runs once, which is the framework's convention rather than a hot-path optimization here.
  • Where it's used: registered only from AddForwardedJwtBearer (WebApplicationBuilderExtensions.cs:462-466). Its registration conditions are asserted by ForwardedJwtBearerSecurityTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs). In the deployed apps the ADC service hosts document the override explicitly: Azure sets Authentication:JwtBearer:RequireHttpsMetadata to false because the authority is the internal-ingress h2c URL (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:270-273), which is precisely the case this filter exists to annotate.
  • Caveats / not-in-source: nothing in the framework fails a build or a deployment on this warning. Whether an operator acts on it is a process concern, and there is no ADR for this decision in Website/docs-src/adr/ at the time of writing.

OidcDiscoveryEndpointExtensions

MMCA.Common.API · MMCA.Common.API.Startup.Endpoints · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OidcDiscoveryEndpointExtensions.cs:22 · Level 2 · class (static, extension block)

  • What it is: maps a minimal OpenID Connect discovery document at /.well-known/openid-configuration. It returns just enough for token validation (the issuer and jwks_uri fields, plus three supported-value arrays), so a downstream service that points its JWT authority here can discover the signing keys automatically. When no issuer is configured it returns 404.
  • Depends on: JwksEndpointExtensions.DefaultJwksPath to compose the jwks_uri (OidcDiscoveryEndpointExtensions.cs:76); IConfiguration for Jwt:Issuer; System.Text.Json.
  • Concept (OIDC discovery as the bootstrap for JWKS-based validation). [Rubric §7, Microservices Readiness] and [Rubric §11, Security]: AddForwardedJwtBearer sets an Authority, and the JWT bearer middleware fetches {authority}/.well-known/openid-configuration to learn the issuer and the JWKS URL. This endpoint answers that fetch, which is the other half of ADR-004 alongside JwksEndpointExtensions.
  • Walkthrough
    • DefaultOidcDiscoveryPath constant (OidcDiscoveryEndpointExtensions.cs:27).
    • Three static arrays (token, public, RS256, :32-34) and an OidcJsonOptions with PropertyNamingPolicy = null (:43-46). Disabling the naming policy is load-bearing: the field names are already OIDC snake_case per RFC 8414, and camelCasing jwks_uri to jwksUri would leave OpenIdConnectConfigurationRetriever unable to recognise the document (:36-42). The fields sit under a scoped #pragma warning disable IDE0052 (:31, restored :47) because the analyzer does not look into C# extension blocks for field usage, and the comment records that (:29-30).
    • MapOidcDiscoveryEndpoint() (:58) maps a GET that is .AllowAnonymous() (:86) and reads Jwt:Issuer (:62); a blank issuer returns Results.NotFound() (:63-66), safe because no downstream points its authority at a non-Identity host. Otherwise it derives jwks_uri from the configured issuer rather than the inbound request (:76) and returns the issuer, that URI, and the three supported-value arrays (:78-85).
  • Why it's built this way: the comment at :68-75 documents the subtle reason jwks_uri is built from the configured issuer and not from the request. Aspire/DCP fronts the Identity service on per-launchSettings ports and rewrites Host via X-Forwarded-Host to canonical ports that internal callers cannot always reach, so reusing the issuer keeps issuer and jwks_uri origin-aligned (a common OIDC client requirement) and routes both through the same gateway that fronts /Auth, which means one forwarder rule for /.well-known/* covers everything.
  • Where it's used: applied unconditionally as the OidcDiscoveryEndpoint step of the default pipeline (MiddlewarePipelineBuilder.cs:150-152); consumed by the bearer middleware that AddForwardedJwtBearer configures on WebApplicationBuilderExtensions, which deliberately leaves ValidIssuer unset so the issuer comes from this document (WebApplicationBuilderExtensions.cs:489-494). Covered by OidcDiscoveryEndpointTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/OidcDiscoveryEndpointTests.cs).
  • Caveats / not-in-source: the pre-forwarded scheme and host captured into HttpContext.Items by the PreForwardedCapture step exist for exactly this endpoint's benefit (WebApplicationExtensions.cs:18-35), but the current handler composes jwks_uri from the configured issuer only, so those items are not read on this path today.

MiddlewarePipelineBuilder

MMCA.Common.API · MMCA.Common.API.Startup.Pipeline · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:16 · Level 10 · class (sealed)

  • What it is: the mutable, ordered list of MiddlewarePipelineStep values behind UseCommonMiddlewarePipeline. CreateDefault() seeds the framework's eighteen-step edge pipeline; a host may then insert, replace, or remove steps by name; and Build() re-checks four load-bearing adjacencies before anything is applied.
  • Depends on: MiddlewarePipelineStep and MiddlewarePipelineStepNames; CorrelationIdMiddleware, TenantResolutionMiddleware and SoftDeletedUserMiddleware (the three custom middlewares it wires); WebApplicationBuilderExtensions for the two CORS policy names; JwksEndpointExtensions and OidcDiscoveryEndpointExtensions for the two always-mapped well-known endpoints; WebApplicationExtensions for UseCommonRequestLocalization and the two pre-forwarded HttpContext.Items keys; ASP.NET forwarded-headers primitives.
  • Concept introduced (a customization point that validates itself, not a free-for-all). The tension a shared pipeline has to resolve: one fixed order is safe but blocks any host with a legitimate extra step, while an open Action<WebApplication> hook gives the order back to every host and re-opens exactly the bugs the shared pipeline closed. The resolution here is a scoped escape hatch with startup-enforced invariants: the host may edit the list, but four adjacencies are re-asserted afterwards and a violation throws while the host is starting, naming the invariant it broke and printing the current order. [Rubric §15, Best Practices & Code Quality] assesses whether a change can be made locally without breaking distant behavior; encoding the rationale as an executable check rather than a comment is what makes that true here. [Rubric §13, Observability & Operability] also applies: the failure modes these invariants prevent (an unreachable jwks_uri, a tenant that never resolves, a per-user rate cap that never engages) all look like configuration bugs at runtime, so converting them into a startup exception with a named cause is a large operability win. See ADR-079.
  • Walkthrough
    • One field, _steps (MiddlewarePipelineBuilder.cs:18), and a private constructor (:19), so the only way in is CreateDefault(). StepNames (:24) projects the current names in application order, which is what the fitness function asserts on and what error messages print.
    • CreateDefault() (:31-156) seeds the eighteen steps. Reading it top to bottom is the fastest way to learn the edge: exception handler (:34-36), correlation id (:38-40), request localization (:42-47, with the ADR-027 note that this runs early so edge error localization uses the caller's culture), the pre-forwarded scheme and host capture (:49-62), forwarded headers with KnownProxies and KnownIPNetworks cleared for cloud reverse proxies (:64-80), an HTTPS redirect wrapped in UseWhen that skips application/grpc so h2c gRPC calls are not 307-redirected (:82-92), response compression (:94-96), routing (:98-100), CORS choosing the development or production policy by environment (:102-106), authentication (:108-110), tenant resolution (:112-118), the rate limiter (:120-126), the soft-deleted-user filter (:128-130), authorization (:132-134), output cache (:136-138), the always-mapped JWKS (:140-147) and OIDC discovery (:149-151) endpoints, and finally MapControllers() (:153-155). Each Configure delegate is static, so no closure is allocated per step.
    • Four mutators, all returning this for chaining and all validating first: InsertBefore (:166), InsertAfter (:183), Replace (:203) and Remove (:224). Replace keeps the replaced step's position and permits a different name, but rejects a name another step already carries (:208-214).
    • Build() (:257) runs the four checks and returns a defensive copy (:279). Two adjacency checks: PreForwardedCapture immediately before ForwardedHeaders (:259-262) and Authentication immediately before TenantResolution (:264-267). Two precedence checks: Authentication before RateLimiting (:269-272, ADR-019) and ForwardedHeaders before HttpsRedirection (:274-277). Every check carries its rationale string, which is what the exception message prints.
    • The private helpers hold the guard semantics. RequireIndexOf (:285) rejects a blank name and, for an unknown one, throws listing every known step (:296-299), which turns a typo into a self-answering error. RequireUniqueName (:304) enforces name uniqueness across the list. RequireImmediatelyBefore (:314) and RequirePrecedes (:329) share one subtle rule: an invariant binds only when both of its steps are still present (:320, :335), so a host that removes a whole capability (both members of a pair) stays legal, while a host that removes only one half is not constrained by a rule that no longer has anything to say.
  • Why it's built this way: the "both present or the rule is silent" clause is the design decision worth internalizing. Without it, Remove(MiddlewarePipelineStepNames.TenantResolution) on a single-tenant host would fail the authentication adjacency check for no reason, and the escape hatch would be unusable. With it, the invariants constrain reordering rather than composition, which is what they were written to protect. Constructing the defaults as data rather than as calls also means the whole order can be asserted with no WebApplication built at all, which is what puts the fitness function in the fast unit tier.
  • Where it's used: only through WebApplicationExtensions.ApplyPipeline (WebApplicationExtensions.cs:140-151), which both UseCommonMiddlewarePipeline overloads route through, so the zero-argument path is exactly the validated default pipeline. MiddlewarePipelineOrderTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:29) subclasses into each app's architecture tier (ADC, Store, Helpdesk, and Common's own testing tier) and freezes the eighteen-name order; MiddlewarePipelineBuilderTests covers the mutators and the invariants directly.
  • Caveats / not-in-source: the builder validates order, not semantics. A Replace that keeps a step's name but swaps in an unrelated middleware passes every check, because nothing inspects the Configure delegate.

SupportedCultures

MMCA.Common.Shared · MMCA.Common.Shared.Globalization · MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:9 · Level 0 · class (static)

  • What it is: the framework-wide allowlist of supported UI cultures (ADR-027). A static class holding the default culture, the full supported set, the Development-only pseudo-localization locale, a closest-match resolver, and two membership tests.
  • Depends on: nothing first-party. Uses only BCL types (IReadOnlyList<string>, StringComparison).
  • Concept introduced (internationalization allowlist as one source of truth). [Rubric §27, i18n] assesses whether locale support is centralized, discoverable, and drift-resistant rather than scattered string checks. Every consumer that decides "is this a language we support" reads this one list: the host request-localization setup, the culture switcher, the MAUI head's culture resolution, and the domain guard on a user's preferred culture. Adding a locale means adding a .<culture>.resx sibling set plus one entry here, with no other infrastructure change (SupportedCultures.cs:3-8).
  • Walkthrough
    • Default = "en-US" (SupportedCultures.cs:12) is the fallback used when no cookie, profile, or Accept-Language preference resolves.
    • All (SupportedCultures.cs:18) is the supported set, default first, and today it is exactly [Default, "es"]: English and Spanish. Both the request-localization options and the culture switcher iterate it.
    • PseudoLocale = "qps-Ploc" (SupportedCultures.cs:28) is the Windows-standard pseudo-localization locale, deliberately not part of All so the translation-completeness fitness gate does not demand a .qps-Ploc.resx sibling (:21-23). It is wired into request localization, the culture-switch endpoint, and the culture switcher in Development only, where it runtime-transforms every resolved resource string (accents, padding, bracket sentinel) to surface hard-coded strings, truncation, and string concatenation without translating anything (:23-26).
    • IsSupported(string?) (SupportedCultures.cs:35-37) returns true for a non-empty culture matched case-insensitively against All; IsPseudoLocale(string?) (SupportedCultures.cs:76-77) tests case-insensitively against PseudoLocale. Both take a nullable string so callers can pass an unvalidated cookie, query value, or profile field straight in.
    • ResolveClosest(string?) (SupportedCultures.cs:51) is the fallback ladder: blank returns Default (:53-56), an exact case-insensitive All match wins (:58-62), otherwise the language subtag is matched, so "es-MX" resolves to "es" (:64-66), and anything left over falls to Default (:68). The subtag split is done by the private LanguageOf (:83-87), which returns the input unchanged when there is no -, avoiding an allocation for an already-neutral culture. Because PseudoLocale is not in All, this method can never return it (stated at :42).
  • Why it's built this way: a single const plus IReadOnlyList allowlist keeps the localization middleware, the switcher UI, the fitness gate, and the domain guard from drifting apart; separating PseudoLocale from All lets a diagnostic locale ship in Development without polluting the production culture set or the resx-completeness gate. ResolveClosest exists because web heads get language-level fallback for free from request localization's Accept-Language matching, while a head with no request pipeline (the MAUI Blazor Hybrid, which resolves against the device locale) has to do it itself, and the doc comment (SupportedCultures.cs:43-48) says explicitly that the point is to keep the two paths from diverging.
  • Where it's used: WebApplicationExtensions.UseCommonRequestLocalization builds the supported list from All (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:75), appends PseudoLocale only in Development (:80), and sets Default as the default culture (:85); the culture-switch endpoint validates the incoming value with IsSupported or, when pseudo is allowed, IsPseudoLocale (:107). MmcaCultureBootstrap falls back to Default when the stored culture is not supported (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:30), and PseudoStringLocalizer activates only under IsPseudoLocale (MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizer.cs:17). The MAUI head is the ResolveClosest caller: MauiCultureStore resolves the device culture when nothing is stored (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Globalization/MauiCultureStore.cs:41-43), and MauiCultureApplier refuses an unsupported culture outright (.../MauiCultureApplier.cs:32). The domain guard is CommonInvariants.EnsurePreferredCultureIsValid, which allows null or an IsSupported culture (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:144).

MiniProfilerExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9 · Level 1 · class (static, extension block)

  • What it is: a conditional MiniProfiler registration helper. When ApplicationSettings.UseMiniProfiler is true it registers MiniProfiler plus its Entity Framework integration; otherwise it does nothing.
  • Depends on: ApplicationSettings and StackExchange.Profiling (NuGet).
  • Concept (opt-in, settings-gated profiling). [Rubric §13, Observability & Operability] assesses whether diagnostics exist and whether they cost anything when switched off. One configuration flag turns a cross-cutting profiler on or off with no application code involved, and when off the MiniProfiler services are never registered at all, so there is no middleware and no per-request work.
  • Walkthrough: one member, AddMiniProfilerIfEnabled(ApplicationSettings) (MiniProfilerExtensions.cs:16). It tests applicationSettings.UseMiniProfiler (:18) and only then calls AddMiniProfiler(...) with a /profiler route base, PopupShowTimeWithChildren, the dark color scheme (:20-25), and .AddEntityFramework() so EF and SQL timings appear inline. It returns services either way (:28) so the call chains.
  • Why it's built this way: gating on a settings flag rather than #if DEBUG lets one specific environment (a staging slot, say) enable profiling without a rebuild, while production leaves it off and pays nothing.
  • Where it's used: no host in this workspace calls it today. AddAPI(...) in MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs does not invoke it, and no ADC, Store, or Helpdesk Program.cs does either; the helper is available for a host that opts in.
  • Caveats / not-in-source: the helper registers services only. Nothing in this file maps the profiler's own middleware, so a host opting in must also call UseMiniProfiler() itself.

CorrelationContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CorrelationContext.cs:9 · Level 1 · class (sealed)

  • What it is: the scoped service that holds the correlation ID for the current request, defaulting to a fresh GUID when no middleware sets one.
  • Depends on: ICorrelationContext (Level 0, MMCA.Common.Application.Interfaces), the abstraction it implements (CorrelationContext.cs:1, :9). Uses BCL Guid only.
  • Concept (where the correlation value actually lives). The pattern itself is introduced at ICorrelationContext; this is its one shipped implementation. [Rubric §13, Observability & Operability]: the eager default is what makes correlation always-on, so a log line emitted from a path with no HTTP request still carries an ID. [Rubric §3, Clean Architecture]: the concrete holder sits in Infrastructure while every consumer depends on the Application interface.
  • Walkthrough: CorrelationId (CorrelationContext.cs:12) is { get; private set; }, initialized eagerly to Guid.NewGuid().ToString("N") so a value always exists even if no middleware runs (a background processor, a test path, a gRPC call). SetCorrelationId(string) (CorrelationContext.cs:15-19) overwrites it, guarding the input with ArgumentException.ThrowIfNullOrWhiteSpace (:17) so a blank header can never wipe the ID. The private setter means the only write path is that one guarded method.
  • Why it's built this way: a scoped holder with an eager default keeps correlation cheap and unconditional: every code path has an ID without a null check, and inbound requests still adopt the caller's ID for cross-service tracing. The "N" GUID format (32 hex digits, no hyphens) keeps the value compact in log lines and headers. Registration is TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:547): scoped, so one instance lives per request, and TryAdd, so a host that wants its own implementation can register it first and win.
  • Where it's used: CorrelationIdMiddleware resolves it per request and calls SetCorrelationId with the inbound X-Correlation-ID header, falling back to the current Activity trace ID and then to HttpContext.TraceIdentifier (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:32-36), then echoes the value back on the response through OnStarting (:37-41). Downstream, LoggingCommandDecorator<TCommand, TResult> and LoggingQueryDecorator<TQuery, TResult> take it as a constructor dependency and wrap the ID into their log scope for the full pipeline duration.

WebApplicationBuilderExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:33 · Level 2 · class (static, extension block)

  • What it is: the consolidated builder-side registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of WebApplicationExtensions, which owns the runtime pipeline; this one owns what goes into the DI container.
  • Depends on: JwtSettings and its JwtSigningAlgorithm; AddAuthorizationPolicies from MMCA.Common.API.Authorization; ApiParameterDescriptorBackfillProvider; RateLimitingSettings, RateLimitAlgorithm and RedisFixedWindowRateLimiter; InsecureJwtMetadataWarningStartupFilter; ASP.NET rate-limiting, compression, CORS and Asp.Versioning primitives; Microsoft.IdentityModel.Tokens and StackExchange.Redis.
  • Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation). [Rubric §12, Performance & Scalability] (a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer), [Rubric §11, Security] (algorithm pinning, HTTPS metadata resolution, per-IP anonymous auth throttling) and [Rubric §9, API & Contract Design] (versioning, OpenAPI and compression handled identically across hosts rather than per host).
  • Walkthrough: the load-bearing members, in file order.
    • CorsPolicyAllowSpecificOrigins / CorsPolicyAllowAll (WebApplicationBuilderExtensions.cs:36, :38): the two policy names the pipeline chooses between by environment.
    • RateLimitPolicyAuthIp (:47): the named "auth-ip" policy for anonymous authentication attempts. Its comment (:40-46) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled.
    • RequireHttpsMetadataConfigKey (:55, value "Authentication:JwtBearer:RequireHttpsMetadata"): the one configuration key that can override the secure-by-default metadata posture. Its comment (:49-54) narrows the legitimate use to an authority that is genuinely plain HTTP (an internal-ingress h2c service URL) and asks for the justification to be recorded beside the setting.
    • IsRateLimitBypassed(HttpContext) (:61) exempts /health, /alive, /.well-known/* and application/grpc content types (:62-65), all legitimately high-frequency. It is internal rather than private specifically so the exemption logic is unit-testable through InternalsVisibleTo instead of only under a request flood (:59-60).
    • GlobalRateLimitPartition has two overloads. The permit-count one (:71-72) simply wraps its argument in a RateLimitingSettings and delegates; the settings one (:79) holds the logic: a NoLimiter for bypassed infrastructure (:81-84) and for unauthenticated callers (:86-89), otherwise a partition keyed by Identity.Name, then the user-id claim, then the remote IP, then the literal "authenticated" (:91-94), built through CreateLimitedPartition with redisScope: "global", queueLimit: 0 and allowDistributed: true (:96-103).
    • UserPolicyRateLimitPartition (:114) is the same shape for the opt-in "UserPolicy" limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (:116-118), with redisScope: "user" and the configured queue limit (:120-127). It was extracted from the inline lambda it used to be so the key selection is unit-testable (:110-113).
    • CreateLimitedPartition (:148) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. When allowDistributed && settings.Distributed it resolves IConnectionMultiplexer through a nullable local, because HttpContext.RequestServices is declared non-nullable but is genuinely null outside a request pipeline such as a bare DefaultHttpContext in a unit test (:157-163). With a connection it returns a partition whose factory builds a RedisFixedWindowRateLimiter over $"{redisScope}:{key}" (:170-172), falling back to a null logger when none is registered (:167-168). Without a connection it deliberately falls through to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (:175-177). The in-memory branch honours RateLimitAlgorithm: a sliding window with SegmentsPerWindow segments (:180-190) or the default fixed window (:192-198), both over TimeSpan.FromMinutes(1) with QueueProcessingOrder.OldestFirst.
    • AuthIpRateLimitPartition (:212 permit-count overload, :221 settings overload) partitions the "auth-ip" policy on the client IP and returns no limiter at all when the IP is unattributable (:223-226). The remark (:205-211) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-process TestServer and the integration tier to a standstill. It passes allowDistributed: false (:234), so login throttling stays per-instance whatever Distributed says, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (:143-147).
    • AddCommonApiVersioning() (:244): header-based versioning through the api-version reader with AssumeDefaultVersionWhenUnspecified and ReportApiVersions (:249-253), the API explorer group format 'v'VVV and SubstituteApiVersionInUrl (:255-259), then the backfill guard (:261). The comment (:246-248) records that DefaultApiVersion is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See ADR-046.
    • AddCommonRateLimiting has three overloads. The permit-count one (:296) keeps the defaults permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30 and simply builds a RateLimitingSettings from them (:296-304). The IConfiguration one (:314) binds the RateLimiting section, falling back to a default instance when the section is absent (:318-319). The settings one (:332) does the work: rejection status 429 (:338), the always-on GlobalLimiter (:340-341), the opt-in "FixedPolicy" (:346-353, allowDistributed: false), "UserPolicy" (:355), and auth-ip (:365-367). Two comments carry the reasoning: "FixedPolicy" keeps its name whichever algorithm is configured, because it is referenced by name from [EnableRateLimiting] attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (:343-345); and the auth-ip policy takes the client IP from Connection.RemoteIpAddress, which the shared pipeline has already resolved from X-Forwarded-For because UseForwardedHeaders runs before UseRateLimiter (:357-364). The long doc comment on the permit-count overload (:266-295) explains why anonymous traffic is deliberately unlimited and why authIpPermitLimit is 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See ADR-019.
    • AddCommonResponseCompression() (:374): Brotli plus Gzip, enabled for HTTPS, both at CompressionLevel.Fastest (:376-388); the comment (:384-386) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads.
    • AddCommonOpenApi() (:403): services.AddApiVersioning().AddOpenApi() (:405) plus the backfill guard (:406). The comment (:393-402) notes the parameterless AddApiVersioning() only returns the builder, so options configured by AddCommonApiVersioning accumulate independently of call order. Pair it with MapCommonOpenApi() on OpenApiEndpointExtensions.
    • AddApiParameterDescriptorBackfill() (:418, private) registers ApiParameterDescriptorBackfillProvider via TryAddEnumerable (:419-420), which de-duplicates on implementation type, so calling both AddCommonApiVersioning and AddCommonOpenApi installs the guard exactly once.
    • AddForwardedJwtBearer(authority, audience, configuration, environment, requireHttpsMetadata = null) (:445) is the extracted-service mode. It validates all four required arguments (:452-455), then resolves the metadata posture in three steps: the explicit argument when it is not null, then RequireHttpsMetadataConfigKey, then true everywhere except Development (:457-459). When the resolved value is false outside Development it registers InsecureJwtMetadataWarningStartupFilter so the deviation is logged once at startup (:461-465), and finally delegates to the private AddForwardedJwtBearerCore (:467). Its authority argument is normally the result of JwtAuthorityExtensions.GetRequiredJwtAuthority().
    • AddForwardedJwtBearerCore (:470, private) does the JWT wiring: Authority, Audience and RequireHttpsMetadata (:478-480), validation parameters that deliberately leave ValidIssuer unset so the middleware takes the issuer from the discovery document (:488-493), and ValidAlgorithms = [RsaSha256] as defense against an algorithm-confusion swap (:495-501). It also installs the SignalR access_token query-string fallback for /hubs (:505-518) and then calls AddAuthorizationPolicies() (:521).
    • AddCommonAuthentication(IConfiguration) (:538) is the in-process mode: it binds JwtSettings with data-annotation validation on start (:540-543), builds validation parameters through BuildValidationParameters (:551), wires the same /hubs access-token fallback (:556-569), and adds the authorization policies (:572).
    • AddCommonCors(IConfiguration) (:581): the restrictive production policy takes its origins from Cors:AllowedOrigins and allowlists four headers and five methods with AllowCredentials (:585-592); the development any-origin policy sits under a justified #pragma warning disable S5122 explaining it is only ever selected when the environment is Development (:593-598). See ADR-082.
    • GetValidatedSigningKey(string) (:609, internal static, outside the extension block) decodes the Base64 HMAC key and throws when it is under 256 bits (:611-616), so a too-short secret fails at startup rather than weakening every token.
    • BuildValidationParameters(JwtSettings) (:628, also internal static) branches on JwtSigningAlgorithm: RS256 requires RsaPublicKeyPem and throws a message that points at AddForwardedJwtBearer when it is missing (:632-636), imports the PEM into an RSA held for the app lifetime (:638-641, with a justified CA2000 suppression) and pins RS256 (:651); the default HS256 path builds a SymmetricSecurityKey from the validated secret and pins HmacSha256 (:655-668).
  • Why it's built this way: two authentication entry points are the framework's monolith-to-microservice hinge (ADR-004): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit ValidAlgorithms pin on both paths is deliberate defense in depth rather than trust in the token header. Taking IConfiguration and IHostEnvironment as required arguments on AddForwardedJwtBearer is what makes "HTTPS metadata unless you say otherwise" the default a host cannot forget: the insecure value stays reachable for the deployments that need it, but only through a named key and never silently. On the limiter side, factoring every partition through CreateLimitedPartition is what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (:324-328).
  • Where it's used: every ADC and Store service host calls the builder-side quartet in one block (AddCommonCors, AddCommonApiVersioning, AddCommonRateLimiting, AddCommonResponseCompression). Identity hosts take the in-process mode while the other services take the forwarded mode, passing builder.Configuration and builder.Environment so the framework resolves the metadata posture (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:274-278, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:177-181). The "auth-ip" policy is applied by attribute on the login and register actions of AuthControllerBase (AuthControllerBase.cs:72 and :94), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly by WebApplicationBuilderExtensionsTests, RateLimitPartitionTests and RateLimitAlgorithmSelectionTests; the metadata resolution by ForwardedJwtBearerSecurityTests.
  • Caveats / not-in-source: the five permit-limit defaults are framework defaults only. What a given deployment actually enforces is whatever the host passes or configures under RateLimiting, and that configuration value is not determinable from this file. Likewise Distributed only takes effect when the host also registers an IConnectionMultiplexer; whether it does is host composition, not this file.

ModuleHostContext

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:21 · Level 3 · class (sealed)

  • What it is: the return value of AddModuleHost (see ModuleHostExtensions). It carries the two settings objects a module-hosting service binds once at startup, the ModuleLoader that discovers its modules, and a single method the host registers as a step of its application pipeline to actually run discovery.
  • Depends on: ModuleLoader, ApplicationSettings and ModulesSettings; IConfigurationManager, IServiceCollection and System.Reflection.Assembly from the BCL. It is constructed only by ModuleHostExtensions.
  • Concept introduced (deferring a registration step so the host keeps control of its position). The obvious design would have AddModuleHost bind the settings and run module discovery in one call. It deliberately does not, and the doc comment (ModuleHostContext.cs:11-19) states why: discovery has to happen inside the host's ADR-014 application pipeline, between AddApplication() and AddApplicationDecorators(), so every module's handlers land in the container before the decorator pipeline is closed and sealed. Where discovery sits relative to a host's other pipeline steps (gRPC client replacements, broker messaging) is a per-host decision. So the discovery call is handed back to the host as a void-returning method it can register as a delegate, and the helper stays a settings-and-loader factory. [Rubric §1, SOLID] assesses whether responsibilities are separated: binding and constructing (this call) versus registering handlers at a host-chosen point (a later call) really are two decisions with different owners. [Rubric §15, Best Practices & Code Quality] also applies: pre-capturing the four discovery arguments in the object means a host writes pipeline.Register(moduleHost.RegisterModules) and cannot get the argument list wrong.
  • Walkthrough: three captured fields, three read-only properties, one method.
    • _configuration, _environmentName and _moduleAssemblies (ModuleHostContext.cs:23-25) are the discovery arguments captured at AddModuleHost time, so the deferred call needs no state from the caller.
    • The internal constructor (:27-41) means only ModuleHostExtensions in the same assembly can build one; a host receives an instance, it never constructs one.
    • ApplicationSettings (:44) and ModulesSettings (:47) expose the bound sections, so the host can pass them onward without re-reading configuration (ADC passes moduleHost.ModulesSettings straight into AddAPI, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313).
    • ModuleLoader (:54) exposes the loader that AddModuleHost registered as a singleton. Its doc comment names the two later calls that need it: AddModuleHealthChecks after the pipeline has run discovery, and InitializeDatabaseAsync (:49-53).
    • RegisterModules(IServiceCollection services) (:66) null-guards the collection (:68) then calls ModuleLoader.DiscoverAndRegister with the captured configuration, settings, environment name and assemblies (:70-76). That signature match is the whole point of the captured fields.
  • Why it's built this way: the ordering constraint is real, not stylistic. Module handlers registered after AddApplicationDecorators() would run undecorated (no logging, no caching, no transaction), and the ADR-014 pipeline seals itself, so a late registration throws instead. Exposing RegisterModules as a method group rather than running it eagerly is what lets each host place discovery exactly where its own pipeline needs it while still getting the settings-bind collapsed into one call. See ADR-014.
  • Where it's used: every ADC and Store service host holds one as moduleHost and touches it three times: moduleHost.ModulesSettings into AddAPI, pipeline.Register(moduleHost.RegisterModules) inside AddMmcaApplicationPipeline, and moduleHost.ModuleLoader into AddModuleHealthChecks (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313, :348, :364; the Identity, Engagement and Notification services and MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:131 follow the same shape). ModuleHostExtensionsTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ModuleHostExtensionsTests.cs) covers the properties and the null guard.
  • Caveats / not-in-source: nothing in this type enforces that RegisterModules is ever called, or called in the right place. A host that builds a ModuleHostContext and never registers the step gets a container with no module handlers in it, and the failure surfaces later as an unresolved handler rather than here. The test file records that there is deliberately no test driving RegisterModules through to a real discovery pass (ModuleHostExtensionsTests.cs:134).

SignalRExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12 · Level 3 · class (static, extension block)

  • What it is: a one-method helper that maps the NotificationHub SignalR endpoint at the path configured in PushNotificationSettings, and no-ops when push notifications are disabled or their settings were never registered.
  • Depends on: NotificationHub (Infrastructure), PushNotificationSettings, and IOptions<T>.
  • Concept (conditional real-time endpoint mapping). [Rubric §6, CQRS & Event-Driven]: the SignalR hub is the real-time delivery arm of the notification pipeline, so mapping it behind a settings gate means a host that does not push notifications simply never opens the endpoint, and the same Program.cs line is safe in every host.
  • Walkthrough: MapNotificationHub() (SignalRExtensions.cs:22) resolves IOptions<PushNotificationSettings> through GetService<T>(), which returns null when nothing registered it, and takes ?.Value (:24). Only when settings is { Enabled: true } does it call MapHub<NotificationHub>(settings.HubPath) (:25-28). The doc comment (:16-21) notes it must run after UseCommonMiddlewarePipeline() so authentication and routing are already in place.
  • Why it's built this way: GetService rather than GetRequiredService, plus the property-pattern guard, is what makes the call unconditionally safe; it matches the same "always call, no-op if not applicable" convention as the JWKS and OIDC mappers. The hub path is also why the JWT bearer options carry an access_token query-string fallback for /hubs on both authentication paths (WebApplicationBuilderExtensions.cs:506-519 and :556-569): a WebSocket cannot send an Authorization header.
  • Where it's used: the ADC Notification service maps it after the shared pipeline (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:262, with the pipeline itself at :244); that host reads the hub path from configuration rather than hard-coding it, which the comment there records (:257-260).

ModuleHostExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:22 · Level 4 · class (static, extension block)

  • What it is: a single extension(WebApplicationBuilder builder) member, AddModuleHost, that collapses the settings-bind plus ModuleLoader construction every module-hosting service used to repeat verbatim in its Program.cs. It returns a ModuleHostContext.
  • Depends on: ModuleHostContext (which it constructs), ModuleLoader, ApplicationSettings, ModulesSettings and IModule (the interface discovery looks for); Microsoft.Extensions.Options validation, IConfiguration, ILogger<T> and System.Reflection.Assembly.
  • Concept (a deliberately narrow composition helper). The interesting design property is what this helper refuses to do. It binds and validates two settings sections, builds the loader, and registers it: it does not run discovery, add the module health checks, or touch the surrounding registration order, because discovery has to sit at a host-chosen position inside the ADR-014 pipeline and the health checks can only enumerate what discovery has already found (ModuleHostExtensions.cs:11-20). [Rubric §3, Clean Architecture] assesses whether composition-root concerns stay in the composition root; hoisting the mechanical part into the framework while leaving the ordering decisions visible in each Program.cs is that line drawn on purpose. [Rubric §15, Best Practices & Code Quality] also applies: ValidateDataAnnotations().ValidateOnStart() on both sections turns a misconfigured section into a startup failure rather than a null that surfaces on the first request.
  • Walkthrough: AddModuleHost(IEnumerable<Assembly> moduleAssemblies, ILogger<ModuleLoader>? moduleLoaderLogger = null) (ModuleHostExtensions.cs:51).
    • Null-guards the builder and the assembly list (:55-56), then takes local aliases for builder.Services and builder.Configuration (:58-59).
    • Registers ApplicationSettings as options bound to its SectionName, with data-annotation validation on start (:61-64), then also reads the section eagerly and throws InvalidOperationException when it is absent (:66-67). The two are not redundant: ValidateOnStart fires when the host starts, while the eager Get<T>() is what lets the returned context hand the bound settings back to the caller immediately, before builder.Build().
    • Does the same for ModulesSettings (:69-72), but falls back to an empty instance rather than throwing when the section is missing (:74-76): a host with no per-module enable/disable configuration is legal, a host with no ApplicationSettings is not.
    • Builds the ModuleLoader, attaching the caller's logger through an object initializer only when one was supplied so the loader keeps its own NullLogger default otherwise (:78-80), and registers it as a singleton (:82).
    • Returns a ModuleHostContext carrying the configuration, the environment name, both settings objects, the loader and the assembly list (:84-90).
  • Why it's built this way: the moduleAssemblies parameter is required rather than defaulted to an AppDomain scan, and the doc comment shows the intended call shape (one marker type per module, :31-35). Naming the assemblies explicitly means discovery looks nowhere else, which is what makes a single-module extracted service provably single-module: the ADC Conference host says exactly that in its comment (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:305-307), and the Store Sales host uses the same property to explain why Catalog and Identity reach it only as Contracts projects (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:125-129). The optional logger exists because module discovery runs before the host's real logging pipeline is available, so a host that has bootstrapped a Serilog logger passes one in (:36-42).
  • Where it's used: every ADC service host (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:309-311, plus Identity :248, Engagement :209 and Notification :187 in their own files) and the Store Sales and Catalog hosts (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:131-133), each passing SerilogHostExtensions.CreateBootstrapLoggerFactory().CreateLogger<ModuleLoader>(). ModuleHostExtensionsTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ModuleHostExtensionsTests.cs:49-143) covers the bind, the missing-section throw, the singleton registration and the optional logger.
  • Caveats / not-in-source: MMCA.Helpdesk does not use this helper. Its single-module host still constructs the ModuleLoader inline and calls DiscoverAndRegister directly (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:97 and :104), which is the longhand this extension collapses.

CurrencyJsonConverter

MMCA.Common.API · MMCA.Common.API.JsonConverters · MMCA.Common/Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:12 · Level 4 · class (sealed)

  • What it is: a System.Text.Json.JsonConverter<Currency> that serializes Currency as its ISO 4217 three-letter code string and deserializes by validating that code through Currency.FromCode.
  • Depends on: Currency (MMCA.Common.Shared.ValueObjects, CurrencyJsonConverter.cs:3). Extends BCL JsonConverter<T> (CurrencyJsonConverter.cs:12).
  • Concept (value objects serialize to their natural string form). [Rubric §9, API & Contract Design] assesses whether the wire contract exposes clean primitives rather than leaking internal object graphs. A domain value object should cross the wire as the compact primitive a client expects ("USD"), not as a nested object with a code property. The converter is also a validation gate at the boundary: malformed input is rejected before model binding completes, so no handler ever sees an invalid currency.
  • Walkthrough
    • Read (CurrencyJsonConverter.cs:15) first rejects any non-string token, throwing JsonException("Currency must be a string.") (:17-18), which is what stops a JSON number or object from being coerced.
    • It then reads the string, coalescing null to empty (:20), runs Currency.FromCode(code) (:21), and throws JsonException($"Invalid currency code: {code}") when the result is a failure (:22-23) before returning result.Value! (:25). Because FromCode returns a Result, the converter is bridging the Result world into the exception-based contract JsonConverter<T> requires; the thrown JsonException surfaces as a 400 Bad Request from the framework's model binding (doc comment, :9-10).
    • Write (CurrencyJsonConverter.cs:29-30) is a one-liner: writer.WriteStringValue(value.Code).
    • The type is sealed, holds no state, and has exactly these two methods ([Rubric §15, Best Practices]: the framework-idiomatic converter pattern).
  • Why it's built this way: routing serialization and deserialization through Currency.FromCode keeps the single validation gate for currency codes in the value object itself, so the API layer neither duplicates the allowlist nor accepts a Currency the domain would reject.
  • Where it's used: registered globally for MVC in DependencyInjection.AddAPI, which chains .AddJsonOptions(...) onto AddControllers and adds an instance to the options converters (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:51-53), so every controller request and response serializes Currency as a string uniformly. That registration is also cited as the precedent for the enumeration converter factory added beside it (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:49).
  • Caveats / not-in-source: there is a second, same-named converter in the Shared layer, CurrencyJsonConverter (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:73), attached to the value object by a [JsonConverter] attribute (Currency.cs:13) so that non-MVC paths (cache, outbox, integration events, typed HttpClient calls) also get string form. The two apply the same input rules and differ only in the exception text (Currency.cs:85 quotes the code) and in returning a nullable Currency? (Currency.cs:76). Which of the two wins for a given payload is a System.Text.Json converter-precedence question (an options-registered converter versus a type-level attribute) and is not determinable from this source alone; since their input rules match, the answer does not change what is accepted.

IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Mapping · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:14 · Level 4 · interface

  • What it is: the contract for mapping a domain entity to its DTO. One required method, MapToDTO(entity), plus a default MapToDTOs(collection) that fans the single map over a collection.
  • Depends on: AuditableBaseEntity<TIdentifierType> and IBaseDTO<TIdentifierType> as generic constraints (IEntityDTOMapper.cs:15-17), and the file's usings pull in the Domain entities and the Shared DTO namespace (:1-3).
  • Concept introduced (manual DTO mapping as a first-class contract). [Rubric §15, Best Practices & Code Quality] assesses whether a change is caught where it happens: ADR-001 chose hand-written mappers over a convention-based mapping library precisely so that adding a DTO property that nobody maps is a compile error, not a silently null field at runtime, and so that "where is this field filled in" is a single go-to-definition. [Rubric §1, SOLID] shows up twice: the interface has one job (ISP), and every generic read path depends on this abstraction rather than on any concrete mapper (DIP). [Rubric §2, Design Patterns]: the C# interface default method at :27-32 is the code-sharing mechanism, so a concrete mapper inherits collection mapping for free and overrides it only when batch mapping needs something smarter.
  • Walkthrough
    • Three generic parameters with matched constraints (:15-17): TEntity : AuditableBaseEntity<TIdentifierType>, TEntityDTO : IBaseDTO<TIdentifierType>, TIdentifierType : notnull. The shared TIdentifierType is what makes a mismatched entity/DTO pair fail to compile.
    • TEntityDTO MapToDTO(TEntity entity) (:22) is the single member an implementer must write; it is synchronous, because mapping is a pure in-memory shape change.
    • IReadOnlyCollection<TEntityDTO> MapToDTOs(IReadOnlyCollection<TEntity> entityCollection) (:27) is the default implementation: guard (:29), then [.. entityCollection.Select(MapToDTO)] (:31), a collection expression that materializes once into a read-only collection.
  • Why it's built this way: see ADR-001. The default method is what keeps the hand-written cost to one method per entity: without it, every mapper would repeat the same Select. Registration is by convention rather than by hand: the module registration scans each module assembly for implementations and registers them AsSelfWithInterfaces with a scoped lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:202-205), so a new mapper class is wired by existing.
  • Where it's used: it is a constructor dependency of the whole generic write and read stack: EntityQueryService<TEntity, TEntityDTO, TIdentifierType> takes one and re-exposes it as its DTOMapper property (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:36, :90, declared on the interface at Interfaces/IEntityQueryService.cs:25), and it is injected into CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> (UseCases/CreateEntityHandlerBase.cs:44), UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> (UseCases/UpdateEntityHandler.cs:51), and MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> (UseCases/MutateEntityHandlerBase.cs:344). Implementations are one per entity in every app (for example MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:14), plus the framework's own PushNotificationDTOMapper (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOMapper.cs:13).
  • Caveats / not-in-source: mapping is not always the read path. An entity may also have an opt-in IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, scanned beside the mappers (DependencyInjection.cs:207-213), which pushes the same shaping into SQL for list reads; an entity with no projector keeps materialize-then-map through this interface.

IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>

MMCA.Common.Application · MMCA.Common.Application.Interfaces.Mapping · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:42 · Level 4 · interface

  • What it is: the create-side mapper. It turns an incoming create request into a new domain entity by calling that entity's factory method, and returns a Result so a refused invariant travels back as a failure rather than an exception.
  • Depends on: AuditableBaseEntity<TIdentifierType>, the ICreateRequest marker, and Result (IEntityDTOMapper.cs:43-45, :54). It shares a file with IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, because the file covers both mapping directions.
  • Concept (the entity factory is the only construction path). The manual-mapping rationale is introduced at IEntityDTOMapper; what this interface adds is a boundary rule. [Rubric §4, DDD] assesses whether aggregates are constructed through guarded factories that enforce invariants: the mapper does not new an entity, it calls Entity.Create(...) and hands the resulting Result<TEntity> straight back, so no code path can produce an invalid aggregate. [Rubric §1, SOLID]: separating request-to-entity creation from entity-to-DTO reading keeps each mapper single-purpose. The signature is Task<Result<TEntity>> (:54) even though most implementations are synchronous, because creation may need a database round trip first (a uniqueness check, for example, as the doc says at :37).
  • Walkthrough: three constrained generic parameters (:43-45), with TCreateRequest : ICreateRequest as the type-system guard that stops an update request being passed to the create path. The single member is Task<Result<TEntity>> CreateEntityAsync(TCreateRequest request, CancellationToken cancellationToken = default) (:54). A representative implementation is one method: guard the request, then Task.FromResult(Sponsor.Create(...)) with the request's fields spread into the factory (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:15-31).
  • Why it's built this way: ADR-001 again. Because the mapper is a named contract rather than inline handler code, the framework can ship one generic create handler for every aggregate: CreateEntityHandlerBase injects it (UseCases/CreateEntityHandlerBase.cs:43) and awaits requestMapper.CreateEntityAsync(request, cancellationToken), short-circuiting on failure (:89-90), while the module supplies only the per-entity mapping. Registration is the same Scrutor scan as the DTO mappers, AsSelfWithInterfaces and scoped (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:217-220).
  • Where it's used: implemented once per creatable aggregate across the apps (for example SponsorCreateRequestMapper.cs:11, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestMapper.cs:12), and consumed by the generic create handlers (UseCases/CreateEntityHandlerBase.cs:43, UseCases/CreateEntityHandler.cs:30).
  • Caveats / not-in-source: this interface has a write-side twin declared in the same file, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> (IEntityDTOMapper.cs:79-91), which applies a request onto an already-loaded aggregate and returns a bare Result because the instance handed in is the tracked one (:70-74). Do not reach for the create mapper on the update path.

SupportsIfMatchAttribute

MMCA.Common.API · MMCA.Common.API.Concurrency · MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:49 · Level 4 · class (sealed)

  • What it is: an attribute that is also an action filter. It makes a write action conditional: the optimistic-concurrency token comes from the HTTP If-Match header, the header is mandatory, and a conflict outcome is answered with 412 Precondition Failed rather than 409 Conflict.
  • Depends on: ConcurrencyETag (the header name, the wildcard, and the tag decoder), Error, ErrorHttpMapping and IErrorLocalizer for the problem body, plus Microsoft.AspNetCore.Mvc.Filters.IAsyncActionFilter, ProblemDetailsFactory, and Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException (SupportsIfMatchAttribute.cs:1-10).
  • Concept introduced (conditional writes, and why two conflict statuses are not one). The type doc (:14-47) is the whole design, in four decisions.
    • The token never touches the request model (:21-24, :51-56): the filter decodes the header and puts the bytes in HttpContext.Items under TokenItemKey, and the action reads them from there. A request body therefore never carries a concurrency token, and no request record has to loosen its immutability to receive one.
    • The header is required (:26-32). A guarded mutation without a usable token would be a last-write-wins write, so a request that states no precondition is refused with 428 Precondition Required and the action never runs. A malformed tag is a 400 Bad Request, because the server cannot tell what the caller meant. * counts as no precondition, since it names no particular version.
    • Why 412 and not 409 (:33-41): RFC 9110 reserves 412 for a precondition the client stated in a conditional request header, which is precisely what If-Match is, while 409 stays the answer for a conflict the client did not condition on. [Rubric §9, API & Contract Design] assesses whether status codes carry their standard meaning rather than a local one. The doc also names the consequence worth carrying, because the rewrite keys on the outcome rather than on an error code: an endpoint whose 409 means something else (a duplicate key, say) reports that conflict as 412, with the original problem details, error codes included, intact.
    • The attribute IS the filter (:42-46): unlike IdempotentAttribute this needs no scoped service, so it implements IAsyncActionFilter directly and requires no DI registration by the host.
  • Walkthrough
    • [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] (:48); TokenItemKey (:57) is the HttpContext.Items slot. RequiredToken(HttpContext) (:68-76) is the action-side accessor: it returns the decoded byte[], or throws InvalidOperationException naming the attribute when the slot is empty. That is deliberate (:64-67): reaching the action with no token means the attribute is missing, which is a wiring mistake rather than a client error.
    • OnActionExecutionAsync (:79) guards its arguments (:81-82), calls TryApplyIfMatch (:84), and returns without running the action when it is false (:84-88), because the 428 or the 400 is already on the context. Otherwise it awaits next() (:90) and passes the outcome to the rewrite (:92).
    • TryApplyIfMatch (:104) reads the header (:106-107), then refuses with PreconditionRequiredResult when the value is blank or is the wildcard (:109-114), refuses with MalformedIfMatchResult when ConcurrencyETag.TryParse cannot decode it (:116-120), and otherwise stores the bytes in HttpContext.Items and returns true (:122-123).
    • RewriteConflictToPreconditionFailed (:130) handles two shapes. A DbUpdateConcurrencyException is replaced with the 412 result and marked ExceptionHandled = true (:132-139), which is what stops DbUpdateExceptionHandler from mapping it to a 409 like any other DbUpdateException. An ObjectResult already carrying 409 has both its own StatusCode and its ProblemDetails.Status moved to 412, leaving the body otherwise intact (:143-150); a bare StatusCodeResult 409 is swapped for a 412 (:152-154); anything else falls through untouched (:156-157).
    • The three responses are built by one private helper, Problem (:204-220), which asks the registered ProblemDetailsFactory for the body so the response carries the same diagnostic extensions (traceId) as the rest of the API, falls back to a hand-built ProblemDetails when no factory is registered, and attaches the framework's standard errors extension through ErrorHttpMapping.BuildErrorsExtension with the optional IErrorLocalizer (:216-217). The three call sites differ only in status, title, detail and error: 428 Concurrency.PreconditionRequired (:162-171), 400 Concurrency.MalformedIfMatch (:174-183), and 412 Concurrency.PreconditionFailed (:186-195), each detail line showing a sample tag or telling the caller to re-read and retry.
  • Why it's built this way: making the attribute its own filter keeps adoption to a single line on an action with no host wiring, which matters for a framework whose consumers upgrade in lockstep (ADR-035). Routing the token through HttpContext.Items instead of through the bound model is what removed reflection from the request path entirely: there is no per-type property lookup, and request records stay init-only as the immutability fitness rules require.
  • Where it's used: applied across all three consumer apps on writes whose stale-view risk is real: ADC's EventsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:221, :264, :297), SessionsController (.../SessionsController.cs:319) and SessionQuestionsController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:134, :160, :186); Store's OrdersController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:293, :325, :385, each reading the token with SupportsIfMatchAttribute.RequiredToken(HttpContext) at :306, :338, :401) and InventoryItemsController (.../InventoryItemsController.cs:91, :124); and Helpdesk's TicketsController (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.API/Controllers/TicketsController.cs:113, :141, :209). ADC pins the convention with a ConditionalWriteConventionTests fitness test that asserts the attribute is present on the endpoints that need it (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Conventions/ConditionalWriteConventionTests.cs:29). The read half of the round trip is EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETag (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:471-479), which gives the client the tag to send back.

WebApplicationExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:16 · Level 10 · class (static, extension block)

  • What it is: the extension(WebApplication app) type every downstream host calls to wire its HTTP edge: the two UseCommonMiddlewarePipeline overloads, the request-localization member, and the culture-switch endpoint. It is the runtime-side sibling of WebApplicationBuilderExtensions.
  • Depends on: MiddlewarePipelineBuilder (which owns the step list) and SupportedCultures; ASP.NET localization and cookie primitives.
  • Concept (one canonical, ordered pipeline, applied through one private helper). [Rubric §13, Observability & Operability] and [Rubric §13, Observability & Operability]: middleware order is behavior, not taste. Correlation must be established before anything downstream logs, and authentication must run before the rate limiter so the per-user partition sees a principal at all. Centralizing the order means a host cannot get it wrong (ADR-079). [Rubric §27, i18n] applies through the localization wiring (ADR-027).
  • Walkthrough
    • Two internal constants, PreForwardedSchemeKey (WebApplicationExtensions.cs:24) and PreForwardedHostKey (:33), name the HttpContext.Items slots that the pipeline's PreForwardedCapture step writes before UseForwardedHeaders rewrites scheme and host. The comment on the host key (:24-32) records why: Aspire/DCP injects an X-Forwarded-Host pointing at the canonical launchSettings URL, which internal callers cannot reach.
    • UseCommonMiddlewarePipeline() (:46) is a one-liner: ApplyPipeline(app, configure: null). Its doc comment (:37-45) states the contract in one sentence worth keeping: the order is data, not prose, named by MiddlewarePipelineStepNames and frozen by the MiddlewarePipelineOrderTestsBase fitness function.
    • UseCommonMiddlewarePipeline(Action<MiddlewarePipelineBuilder> configure) (:58) is the scoped escape hatch: it null-guards the delegate (:60) and routes through the same helper (:61). The XML doc declares both failure modes, ArgumentNullException and the InvalidOperationException an invariant violation raises (:56-57).
    • UseCommonRequestLocalization() (:71) builds the supported list from SupportedCultures.All (:73), appends the pseudo-locale in Development only (:78-81), and sets the default plus both supported and supported-UI culture lists (:84-87). It is itself the RequestLocalization step of the default pipeline, and Blazor UI hosts call it explicitly before MapRazorComponents so SSR prerender runs under the right culture (:64-70).
    • MapCultureEndpoint() (:100) maps the anonymous GET /culture/set?culture=&redirectUri= that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (:104, :107), writes the standard ASP.NET culture cookie as non-HttpOnly so the WASM client can read it (:110-121, with Secure conditional on the environment and both deviations justified inline at :109), then local-redirects (:125-126) to force a full reload.
    • ApplyPipeline (:138, private) is the whole application step: seed the defaults (:140), let the host's delegate adjust them if there is one (:141), then foreach over builder.Build() invoking each step's Configure in order (:143-146). Because both public overloads route through here, the zero-argument path is exactly the validated default pipeline (:133-137).
  • Why it's built this way: pushing the step list out into MiddlewarePipelineBuilder and keeping only ApplyPipeline here is what lets the order be inspected and asserted while the entry point stays a single line in a host's Program.cs. The configure-then-Build sequence is deliberate: the host mutates first and the invariants are checked last, so a customization is judged on its result rather than on the order the host happened to make its edits.
  • Where it's used: called once per service host after app.Build(), for example MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:377, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:245, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:278, and MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:130. The Blazor UI hosts instead call the localization and culture-endpoint members directly (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:127 and :169).
  • Caveats / not-in-source: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example SignalRExtensions.MapNotificationHub, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21). Note also that MMCA.Common.UI ships a different WebApplicationExtensions (see WebApplicationExtensions in the UI framework chapter); the two share a name and nothing else.

DataExportControllerBase<TQuery>

MMCA.Common.API · MMCA.Common.API.Controllers.Privacy · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59 · Level 10 · class (abstract)

  • What it is: the shipped base controller for the data-subject export endpoint (GET {route}/{userId}/export, the GDPR/CCPA access and portability request). A subclass supplies its app's query type and a route; the base owns the action, the authorization posture, the feature gate, and the file-download delivery.
  • Depends on: ApiControllerBase (its base, for HandleFailure at MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:35), IQueryHandler<in TQuery, TResult> closed over UserDataExportDTO, ICurrentUserService, Result and Error, PrivacyFeatures, and the constraint IUserOwnedRequest on TQuery (DataExportControllerBase.cs:59-62). Externals: ASP.NET Core MVC, System.Text.Json, and Microsoft.FeatureManagement.Mvc's [FeatureGate].
  • Concept (a privacy endpoint shipped as a base class, not a registered controller). [Rubric §30, Compliance/Privacy/Data Governance] assesses whether legal obligations such as subject access, portability, and erasure are first-class code rather than manual operations: the access/portability half of a DSAR is framework code here, so an app gets it by subclassing rather than by re-implementing the dispatch, the ownership posture, and the delivery format. [Rubric §11, Security] shows up as defence in depth: the class-level [Authorize] (:57) demands an authenticated caller, while the handler independently enforces owner-or-privileged-role, so the endpoint cannot leak another subject's data even if a subclass is mounted without its own [Authorize] (:49-53). [Rubric §6, CQRS & Event-Driven Design] covers the [FeatureGate(PrivacyFeatures.DataExport)] (:58, the flag string is "Privacy.DataExport" at MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:9): the whole surface stays off, answered by DisabledFeatureHandler's 404, until a host deliberately turns the flag on.
  • Walkthrough
    • The primary constructor takes the app's query handler and ICurrentUserService (:59-61); the latter is re-exposed as the protected CurrentUserService property (:68) so a subclass can reuse it. ExportContentType = "application/json" (:65) is the one media type the package is served as.
    • ExportAsync(UserIdentifierType userId, CancellationToken) (:82-84) is routed by the action-level [HttpGet("{userId}/export")] (:77) only: the route prefix lives on the subclass, so a controller routed at Users serves /Users/{userId}/export (:38-42). The declared responses are 200 with a UserDataExportDTO, plus 401/403/404 as ProblemDetails (:78-81).
    • It reads CurrentUserService.UserId first and, when there is none, short-circuits through HandleFailure with Error.Unauthorized("Privacy.Unauthorized", ...) (:86-90), so even the unauthenticated case comes back as RFC 9457 Problem Details rather than a bare status.
    • It then builds the app's query through the abstract CreateQuery(userId, currentUserId, CurrentUserService.Role) factory (:92, declared at :119-122), awaits the handler (:93-94), and maps a failed Result through HandleFailure(result.Errors) (:96-99), which is what turns the handler's own ownership refusal into a 403.
    • On success it serializes the package itself with JsonSerializer.SerializeToUtf8Bytes(export, JsonSerializerOptions.Web) (:107) and returns File(payload, ExportContentType, BuildFileName(...)) (:109). The inline comment (:103-106) is explicit about why: Ok(export) would content-negotiate and render inline, and this document exists to be saved, while JsonSerializerOptions.Web keeps the payload byte-shape identical to every other response the API produces.
    • BuildFileName (:133-134) composes user-data-{userId}-{yyyyMMdd}.json with CultureInfo.InvariantCulture, taking the date from the package's own UserDataExportDTO.GeneratedOn so the file name and the document always agree, and so a saved file sorts and parses the same in every locale (:124-129).
  • Why it's built this way: ADR-076 hoists the export idiom that ADC and Store each wrote by hand. It ships as an abstract base with a CreateQuery factory rather than as a concrete controller added through an application part because the query type is app-owned (each app's ExportUserDataQuery lives in its own Application assembly), and a concrete controller could not construct a type it cannot see (DataExportControllerBase.cs:43-47). The route staying on the subclass follows the AuthControllerBase precedent: the app owns its URL space (:38-42).
  • Where it's used: both full apps now derive from it, and each subclass is nothing but a route plus a query factory: ADC's UsersDataExportController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26-35) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersDataExportController.cs:19-29). Both are routed at the literal Users rather than [controller], and both say why: [Route("[controller]")] would resolve to UsersDataExport and move the published path (ADC :19-22, Store :9-13). The framework's own coverage is DataExportControllerBaseTests, whose TestDataExportController is the third subclass in source (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:270).

DatabaseInitializationExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21 · Level 13 · class (static, extension block)

  • What it is: the shared startup routine every downstream host calls once after app.Build(). It walks each physical data source the host actually uses, creates or migrates that schema according to the configured strategy, repeats the pass for every tenant that keeps its own copy of a source, and finally runs each enabled module's seeder.
  • Depends on: IEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, DataSourceKey and its DataSource engine enum, PhysicalDataSource, TenantDataSourceTargets / TenantDataSourceTarget, ITenantContext, TenancySettings, ApplicationSettings, and ModuleLoader. Externally it uses only IServiceProvider scoping, IOptions<T>, and EF Core's DatabaseFacade (MigrateAsync, EnsureCreatedAsync, GetPendingMigrationsAsync).
  • Concept (strategy-driven, per-source database initialization). [Rubric §8, Data Architecture] assesses deliberate persistence decisions including migrations: because of database-per-service (ADR-006) a host may own several physical databases at once, so "initialize the database" is really a loop over sources, and polyglot persistence (ADR-018) means those sources do not all have a migrations pipeline. [Rubric §17, DevOps] assesses how schema reaches an environment: the single DatabaseInitStrategy string is the switch between a development host that self-migrates on boot and a production host whose migrations are applied by the deployment pipeline and which must therefore refuse to start if it finds itself behind. [Rubric §13, Observability & Operability]: every failure path here throws a message that names the offending value or the exact pending migrations plus the command to run, so a bad boot is diagnosable from the startup log alone.
  • Walkthrough: the entire public surface is one extension member on IServiceProvider (DatabaseInitializationExtensions.cs:23), declared with C# extension(T) syntax like the rest of the framework's DI and startup helpers.
    • InitializeDatabaseAsync(applicationSettings, moduleLoader, cancellationToken) (:35-38) starts by null-guarding all three inputs (:40-42).
    • Strategy is validated first (:47). EnsureKnownStrategy (:182) accepts only the ordinal strings "Migrate" and "None" (:184-185) and otherwise throws UnknownStrategy (:187), whose message names the valid values (:194-195). The comment (:44-46) gives the reason for doing it up front: a misspelled strategy is a configuration mistake, and the host must stop before the code below has already created the migrationless sources.
    • It then opens a scope (:49) and warms the entity data-source registry: resolving IEntityDataSourceRegistry and calling GetPhysicalSourcesInUse() (:54-56) scans the configuration assemblies once so entity-to-database routing is deterministic before the first repository call, replacing a lazy model-building side effect (:51-53).
    • The migrationless pass (:70-87) covers Cosmos and SQLite sources in use (:71). A source with an empty connection string is skipped (:75-78), since integration tests may omit one; a source where PhysicalDataSource.UsesMigrations is true is also skipped (:80-83); everything left is created outright with EnsureCreatedAsync (:85-86). Two comments carry the load here. The first (:60-65) says this is the only path that creates such a source: without it the first repository call fails. The second (:67-69) explains the UsesMigrations exclusion: running EnsureCreated against a SQLite source that does have a migrations assembly writes the tables with no __EFMigrationsHistory row, after which every migration is both pending and un-appliable because its CREATE TABLE hits an existing table. UsesMigrations itself is the per-engine rule on the source record (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:48-53): SQL Server always, SQLite only with a configured SqliteMigrationsAssembly, Cosmos never.
    • The strategy switch (:92-102) has exactly two live branches. "Migrate" delegates to IDbContextFactory.MigrateAsync (:95), which applies pending migrations for every migration-owned source in use (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:74-81). "None" calls ThrowIfPendingMigrationsAsync (:98). The default arm rethrows UnknownStrategy (:101), unreachable in practice because of the guard at :47 but kept so the switch is total.
    • The tenant pass runs next (:104-105) and module seeding last (:111). Seeding deliberately runs on the default scope only; the comment (:107-110) states the rationale: a seeder writes reference data the application needs to boot, no module declares which of its seeders are tenant-scoped, and running one twice against a shared database is worse than not running it per tenant at all.
    • InitializeTenantDatabasesAsync (:125) resolves IOptions<TenancySettings> with GetService rather than GetRequiredService (:132) and returns immediately when tenancy was never registered or no tenants are configured (:133-136). Otherwise it expands the sources into targets via TenantDataSourceTargets.Expand and keeps only the ones with a tenant id (:138-139), because Expand also emits the shared, tenant-less target for each source, which the pass above already handled. For each target it opens a fresh scope and calls ITenantContext.SetTenant before asking that scope for anything (:141-142); the remark (:120-124) is the reason: the scoped context factory binds one physical database per source for the life of a scope, so reusing the outer scope would keep handing back the shared database.
    • Per tenant it reads UsesMigrations off the shared resolved source (:150), on the grounds stated at :147-149: a tenant's copy is the same schema on a different connection, the migrations assembly is declared once on the source, and a per-tenant override only replaces the connection string. It then applies the same strategy (:152-171): under "Migrate" a migrated source gets MigrateAsync and anything else gets EnsureCreatedAsync (:155-162), under "None" it defers to ThrowIfTenantPendingMigrationsAsync (:166).
    • The two production rails. ThrowIfTenantPendingMigrationsAsync (:201) no-ops for a non-migrated source (:207-210), otherwise queries GetPendingMigrationsAsync (:212) and, when anything is pending, throws naming the tenant target, the migrations, and the dotnet ef database update remedy (:218-221). ThrowIfPendingMigrationsAsync (:241) short-circuits on IDbContextFactory.HasPendingMigrationsAsync (:247-250), then builds a per-source breakdown by re-querying each key that passes IsMigrationTarget (:252-261) before throwing (:263-265). IsMigrationTarget (:231-233) mirrors the factory's own rule (a source a migrations pipeline owns, minus an optional non-SQL-Server source left without a connection string) so the breakdown names exactly the sources the factory checked, rather than reporting an empty list (:224-228).
  • Why it's built this way: one shared init path keeps every service host consistent, so adding a database engine or a tenant changes this file rather than five Program.cs files. The "None" strategy is the deploy-time guarantee that an app never serves traffic against an un-migrated database when migrations are the pipeline's job, and validating the strategy string before any side effect keeps a typo from silently degrading into "schema untouched, first query fails". The tenant pass exists because nothing else ever opens a per-tenant database (ADR-073), so without it such a database is never created and never migrated (:116-118).
  • Where it's used: called once per host after app.Build(), before the middleware pipeline is wired: MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:374, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:275, and MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:127 are representative; the ADC and Store service hosts pass moduleHost.ApplicationSettings and moduleHost.ModuleLoader off ModuleHostContext (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:52). The migrationless-engine loop and the strategy contract are covered by DatabaseInitializationExtensionsTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:42, :95, :143, :184), which exercise SQLite create, SQLite migrate, the "None" failure message, and the unknown-strategy guard.
  • Caveats / not-in-source: which strategy a given deployment runs is configuration, not code. The framework default is "Migrate" (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:43); what any environment overrides it to lives in that app's settings and cannot be read here. The tenant pass is also only as complete as TenancySettings: Expand emits a tenant target only when the tenant declares an override connection string for that source, so a tenant that shares the default database is initialized by the shared pass and never appears in the per-tenant loop.

⬅ Navigation Metadata & Populators (EF-decoupled eager loading)IndexgRPC & Inter-Service Contracts ➡