↑↓ 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:45) registers MVC controllers with the global UnhandledResultFailureFilter (DependencyInjection.cs:50) and ReturnHttpNotAcceptable = false (:49), wires three JSON converters into the serializer options, the CurrencyJsonConverter (DependencyInjection.cs:54), the EnumerationJsonConverterFactory (DependencyInjection.cs:59, registered here because a concrete enumeration does not inherit the base class's [JsonConverter] attribute) and StronglyTypedIdJsonConverterFactory (DependencyInjection.cs:65, registered for the same reason so a wrapped identifier serializes as the bare primitive it wraps, ADR-115), adds the XML formatters (DependencyInjection.cs:67), optionally installs the ModuleControllerFeatureProvider when a ModulesSettings instance is supplied (DependencyInjection.cs:69-73), binds IdempotencySettings with ValidateDataAnnotations().ValidateOnStart() when configuration is supplied (DependencyInjection.cs:75-81, ADR-070), registers the two scoped action filters IdempotencyFilter and OwnerOrAdminFilter (DependencyInjection.cs:84-85, scoped because they depend on scoped services), turns on feature management with targeting through CurrentUserTargetingContextAccessor and the DisabledFeatureHandler (DependencyInjection.cs:105-107), and registers the edge error-localization boundary (AddErrorLocalization, called at DependencyInjection.cs:110 and declared at :121, with AddErrorResources<TResource> at :136 for each module's own .resx set). Three sibling methods on the same class complete the picture: AddCommonExceptionHandlers (DependencyInjection.cs:149) registers the Problem Details service and the five exception handlers in most-specific-first order (:154-158), AddServerAuthSessionCookie (DependencyInjection.cs:174) registers the Blazor Server host's SSR cookie reader (CookieTokenReader, :180) plus the singleton CookieSessionRefresher (:186, singleton so its in-flight map is shared across requests), and AddModuleHealthChecks (DependencyInjection.cs:212) turns ModuleLoader discovery results into module-{Name} health checks, tagged module so /health?tag=module filters them (Healthy for enabled modules at :206-212, Degraded for the disabled ones from :214). WebApplicationBuilderExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:24, a partial class split across that file plus WebApplicationBuilderExtensions.RateLimiting.cs and WebApplicationBuilderExtensions.Authentication.cs) carries the identical builder-side setup every service shares: header-based API versioning through the api-version header (AddCommonApiVersioning, line 39, reader at line 48, v1.0 assumed when the header is absent at line 46, ADR-046), rate limiting (AddCommonRateLimiting, three overloads at WebApplicationBuilderExtensions.RateLimiting.cs:353, :371, and :393), Brotli and Gzip compression at CompressionLevel.Fastest (AddCommonResponseCompression, line 68, both providers pinned to Fastest at lines 77 and 82 because these are dynamic per-request payloads on fractional vCPUs), OpenAPI (line 101), CORS (line 132, ADR-082, with the two policy names as constants at lines 27 and 30 and the allow-any-origin policy selected only in Development by the pipeline's CORS step, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:99-101), and the two JWT bearer registrations: in-process AddCommonAuthentication (WebApplicationBuilderExtensions.Authentication.cs:133) for the Identity host and AddForwardedJwtBearer (WebApplicationBuilderExtensions.Authentication.cs:52) 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, throwing at :39-42), 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. JwtAudience (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAudience.cs:17) applies the same rule to the audience those hosts hand to AddForwardedJwtBearer: RequireConfigured (:28-32) returns the value read from Jwt:Audience (ConfigKey, :20) or throws naming the key and its Jwt__Audience environment-variable form, and the intended call shape carries no ?? fallback, so a hard-coded default can never boot a service with an audience nobody configured (JwtAudience.cs:8-15, a shape ForwardedJwtAudienceTestsBase pins in each consumer). 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:88) 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:170-180). 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:15) owns the list: CreateDefault (MiddlewarePipelineBuilder.cs:31) seeds the eighteen framework steps in order (:34-150) and InsertBefore, InsertAfter, Replace, and Remove (:161, :178, :198, :219) 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:252) re-checks them before a single step is applied (the Require... calls start at :254): 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 (:320-321 and :335-336), and an invariant binds only when both of its steps are still present (:315 and :330), 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 only cleartext HTTP/2 (MiddlewarePipelineBuilder.cs:85-87, through the public IsCleartextHttp2 predicate at :350), because extracted gRPC services speak HTTP/2 cleartext (h2c) and a 307 would break the call; the exemption is keyed on the protocol Kestrel negotiated rather than on the Content-Type, because this step runs before routing and a header is caller-supplied, so a forged application/grpc cannot buy a plaintext response while every browser-reachable request (HTTP/1.1, or HTTP/2 over TLS) is still redirected (:79-84). And the forwarded-headers step (:69) takes its options from CommonForwardedHeaders (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeaders.cs:27), the framework's one forwarded-headers posture: X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host by default (DefaultHeaders, :33-34), with the known-proxy and known-network allowlists cleared (Create, :42, clearing at :49-50) so cloud reverse proxies are trusted regardless of their internal IPs. That is safe only because the posture assumes the ingress is the only thing that can reach the container (CommonForwardedHeaders.cs:19-20) and because the pre-forward scheme and host were already stashed in HttpContext.Items under the two keys declared at WebApplicationExtensions.cs:24 and :35. Server-rendered UI hosts adopt the identical posture through CommonForwardedHeadersExtensions's UseCommonUiForwardedHeaders (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeadersExtensions.cs:24), called first in the UI pipeline before anything reads the scheme, the host, or the client address, so every host behind the same ingress reads them the same way (CommonForwardedHeaders.cs:6-11); the gateway keeps its own dependency-free copy of the values because it does not reference this package (:23-24). 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; its bool httpOnly overload (WebApplicationExtensions.cs:115) controls the culture cookie's HttpOnly flag, which the parameterless form leaves off because a WebAssembly client reads the cookie on startup, while a Server-only host can turn it on (:97 and :109) (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 (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:141) routes anonymous traffic to AnonymousPartition (line 62), and health, liveness, /.well-known/*, and gRPC traffic bypass the limiter outright (IsRateLimitBypassed, lines 46-50). The gRPC arm of that exemption is keyed on the routed endpoint rather than on the request's Content-Type (IsGrpcEndpoint, line 107): a header is caller-supplied, so stamping application/grpc on an ordinary request would otherwise hand any authenticated account the no-limiter partition, and endpoint metadata comes from the host's own MapGrpcService registrations instead (the predicate runs after UseRouting, which the pipeline order guarantees). Anonymous callers are exempt everywhere except a real-time hub path, where AnonymousPartition meters them per client IP at RateLimitingSettings.AnonymousHubPermitLimit (default 60, RateLimitingSettings.cs:99) over the configured HubPathPrefixes (default /hubs, RateLimitingSettings.cs:91, matched by IsAnonymousHubRequest at WebApplicationBuilderExtensions.RateLimiting.cs:82) and otherwise returns a no-limiter partition (WebApplicationBuilderExtensions.RateLimiting.cs:72): a gateway cannot meter /hubs at the edge, because the hub authenticates from a query-string token the edge cannot read (ADR-024), so an anonymous negotiate flood would be counted nowhere at all. Because the global limiter otherwise 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.RateLimiting.cs:27), whose partition selector AuthIpRateLimitPartition (line 305) is a per-client-IP window defaulting to 30 requests per minute (RateLimitingSettings.cs:47) and fails open on an unattributable IP (line 310) 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), as does EmailConfirmationControllerBase<TSendCommand, TConfirmCommand> on both of its anonymous actions (EmailConfirmationControllerBase.cs:81 and :109), so every consumer inherits spray protection by construction, while RefreshAsync (AuthControllerBase.cs:124-126) 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.RateLimiting.cs:371) 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:40), 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.RateLimiting.cs:225), 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 (the multiplexer probe at line 240). 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:31); 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:172) 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.RateLimiting.cs:318): 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:20) reads the X-Correlation-ID request header (the constant lives at CorrelationIdMiddleware.cs:23), falling back to the current W3C trace id then ASP.NET's TraceIdentifier (lines 41-43), after cutting a caller-supplied value to MaxLength (64, the width of the persisted correlation columns, line 26) and discarding it unless every character is printable ASCII (Sanitize, lines 61-64), so a hostile header is neither echoed back nor persisted. It writes the id onto the scoped ICorrelationContext (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8, implemented by CorrelationContext, which self-seeds from the correlation id of the enclosing background hop when one is being delivered and otherwise from a new GUID, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CorrelationContext.cs:13), and echoes it back through Response.OnStarting (CorrelationIdMiddleware.cs:46-50). That single id is what the CQRS logging decorators read (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:17 and :24) and stamp onto every log scope through the Command {CommandName} [Module: {ModuleName}] [CorrelationId: {CorrelationId}] scope definition (LoggingCommandDecorator.cs:87-88), 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:37) enforces business rule BR-133: an authenticated caller whose account was soft-deleted is rejected with a bare 401 (lines 110 and 171), checked first against a deleted-user marker read from the shared cache store rather than this replica's local copy (GetFromSharedStoreAsync, SoftDeletedUserMiddleware.cs:91-93), because whichever replica handled the delete is the one that wrote it. The marker lives for 15 minutes (SoftDeletedUserCache.MarkerDuration, MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:32, written at SoftDeletedUserMiddleware.cs:160) so it outlives any access token issued before the delete, and the database answer is cached only when it says deleted: a cached "not deleted" would sit in each replica's local copy and outlive a delete made on another replica (lines 133-135) (ADR-047). It resolves ISoftDeletedUserValidator lazily from RequestServices (line 114) instead of as an InvokeAsync parameter, so a service that does not host Identity still honors the marker and otherwise passes the request through (lines 115-122) 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 95-103), and a validator query that throws lets the request continue (lines 143-150), 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:154-158), evaluated most-specific-first: OperationCanceledExceptionHandler (499 Client Closed Request, OperationCanceledExceptionHandler.cs:37), 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:30), which is the 500 catch-all (line 102) plus two special cases. A CrossTenantWriteException is answered 400 with a fixed title and detail that name nothing the exception carries (constants at :36 and :44-46, handled at :54-72), 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. A BadHttpRequestException raised by Kestrel or model binding keeps the status it already carries (413 Payload Too Large for a body over the endpoint's limit, typically 400 otherwise) and is likewise logged as a warning, because a malformed or oversize request is a caller fault (handled from :74). 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-32) 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, TooManyRequests to 429, Unexpected to 500) to a status code, with 400 as the fallback for anything unmapped (lines 38-39) and the list-taking overload at lines 51-52 applying the severity ranking. Its BuildErrorsExtension (line 62) 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 66). 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:121) 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:34) adds the read surface (GetAllAsync at line 107, paged at line 151, lookup at line 305, GetByIdAsync at line 349) over an IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, with field projection through a fields query parameter, X-Pagination header metadata (line 185), and a page size clamped to MaxPageSize (resolved per request from IOptions<ApplicationSettings>, defaulting to 500, lines 56-62). 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 ten special-purpose bases: AuthControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:43, anonymous login, register, and refresh over IAuthenticationService, lines 69-130, plus the [Authorize] revoke at lines 152-154 and the multi-device pair GET my-sessions (line 183) and POST revoke/{sessionId:guid} (line 216), 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); EmailConfirmationControllerBase<TSendCommand, TConfirmCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:43), a second sibling built the same way for the same single-inheritance reason (EmailConfirmationControllerBase.cs:16-19), serving POST send-email-confirmation (line 78) and POST confirm-email (line 106), both [Idempotent], both anonymous because the link is followed from a mail client with no session and an unconfirmed account may be unable to sign in (:23-27), and both behind auth-ip (lines 79-81 and 107-109); send answers 202 on every well-formed request so the response never reveals which addresses hold accounts (:30-32), and the two command records stay app-side behind one-line factories (CreateSendCommand at :61, CreateConfirmCommand at :69, ADR-116); 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:50, the Google, GitHub, and Apple external-provider challenges at lines 103, 113, and 126, the GET complete provider callback at line 145 and the POST exchange redemption at line 230, whose single-use exchange code, cached for two minutes at OAuthControllerBase.cs:75, keeps tokens out of the redirect URL; a multi-replica host passes an IDistributedLock to the four-argument constructor so the code is read and burned inside that lock (lease and wait at lines 80-81) and two replicas redeeming the same code at once cannot both mint a token pair, while the three-argument constructor (line 90) keeps an unlocked read-then-burn that is single-use only per replica (:35-40); ADR-036 and ADR-043); DataExportControllerBase<TQuery> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:61), 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); LegalAcceptanceControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:45), the signed-in user's Terms of Service standing behind a class-level [Authorize] (line 44): a GET that reads the caller's standing against the configured current version (line 59) and an [Idempotent] POST that records an acceptance (lines 93-94), both on LegalAcceptanceRoutes.Action, with the current version resolved from options (CurrentTermsVersion, line 54) and the derived controller supplying only the route, the DataExportControllerBase precedent (:19-21); 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). The last two are the administration surface, and both are gated on a capability rather than a role name, so the framework never has to know an app's role vocabulary. UsersAdminControllerBase<TUserDto> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:49) carries a single class-level [HasPermission(AdministrationPermissions.ManageUsers)] (line 48) over GET paged (line 71, page size clamped to the MaxPageSize of 100 at line 53 and the same X-Pagination metadata the entity read endpoints emit), GET {userId} (line 108), the POST {userId}/lock and POST {userId}/unlock pair (lines 133 and 153), and PUT {userId}/roles (line 175), all over the app's own IUserAdministrationService<TUserDto>, so a host that grants the permission to nobody serves endpoints that deny everyone, which is the safe direction. RolesAdminControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs:61, gated on AdministrationPermissions.ManageRoles at line 60) reports each role's compiled and stored permissions (GET at line 80, GET {role} at line 121), publishes the permission catalog (GET catalog at line 102), and writes a stored set through PUT {role}/permissions (line 143), recording the caller through an overridable ResolveChangedBy (line 162). Two refusals on that write are the ones to memorize, both 400: a set that names ManageRoles itself is rejected, because granting from here would make access to role administration a matter of data and deleting the row would lock every operator out of the screen that could restore it, and a set naming anything outside the catalog is rejected rather than stored as a row no endpoint will ever check. 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:252-257) 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:199-204). The work is split in two. The controller keeps the HTTP concerns (the action, the row scope, the file name, and the headers) and hands the format to EntityCsvExporter<TEntityDTO> (MMCA.Common/Source/Presentation/MMCA.Common.API/Export/EntityCsvExporter.cs:17, an internal static class whose own summary draws that line at :11-14), which owns which DTO properties can be exported, how a page of rows becomes CSV records, and the paging loop that streams every page under a row ceiling (WriteAsync, line 116, invoked from EntityControllerBase.cs:279). Its record-writing primitive is CsvWriter (MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:44), a deliberately hand-written internal helper: it quotes only when RFC 4180 requires it (WriteField, line 203, with the trigger set as a SearchValues<char> at line 71), terminates records with CRLF regardless of host OS (line 58), formats cells invariantly so the same row produces the same bytes on every machine (FormatCell, line 141, which writes a Money cell as its amount plus ISO 4217 code through FormatMoney at line 173), and writes a UTF-8 BOM explicitly (line 55, through WriteByteOrderMark at line 82, paired with the preamble-free encoding at line 65) 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 DTO type and dropped (UnexportablePropertyNames at EntityCsvExporter.cs:31, the type test IsExportableType at :239-242); value objects and other class-typed properties are deliberately kept, because CsvWriter renders each as a value (Money as amount and currency code, Currency as its code, any other record as compact JSON rather than its compiler-generated ToString, :26-29). And a caller who names one of those dropped properties in fields= gets an Error.InvalidEntityField validation failure rather than a quietly missing column (ValidateFields, EntityCsvExporter.cs:61, called first thing in the action at EntityControllerBase.cs:265-267). Then the exporter renders into an in-memory buffer rather than the response body (a StreamWriter over a MemoryStream, EntityCsvExporter.cs:132-135) and copies each page to the body with awaited writes only (DrainAsync, :210-222), because the synchronous CSV writer would otherwise flush synchronously whenever its buffer filled, which Kestrel rejects (:104-108). Nothing reaches the body until the first page has succeeded: headers, BOM, and the header row go out only then (:158-165), and a first-page failure comes back as a plain failed Result (:144-145), which is what keeps the "a failure on page one still returns Problem Details" path honest (EntityControllerBase.cs:300-301). A failure on a later page can no longer change the status, so the exporter logs it through the controller (LogExportPageFailure, EntityControllerBase.cs:646) and ends the file with an explicit # export incomplete after N rows line (EntityCsvExporter.cs:147-151, text at :364) rather than letting it pass for a complete export of fewer rows. The row ceiling is announced up front through the X-Export-Row-Limit header (constant at EntityControllerBase.cs:481, default 100,000 at line 460, overridable per host through MaxExportRows at lines 76-83, written alongside the Content-Disposition attachment name in BeginExportResponse at lines 571-572, the name being a UTC {controller}-{yyyyMMddTHHmmssZ}.csv built at lines 582-585) with a # export truncated at N rows notice written as a final body line (EntityCsvExporter.cs:193-196, text at :358), because headers are frozen the moment the first byte flushes; when the ceiling lands exactly on a page boundary the exporter consults the page's TotalItemCount instead of issuing one more query just to find out (:182-188), and a short page ends the loop only when that total agrees, because the query pipeline can clamp a full page below the requested size and stopping there would truncate the file with no marker (IsLastPage, :230-231). Row scoping is a shared hook rather than an export-only one: GetReadSpecificationAsync (EntityControllerBase.cs:571-573) is what all five read actions apply (both GetAllAsync overloads, the lookup, GetByIdAsync, and the export, :497-499), resolved once per request so one instance filters every page of an export loop (:527-528). It is asynchronous because row scoping is rarely a pure function of the principal (:507-512), it returns GetExportSpecification(), itself null by default (line 560), and the lookup, which takes a predicate rather than a specification, receives the same scope as its Criteria (:299-303). So an export or a dropdown can no longer drift wider than the list it mirrors, a scoped GetByIdAsync answers a filtered-out row with 404 rather than 403 so the read cannot become an existence oracle (:339-342), and every one of them is unscoped until a controller overrides one of the two hooks. 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:68) 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: all five of its POST actions (login, register, refresh, revoke, and the per-session revoke) carry a written reason for staying outside the contract (AuthControllerBase.cs:70, :94, :125, :153, and :217), since replaying a stored token pair would hand a retrying client credentials minted for an earlier call or already invalidated by rotation (a retried register hits the email-exists 409 instead, which is the correct answer), and replaying a cached 204 would report success for a revoke that never ran. OAuthControllerBase's POST exchange opts out for the same reason, since it burns a single-use code (OAuthControllerBase.cs:231). 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:382), which resolves the DTO's RowVersion property once per closed controller type (line 379, so a DTO with no token costs no per-request reflection), reads the token off either the typed DTO or the camelCase dictionary a fields= projection produces (ReadRowVersion, lines 422-431), and writes the header at line 413; the method is protected rather than private (line 405) 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:44), registered by name through OutputCacheOptionsExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6, both overloads at lines 20 and 34), drops that identity bail-out and enables caching for the authenticated request anyway (lines 88-90), varies by every query-string key (QueryKeys = "*" at line 96) 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 125-128), and offers a bypassRoles escape hatch so a privileged caller who receives an elevated payload always reads fresh, skipping both lookup and storage (the constructor parameter at line 69, the role test at line 142, applied at lines 88-90, 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. One more type exists only because of the cache: ApiVersionReportingResultFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/ApiVersionReportingResultFilter.cs:21), an internal IResultFilter that AddCommonApiVersioning adds to every MVC action (WebApplicationBuilderExtensions.cs:60). The versioning library writes api-supported-versions and api-deprecated-versions from a Response.OnStarting callback, but the output cache snapshots the headers when the body starts, before those callbacks run, so a cached entry would otherwise be stored and replayed without the report (ApiVersionReportingResultFilter.cs:13-17). The filter reports ahead of the body through whatever IReportApiVersions the host registered (OnResultExecuting, lines 24-40, skipping a response that has already started or an endpoint with no version metadata), so the headers land in the snapshot, the library's own callback finds them present and adds nothing, and a host with ReportApiVersions off still reports nothing. [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:14) 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:14) 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:91), 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:83-88). 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.Authentication.cs:25), then "true outside Development" (WebApplicationBuilderExtensions.Authentication.cs:64-66); 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.Authentication.cs:68-72) 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:28) initializes every physical data source the host owns: InitializeDatabaseAsync (line 51) validates the strategy before touching anything (EnsureKnownStrategy, called at line 63 and declared at line 290, which accepts only Migrate or None and otherwise throws naming the valid values through UnknownStrategy at line 302), warms the IEntityDataSourceRegistry so entity-to-database routing is deterministic before the first repository call (lines 70-72), runs EnsureCreated for the migration-less Cosmos, PostgreSQL, and SQLite sources up front (lines 76-106, skipping a PostgreSQL or 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, under a raised five-minute command timeout so a replica queued behind another's EF migrations lock does not die at the 30-second default (InitializationCommandTimeout, line 37, applied at line 112 through RaiseCommandTimeouts, line 257), applies the configured strategy per migrated source (lines 118-128), where None is the production guard that throws with a per-source breakdown of pending migrations (line 124 into ThrowIfPendingMigrationsAsync at line 359; 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 130 into InitializeTenantDatabasesAsync at line 196, ADR-073), and finishes by running the enabled modules' seeders on the default scope only (line 137). Five smaller startup helpers round out the host: OpenApiEndpointExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:25) 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:56 and :103, through the private helper at :124-126), the paired StronglyTypedIdSchemaTransformer (MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/StronglyTypedIdSchemaTransformer.cs:24) and StronglyTypedIdParameterTransformer (same file, line 74), both registered by AddCommonOpenApi through a ConfigureAll that reaches every versioned document (WebApplicationBuilderExtensions.cs:108-112) and both inert in a host that declares no wrappers: the schema half clears the object schema the default generator produces for a one-property struct and rewrites it as the wrapped primitive's type and format (:38-41, mapped at :46 and :51) so the document matches the bare-primitive wire shape, and the operation half does the same for route and query parameters, which the API explorer describes as plain strings because they bind through a TypeConverter (:74-107, ADR-115), 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.

ApiVersionReportingResultFilter

MMCA.Common.API · MMCA.Common.API.Caching · MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/ApiVersionReportingResultFilter.cs:21 · Level 0 · class (internal sealed)

  • What it is: an MVC IResultFilter that writes the api-version report headers (api-supported-versions, api-deprecated-versions) before a result writes its body, so the output cache stores them with the entry (ApiVersionReportingResultFilter.cs:8-12).
  • Depends on: Asp.Versioning (IReportApiVersions, ApiVersionMetadata), Microsoft.AspNetCore.Http, and Microsoft.AspNetCore.Mvc.Filters (ApiVersionReportingResultFilter.cs:1-4). No first-party dependencies.
  • Concept introduced (report before the body so the cache snapshot sees it). The versioning library reports from a Response.OnStarting callback, but the output cache snapshots response headers when the body starts, before those callbacks run, so a cache hit replayed the stored headers without the version report (:14-17). [Rubric §9, API & Contract Design] assesses whether the contract a client observes is stable; this filter keeps the version headers present on cached and uncached responses alike. Versioning itself is the decision recorded in ADR-046, and the cache that makes the ordering matter is the one configured by OutputCacheOptionsExtensions.
  • Walkthrough: OnResultExecuting (:28-44) returns early if the response has already started (:31-34). Otherwise it reads ApiVersionMetadata from the matched endpoint (:36) and the host's IReportApiVersions from RequestServices (:37); if either is missing it returns (:38-41), else it calls reporter.Report(response, metadata.Map(reporter.Mapping)) (:43). OnResultExecuted is an empty body (:47-49).
  • Why it's built this way: reporting ahead of the body puts the headers in the cache snapshot, and the library's own OnStarting callback then finds them present and adds nothing (:17-18). Resolving IReportApiVersions from DI, rather than writing the headers directly, means a host with ReportApiVersions off still reports nothing (:18-19).
  • Where it's used: added to the global MVC filter collection by WebApplicationBuilderExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:60), right after AddApiParameterDescriptorBackfill() in the versioning setup. See WebApplicationBuilderExtensions.

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:44 · Level 1 · 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-5, plus ITenantContext resolved from the request's RequestServices for the tenant-vary key. No other 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:15-20). [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 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 administrators seeing unpublished rows) is never served or stored from the shared cache (:32-38). A second SECURITY remark covers multi-tenancy (:43-48): the tenant-vary rule below is a backstop, not a license, an endpoint whose payload depends on the caller (rather than only on the tenant) still must not use this policy. This is the edge tier of the two-tier caching model (ADR-026); the authenticated-read decision itself is ADR-040.
  • Walkthrough: a TenantVaryByKey = "t" constant (:50) mirrors the t:{tenantId} prefix the caching CQRS decorators use, and three instance fields hold the rest of the config, _expiration, _bypassRoles, _tags (:52-54). Two constructors: the params string[] tags overload delegates to the full one with an empty bypass-roles array, and the primary constructor guards its inputs (ThrowIfLessThanOrEqual(expiration, TimeSpan.Zero), null checks on both arrays). All three interface methods are explicitly implemented, so they are reachable only through IOutputCachePolicy. CacheRequestAsync (:81) computes attemptOutputCaching as "is a GET/HEAD request" AND "is not a bypassed caller" (:86-87), enables output caching, sets AllowCacheLookup/AllowCacheStorage to that flag, allows locking, sets the expiration (:88-92), and (matching the built-in default) varies the cache key by every query-string parameter via CacheVaryByRules.QueryKeys = "*" (:96). It then resolves the tenant from context.HttpContext.RequestServices and, when one is present, stamps it into CacheVaryByRules.VaryByValues[TenantVaryByKey] (:98-106), an unresolved tenant, single-tenant host or background call, adds nothing; then copies the eviction tags in. ServeFromCacheAsync is a no-op returning ValueTask.CompletedTask. ServeResponseAsync refuses to store any response that set a cookie or returned a non-200 status, the same guard the built-in default applies. Two private helpers close it out: IsCacheableRequest (:134-135, GET or HEAD) and IsBypassedCaller (:141-142, Array.Exists(_bypassRoles, user.HasRole)). HasRole is deliberate, not ClaimsPrincipal.IsInRole: IsInRole only matches the identity's own role claim type, so against a provider that emits bare role/roles claims the permission handler would grant the elevated payload while this bypass check missed it, and that response would be stored under the shared public key (:137-140).
  • Why it's built this way: it mirrors the built-in default policy minus exactly one behavior, the authenticated-request bail-out, 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. Stamping the resolved tenant into the vary key means a multi-tenant host's shared cache entry per path+query never crosses tenants, since the same path and query mean different rows per tenant (:98-101). 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:270-301), 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 2 · 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:270-301).

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:71-72), but only when a non-null ModulesSettings is supplied (DependencyInjection.cs:69); 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:307 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:307) 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:349) and SpeakerCategoryItemsController, which evicts three tags in one call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:186); Store's Catalog controllers use the best-effort form, for example ProductsController (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ProductsController.cs:171-172) and CategoriesController (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/CategoriesController.cs:173-174). 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:26 · Level 14 · 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:28) 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-19). Notable first-party types wired here: CurrencyJsonConverter, the Shared-layer EnumerationJsonConverterFactory (aliased because the Shared and API namespaces both surface converters), and StronglyTypedIdJsonConverterFactory (ADR-115 strongly typed identifiers, serializing wrapper types as their bare primitive), UnhandledResultFailureFilter, IdempotencyFilter and IdempotencySettings, OwnerOrAdminFilter and its OwnerOrAdminFilterOptions, ModuleControllerFeatureProvider, CurrentUserTargetingContextAccessor and DisabledFeatureHandler, IErrorLocalizer/ErrorLocalizer, ErrorResources/ErrorResourceSource, the exception handlers (OperationCanceledExceptionHandler, DomainExceptionHandler, DbUpdateExceptionHandler, ValidationExceptionHandler, GlobalExceptionHandler), CookieTokenReader and ICookieSessionRefresher/CookieSessionRefresher, the session-cookie options and store (SessionCookieSettings, ISessionCookieStore/SessionCookieStore, from MMCA.Common.API.SessionCookies, :15), 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 (:212), which projects module state into /health checks tagged module so /health?tag=module reports each module's status (:204-205). [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:45) registers controllers with ReturnHttpNotAcceptable = false and the UnhandledResultFailureFilter global filter (:47-50), adds three JSON converters (:52-65) and the XML DataContract formatters (:67). The second converter is the load-bearing one for enumerations: 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 (:56-59). The third, StronglyTypedIdJsonConverterFactory (:61-65), does the same job for ADR-115 strongly typed identifiers, so wrapper types serialize as the bare primitive they wrap without the attribute being repeated on every wrapper a consumer declares; in a host that declares no wrappers the factory converts nothing. It then conditionally registers ModuleControllerFeatureProvider when modulesSettings is non-null (:69-73), conditionally binds IdempotencySettings from the config section with data-annotation validation on start (:75-81), and registers the scoped IdempotencyFilter and OwnerOrAdminFilter (scoped because they depend on scoped services such as ICacheService and ICurrentUserService, :83-85). Right after that it binds OwnerOrAdminFilterOptions with data-annotation validation but deliberately not ValidateOnStart (:87-92): BypassRole is required with no default, the framework knows no role names, so validating at startup would fail every host that never applies the filter; validating on first resolve puts the message in front of the host that actually uses it. Feature management comes next (:94-107) and is worth reading closely: AddHttpContextAccessor() is called first because it is TryAdd-based and therefore safe to repeat, then AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() 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. Finally the singleton DisabledFeatureHandler is bound to IDisabledFeaturesHandler and AddErrorLocalization() is called (:110).
    • AddErrorLocalization() (:121) registers ASP.NET localization (:123), the singleton IErrorLocalizer via TryAddSingleton so a host can substitute its own (:124), and the framework's own ErrorResources source (:125); AddErrorResources<TResource>() (:136) adds a module's resource anchor as another ErrorResourceSource built from an IStringLocalizerFactory (:138-139). This is the ADR-027 edge error-localization extension point, keyed by Error.Code, and modules add their translations additively (:116-118).
    • AddCommonExceptionHandlers() (:149) registers ProblemDetails (adding a requestId extension from TraceIdentifier, :151-153) then five IExceptionHandlers in specificity order (:154-158): 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 (:145-146).
    • AddServerAuthSessionCookie(string apiBaseAddress) (:174) wires the SSR-prerender auth path: it guards the address (:176), then adds HttpContextAccessor, memory cache, the scoped CookieTokenReader (:178-180), a named HttpClient pointed at the internal API base address (:182-183), and the CookieSessionRefresher as a singleton (:186). The singleton is load-bearing: its in-flight map must be shared across requests for single-flight refresh to work (:185). It then registers the SessionCookieSettings options with no configuration binding, so SameSite and the claims-only browser token keep their default behavior unless the same-origin API proxy in MMCA.Common.UI.Web tightens them on opt-in (ADR-131), and the singleton ISessionCookieStore/SessionCookieStore via TryAddSingleton (:188-191). Last it registers TimeProvider.System via TryAddSingleton (:193-196): the refresher judges access-token expiry against the injected clock, and a Blazor Web host that calls only this method has no AddServices registration to supply one, while TryAdd never displaces a clock the host registered itself. The doc comment is explicit that the address is the internal endpoint, not the browser-facing one (:169-171).
    • AddModuleHealthChecks(ModuleLoader moduleLoader) (:212) adds one health check per module, Healthy for each enabled module (:216-222) and Degraded for each disabled one (:224-231), named module-{Name} and tagged module. It must run after ModuleLoader's DiscoverAndRegister (:208-209).
  • 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 and strongly-typed-id converter factories, the OwnerOrAdminFilterOptions validate-on-resolve (not on-start) choice, the targeting accessor's singleton lifetime, the refresher's singleton lifetime, and the TryAdd fallback clock for hosts that call only AddServerAuthSessionCookie are the 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.

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

DisabledFeatureHandler

MMCA.Common.API · MMCA.Common.API.FeatureManagement · MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:14 · 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:17) sets context.Result to an ObjectResult wrapping a ProblemDetails with Status = 404 and a fixed title/detail ("Feature not available", DisabledFeatureHandler.cs:19-24), and also sets the outer StatusCode = 404 (DisabledFeatureHandler.cs:26) so the response code and the body agree. It returns Task.CompletedTask (DisabledFeatureHandler.cs:29): 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:107, immediately after services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() on DependencyInjection.cs:105-106); 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:60) and the three notification controllers gated on NotificationFeatures.PushNotifications (Controllers/Notifications/DevicesController.cs:23, NotificationInboxController.cs:27, NotificationsController.cs:28).

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:47, inside the extension member declared at WebApplicationBuilderExtensions.cs:39) 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:51-52), which resolves the most severe ErrorType present through ErrorTypeSeverity. The ranking is spelled out on the method doc (ApiControllerBase.cs:21-30): Unexpected (500), then Unauthorized (401), Forbidden (403), TooManyRequests (429), 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:46-47), 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:38, :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:57-60 for the contract, ErrorHttpMapping.cs:62-70 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:34 · 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, a fail-closed export, 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, EntityCsvExporter<TEntityDTO>, ConcurrencyETag, QueryFieldService, and Specification<TEntity, TIdentifierType>; EntityQueryPipeline.MaxUnboundedResultLimit for the page-size clamp; ASP.NET Core MVC, Asp.Versioning, System.Text.Json, 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:31-33) plus the three generic constraints (EntityControllerBase.cs:41-43) 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:34-39) takes the query service and an ILogger, both null-guarded into the QueryService (:45) and Logger (:50) protected properties.
    • MaxPageSize (:59-66) resolves IOptions<ApplicationSettings> per-request from HttpContext.RequestServices, falling back to 500, and clamps the result to the range 1 to EntityQueryPipeline.MaxUnboundedResultLimit (:64; the ceiling is 1000 at MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23). The pipeline caps every page at that ceiling anyway, so the clamp keeps the pagination metadata and the export loop from assuming a page size the pipeline does not serve (:53-58). Per-request resolution means a settings change takes effect without a restart. MaxExportRows (:79-87) does the same for the export ceiling, with the extra rule that a configured value of zero or less is treated as unconfigured (:84-85), because a cap of zero would silently serve every caller a header-only file (:75-77). EntityName (:92) is typeof(TEntity).Name, used in log messages and in the export refusal.
    • GetAllAsync unpaged (:110, [HttpGet] at :107): resolves the read specification (:116), then delegates to the query service with pageNumber: 1 and pageSize: MaxPageSize (:123-124), so even the "all" endpoint is capped, then either HandleFailure or Ok.
    • GetAllAsync paged (:158, [HttpGet("paged")] at :154): clamps with Math.Min(pageSize, MaxPageSize) (:169), and on success serializes PaginationMetadata into the X-Pagination response header (:188) rather than mixing it into the body, [Rubric §9] again, and [Rubric §12, Performance & Scalability] for the clamp.
    • ExportAsync (:257, [HttpGet("export")] at :252) is covered as its own concept below.
    • GetAllForLookupAsync (:320, [HttpGet("lookup")] at :317): returns CollectionResult<BaseLookup<TIdentifierType>>, a lightweight id/label pair, with a [Required] nameProperty (:321) choosing the label.
    • GetByIdAsync (:361, [HttpGet("{id}")] at :356): includeFKs defaults to true for the single-entity case (:363), and on success it calls SetConcurrencyETag(result.Value) before returning (:382-383).
    • HandleFailure override (:451-465): logs the first error at Warning, guarded by Logger.IsEnabled (:454), 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 :125), 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) (:571-573) returns the Specification<TEntity, TIdentifierType> applied by all five read actions: both GetAllAsync overloads, GetAllForLookupAsync, GetByIdAsync, and ExportAsync (each resolves it at :116, :171, :324, :368, and :270). Four properties are worth internalizing, all stated on the hook's own doc (:535-570).
    • It is asynchronous because row scoping usually is (:547-552). 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 (:555-559). 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 (:139-142).
    • A rejected row is a 404, not a 403 (:562-564, restated on GetByIdAsync at :350-355). 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 (:311-316). GetAllForLookupAsync has no specification parameter, so the scope travels as specification?.Criteria (:328); 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() (:601) is the synchronous half: it returns null by default and is what GetReadSpecificationAsync returns by default (:573). 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 (:583-587). For the four JSON reads a null specification still means unscoped; for the export it means refused (next concept), so a row-scoped controller that overrides neither hook gets a 403 on /export rather than handing every caller the whole table (:590-593). See ADR-033 for the ownership model this hook reproduces in the query.
  • Concept introduced: a bulk read that fails closed when no scope was declared. [Rubric §11, Security] assesses whether the default on a sensitive path is the safe one. ExportAsync is the one read that can hand a caller a whole table in one request, so it does not treat a null specification as "unscoped": when the hook resolves to null and AllowUnscopedExport is false, it returns before any query runs (:273-274) with the Error.Forbidden built by UnscopedExportRefused (:608-613), which HandleFailure turns into a 403 Problem Details carrying the code ExportRowScopeRequiredErrorCode = "Export.RowScopeRequired" (:487; the 403 is declared on the action at :255). The error message names the three ways out: override GetReadSpecificationAsync, override GetExportSpecification, or opt in.
    • AllowUnscopedExport (protected virtual bool, default false, :508) is the deliberate opt-in. Its doc (:489-507) limits it to controllers whose rows every caller allowed to reach the endpoint may see (reference data, a catalog, an admin-only table already behind a role gate), and notes it is read per request, so it may depend on the principal: return null from the read hook for an administrator and a specification for everyone else, and opt in only for the administrator.
    • A non-null specification never consults the property (:504-505): a scoped export needs no opt-in. The effect is that a controller which forgot to scope its export serves no rows rather than every row (:231-238).
  • 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) (:417) 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 (:391-395) 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 (:419-420).
    • ReadRowVersion (:434) handles both shapes a served row can take: a typed DTO, read through the cached PropertyInfo (:436-437), or a shaped dictionary keyed by JSON names when the caller asked for a field projection, probed under both rowVersion and RowVersion (:439-442).
    • A DTO without a token gets no header at all (:422-423). The remark says why (:406-408): 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 (:411-414) 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). The action itself is a thin adapter over EntityCsvExporter<TEntityDTO>, which owns the page loop, the column resolution, and the CSV format; this controller keeps only the HTTP concerns.
    • A dedicated route, not content negotiation ([HttpGet("export")], :252). The remarks name the two behaviors that make an Accept: text/csv formatter wrong here (:199-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:49), 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 (:265-267): EntityCsvExporter<TEntityDTO>.ValidateFields rejects a fields= request naming a property the CSV cannot render, as an Error.InvalidEntityField failure, rather than quietly dropping the column.
    • Row scoping through the shared hook, fail-closed: the specification is resolved once, before the first page (:270), and refused when null without the opt-in (:273-274, previous concept), so the same closed-over instance filters every page the fetch delegate reads, and it is the same hook the list endpoints read, so an export cannot drift wider than the list it mirrors.
    • The page loop lives in the exporter: ExportAsync builds a fetch delegate that calls QueryService.GetAllAsync (:281-292) at pageSize = Math.Max(1, MaxPageSize) (:277) and hands it, beginResponse, and onFailureAfterStart to EntityCsvExporter<TEntityDTO>.WriteAsync (:279); the loop, its termination conditions, and the truncation bookkeeping are documented on that type.
    • Headers first, then body (BeginExportResponse, :619): content type text/csv; charset=utf-8 (CsvContentType, :475, set at :623), a Content-Disposition attachment whose file name is {controller}-{yyyyMMdd'T'HHmmss'Z'}.csv built invariantly from an injected TimeProvider (:621, :624, BuildExportFileName at :635-638, prefix ExportFileNamePrefix at :520-533), and X-Export-Row-Limit (ExportRowLimitHeaderName, :481, appended at :625) 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 (marker built by EntityCsvExporter<TEntityDTO>.TruncationMarker) and a mid-stream query failure ends with an incomplete marker plus a Warning log (LogExportPageFailure, :646-658, wired as onFailureAfterStart at :297). A failure on the FIRST page, before anything was written, still returns Problem Details through HandleFailure (:301), which is exactly what "constructing the writer sends nothing" protects on the exporter side.
    • Column resolution is derived, not invented: EntityCsvExporter<TEntityDTO>.ResolveColumns takes the shaped keys of the first row, 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. UnexportablePropertyNames on that type drops the properties that cannot render a faithful scalar cell, byte[]/ReadOnlyMemory<byte> concurrency tokens and every collection type except string, computed once per closed DTO type. Value objects and other class-typed properties are deliberately NOT dropped, because a record or value object has a meaningful invariant ToString. See EntityCsvExporter<TEntityDTO> for the full walkthrough.
  • 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, :472, matching ApplicationSettings.MaxExportRows's own default) is a number an operator can reason about rather than an unbounded connection hold. The fail-closed default puts the cost of a forgotten scope on the controller author (a 403 in their own tests) instead of on the data.
  • Caveats / not-in-source: the export carries no authorization attributes of its own. It inherits whatever [Authorize] or [FeatureGate] the derived controller declares, so a controller that exposes paged anonymously exposes export anonymously once it scopes the rows or opts in; the symmetry is deliberate and documented (:207-211). The fail-closed rule applies to ExportAsync only: the four JSON reads still run unscoped on a null specification. 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: the async GetReadSpecificationAsync by ADC's SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:109), SessionsController (SessionsController.cs:75), 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:200); the synchronous GetExportSpecification by ADC's EventsController (EventsController.cs:72) and SessionQuestionAnswersController (SessionQuestionAnswersController.cs:109) and by Store's OrdersController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:241). AllowUnscopedExport is overridden two ways. Unconditional true on reference and catalog data: ADC's ConferenceCategoriesController (ConferenceCategoriesController.cs:47), QuestionsController (QuestionsController.cs:46) and CategoryItemsController (CategoryItemsController.cs:73), and Store's ProductsController (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ProductsController.cs:51), CategoriesController (CategoriesController.cs:48), InventoryItemsController (InventoryItemsController.cs:43) and CustomersController (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:48), whose export sits behind CustomersManage and whose list endpoints are Admin-only rather than row-scoped (:90-104). Per principal, where the read hook returns null to a privileged caller: ADC's SpeakersController (SpeakersController.cs:119), SessionsController (SessionsController.cs:93), RoomsController (RoomsController.cs:138), EventsController (EventsController.cs:80), SessionQuestionAnswersController (SessionQuestionAnswersController.cs:121) and their siblings, and Store's OrdersController (OrdersController.cs:249) and ShoppingCartsController (ShoppingCartsController.cs:209). Framework coverage lives in EntityControllerBaseTests, EntityControllerBaseExportTests (opt-in doubles at EntityControllerBaseExportTests.cs:558 and :652), 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:358 and :377).

EmailConfirmationControllerBase<TSendCommand, TConfirmCommand>

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

  • What it is: the abstract base for the two anonymous email-confirmation endpoints, POST send-email-confirmation and POST confirm-email (ADR-116 is the identity-completions decision it implements; the route names are at EmailConfirmationControllerBase.cs:78 and :106). The app subclass supplies the route prefix, the API version and two one-line command factories; the base owns the HTTP shape.
  • Depends on: ApiControllerBase (base, for HandleFailure), ICommandHandler<in TCommand, TResult> (one handler per action, both returning Result), ICommandWithRequest<out TRequest> (the generic constraints, :46-47), SendEmailConfirmationRequest and ConfirmEmailRequest (the bodies), IdempotentAttribute, and the RateLimitPolicyAuthIp constant on WebApplicationBuilderExtensions.
  • Concept introduced: a sibling controller instead of a longer inheritance chain. [Rubric §9, API & Contract Design] assesses uniform endpoint conventions; [Rubric §1, SOLID] covers keeping the base closed and the variation in small abstract members. The app's own AuthController already occupies the single-inheritance chain (AuthControllerBase to UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>), so confirmation ships as a separate controller routed to the same Auth prefix, which reaches the gateway through the existing /Auth route (:13-20). The commands stay app-side, the same split PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> uses: the derived controller supplies each through CreateSendCommand (:61) and CreateConfirmCommand (:69), and the base reads the request back only through ICommandWithRequest<TRequest> (:35-38).
  • Concept introduced: anonymous by necessity, and non-enumerating. [Rubric §11, Security] is the angle. Both actions are [AllowAnonymous] because the link is followed from a mail client with no session, and an unconfirmed account may be unable to sign in at all (:23-27). Send answers 202 on every well-formed request: the handler treats an unknown address and an already-confirmed one as success, so the response never reveals which addresses hold accounts, and only a malformed payload reaches 400 through the request validator (:30-33, :78-84). Both carry [EnableRateLimiting(RateLimitPolicyAuthIp)] (:81, :109) and declare the 429 (:84, :113), for the same reason login and recovery do (ADR-019, ADR-029).
  • Walkthrough
    • Primary constructor (:43-45) exposes the two handlers as protected SendHandler (:50) and ConfirmHandler (:53).
    • SendEmailConfirmationAsync (:85): [HttpPost("send-email-confirmation")], [Idempotent], [AllowAnonymous] (:78-80); dispatches CreateSendCommand(request) (:89-91) and returns Accepted() on success, HandleFailure(result.Errors) otherwise (:93-95).
    • ConfirmEmailAsync (:114): [HttpPost("confirm-email")], [Idempotent], [AllowAnonymous] (:106-108); the link carries the address and token in the URI fragment, so the page reads them client-side and posts them here and neither reaches a server log or a Referer header (:98-101). It returns NoContent() on success (:122-124) and declares 400, 401 and 429 (:111-113).
  • Why it's built this way: unlike the credential-issuing endpoints in AuthControllerBase, neither action mints a token pair, so both opt in with [Idempotent] (ADR-017): a retried send or confirm replays the stored answer instead of re-running the handler. Both methods are virtual so an app can wrap them. Errors go through the shared HandleFailure, so a failed confirm is the same RFC 9457 shape as every other endpoint.
  • Where it's used: subclassed by ADC's EmailConfirmationController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/EmailConfirmationController.cs:29). Both actions are listed by name in the framework's anonymous-endpoint architecture gate (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:42-43), so their [AllowAnonymous] is an approved exception. Framework coverage is EmailConfirmationControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/EmailConfirmationControllerBaseTests.cs).

OAuthControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:50 · 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 (SetAsync, GetFromSharedStoreAsync, RemoveAsync), IConfiguration, an optional IDistributedLock (TryAcquireAsync), ExternalAuthExtensions (the scheme constant, OAuthControllerBase.cs:56), NonIdempotentAttribute, AuthenticationResponse, OAuthCodeExchangeRequest, and Error; the Google, GitHub, and Apple OAuth packages 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:186-188). [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, and the burn check reads the shared cache store so the single-use guarantee holds across replicas. A subclass can go further and pass an IDistributedLock to the four-argument constructor (:50-54), which makes the read-then-burn one critical section across replicas (:245-264); AddCaching registers a Redis-backed lock when an IConnectionMultiplexer is present, and the three-argument constructor (:90) leaves the lock null, so single-use then holds per replica only. See ADR-036.
  • Walkthrough
    • OAuthExchangeCodePrefix and a 2-minute OAuthExchangeCodeLifetime (OAuthControllerBase.cs:61-75) namespace and time-box the server-side token stash; the short TTL matches the single redirect-then-POST round trip. A second constant, ClientStateItemKey = "clientState" (:65), names a separate AuthenticationProperties.Items slot for the client's own opaque per-attempt value, deliberately not called "state" because that name belongs to the OAuth handler's own protocol value (:63-64). The redeem lock uses a 10-second lease and a 5-second wait (RedeemLockTimeToLive, RedeemLockWait, :80-81), matching the password-reset token service: the critical section is one cache read and one remove.
    • GoogleLogin (:105), GitHubLogin (:115), and AppleLogin (:128) each take an optional state query parameter alongside returnUrl and carry [AllowAnonymous] directly (:104, :114, :127); all three call ChallengeProvider (:397), which stashes both returnUrl and the client's state under ClientStateItemKey in AuthenticationProperties.Items and sets RedirectUri = "/auth/oauth/complete" (:399-412). Apple 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 (:119-123).
    • CompleteAsync (:148) carries [AllowAnonymous] (:146). After the middleware handles the provider callback, it reads the external cookie (:151), redirects to /login?error=oauth_failed when the ticket did not survive (:153-158), reads both the stashed returnUrl and clientState via ReadChallengeState (:163, :199-200), extracts provider claims (ExtractClaims, :166), calls ExternalLoginAsync to find or create the local user and mint tokens (:173-174), signs out the temporary external cookie (:184), then mints a 32-byte hex exchangeCode (:189), stashes the token pair in the cache under it (:190-191), and redirects with the code and, when the client sent one, the echoed state (:193, URL built by BuildSuccessRedirectUrl at :202-216).
    • ReadChallengeState (:199-200) is a safe-lookup helper (GetString is TryGetValue under the hood): the challenge normally stashes both returnUrl and clientState, but a ticket minted without them (custom challenge, provider round-trip edge) falls back to "/" and null instead of throwing on the Items indexer.
    • BuildSuccessRedirectUrl (:202-216) appends &state= (URL-escaped) only when clientState is non-empty (:209-211), for both the web redirect and the native AppendQuery branch (:213-215), so callers that never sent a state see no change to the redirect shape.
    • Name handling is defensive and never yields an empty name, because user invariants reject an empty first or last name and an empty value would fail the first external sign-up outright (:70-73). ExtractName (:303) uses GivenName/Surname only when both are non-blank (:308-311); otherwise it takes the full name from the GitHub display-name claim urn:github:name (GitHubDisplayNameClaimType, :68), because the GitHub handler maps ClaimTypes.Name to the login handle, and falls back to ClaimTypes.Name when that claim is blank (:313-319). It then splits through SplitFullName (:332), which breaks at the first space, trims, and returns the "User" / "User" placeholders (:72-73) for a blank or single-token name or an empty remainder (:334-347). A blank given or family name is replaced individually by its fallback (:322-323), 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 (:372) 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 (:374-379), 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 (:381-385). Failures route to the same surface through RedirectError (:360-363), so a native window closes on an error instead of stranding on a web login page.
    • ExchangeAsync (:234): [HttpPost("exchange")] with [NonIdempotent(...)] and [AllowAnonymous] (:230-232); the UI swaps the code for the real AuthenticationResponse out-of-band. A blank code is answered as an invalid code (:238-241). With no lock it calls RedeemExchangeCodeAsync directly (:245-248); with an IDistributedLock it first takes lock:{cacheKey} through TryAcquireAsync (:253-255), answers an invalid code if the lock cannot be taken within the wait (:256-259), and redeems inside await using on the handle (:261-264). Two replicas that both read before either removes would otherwise both hand out the token pair (:250-252). RedeemExchangeCodeAsync (:267) does the read and the burn: the lookup goes through ICacheService.GetFromSharedStoreAsync (:273) rather than the ordinary read, because a code already burned on another replica must be a miss here, not a stale local copy that would mint a second token pair (:271-272). Because the response is a struct, a cache miss yields a default value rather than null, so the miss is detected via an empty AccessToken (:274-277). The code is then removed (:280), making it single-use so a leaked or replayed code cannot mint a second token pair. Every failure path returns the same opaque 400 "Invalid sign-in code" (InvalidCode, :285-291).
  • 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. Reading the stash from the shared store keeps "single-use" true when the API runs on more than one replica, where a per-node copy could otherwise outlive the burn. The [NonIdempotent] justification on ExchangeAsync (:231) 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 lock is optional so a single-node host pays nothing, while a multi-replica host opts in by constructor argument; a caller that loses the lock race is answered as an invalid code, never as a second success (:250-252). The AppendQuery helper (:388) deliberately uses OriginalString rather than ToString(), because Uri normalization appends a trailing slash to authority-only URIs (e.g. myapp://oauth-complete) and native authenticator callback matching can be exact (:390-392). The client-supplied state is carried opaquely: the server never interprets or trusts it, only stores and echoes it back so the client can bind the completion to the flow it started (:406-408).
  • 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. How GetFromSharedStoreAsync bypasses a local tier is the cache implementation's concern (ICacheService), not this file's. The complete and exchange actions are hidden from OpenAPI with [ApiExplorerSettings(IgnoreApi = true)] (:147, :233) 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:21), 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/Auth/OAuthControllerBaseTests.cs), and ADC exercises the exchange end to end in OAuthExchangeTests (MMCA.ADC/Tests/Integration/MMCA.ADC.Identity.IntegrationTests/Auth/OAuthExchangeTests.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:41) 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:105, awaited at IdempotencyFilter.cs:251) is answered with 409 Conflict rather than a replay (IdempotencyFilter.cs:305-310). 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:57) and SessionsController (SessionsController.cs:55), 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).

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:541); 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:105-106), 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>.

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.

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), 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 :96), so any consumer inheriting this base gets per-IP protection without opting in. The class doc (AuthControllerBase.cs:21-28) 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, and here every one opts out. This base is the framework's worked example of the NonIdempotentAttribute rule: all five POSTs carry [NonIdempotent("...")] with a written harm, because each one either mints credentials or must reach the store. 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 (:70). Register issues a token pair too, so a replay would hand back tokens minted for an earlier call and keep serving them after the session was revoked; a retried registration instead hits the email-exists 409, which the justification names as the correct answer (:94, the 409 declared at :101). Refresh rotates the refresh token, so a replay would return a token the rotation has already invalidated and hand the client dead credentials (:125). 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 (:153) or a device signed in (:217) after the user asked otherwise.
  • Walkthrough
    • Primary constructor (:43-45) exposes AuthenticationService and CurrentUserService as protected properties (:48 and :51) so derived controllers can reach them for extra endpoints.
    • ClientIpAddress (:58) and ClientUserAgent (:64) 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 (:53-57). The user agent is purely informational, so a device list can name the session a user is looking at; nothing validates against it (:60-63).
    • LoginAsync (:76): [HttpPost("login")], [NonIdempotent], [AllowAnonymous], throttled per IP (:69-72); calls LoginAsync(request, ClientIpAddress, ClientUserAgent, ...) (:80-82) and returns Ok or HandleFailure. The [ProducesResponseType] attributes (:73-75) feed the OpenAPI contract and include the 429 the limiter can produce.
    • RegisterAsync (:102): [HttpPost("register")], [NonIdempotent], anonymous and throttled (:93-96); returns StatusCode(StatusCodes.Status201Created, ...) (:112), correctly 201 Created for a new account rather than 200. It is virtual so a module can override it to inject extra context (:90-91). Its declared responses include 401, 429, 400 and 409 (:98-101).
    • RefreshAsync (:130): [AllowAnonymous] (:126), since exchanging an expired token pair is pre-authentication, and deliberately not throttled (:31-35): 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. It declares a 409 (:129): a refresh token that a concurrent request rotated less than RefreshSessions:ReuseGraceSeconds ago is answered 409 Conflict (Auth.RefreshSuperseded) with nothing revoked, because the session is still live and the client should retry with the winner's token rather than sign out (:117-122).
    • RevokeAsync (:157): [Authorize] (:154); reads CurrentUserService.UserId, returns Unauthorized() if null (:159-161) as a defensive guard even though [Authorize] should already prevent a null id, then calls RevokeAllSessionsAsync and returns NoContent() (:163-169). 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 (:145-150).
    • GetMySessionsAsync (:187): [HttpGet("my-sessions")] plus [Authorize] (:183-184), 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() (:195), 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 (:176-180).
    • RevokeSessionAsync (:222): [HttpPost("revoke/{sessionId:guid}")] (:216), 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 also answers 404 (code Auth.SessionAlreadyRevoked) and writes nothing, so the device list can say the device was already signed out rather than claim this click signed it out (:207-212).
  • 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.RateLimiting.cs:353, which registers the "auth-ip" policy at :395-397, constant defined at :23, with an authIpPermitLimit default of 30 requests per minute per IP at :326) fails at startup on an unregistered policy rather than silently serving unthrottled logins (AuthControllerBase.cs:38-40).
  • Caveats / not-in-source: the per-IP policy partitions on Connection.RemoteIpAddress (WebApplicationBuilderExtensions.RateLimiting.cs:307) 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.RateLimiting.cs:310), a fail-open posture matching the global limiter and documented at WebApplicationBuilderExtensions.RateLimiting.cs:418-425.- 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:42) and Store (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:34) extend. Password recovery is deliberately NOT on this chain: it ships as the sibling PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>. RegisterAsync is one of the anonymous endpoints the framework's architecture gate lists by name (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:27). 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:370), 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:31) 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:138-139), 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:125), which DependencyInjection's AddAPI calls automatically (DependencyInjection.cs:110); modules add theirs by calling AddErrorResources<TResource>() (DependencyInjection.cs:136).

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:256), a failed cache read (IdempotencyFilter.cs:365) and a failed cache write (IdempotencyFilter.cs:454); RecordConflict for an in-flight duplicate (IdempotencyFilter.cs:268) and a body mismatch (IdempotencyFilter.cs:375); RecordReplayed on every served replay (IdempotencyFilter.cs:381).

IdempotencyRecord

MMCA.Common.API · MMCA.Common.API.Idempotency · MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyRecord.cs:21 · 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, the hash of the request body that produced it, and the two response headers a client may act on (Location and ETag). 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 five-parameter positional record whose first three parameters are required and whose last two are optional and nullable.
  • Concept: introduced fully by IdempotencyFilter; this is the value it persists. Two properties of the shape are load-bearing. First, the record captures the status, the body and exactly two headers, not the whole header collection: Location so a replayed 201 Created still says where the resource is, and ETag so a replayed response still carries its concurrency token (parameter docs, IdempotencyRecord.cs:14-20; replay at IdempotencyFilter.cs:386-392). The replay path adds X-Idempotent-Replay itself (IdempotencyFilter.cs:384). 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, string? Location = null, string? ETag = null) (IdempotencyRecord.cs:21-26). StatusCode is the original response's code, defaulting to 200 when an ObjectResult carried none (IdempotencyFilter.cs:476). ResponseBody is non-nullable: an ObjectResult stores objectResult.Value serialized with the host's MVC JSON options (falling back to JsonSerializerOptions.Web), and stores string.Empty when the value is null (IdempotencyFilter.cs:435-436 and :481), while a body-less 2xx such as the 204 from NoContent() also stores string.Empty (IdempotencyFilter.cs:495); the empty string 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:190-193). Location is resolved only for an ObjectResult (IdempotencyFilter.cs:487) and is null when the response carried none; ETag is whatever the action wrote to the response headers, null when empty (IdempotencyFilter.cs:468-469). The parameter doc notes that Location is also null for records written before the field existed (IdempotencyRecord.cs:16), which is why both new parameters are optional.
  • Why it's built this way: keeping the record to primitives makes it provider-agnostic, so the same artifact round-trips through the in-memory cache or Redis with no serializer coupling. Adding the two headers as trailing optional parameters keeps a record cached before they existed deserializable, with the replay simply skipping a header it does not have (IdempotencyFilter.cs:388-392). 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:490-492 names that convention at the place a body-less status result is stored.
  • Where it's used: read by IdempotencyFilter through ICacheService (IdempotencyFilter.cs:361), built by its BuildRecord (IdempotencyFilter.cs:463), and written back with the configured expiration (IdempotencyFilter.cs:450).

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:77-80), 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:442-446). 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:81) 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:124), 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:62), which accepts a null localizer and leaves messages in English (ErrorHttpMapping.cs:66) while leaving the machine Code verbatim so clients can still branch on it (ErrorHttpMapping.cs:57-59).

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:231). 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:132), 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:124) is safe precisely because the type holds no per-request state.
  • Where it's used: registered by AddErrorLocalization() (DependencyInjection.cs:121-127, which also calls services.AddLocalization() at :109), and that method is called by AddAPI (DependencyInjection.cs:110); 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:68 · 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:69), which is the detail that makes body-aware deduplication possible.
  • Depends on: ICacheService and IDistributedLock, both resolved per request from RequestServices (IdempotencyFilter.cs:141 and IdempotencyFilter.cs:149); 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 (with the host's MVC IOptions<JsonOptions> resolved per request when present), IUrlHelperFactory for reconstructing a created result's Location, 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:23-33): 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:34-39, restated at IdempotencyFilter.cs:221-224), 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:44-52).
  • Walkthrough
    • Constants and shared state: IdempotencyKeyHeader delegates to IdempotencyHeaders.IdempotencyKey (IdempotencyFilter.cs:74, defined at MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19 as "Idempotency-Key"); CacheKeyPrefix returns "idempotency:" (IdempotencyFilter.cs:76); DefaultExpiration of 24 hours (IdempotencyFilter.cs:81); LockTimeToLive of 30 seconds and LockWait of 5 seconds (IdempotencyFilter.cs:98 and IdempotencyFilter.cs:105), 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:111-112).
    • KeyLocks (IdempotencyFilter.cs:91) is a static KeyedSemaphoreStripe. The comment above it (IdempotencyFilter.cs:83-90) 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:120) 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:122-123), so ordinary traffic on an [Idempotent] action pays nothing, then awaits next() (IdempotencyFilter.cs:125).
    • Action stage. OnActionExecutionAsync (IdempotencyFilter.cs:129): no header means straight through to next() with nothing else resolved (IdempotencyFilter.cs:132-137), so idempotency is opt-in per request as well as per action. Otherwise it derives the cache key (IdempotencyFilter.cs:139), hashes the body (:140), resolves ICacheService (:141), and tries the lock-free replay (:144-145). On a miss it picks a lock strategy: GetService<IDistributedLock>() returning null routes to the process-stripe path (:149-154), otherwise the distributed path (:156-157). The comment at :147-148 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:164-171) 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:183-194): a stream that cannot seek was never buffered, so it takes EmptyBodyHash rather than throwing (:186-187), which leaves such a request deduplicated on its key alone. Otherwise it rewinds to 0, hashes with SHA256.HashDataAsync, and rewinds again (:189-191) because model binding still has to read the whole body.
    • ExecuteUnderProcessLockAsync (IdempotencyFilter.cs:200): acquires the stripe honoring RequestAborted inside a using so release survives a throw (:207), re-checks the cache (:210), then executes and stores (:213).
    • ExecuteUnderDistributedLockAsync (IdempotencyFilter.cs:239) is where the interesting policy lives, and it separates three outcomes that a naive implementation conflates (rationale in the remarks, :220-238).
      • The lock backend faults: counted on idempotency.degraded, logged, and the action runs unguarded (:254-260). 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() (:262-274). That helper (:302-311) 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 (:276), double-check (:279), then execute and store (:282).
    • ExecuteAndStoreAsync (IdempotencyFilter.cs:287-296) is the two-line shared tail of all three paths: await next(), then TryStoreAsync the executed result.
    • TryReplayAsync (IdempotencyFilter.cs:352) 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 (:359-367). A stored record whose RequestBodyHash differs from this request's hash (an ordinal comparison, :373) yields a body_mismatch conflict and BodyMismatchResult() (:373-379), 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 (:318-322). On a genuine hit it counts the replay (:381), appends X-Idempotent-Replay: true (:384, name from IdempotencyHeaders.cs:25), restores the stored Location and ETag headers when the record carries them (:386-392, so a replayed 201 still says where the resource is and a replayed response still carries its concurrency token), and short-circuits with a bare StatusCodeResult when the stored body is empty or a ContentResult with application/json otherwise (:394-401). That split is what makes a replayed 204 look like the original 204 instead of a 204 carrying a content type.
    • TryStoreAsync (IdempotencyFilter.cs:426) resolves the host's MVC JsonOptions and falls back to JsonSerializerOptions.Web only when none is registered (:433-436): the replayed body has to be shaped exactly like the original, which MVC wrote with the host's converters. It then builds the record and returns early when it is not cacheable (:438-440), reads the expiration from IdempotencySettings when registered and DefaultExpiration otherwise (:442-446), and swallows a failing SetAsync after counting it (:448-456): the action already ran, so failing here would push the client into the very retry the filter exists to deduplicate (:421-424).
    • BuildRecord (IdempotencyFilter.cs:463) decides what is cacheable. It first reads any ETag the action wrote to the response, storing null when it is empty (:468-469). An ObjectResult with a non-2xx status returns null (:476-478); a 2xx one stores its value serialized with the resolved options, or the empty string when the value is null so an Accepted() with no payload replays as a body-less status rather than a JSON null (:473-474 and :481; the VSTHRD103 suppression at :480 documents that serializing to a string is correctly synchronous), together with the resolved Location and the ETag (:483-488). A StatusCodeResult with a 2xx status, which is what NoContent(), OkResult and StatusCode(int) produce, stores the empty string plus the ETag (:493-496). Everything else, including every redirect or file result, returns null and is not stored (:498-499); IsSuccess is the >= 200 and < 300 test (:530).
    • ResolveLocation (IdempotencyFilter.cs:507-528) exists because a created result writes its Location when the RESULT executes, after this filter has already run, so the filter computes it the same way the result will (:503-506): a CreatedResult gives its Location directly (:511-512), a CreatedAtRouteResult goes through IUrlHelperFactory and Link (:514-517), a CreatedAtActionResult through IUrlHelperFactory and Action with the request's scheme and host (:519-523), and any other ObjectResult yields null (:525-526).
    • BuildCacheKey (IdempotencyFilter.cs:539-553) is the security-relevant part: the subject is the caller's user-id claim via FindUserIdValue() (:541, 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} (:542); the route is the attribute route template falling back to the request path (:544-546); those plus the HTTP method and the client key are joined with a newline (:548-549, a character valid in none of the components, so the tuple cannot be forged), SHA-256 hashed (:550), and emitted as idempotency:{lowercase hex} (:552).
    • Logging is entirely source-generated [LoggerMessage] partials with instance forwarders (IdempotencyFilter.cs:555-606): one Information message for a served replay (:555-561) and six Warnings covering body mismatch (:563-568), in-flight duplicate (:570-575), lock-wait timeout (:577-582), cache-read failure (:584-590), cache-store failure (:592-598) and lock unavailability (:600-606). [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:60-66; ADR-017). Hashing also bounds the stored key length regardless of what a client sends (:532-538). Non-2xx results are deliberately not stored (IdempotencyFilter.cs:410-413) 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 (:416-419): 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:53-59): 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 the status code, the JSON body, and the Location and ETag headers stored on IdempotencyRecord (IdempotencyFilter.cs:386-392); any other header the original action set is not captured and is therefore not reproduced, and X-Idempotent-Replay is added. Location is reconstructed only for CreatedResult, CreatedAtRouteResult and CreatedAtActionResult, and for the two URL-helper cases only when an IUrlHelperFactory is registered (IdempotencyFilter.cs:515 and :521); a Location the action set on the response by hand is not captured. A record cached before these fields existed replays without them.
  • Where it's used: registered scoped in AddAPI because it depends on scoped services (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:84, 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]), both actions of PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (PasswordResetAuthControllerBase.cs:76 and :100), and the send endpoint of NotificationsController (NotificationsController.cs:44). The token-issuing and revoking actions of AuthControllerBase, register included, opt out explicitly with NonIdempotentAttribute (AuthControllerBase.cs:70, :94, :118, :145, :208). 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:84); 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:132-137). 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:124-126), which uses TryAddEnumerable so calling both AddCommonApiVersioning() (WebApplicationBuilderExtensions.cs:39, 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:149-160) 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:151-153). [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:156), 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:18 · Level 0 · class (sealed)

  • What it is: the handler that maps an OperationCanceledException caused by the client hanging up mid-request (HttpContext.RequestAborted is cancelled) to HTTP 499 Client Closed Request instead of letting it inflate the 500 rate. Any other cancellation is deliberately left alone.
  • 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-12) that monitoring stacks read as "the caller gave up", so client cancellations stop looking like server errors on a dashboard. The same comment draws the other half of the line (:13-14): a cancellation the client did not cause (a downstream HttpClient timeout, an internal token) IS a server error and must still count as one.
  • Walkthrough: TryHandleAsync (OperationCanceledExceptionHandler.cs:24) applies a two-part guard (:29-33): it returns false unless the exception is an OperationCanceledException and httpContext.RequestAborted.IsCancellationRequested is true. Past the guard it logs at LogWarning with a client-disconnected message (:35), sets StatusCodes.Status499ClientClosedRequest (:37), and still builds a problem document titled "Operation Canceled Exception" (:38-48) which it writes through TryWriteAsync (:50). 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:154). That position matters: a client abort propagating out of a CancellationToken would otherwise be claimed by nothing until GlobalExceptionHandler turned it into a 500. The RequestAborted check is the reverse protection: an OperationCanceledException thrown while the client is still connected (a timeout inside the request) falls through to GlobalExceptionHandler and is answered and logged as the 500 it is, rather than being hidden as a 499 Warning. 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:95 and RedisFixedWindowRateLimiter.cs:150).
  • Where it's used: AddCommonExceptionHandlers() (DependencyInjection.cs:154); it fires for request-abort propagation from any handler that honors its CancellationToken, and only while RequestAborted is signalled.
  • Caveats / not-in-source: whether the 499 body actually reaches a disconnected client is not determinable from this file; once the guard passes, the write is attempted unconditionally at :50.

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:166 for the paged read and :256 for the CSV export).

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:157), 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:155), 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:155).

GlobalExceptionHandler

MMCA.Common.API · MMCA.Common.API.Middleware · MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:30 · 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 two exception types mapped on the way: a CrossTenantWriteException is answered 400 instead, and a BadHttpRequestException is answered with the status it carries (413 or 400 typically).
  • Depends on: IProblemDetailsService and ILogger<GlobalExceptionHandler> (primary-constructor injected, GlobalExceptionHandler.cs:30-33), 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:38-43). The bad-request path applies the same restraint: its detail is a fixed sentence, never the exception message (:93-95).
  • Walkthrough: TryHandleAsync (GlobalExceptionHandler.cs:49) has three paths.
    • CrossTenantWriteTitle (:36) and CrossTenantWriteDetail (:44-46) are internal const strings, so the tests assert against the same literals the handler writes.
    • The tenant-boundary path (:54-72): a CrossTenantWriteException is logged at LogWarning (:58), 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 (:56-57). It sets Status400BadRequest (:60) and returns TryWriteAsync with the two constants (:61-71).
    • The bad-request path (:74-98): a BadHttpRequestException (Kestrel or model binding rejected a malformed or oversize request) is a caller fault, so it is logged at LogWarning with its status code (:80) and answered with the status the exception already carries, passed through unchanged (:82). The title is "Payload Too Large" when that status is Status413PayloadTooLarge and "Bad Request" otherwise (:90-92), and the detail follows the same split (:93-95).
    • The catch-all path (:100-113): logs "Unhandled exception occurred" with the exception at LogError (:100), sets StatusCodes.Status500InternalServerError (:102), and returns the result of problemDetailsService.TryWriteAsync(...) (:103-113) with the generic title "Internal Server Error" and a "please try again" detail (:110-111). The exception object is attached to the ProblemDetailsContext (:106) 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. The BadHttpRequestException mapping is documented beside it (:20-26): the exception already knows which client status applies, so the handler passes it through rather than collapsing an oversize body into a 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:158); its own doc says so (DependencyInjection.cs:145-146).
  • Where it's used: AddCommonExceptionHandlers() (DependencyInjection.cs:158); every host that calls it inherits the same 500 contract, the same tenant-boundary 400 and the same bad-request pass-through.

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:71) so the gRPC edge classifies the same aggregate identically (ErrorHttpMapping.cs:46-47, and see MMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:124). [Rubric §27, i18n] applies through the localizer parameter.
  • Walkthrough
    • ErrorTypeToStatusCode (ErrorHttpMapping.cs:20-32): 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, Unexpected to 500, and TooManyRequests to 429 (:31). 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) (:38-39) uses GetValueOrDefault(..., Status400BadRequest), so a future error type falls back to 400 rather than throwing inside an error path.
    • GetStatusCode(IReadOnlyList<Error>) (:51-52) 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 (:41-50).
    • BuildErrorsExtension(IReadOnlyList<Error>, IErrorLocalizer?) (:62-70) 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 (:66); a null localizer leaves the original English text. Code, Type, Source and Target stay verbatim so clients can branch on them regardless of culture (:54-61).
  • 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).

StronglyTypedIdSchemaTransformer

MMCA.Common.API · MMCA.Common.API.OpenApi · MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/StronglyTypedIdSchemaTransformer.cs:24 · Level 3 · class (sealed)

  • What it is: an IOpenApiSchemaTransformer that rewrites the OpenAPI schema for a strongly typed identifier (ADR-115) so the generated document describes it as the primitive it wraps (an integer or a string), not as the wrapper's own object shape.
  • Depends on: Microsoft.AspNetCore.OpenApi.IOpenApiSchemaTransformer, Microsoft.OpenApi.Models.OpenApiSchema and JsonSchemaType; the shared StronglyTypedId.TryDescribe helper that both this type and StronglyTypedIdParameterTransformer call to recognize a wrapper and extract its underlying value type.
  • Concept introduced: OpenAPI schema transformers as the escape hatch for a type the JSON pipeline flattens but the schema generator does not. AddStronglyTypedIds teaches System.Text.Json to serialize a wrapper as its bare value, but ASP.NET Core's OpenAPI generator still reflects the wrapper's own properties unless something intervenes; IOpenApiSchemaTransformer is exactly that intervention point, run once per discovered schema. [Rubric §9, API & Contract Design] assesses whether the published contract matches the wire format: without this transformer a client generator would model OrderId as { "value": 123 } instead of a bare integer, which is not what the JSON converter actually emits.
  • Walkthrough: TransformAsync(OpenApiSchema, OpenApiSchemaTransformerContext, CancellationToken) (StronglyTypedIdSchemaTransformer.cs:28-45):
    • Null-guards schema and context (:33-34).
    • StronglyTypedId.TryDescribe(context.JsonTypeInfo.Type, out _, out var valueType) (:36): a no-op return when the schema being generated is not for a strongly typed identifier (:37).
    • For a match, it clears schema.Properties and schema.Required (:39-40), which erases the object shape the reflection-based generator would otherwise have built, then sets schema.Type = MapType(valueType) and schema.Format = MapFormat(valueType) (:41-42).
    • MapType (:47-50, internal static) returns JsonSchemaType.Integer for int/long, else JsonSchemaType.String. MapFormat (:52-61) returns "int32", "int64", "uuid" (for Guid), or null, matching the OpenAPI format vocabulary a client generator already understands.
  • Why it's built this way: both mapping methods are internal static rather than private, so StronglyTypedIdParameterTransformer reuses the identical type/format decision for route and query parameters instead of duplicating the switch; the two transformers can never disagree about how one wrapper renders.
  • Where it's used: registered unconditionally inside AddCommonOpenApi() via options.AddSchemaTransformer(new StronglyTypedIdSchemaTransformer()) on every OpenApiOptions instance (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:110, inside the ConfigureAll<OpenApiOptions> block at :502-506, whose comment states it is inert in a host that declares no wrappers, :498-501). ConfigureAll rather than a single named document is deliberate: AddOpenApi() creates one OpenApiOptions per discovered API version, and the wire shape is identical in every one.

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:259-262), 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:107-113), which WebApplicationExtensions.UseCommonMiddlewarePipeline() applies. It is wired unconditionally and inert by default.

StronglyTypedIdParameterTransformer

MMCA.Common.API · MMCA.Common.API.OpenApi · MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/StronglyTypedIdSchemaTransformer.cs:74 · Level 4 · class (sealed)

  • What it is: an IOpenApiOperationTransformer that rewrites the per-operation parameter schema for a strongly typed identifier bound from a route segment or a query string, the operation-level counterpart to StronglyTypedIdSchemaTransformer.
  • Depends on: Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer, Microsoft.OpenApi.Models.OpenApiParameter/OpenApiSchema; StronglyTypedIdSchemaTransformer.MapType/.MapFormat for the type/format decision, and StronglyTypedId.TryDescribe to recognize a wrapper.
  • Concept: a component-level schema and a parameter-level schema are generated by two different ASP.NET Core OpenAPI extension points, so fixing the component schema alone leaves an operation's own parameter list still describing the wrapper's object shape; the framework installs one transformer of each kind so both descriptions agree. [Rubric §9, API & Contract Design] applies for the same reason as the sibling type: a client generator reading only the operation's parameters (the common case for a route or query parameter) needs the primitive shape too.
  • Walkthrough: TransformAsync(OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken) (StronglyTypedIdSchemaTransformer.cs:77-109):
    • Null-guards operation and context (:82-83), then exits early when operation.Parameters is null (:85-86).
    • Iterates context.Description.ParameterDescriptions (:88), one entry per bound parameter on the action. For each, it resolves the effective type as described.ModelMetadata?.ModelType ?? described.Type (:90) and skips it when that type is null or is not a strongly typed identifier per StronglyTypedId.TryDescribe (:91-92).
    • Matches the description back to the operation's OpenApiParameter by name: operation.Parameters.OfType<OpenApiParameter>().FirstOrDefault(p => string.Equals(p.Name, described.Name, StringComparison.Ordinal)) (:94-96), and skips a miss (:98-99).
    • Replaces parameter.Schema outright with a new OpenApiSchema { Type = StronglyTypedIdSchemaTransformer.MapType(valueType), Format = StronglyTypedIdSchemaTransformer.MapFormat(valueType) } (:101-105), rather than mutating the existing schema in place, since the wrapper's original parameter schema carries nothing worth preserving.
  • Why it's built this way: reusing the sibling transformer's MapType/MapFormat instead of re-deriving the mapping keeps the component schema and every parameter schema for the same wrapper type identical by construction; there is no separate table to fall out of sync.
  • Where it's used: registered unconditionally alongside its sibling inside AddCommonOpenApi() via options.AddOperationTransformer(new StronglyTypedIdParameterTransformer()) (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:111), inside the same ConfigureAll<OpenApiOptions> block (:502-506) that installs StronglyTypedIdSchemaTransformer.

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:50), 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:20 · 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:23) is a public const, so clients, tests and downstream code all name the header from one place.
    • MaxLength = 64 (:26) is an internal const: the width of the persisted correlation columns (its doc comment at :25).
    • InvokeAsync(HttpContext, ICorrelationContext) (:35): 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 (:37-38).
    • The resolution waterfall (:40-43): an inbound X-Correlation-ID header wins when it is not null, empty or whitespace (string.IsNullOrWhiteSpace) and survives Sanitize (:41); otherwise Activity.Current?.TraceId (the W3C trace ID that OpenTelemetry propagates, :42), else ASP.NET's HttpContext.TraceIdentifier (:43). The chain is null-coalescing, so a header that Sanitize rejects falls through to the generated ID exactly as a blank one does. A usable caller-supplied ID is therefore honored, and the ID always exists.
    • Sanitize (:61-65) cuts a caller value longer than MaxLength to its first 64 characters with a range slice (:63), then keeps it only when every character is printable ASCII, >= ' ' and <= '~' (:64); any other character yields null.
    • correlationContext.SetCorrelationId(correlationId) (:45) publishes it, then context.Response.OnStarting(...) (:46-50) 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) (:52) 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. Truncating at the boundary rather than at the database means the id echoed in the response header is exactly the one stored (class comment, :13-15), and treating a blank header as absent keeps an empty value from displacing the trace-ID fallback. Replacing a non-ASCII or control-character value with the generated ID is a defense for the echo itself: Kestrel refuses to write such a value into a response header, which would fail the request after its handler had already run (class comment, :15-17).
  • 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:38-40, 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:20), 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:37 · 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), by reading a deleted-user marker from the shared cache store, and (on a host that registers the validator) falling back to a database query when no marker is set. A "not deleted" answer is never cached.
  • Depends on: ICurrentUserService, ICacheService, ISoftDeletedUserValidator (resolved lazily), and SoftDeletedUserCache for the key and the marker duration (SoftDeletedUserMiddleware.cs:64-68).
  • 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:22-36) 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 15 minutes (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:32), matching the access-token lifetime so it outlives any token issued before the delete. The trade is that a live user costs one shared-store read, plus one validator query on the Identity host, per authenticated request (class comment, SoftDeletedUserMiddleware.cs:17-19).
  • Walkthrough: InvokeAsync(HttpContext, ICurrentUserService, ICacheService, ILogger<SoftDeletedUserMiddleware>) (SoftDeletedUserMiddleware.cs:64-68), per-invoke injected including the logger (the reason is stated at :59-62):
    • Anonymous fast path (:72-80): 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.
    • Cache read (:82-103): key from SoftDeletedUserCache.KeyFor(userId.Value) (:82). The read is GetFromSharedStoreAsync<bool?> (:91-93, declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:65), never the replica's local L1 copy, because the marker is written by whichever replica handled the delete and a local read could miss it for the local cache duration (:88-90). A cache exception that is not an OperationCanceledException is logged and treated as a miss (:95-102), and cacheReachable goes false so the write is skipped too.
    • cachedResult is true (:105-112): the user is known deleted, respond 401 and stop the pipeline. This happens before the validator is looked up, so the marker is honored on every host, including one that does not host Identity.
    • Validator resolution (:114-122): 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 :48-58). On such a host a cache miss or unreachable cache simply passes the request through. This is the "wired unconditionally, inert when not applicable" convention that TenantResolutionMiddleware later copied by name (TenantResolutionMiddleware.cs:16).
    • cachedResult is false (:124-131): an explicit "not deleted" entry, written only by an earlier framework version; it is honored because it came from the shared store, where a later delete overwrites it with the marker.
    • No marker (:133-150): query IsUserSoftDeletedAsync (:139-141). If that throws (again excluding cancellation) the request is allowed through with a warning (:143-149), which is the fail-open path.
    • A deleted user (:152-173): the marker is written with SetAsync(cacheKey, true, SoftDeletedUserCache.MarkerDuration, ...) when the cache is reachable (:154-169, a failed write only costs the next request a lookup), then 401 (:171-172). A live answer is never written back, because each replica keeps its own local copy of a cached value and a cached live answer would outlive a delete made on another replica (:133-135).
    • Otherwise next runs (:175).
    • Three [LoggerMessage] warnings (:178-200) name the failure precisely: cache read (which now says it falls back to the validator query when one is registered), cache write, validator, each stating what the request did next.
  • Why it's built this way: every catch filters out OperationCanceledException (:95, :143, :163), so a client disconnect is never misread as a dependency outage and is left to OperationCanceledExceptionHandler. Returning a bare 401 status with no body (:110, :171) 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:123-125), placed after RateLimiting (:115-121) and before Authorization (:127-129), 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).

CommonForwardedHeaders

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/CommonForwardedHeaders.cs:27 · Level 0 · class (static)

  • What it is: a two-member helper that names the framework's default forwarded-header mask and builds a fresh ForwardedHeadersOptions with that mask and a cleared proxy/network allowlist. It is the shared posture behind ASP.NET Core's own UseForwardedHeaders middleware.
  • Depends on: Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders and ForwardedHeadersOptions only. CommonForwardedHeadersExtensions and MiddlewarePipelineBuilder both call Create.
  • Concept (one named default, cleared allowlists by default). [Rubric §13, Observability & Operability]: forwarded-header handling decides what scheme, host and client address every downstream component sees, so naming the default mask once (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, CommonForwardedHeaders.cs:31-32) keeps every host consistent instead of re-typing the three-flag combination. Clearing KnownProxies and KnownIPNetworks rather than leaving ASP.NET Core's own defaults in place is deliberate: those lists exist to restrict which upstream hop is trusted, and a containerized host behind Aspire/DCP or an Azure ingress has no fixed proxy IP to allowlist, so the framework accepts the forwarded values from any hop and relies on the ingress topology itself to keep the header trustworthy.
  • Walkthrough
    • DefaultHeaders (:31-32) is a static get-only property returning the three-flag ForwardedHeaders mask.
    • Create(ForwardedHeaders? headers = null) (:40) builds a ForwardedHeadersOptions with ForwardedHeaders = headers ?? DefaultHeaders (:42-45), clears both allowlists (:47-48), and returns the new instance (:50).
  • Why it's built this way: returning a fresh options instance rather than a cached singleton means a caller that mutates the result (adding back a known proxy, say) never affects another caller's options.
  • Where it's used: MiddlewarePipelineBuilder calls CommonForwardedHeaders.Create() for the default pipeline's forwarded-headers step (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:69), and CommonForwardedHeadersExtensions calls it from the UI-host convenience wrapper. Both ADR-079 (shared HTTP middleware pipeline) and ADR-124 (Blazor circuit ceiling and UI edge) discuss forwarded-header handling at the edge.
  • Caveats / not-in-source: nothing here restricts which upstream hop is trusted; that is left entirely to the hosting environment's own network boundary.

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:123-125). AddErrorLocalization() is called automatically from AddAPI (DependencyInjection.cs:110), 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:136-140). 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:125. 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.

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.RateLimiting.cs:264) to build either a SlidingWindowRateLimiterOptions partition (:239-246) or a FixedWindowRateLimiterOptions one (:249-255).

RedisRateLimitLease

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:172 · 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:167-171): 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:175) 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).

CommonForwardedHeadersExtensions

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

  • What it is: a one-member extension(IApplicationBuilder app) wrapper, UseCommonUiForwardedHeaders, that a server-rendered UI host calls to adopt forwarded headers with the CommonForwardedHeaders posture.
  • Depends on: CommonForwardedHeaders.Create and ASP.NET Core's UseForwardedHeaders extension.
  • Concept (a UI-specific entry point, ordered first). The doc comment (CommonForwardedHeadersExtensions.cs:14-17) states the ordering contract directly: call it FIRST in a server-rendered UI host's pipeline, before anything reads the scheme, the host, or the client address (security headers, rate limiting, HTTPS redirection, antiforgery). [Rubric §13, Observability & Operability] and [Rubric §29, Resilience & Business Continuity] both apply: a UI host that reads the wrong scheme before forwarded headers are adopted can mis-evaluate an HTTPS-only cookie or antiforgery check.
  • Walkthrough: UseCommonUiForwardedHeaders(ForwardedHeaders? headers = null) (:24-25) is a one-line extension member that calls app.UseForwardedHeaders(CommonForwardedHeaders.Create(headers)).
  • Why it's built this way: a named extension on IApplicationBuilder rather than a bare call to CommonForwardedHeaders.Create() plus UseForwardedHeaders gives UI hosts one call to place first, matching the naming convention of the service-host pipeline members on WebApplicationExtensions. CommonForwardedHeaders's own doc comment cross-references this member (CommonForwardedHeaders.cs:9) so a reader lands on the UI-host entry point from either type.
  • Where it's used: the ADC Blazor UI host calls it first in its pipeline, before UseCommonMiddlewarePipeline-equivalent steps (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:210).
  • Caveats / not-in-source: nothing enforces the "call it first" contract; a host that places it later gets whatever scheme and host the raw connection reports until this call runs.

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 five permit counts (including the anonymous hub limit), the queue depth, the algorithm choice, the sliding-window segment count, the hub path prefixes that get anonymous metering, 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.
    • HubPathPrefixes (:91, default ["/hubs"]) and AnonymousHubPermitLimit (:99, [Range(1, 1_000_000)], default 60) are a pair added for SEC-ADC-25 (RateLimitingSettings.cs:75-91, :93-99). The global limiter exempts anonymous callers everywhere else on purpose (public reads are output-cached, server-rendered Blazor traffic shares one host IP); a real-time hub is the exception, because a gateway that bypasses /hubs at the edge (ADR-024 requires it, since the hub authenticates from a query-string token the edge cannot read) left anonymous /hubs/*/negotiate traffic metered nowhere. HubPathPrefixes names which paths get that anonymous-IP metering, and AnonymousHubPermitLimit is deliberately generous, since one browser tab reconnecting behind a flaky network legitimately negotiates several times a minute.
  • 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.RateLimiting.cs:353-361 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.RateLimiting.cs:345-351): 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.RateLimiting.cs:373-374), then threaded through every partition selector (GlobalRateLimitPartition at :136, UserPolicyRateLimitPartition at :171, AuthIpRateLimitPartition at :278) and into CreateLimitedPartition (:205-256), which reads Distributed (:214), Algorithm (:237), and SegmentsPerWindow (:242). HubPathPrefixes and AnonymousHubPermitLimit are read by AnonymousPartition (WebApplicationBuilderExtensions.RateLimiting.cs:62-72) and IsAnonymousHubRequest (:78-88, internal so InternalsVisibleTo lets it be unit-tested directly): an unauthenticated request that matches a hub prefix is metered per client IP through CreateLimitedPartition with redisScope: "hub" and allowDistributed: true (:60-67); every other anonymous request keeps taking RateLimitPartition.GetNoLimiter("__anonymous") (:68).

RedisFixedWindowRateLimiter

MMCA.Common.API · MMCA.Common.API.RateLimiting · MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:40 · 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:47) 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.RateLimiting.cs:231).
  • Where it's used: constructed only inside CreateLimitedPartition (WebApplicationBuilderExtensions.RateLimiting.cs:229-231), and only when allowDistributed && settings.Distributed is true (:214) and an IConnectionMultiplexer resolves (:220-222); the logger falls back to NullLogger when the request has no service provider (:224-225, which is also why RequestServices is read through a nullable local, :216-219). When no multiplexer is registered the code falls through to the in-memory limiters rather than failing startup (:232-234). The auth-ip policy passes allowDistributed: false (WebApplicationBuilderExtensions.RateLimiting.cs:318), so login throttling never depends on Redis.

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.

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:377).
    • 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:377, :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.Authentication.cs:111-124 and :162-175): 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:284, 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:369-371), 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:373-375, 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:107 and :125), which is the longhand this extension collapses.

WebApplicationBuilderExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.Authentication.cs:17 · Level 5 · class (static, extension block; split across three partials: WebApplicationBuilderExtensions.cs, .RateLimiting.cs and .Authentication.cs)

  • 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; PushNotificationSettings (for the configured hub path); 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, algorithm-pinned JWT validation, and endpoint-metadata-verified exemptions). [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, a gRPC bypass keyed on routed endpoint metadata rather than a spoofable Content-Type header, and an anonymous hub request metered per IP instead of exempted outright) and [Rubric §9, API & Contract Design] (versioning, OpenAPI and compression handled identically across hosts rather than per host).
  • Walkthrough: the load-bearing members, grouped by the partial file that now holds them.
    • CorsPolicyAllowSpecificOrigins / CorsPolicyAllowAll (WebApplicationBuilderExtensions.cs:27, :28): the two policy names the pipeline chooses between by environment.
    • AddCommonApiVersioning() (WebApplicationBuilderExtensions.cs:39): header-based versioning through the api-version reader with AssumeDefaultVersionWhenUnspecified and ReportApiVersions (:44-46), the API explorer group format 'v'VVV and SubstituteApiVersionInUrl (:48-51), then the backfill guard (:54). The comment (:39-41) records that DefaultApiVersion is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See ADR-046.
    • AddCommonResponseCompression() (WebApplicationBuilderExtensions.cs:68): Brotli plus Gzip, enabled for HTTPS, both at CompressionLevel.Fastest (:64-76); the comment (:72-74) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads.
    • AddCommonOpenApi() (WebApplicationBuilderExtensions.cs:101) registers no OpenAPI document of its own: it installs the backfill guard (:97) and registers StronglyTypedIdSchemaTransformer and StronglyTypedIdParameterTransformer through services.ConfigureAll<OpenApiOptions> (ADR-115, :102-106), rather than on a named document, so whatever document names the host registers are all covered; the comment (:99-101) notes this is inert in a host that declares no strongly typed identifier wrappers. The host must make the services.AddOpenApi() call in its own project (either order relative to this one), because the OpenAPI XML-comment source generator attaches the host's controller summaries by intercepting the AddOpenApi call sites of the project being compiled, and a registration made inside this assembly would carry none of them (doc comment :82-87). Pair it with MapCommonOpenApi() on OpenApiEndpointExtensions, which the same doc comment says fails at startup outside Production when no v1 document was registered (:87-89).
    • AddApiParameterDescriptorBackfill() (WebApplicationBuilderExtensions.cs:124, private) registers ApiParameterDescriptorBackfillProvider via TryAddEnumerable (:119-120), which de-duplicates on implementation type, so calling both AddCommonApiVersioning and AddCommonOpenApi installs the guard exactly once (:111-117).
    • AddCommonCors(IConfiguration) (WebApplicationBuilderExtensions.cs:132): the restrictive production policy takes its origins from Cors:AllowedOrigins and allowlists four headers and five methods with AllowCredentials (:130-136); the development any-origin policy sits under a justified #pragma warning disable S5122 explaining it is only ever selected when the environment is Development (:138-143). See ADR-082.
    • RateLimitPolicyAuthIp (WebApplicationBuilderExtensions.RateLimiting.cs:27): the named "auth-ip" policy for anonymous authentication attempts. Its doc comment (:16-22) 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.
    • IsRateLimitBypassed(HttpContext) (WebApplicationBuilderExtensions.RateLimiting.cs:46) exempts /health, /alive and /.well-known/* (:43-45), then a mapped gRPC endpoint through IsGrpcEndpoint (:46), all legitimately high-frequency. The gRPC arm used to key on the request's Content-Type header; the remark (:27-36, SEC-Common-44) records why it no longer does: a caller-supplied Content-Type: application/grpc header is unverifiable and used to hand any authenticated account the no-limiter partition, switching off its 300/min cap entirely, while routed endpoint metadata comes from the server's own MapGrpcService registrations and cannot be forged. It is internal rather than private specifically so the exemption logic is unit-testable through InternalsVisibleTo instead of only under a request flood (:37-40).
    • AnonymousPartition(HttpContext, RateLimitingSettings) (:58, private) is the partition an unauthenticated request now counts against: RateLimitPartition.GetNoLimiter("__anonymous") everywhere except a real-time hub path, where IsAnonymousHubRequest routes it through CreateLimitedPartition keyed on the client IP with redisScope: "hub" and settings.AnonymousHubPermitLimit (:59-68). The remark (SEC-ADC-25, :52-57) is the reason: a gateway that bypasses /hubs at the edge (ADR-024 requires it, because the hub authenticates from a query-string token the edge cannot read) left an anonymous negotiate flood metered nowhere at all.
    • IsAnonymousHubRequest(HttpContext, RateLimitingSettings) (:78) null-guards both arguments (:80-81) and matches the request path against settings.HubPathPrefixes (:85-87); internal for the same InternalsVisibleTo reason.
    • GrpcServerMetadataNamespace (:95, the literal "Grpc.AspNetCore.Server") and IsGrpcEndpoint(HttpContext) (:103) together are the routed-metadata check: matched by namespace name so MMCA.Common.API needs no package reference on the gRPC server stack, which only the extracted service hosts take (:90-93), returning true only when the resolved endpoint carries metadata from that namespace (:105-121).
    • GlobalRateLimitPartition has two overloads. The permit-count one (:129-130) simply wraps its argument in a RateLimitingSettings and delegates; the settings one (:137) holds the logic: a NoLimiter for bypassed infrastructure (:139-142) and, for unauthenticated callers, a delegate to AnonymousPartition rather than an unconditional NoLimiter (:144-147), otherwise a partition keyed by the user-id (subject) claim, then Identity.Name, then the remote IP, then the literal "authenticated" (:149-152), built through CreateLimitedPartition with redisScope: "global", queueLimit: 0 and allowDistributed: true (:154-161). The subject claim leads because it is unique per account, while the name claim carries the full name, which two users can share and would then pool into one bucket (:124-126).
    • UserPolicyRateLimitPartition (:172) is the same shape for the opt-in "UserPolicy" limiter: one bucket per authenticated user (subject claim, then name), falling back to the client IP and then a shared anonymous bucket (:174-177), with redisScope: "user" and the configured queue limit (:179-186). It was extracted from the inline lambda it used to be so the key selection is unit-testable (:168-171).
    • CreateLimitedPartition (:207) 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 (:216-222). With a connection it returns a partition whose factory builds a RedisFixedWindowRateLimiter over $"{redisScope}:{key}" (:229-231), falling back to a null logger when none is registered (:226-227). 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 (:234-236). The in-memory branch honours RateLimitAlgorithm: a sliding window with SegmentsPerWindow segments (:239-249) or the default fixed window (:251-257), both over TimeSpan.FromMinutes(1) with QueueProcessingOrder.OldestFirst.
    • AuthIpRateLimitPartition (:271 permit-count overload, :280 settings overload) partitions the "auth-ip" policy on the client IP and returns no limiter at all when the IP is unattributable (:284-285). The remark (:264-269) 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 (:293), 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 (:202-205).
    • AddCommonRateLimiting has three overloads (all WebApplicationBuilderExtensions.RateLimiting.cs). The permit-count one (:328) keeps the defaults permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30 and simply builds a RateLimitingSettings from them (:328-336). The IConfiguration one (:346) null-guards the configuration (:348), registers RateLimitingSettings as options bound to the RateLimiting section with data-annotation validation on start, so a [Range] violation fails at startup rather than building a limiter that throws on every authenticated request (:350-353), and then reads the same section into the settings it passes on, falling back to a default instance when the section is absent (:355-357). The settings one (:368) does the work: rejection status 429 (:374), the always-on GlobalLimiter (:376-377), the opt-in "FixedPolicy" (:382-389, allowDistributed: false), "UserPolicy" (:391), and auth-ip (:401-403). 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 (:379-381); 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 (:393-400). The doc comment on the permit-count overload (:298-327) 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.
    • AddForwardedJwtBearer(authority, audience, configuration, environment, requireHttpsMetadata = null) (WebApplicationBuilderExtensions.Authentication.cs:52) is the extracted-service mode. It validates all four required arguments (:59-62), then resolves the metadata posture in three steps: the explicit argument when it is not null, then RequireHttpsMetadataConfigKey, then true everywhere except Development (:64-66). When the resolved value is false outside Development it registers InsecureJwtMetadataWarningStartupFilter so the deviation is logged once at startup (:68-72), and finally delegates to the private AddForwardedJwtBearerCore, passing the hub path read by ResolveHubPath(configuration) along with the resolved posture (:74). Its authority argument is normally the result of JwtAuthorityExtensions.GetRequiredJwtAuthority().
    • RequireHttpsMetadataConfigKey (:25, value "Authentication:JwtBearer:RequireHttpsMetadata"): the one configuration key that can override the secure-by-default metadata posture. Its comment (:19-24) 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.
    • AddForwardedJwtBearerCore (:77, private, now taking a fourth hubPath parameter) does the JWT wiring: Authority, Audience and RequireHttpsMetadata (:86-88), validation parameters that deliberately leave ValidIssuer unset so the middleware takes the issuer from the discovery document (:96-101), and ValidAlgorithms = [RsaSha256] as defense against an algorithm-confusion swap (:103-109). It installs the SignalR access_token query-string fallback by assigning options.Events = HubAccessTokenEvents(hubPath) (:113) and then calls AddAuthorizationPolicies() (:116).
    • AddCommonAuthentication(IConfiguration) (:133) is the in-process mode: it binds JwtSettings with data-annotation validation on start (:135-138), reads the hub path once through ResolveHubPath(configuration) (:140), builds validation parameters through BuildValidationParameters (:148), wires the same access-token fallback through HubAccessTokenEvents(hubPath) (:152), and adds the authorization policies (:155).
    • ResolveHubPath(IConfiguration) (:165, private static, outside the extension block) reads the PushNotifications section (PushNotificationSettings.SectionName, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:11) and returns its HubPath, falling back to a default instance's value, /hubs/notifications (PushNotificationSettings.cs:21). It is read once per registration, not per request.
    • HubAccessTokenEvents(string hubPath) (:175, private static) replaces the two identical inline JwtBearerEvents lambdas the two modes used to carry. OnMessageReceived takes the token from the access_token query parameter only when it is non-empty and the request path is under the configured hubPath or under the literal /hubs floor (:177-190), so a host that moves its notification hub still authenticates, and a host mapping further hubs under /hubs keeps working. A WebSocket upgrade cannot carry an Authorization header, which is why the token travels in the query string at all.
    • GetValidatedSigningKey(string) (:196, internal static, outside the extension block) decodes the Base64 HMAC key and throws when it is under 256 bits (:198-203), so a too-short secret fails at startup rather than weakening every token.
    • BuildValidationParameters(JwtSettings) (:215, also internal static) branches on JwtSigningAlgorithm: RS256 requires RsaPublicKeyPem and throws a message that points at AddForwardedJwtBearer when it is missing (:219-223), imports the PEM into an RSA held for the app lifetime (:225-228, with a justified CA2000 suppression) and pins RS256 (:238); the default HS256 path builds a SymmetricSecurityKey from the validated secret and pins HmacSha256 (:242-255).
  • 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 (WebApplicationBuilderExtensions.RateLimiting.cs:385-390). Keying the gRPC exemption and the hub exemption on server-controlled state (routed endpoint metadata, configured hub path prefixes) rather than caller-supplied headers is the same defense-in-depth posture applied to the exemption list itself, not just to the authenticated-caller partitions.
  • Where it's used: every ADC and Store service host calls the builder-side registrations (AddCommonCors, AddCommonApiVersioning, AddCommonRateLimiting, AddCommonResponseCompression) at startup. 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:333-337, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:179-183). Each ADC service host pairs its own services.AddOpenApi() with AddCommonOpenApi() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:322-323). The "auth-ip" policy is applied by attribute on the login and register actions of AuthControllerBase (AuthControllerBase.cs:72 and :96), 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. The doc comment on the permit-count AddCommonRateLimiting overload still describes the gRPC bypass as application/grpc traffic (WebApplicationBuilderExtensions.RateLimiting.cs:330-332); the code keys it on routed endpoint metadata (IsGrpcEndpoint, :103), so trust the code over that sentence.

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 (:35), name the HttpContext.Items slots that the pipeline's PreForwardedCapture step writes before UseForwardedHeaders rewrites scheme and host. The comment on the host key (:26-34) records why: Aspire/DCP injects an X-Forwarded-Host pointing at the canonical launchSettings URL, which internal callers cannot reach.
    • UseCommonMiddlewarePipeline() (:48) is a one-liner: ApplyPipeline(app, configure: null). Its doc comment (:39-47) 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) (:60) is the scoped escape hatch: it null-guards the delegate (:62) and routes through the same helper (:63). The XML doc declares both failure modes, ArgumentNullException and the InvalidOperationException an invariant violation raises (:58-59).
    • UseCommonRequestLocalization() (:73) builds the supported list from SupportedCultures.All (:75), appends the pseudo-locale in Development only (:80-83), and sets the default plus both supported and supported-UI culture lists (:86-89). 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 (:66-72).
    • MapCultureEndpoint() (:102) is a one-line default that calls the MapCultureEndpoint(bool httpOnly) overload with httpOnly: false. MapCultureEndpoint(bool httpOnly) (:115) does the real work: it maps the anonymous GET /culture/set?culture=&redirectUri= that the culture switcher calls, honors only allowlisted cultures and the pseudo-locale only in Development (:119, :122), writes the standard ASP.NET culture cookie with the caller-chosen HttpOnly (:125-136, with Secure conditional on the environment and both S3330/S2092 deviations justified inline at :124), then local-redirects (:143-144) to force a full reload. The redirect target passes through IsLocalRedirectTarget first and falls back to / when the requested target is missing or not local (:140-143): Results.LocalRedirect throws at execution for a non-local URL, which on this anonymous endpoint would surface as a 500 rather than a redirect. The httpOnly parameter (:108-114) exists so a Server-only host that never runs WASM can pass true and keep the cookie out of script reach; a host that serves a WebAssembly client must keep the parameterless overload's false, because the WASM runtime reads the cookie directly on startup (MmcaCultureBootstrap.SetBrowserCultureAsync).
    • IsLocalRedirectTarget(string?) (:159, private) applies the rule LocalRedirect itself enforces, ahead of it (:152-158): a non-blank value with a single leading / that is not followed by a second / or a \, since browsers treat either of those as a protocol-relative URL to another host, and that contains **no control character anywhere** (!url.Any(char.IsControl), :160-163). The control-character clause matches what LocalRedirect rejects when it executes, and the doc comment notes why checking it first matters: by execution time the culture cookie has already been written.
    • ApplyPipeline (:170, private) is the whole application step: seed the defaults (:172), let the host's delegate adjust them if there is one (:173), then foreach over builder.Build() invoking each step's Configure in order (:175-178). Because both public overloads route through here, the zero-argument path is exactly the validated default pipeline (:165-169).
  • 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:450, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:270, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:299, and MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:149. The Blazor UI hosts instead call the localization and culture-endpoint members directly (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:238 and :271).
  • 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.

DatabaseInitializationExtensions

MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:28 · Level 15 · class (static, two extension blocks)

  • What it is: the shared startup routine every downstream host calls once after app.Build(), either directly or through a WebApplication wrapper that skips it during build-time OpenAPI generation. 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, ModuleLoader, and ModuleHostContext for the WebApplication wrapper. Externally it uses only IServiceProvider scoping, IOptions<T>, the host's environment and configuration, 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 public surface is two extension members in two extension(T) blocks, one on IServiceProvider (DatabaseInitializationExtensions.cs:39) and one on WebApplication (:149), declared with C# extension(T) syntax like the rest of the framework's DI and startup helpers. The class carries a justified CA1708 suppression because multiple extension blocks in one static class trip that rule on compiler-generated grouping members (:24-27).
    • InitializeDatabaseAsync(applicationSettings, moduleLoader, cancellationToken) (:51-54) starts by null-guarding all three inputs (:56-58).
    • Strategy is validated first (:63). EnsureKnownStrategy (:290) accepts only the ordinal strings "Migrate" and "None" (:292-293) and otherwise throws UnknownStrategy (:295), whose message names the valid values (:302-303). The comment (:60-62) 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 (:65) and warms the entity data-source registry: resolving IEntityDataSourceRegistry and calling GetPhysicalSourcesInUse() (:70-72) scans the configuration assemblies once so entity-to-database routing is deterministic before the first repository call, replacing a lazy model-building side effect (:67-69).
    • The migrationless pass (:90-106) walks every source in use with no engine filter (:90). A source with an empty connection string is skipped (:94-97), since integration tests may omit one; a source where PhysicalDataSource.UsesMigrations is true is also skipped (:99-102); everything left is created outright with EnsureCreatedAsync (:104-105). The comments carry the load here. The first (:76-82) says this is the only path that creates such a source (without it the first repository call fails) and that the per-tenant pass repeats it for a tenant's own copy under either strategy. The second (:84-86) explains the UsesMigrations exclusion: running EnsureCreated against a PostgreSQL or 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. The third (:88-89) is why no engine filter is needed: a SQL Server source always answers UsesMigrations and is skipped, so only a migration-less source reaches EnsureCreated. UsesMigrations itself is the per-engine rule on the source record, read from the engine's MigrationPolicy capability (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:42-48): Always (SQL Server), WhenAssemblyConfigured (PostgreSQL and SQLite, true only with a configured migrations assembly), Never (Cosmos), per the doc comment above it (PhysicalDataSource.cs:36-39).
    • A long command timeout wraps migration and seeding. InitializationCommandTimeout (:37) is five minutes. Before the strategy switch, RaiseCommandTimeouts (:257, called at :112) walks the sources in use, skips any with no connection string (:265-268) or a non-relational provider (:271-274), and raises each context's command timeout to that value, recording the previous one (:276-277). The comment (:108-111) gives the reason: with several replicas starting together, every replica but one waits on EF's migrations lock (__EFMigrationsLock), and a wait past the 30-second default would kill that replica's startup. The seeders resolve the same scoped context factory, so the raised timeout covers them too. The switch, the tenant pass and the seeding all sit inside a try (:113), and a finally restores every recorded timeout (:139-145). The tenant pass applies the same timeout to each tenant context (:219-222) but restores nothing, because that scope and its context end with the loop iteration (:217-218).
    • The strategy switch (:118-128) has exactly two live branches. "Migrate" delegates to IDbContextFactory.MigrateAsync (:121), 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 (:124). The default arm rethrows UnknownStrategy (:127), unreachable in practice because of the guard at :63 but kept so the switch is total.
    • The tenant pass runs next (:130-131) and module seeding last (:137). Seeding deliberately runs on the default scope only; the comment (:133-136) 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.
    • InitializeDatabaseUnlessDesignTimeAsync(moduleHost, cancellationToken) (:167) is the WebApplication wrapper the service hosts call. It null-guards both the app and the ModuleHostContext (:171-172), returns a completed task without touching any database when the environment is Development and the MmcaOpenApiDesignTime configuration switch (OpenApiDesignTimeKey, :31) is true (:174-177), and otherwise forwards to InitializeDatabaseAsync with moduleHost.ApplicationSettings and moduleHost.ModuleLoader (:179-182). The remarks (:155-162) give the reason: build-time OpenAPI generation (MmcaGenerateOpenApiDocument) starts the host with no database reachable, and the schema step is the one startup action that throws rather than logs when it cannot connect. Honouring the switch only in Development means a host in any other environment can never be talked out of initializing its schema (ADR-122).
    • InitializeTenantDatabasesAsync (:196) resolves IOptions<TenancySettings> with GetService rather than GetRequiredService (:203) and returns immediately when tenancy was never registered or no tenants are configured (:204-207). Otherwise it expands the sources into targets via TenantDataSourceTargets.Expand and keeps only the ones with a tenant id (:209-210), 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 through IServiceScopeFactory.CreateTenantScope(target) (:212), the shared helper (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:100) every other per-tenant background pass (the outbox processor, the internal-command processor, the audit-trail and outbox cleanup jobs) also calls; it creates the scope and, when the target carries a tenant id, calls ITenantContext.SetTenant on it before returning (TenantDataSourceTargets.cs:109), so the tenant is always set before anything asks that scope for a context. The remark (:191-195) is still the reason a fresh scope is required at all: 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 (:227), on the grounds stated at :224-226: 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 (:229-248): under "Migrate" a migrated source gets MigrateAsync and anything else gets EnsureCreatedAsync (:231-241), under "None" it defers to ApplyNoneStrategyToTenantAsync (:243).
    • ApplyNoneStrategyToTenantAsync (:310, private) is the tenant-side "None" branch. A migration-less tenant copy (Cosmos, or PostgreSQL/SQLite with no migrations assembly) is created with EnsureCreatedAsync (:316-320), mirroring the shared migrationless pass, because nothing else ever creates it (:305-309); every other copy goes to ThrowIfTenantPendingMigrationsAsync (:322).
    • The two production rails. ThrowIfTenantPendingMigrationsAsync (:330) no-ops for a non-migrated source (:336-339), otherwise queries GetPendingMigrationsAsync (:341) and, when anything is pending, throws naming the tenant target, the migrations, and the dotnet ef database update remedy (:347-350). ThrowIfPendingMigrationsAsync (:359) short-circuits on IDbContextFactory.HasPendingMigrationsAsync (:365-368), then builds a per-source breakdown by re-querying each key whose resolved source answers PhysicalDataSource.IsMigrationTarget (:370-379) before throwing (:381-383). IsMigrationTarget now lives on the source record (PhysicalDataSource.cs:57-60): a source that UsesMigrations and either belongs to an engine whose sources require a connection string (SQL Server, so a missing one fails loudly at startup) or has one configured. Its doc comment (PhysicalDataSource.cs:50-56) states that the context factory and this initializer share that one rule, so the breakdown names exactly the sources the factory checked rather than an empty list (:353-358).
  • 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. PostgreSQL joining the migrationless pass alongside Cosmos and SQLite (ADR-113) is exactly that: a host that names no PostgreSQLMigrationsAssembly gets the same EnsureCreatedAsync treatment a migrations-less SQLite source already got, with no change to the strategy switch below it. 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 (:186-190). The "None" tenant branch creating a migration-less copy keeps that true under either strategy, matching what the shared pass already did. Skipping initialization for build-time OpenAPI generation through a Development-only switch, rather than a flag any environment can set, keeps the one exemption from becoming a way to boot production against an untouched schema.
  • Where it's used: called once per host after app.Build(), before the middleware pipeline is wired. The ADC and Store service hosts call the wrapper, await app.InitializeDatabaseUnlessDesignTimeAsync(moduleHost) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:447, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:296), which reads ApplicationSettings and ModuleLoader off ModuleHostContext (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:44, :54); Helpdesk calls InitializeDatabaseAsync directly with its own settings and loader (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:146). The migrationless-engine loop and the strategy contract are covered by DatabaseInitializationExtensionsTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:43, :97, :145, :183, :233), which exercise SQLite create, SQLite migrate, the "None" failure message, the "None" creation of a migration-less tenant copy, 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.

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:42), 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:417-425, 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:209-213).
  • 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.

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.Messaging.cs:155), which calls AddHttpContextAccessor() and TryAddTransient<JwtForwardingDelegatingHandler>() (:147-148) and then chains .AddHttpMessageHandler<JwtForwardingDelegatingHandler>() onto the typed HttpClient (:151-153) alongside Aspire service discovery and the standard resilience handler (:156).
  • 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.Messaging.cs:137-140), 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.

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:295-306). 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.

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:31-151), 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:299-307). 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:25 · 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: Microsoft.AspNetCore.OpenApi for MapOpenApi() and the keyed IOpenApiDocumentProvider it probes, and Scalar.AspNetCore (NuGet) for the reference UI. It pairs with the host's own services.AddOpenApi(), which registers the v1 document, plus AddCommonOpenApi() on WebApplicationBuilderExtensions, which only configures the registered documents and registers none of its own (doc comment OpenApiEndpointExtensions.cs:13-14, :37-38).
  • 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:10-24) is explicit that the guarding happens at two levels: the framework-owned part of the generated document (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 that host's committed document and its contract-snapshot tests. 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
    • DefaultDocumentName (OpenApiEndpointExtensions.cs:28) is the private constant "v1": the document name plain AddOpenApi() registers and this endpoint serves.
    • MapCommonOpenApi() (OpenApiEndpointExtensions.cs:60) does nothing in Production and returns app for chaining (:77). Outside Production (:62) it first resolves IServiceProviderIsKeyedService and checks that a keyed IOpenApiDocumentProvider exists for "v1" (:64-65); if not, it throws InvalidOperationException telling the host to call services.AddOpenApi() in the host project itself, because that is the call the OpenAPI XML-comment generator intercepts to attach the host's summaries (:67-71). A host that calls only the framework method therefore fails at startup (and at build-time document generation) instead of silently serving no document (doc comment :36-41). It then maps the document with app.MapOpenApi().AllowAnonymous() (:74). The anonymous declaration is load-bearing: the framework's fallback authorization policy gates every endpoint that states nothing and /openapi is not one of its exempt prefixes, so an undeclared mapping would answer 401 to the contract-snapshot tests and API tooling (doc comment :43-50).
    • The method carries a [SuppressMessage("Usage", "AV0030:Missing WithDocumentPerVersion")] (:56-59). The justification records why: the analyzer fires because AddCommonApiVersioning's AddApiExplorer call lives in this assembly, but no versioned OpenAPI registration exists (Asp.Versioning.OpenApi is not referenced, so WithDocumentPerVersion() is not available). The host serves the single plain v1 document.
    • MapCommonScalarUi() (OpenApiEndpointExtensions.cs:89) is the opt-in developer convenience: it calls MapScalarApiReference() outside Production (:91-94), rendering /scalar/{documentName}, and requires services.AddOpenApi() + AddCommonOpenApi() + MapCommonOpenApi() (doc comment :83-84). Assets ship inside the Scalar.AspNetCore package rather than a CDN (:86-87), 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 registration guard turns a missing host AddOpenApi() call (which would otherwise lose the XML-comment summaries or serve nothing) into a startup failure, and the explicit AllowAnonymous() keeps the contract reachable before a client holds a token without widening the fallback policy's exempt list. API versioning itself is ADR-046.
  • Where it's used: every ADC and Store service host maps the document through it (for example MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:457 and MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:322), and the ADC integration tiers subclass OpenApiContractTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/OpenApiContractTestsBase.cs:22), for example MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Contract/OpenApiContractTests.cs:15. In the framework, OpenApiProbeHost (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:40-41 registers, :61 maps) is what OpenApiBaselineTests boots, and MapCommonOpenApiAuthorizationTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/MapCommonOpenApiAuthorizationTests.cs:23) pins the anonymous declaration and the Production no-op.

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:299). Populated by CorrelationIdMiddleware, which takes it as a method-injected parameter of InvokeAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:35). Consumed by LoggingCommandDecorator<TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:19) 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:91).

JwtAudience

MMCA.Common.API · MMCA.Common.API.Startup.Auth · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAudience.cs:17 · Level 0 · class (static)

  • What it is: the configuration contract for the audience of the access tokens the Identity service mints (JwtAudience.cs:3-7). It names one configuration key and offers one guard that turns a missing value into a startup failure, so every service host that validates those tokens through AddForwardedJwtBearer reads the audience the same way.
  • Depends on: nothing first-party; BCL InvalidOperationException and string only. The file has no using directives. Its sibling in the same folder is JwtAuthorityExtensions, and both feed AddForwardedJwtBearer on WebApplicationBuilderExtensions.
  • Concept, fail fast on missing configuration. The remarks (JwtAudience.cs:8-16) state the failure this prevents: a host that silently fell back to a hard-coded audience would boot with a value nobody configured and answer every request with a 401 "that reads like a token problem instead of a wiring problem." [Rubric §11, Security] assesses token validation correctness: the audience check is only meaningful if the value is deliberately configured, never defaulted. [Rubric §13, Observability & Operability] assesses how quickly a misconfiguration surfaces: throwing at startup with a message that names the key turns a confusing runtime 401 into a boot-time error that says what to set. [Rubric §7, Microservices Readiness]: the type lives in MMCA.Common.API, so each extracted service host consumes one key and one guard rather than re-declaring them.
  • Walkthrough: public const string ConfigKey = "Jwt:Audience" (JwtAudience.cs:20), documented (:19) as Jwt__Audience when supplied as an environment variable. RequireConfigured(string? configuredValue) (:28-32) is an expression-bodied guard: when string.IsNullOrWhiteSpace is true it throws InvalidOperationException with the message "Jwt:Audience is not configured. Set it in the service's appsettings.json or as the Jwt__Audience environment variable." (the key interpolated from ConfigKey); otherwise it returns the value unchanged. Blank and whitespace-only values are rejected exactly like null.
  • Why it's built this way: taking the already-read value (rather than an IConfiguration) keeps the guard a pure function with no framework dependency and trivially unit-testable. The value itself lives only in configuration (each service's appsettings.json and, in Azure, the deployment template, per :9-11). The remarks (:12-15) prescribe the call shape, JwtAudience.RequireConfigured(builder.Configuration[JwtAudience.ConfigKey]), never a ?? fallback, and ForwardedJwtAudienceTestsBase in MMCA.Common.Testing.Architecture pins that shape in each consumer (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Api/ForwardedJwtAudienceTestsBase.cs).
  • Where it's used: the three ADC non-Identity service hosts pass the guarded value as the audience: argument of AddForwardedJwtBearer(...): MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:335, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:181 and MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:171. Unit-tested by MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/Auth/JwtAudienceTests.cs: a configured value is returned as-is (:14-16), blank or missing values throw naming the key and the environment variable (:18-26), and the key itself is pinned (:30-33). Architecture fixtures exercise the rule in MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/ForwardedJwtAudienceFitnessTests.cs.
  • Caveats / not-in-source: the Identity service host (the token issuer) does not call this guard in the files inspected; how it sources the audience it stamps into minted tokens is Not determinable from source within this unit.

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, gateway) 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, gateway) 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.Authentication.cs:59), 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:334), 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.

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.Authentication.cs:70-71), which de-duplicates on implementation type, so a host that calls AddForwardedJwtBearer more than once still gets exactly one warning. Registration is itself conditional (:67): 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.Authentication.cs:68-72). 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:329-332), 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.

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:223) 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:320-335).
  • 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:223), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, the controller base (EntityControllerBase.cs:320), and the UI's IEntityService<TEntityDTO, TIdentifierType>.

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:296), 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:135-142); 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.RateLimiting.cs:49).
  • Where it's used: applied as the JwksEndpoint step of the default pipeline seeded by MiddlewarePipelineBuilder (MiddlewarePipelineBuilder.cs:142), 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).

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:144-146); consumed by the bearer middleware that AddForwardedJwtBearer configures on WebApplicationBuilderExtensions, which deliberately leaves ValidIssuer unset so the issuer comes from this document (WebApplicationBuilderExtensions.Authentication.cs:90-102). 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:15 · 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; CommonForwardedHeaders for the shared forwarded-headers options; 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:17), 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-151) 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 via CommonForwardedHeaders.Create() clearing KnownProxies/KnownIPNetworks for cloud reverse proxies (:64-69; UI hosts take the same options through UseCommonUiForwardedHeaders), an HTTPS redirect wrapped in UseWhen that skips negotiated-h2c requests via IsCleartextHttp2 (:71-87) so gRPC-over-cleartext calls are not 307-redirected, response compression (:89-91), routing (:93-95), CORS choosing the development or production policy by environment (:97-101), authentication (:103-105), tenant resolution (:107-113), the rate limiter (:115-121), the soft-deleted-user filter (:123-125), authorization (:127-129), output cache (:131-133), the always-mapped JWKS (:135-142) and OIDC discovery (:144-146) endpoints, and finally MapControllers() (:148-150). Each Configure delegate is static, so no closure is allocated per step.
    • IsCleartextHttp2(HttpContext) (:350-355, public static) is the HTTPS-redirect exemption predicate, defined at the bottom of the class and pulled out to its own method under SEC-Common-44: it null-checks the context (:352) and returns true only when !Request.IsHttps and HttpProtocol.IsHttp2(Request.Protocol) (:354-355), i.e. the connection negotiated HTTP/2 without TLS. It replaced a Content-Type: application/grpc string check, which any caller could forge in a header; the negotiated protocol is settled by Kestrel at connection setup and cannot be faked by a request header. The method is public specifically so a host that rebuilds the HttpsRedirection step reuses the same predicate instead of reintroducing the forgeable check.
    • Four mutators, all returning this for chaining and all validating first: InsertBefore (:161), InsertAfter (:178), Replace (:198) and Remove (:219). Replace keeps the replaced step's position and permits a different name, but rejects a name another step already carries (:203-209).
    • Build() (:252) runs the four checks and returns a defensive copy (:274). Two adjacency checks: PreForwardedCapture immediately before ForwardedHeaders (:254-257) and Authentication immediately before TenantResolution (:259-262). Two precedence checks: Authentication before RateLimiting (:264-267, ADR-019) and ForwardedHeaders before HttpsRedirection (:269-272). Every check carries its rationale string, which is what the exception message prints.
    • The private helpers hold the guard semantics. RequireIndexOf (:280) rejects a blank name and, for an unknown one, throws listing every known step (:291-294), which turns a typo into a self-answering error. RequireUniqueName (:299) enforces name uniqueness across the list. RequireImmediatelyBefore (:309) and RequirePrecedes (:324) share one subtle rule: an invariant binds only when both of its steps are still present (:315, :330), 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. The IsCleartextHttp2 extraction is the same "encode the invariant, don't comment it" instinct the class applies to step ordering: SEC-Common-44 turned a header the caller controls into a protocol fact Kestrel controls, closing a path where a forged Content-Type could have served plaintext gRPC traffic that should have been redirected.
  • 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).

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.ModuleScanning.cs:65-69), 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.ModuleScanning.cs:74-78), 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.ModuleScanning.cs:80-84).
  • 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:228, :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:31). The read half of the round trip is EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETag (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:417-425), which gives the client the tag to send back.

CurrencyJsonConverter

MMCA.Common.API · MMCA.Common.API.JsonConverters · MMCA.Common/Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:14 · Level 5 · 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, with one exception: the empty code reads back as the Money.Zero() sentinel's currency.
  • Depends on: Currency (MMCA.Common.Shared.ValueObjects, CurrencyJsonConverter.cs:3) and Money.Zero() for the sentinel (CurrencyJsonConverter.cs:27). Extends BCL JsonConverter<T> (CurrencyJsonConverter.cs:14).
  • 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:17) first rejects any non-string token, throwing JsonException("Currency must be a string.") (:19-20), which is what stops a JSON number or object from being coerced.
    • It then reads the string, coalescing null to empty (:22). An empty code short-circuits to Money.Zero().Currency (:26-27): the comment at :24-25 explains that the zero sentinel is internal to Shared and serializes as the empty code, so the read is symmetric with Write, and a non-zero amount paired with it is refused by Money's own JSON read (IJsonOnDeserialized).
    • Any other code runs Currency.FromCode(code) (:29) and throws JsonException($"Invalid currency code: {code}") when the result is a failure (:30-31) before returning result.Value! (:33). 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 (type doc, :9-10).
    • Write (CurrencyJsonConverter.cs:37-38) 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. The empty-code branch exists only so the one value Write can emit for the zero sentinel round-trips.
  • 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:52-54), 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:55).
  • 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:75), attached to the value object by a [JsonConverter] attribute (Currency.cs:15) 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:93 quotes the code) and in returning a nullable Currency? (Currency.cs:78). Whether the Shared converter also carries the empty-code branch was not re-read for this revision. 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.

UsersAdminControllerBase<TUserDto>

MMCA.Common.API · MMCA.Common.API.Controllers.Administration · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:49 · Level 5 · class (abstract)

  • What it is: the shipped base controller for account administration: a paged search/list, a single-account read, lock/unlock, and a full role replacement. A subclass supplies the app's TUserDto and mounts the route; the base owns every action.
  • Depends on: ApiControllerBase (its base, for HandleFailure), the app-supplied IUserAdministrationService<TUserDto> re-exposed as the protected Administration property (:56, taken as a primary-constructor parameter at :50-51), UserIdentifierType, and the request/query types UserAdministrationQuery, SetUserRolesRequest, and PagedCollectionResult<TUserDto>.
  • Concept (a bounded, generic admin surface over an app-owned service). [Rubric §11, Security]: GetPagedAsync clamps every caller-supplied pageSize to MaxPageSize = 100 (:53, Math.Min(pageSize, MaxPageSize) at :80), so a subclass cannot be made to materialize an unbounded page regardless of what the query string asks for. [Rubric §1, SOLID]: the controller depends on IUserAdministrationService<TUserDto> (DIP), not a concrete service, and every action is a thin translation from HTTP to that one interface, so the controller carries no business rule of its own.
  • Walkthrough
    • GetPagedAsync(searchTerm, role, pageNumber, pageSize, cancellationToken) ([HttpGet("paged")] at :67, method at :71-95) builds a UserAdministrationQuery from the clamped page size (:78-82), calls Administration.ListAsync (:84), and on success appends an X-Pagination header serialized with JsonSerializerOptions.Web (:90-92) before returning Ok(result.Value) (:94).
    • GetAsync(userId, cancellationToken) ([HttpGet("{userId}")] at :79 in the brief span, method at :108-113) reads one account through Administration.GetAsync.
    • LockAsync and UnlockAsync ([HttpPost("{userId}/lock")] / [HttpPost("{userId}/unlock")], methods at :133-141 and :153-161) both call the same Administration.SetLockedAsync(userId, locked, cancellationToken), differing only in the boolean literal. The XML doc on LockAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:94-100) is explicit that locking alone does not revoke a live access token: a full implementation also has to revoke refresh sessions for the lock to take effect before that token expires.
    • SetRolesAsync(userId, request, cancellationToken) ([HttpPut("{userId}/roles")] at :169, method at :175-187) guards the request, then replaces the account's complete role set through Administration.SetRolesAsync(userId, request.Roles, cancellationToken) (:182-184): it is a replace, not a merge.
  • Why it's built this way: like the other admin/auth controller bases in this group, the HTTP shape (routes, status codes, ProblemDetails on every failure path) ships once in the framework and an app supplies only its TUserDto and its IUserAdministrationService<TUserDto> implementation, so the account-management surface stays identical across apps.
  • Where it's used: ADC's UsersAdminController is the sole current subclass (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersAdminController.cs), backed by UserAdministrationService (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs) implementing IUserAdministrationService<TUserDto> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs). The framework's own coverage is in AdministrationControllerBaseTests.
  • Caveats / not-in-source: ADR-116 mentions this surface, but the opt-in mechanics it governs live outside the cited span; not determinable from the lines read here.

CsvWriter

MMCA.Common.API · MMCA.Common.API.Export · MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:44 · Level 5 · 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: Money and Currency for their value-form cells. From the BCL: TextWriter, SearchValues<char> (System.Buffers), UTF8Encoding (System.Text), ConcurrentDictionary, System.Text.Json (JsonSerializer, JsonSerializerOptions.Web), reflection, 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:19-24): 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:25-36). See ADR-078.
  • Walkthrough
    • Utf8ByteOrderMark (CsvWriter.cs:55) and LineEnding (CsvWriter.cs:58, the literal "\r\n" regardless of host OS) are the two format constants. Utf8NoPreamble (CsvWriter.cs:65) 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:71) 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]). NoValueTextTypes (CsvWriter.cs:74) is a ConcurrentDictionary<Type, bool> caching, per type, whether ToString is debugging text, so reflection runs once per type.
    • WriteByteOrderMark(writer) (CsvWriter.cs:82) writes the BOM unconditionally. The remarks say why (CsvWriter.cs:49-54): 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:94) and WriteRow(cells, writer) (CsvWriter.cs:118) are the same loop: a comma between fields (CsvWriter.cs:101-104 and CsvWriter.cs:125-128), then LineEnding (CsvWriter.cs:109 and CsvWriter.cs:133). The header escapes a column name exactly like a data field (CsvWriter.cs:106), so a column named full,name cannot break the record.
    • FormatCell(value) (CsvWriter.cs:141-150) 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), and anything else IFormattable formats invariantly. Everything left falls through to FormatObject.
    • FormatObject(value) (CsvWriter.cs:159-165) handles the non-primitive, non-formattable values: Money goes to FormatMoney, Currency writes its code, a type for which HasNoValueText is true writes compact JSON via JsonSerializer.Serialize(..., JsonSerializerOptions.Web) (the same shape the sibling JSON endpoints return), and the last fallback is Convert.ToString with the invariant culture.
    • FormatMoney(money) (CsvWriter.cs:173-177) writes the invariant amount followed by the ISO 4217 code ("49.99 USD"), or the amount alone when the currency code is empty (the zero sentinel).
    • HasNoValueText(type) (CsvWriter.cs:187-195) decides, once per type, that ToString is not a value: the public parameterless ToString is missing, declared on object or ValueType, or marked CompilerGenerated (the record's Name { Member = ... } form).
    • WriteField(field, writer) (CsvWriter.cs:203) is the only quoting member: a clean field is written as-is (CsvWriter.cs:205-209), and a field containing any MustQuote character is wrapped in quotes with embedded quotes doubled (CsvWriter.cs:211-213).
  • 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. Rendering money and records as values (and not as Money { ... } debugging text) keeps a cell something a reader can use.
  • 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:37-42): 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 EntityCsvExporter<TEntityDTO>.WriteAsync, which takes the encoding at EntityCsvExporter.cs:135, writes the BOM and header at EntityCsvExporter.cs:162-163, and each data row via WritePage (EntityCsvExporter.cs:273), reached in turn by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.ExportAsync (EntityControllerBase.cs:279).

EntityCsvExporter<TEntityDTO>

MMCA.Common.API · MMCA.Common.API.Export · MMCA.Common/Source/Presentation/MMCA.Common.API/Export/EntityCsvExporter.cs:17 · Level 6 · class (internal static)

  • What it is: the page-looping CSV export engine EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.ExportAsync delegates to: it validates a fields= request, walks the paged query service page by page, and streams RFC 4180 CSV to a caller-supplied stream. Closed over the DTO type alone (EntityCsvExporter.cs:17), so its exclusion lists and column resolution are computed once per DTO shape without needing the entity or identifier type at all.
  • Depends on: CsvWriter (byte order mark, header, rows), Error, PagedCollectionResult<T>, PaginationMetadata (TotalItemCount, the last-page and cap-boundary arbiter), QueryFieldService (ShapeData, the shaped-row fallback), System.Reflection, System.Text.Json (JsonNamingPolicy.CamelCase), and System.Globalization.
  • Concept introduced: extracting a generic algorithm out of the controller that only invokes it. [Rubric §1, SOLID] covers Single Responsibility: the page loop, the column resolution, and the exclusion lists are export mechanics independent of HTTP, so hoisting them out of EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> lets ExportAsync shrink to validate, delegate, and translate the result (class doc, EntityCsvExporter.cs:10-14). [Rubric §15, Best Practices & Code Quality]: WriteAsync takes the fetch as a delegate (Func<int, CancellationToken, Task<Result<PagedCollectionResult<object>>>>) rather than the query service itself, so the exporter carries no dependency on IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> and could stream any paged source a caller adapts to that shape.
  • Walkthrough
    • The three exclusion sets, UnexportablePropertyNames (EntityCsvExporter.cs:31-37), UnexportableColumns (:43-44), and UnexportableFields (:50-51), are static readonly fields computed once per closed EntityCsvExporter<TEntityDTO>, since a DTO's shape cannot change at runtime: the property names that cannot render a faithful scalar CSV cell (binary concurrency tokens and every collection type except string, IsExportableType at :239), their camelCase column-name form, and a case-insensitive lookup set for fields= matching. Value objects and other class-typed properties are deliberately not dropped: CsvWriter renders Money as amount plus code, Currency as its code, and any other record as compact JSON (remarks, :26-29).
    • ValidateFields(fields) (:61) rejects a fields= request naming one of those properties as an Error.InvalidEntityField failure (:70), before a single byte of the body is written; an empty exclusion set short-circuits to null.
    • WriteAsync(body, fetchPageAsync, pageSize, maxExportRows, fields, beginResponse, onFailureAfterStart, cancellationToken) (:116) is the page loop. The CSV writer is synchronous, so it never writes to body directly: it renders into an in-memory MemoryStream (:132) through a StreamWriter (:135), and DrainAsync (:210) copies each page to the body with awaited WriteAsync/FlushAsync and empties the buffer. The type doc gives the reason: a StreamWriter over the body itself would flush synchronously whenever its buffer filled, which Kestrel rejects, and any export over about 1 KB aborted. Nothing reaches the body before the first page succeeds, so a first-page failure still returns Result.Failure with nothing written (:145) for the caller to turn into Problem Details. On the first successful page it resolves columns, calls beginResponse() (:161), and writes the BOM and header (:162-163); each page is written by WritePage (:167) and drained (:170). Three termination conditions follow: a page that stopped mid-page marks the export truncated (:173-177), IsLastPage ends it on the last page (:179-180), and a cap landing exactly on a page boundary consults PaginationMetadata.TotalItemCount rather than issuing a wasted page (:184-188). A mid-stream failure after the header started calls onFailureAfterStart and writes the incomplete marker instead of returning failure (:150-152). A final drain follows the optional truncation marker (:195-198).
    • IsLastPage(itemCount, pageSize, rowsWritten, metadata) (:230) does not trust a short page on its own: an empty page ends the export, but a short non-empty page ends it only when rowsWritten has reached TotalItemCount. The query pipeline clamps every page to its own ceiling (EntityQueryPipeline.MaxUnboundedResultLimit = 1000, MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23), so a full clamped page can come back shorter than a larger requested page size, and ending there would cut the export short with no marker line.
    • ResolveColumns (:294) takes the shaped keys of the first row when there is one, or falls back to the DTO's own properties in declaration order, filtered by fields, for an empty result, so an export of nothing still owes a header row.
    • ShapeRow (:324) and BuildCells (:335) normalize a row (typed DTO or already-shaped dynamic object) to a camelCase-keyed dictionary and then project it onto the resolved column order, substituting null for a column a row does not carry.
    • TruncationMarker (:357) and IncompleteMarker (:363) format the two trailing marker lines with CultureInfo.InvariantCulture. ParseFields (:349) splits a fields= value into a case-insensitive set.
  • Why it's built this way: generic over TEntityDTO alone, because export needs only the DTO shape to compute its exclusion lists and resolve columns; the fetch delegate carries everything else the caller's specific entity/query pipeline needs. internal static keeps it a framework implementation detail behind ExportAsync, not a public surface a consumer can bind to. The last-page test reads the total rather than the page length because the page length is a property of the pipeline's clamp, not of the data. Buffering a page in memory before an awaited copy trades one page of memory for compatibility with default Kestrel synchronous-I/O rules.
  • Caveats / not-in-source: this type has no HTTP awareness of its own; the authorization symmetry, the fail-closed row scoping, and the no-output-caching caveats belong to EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, which is the only caller.
  • Where it's used: only by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.ExportAsync, which calls ValidateFields (EntityControllerBase.cs:265) and WriteAsync (EntityControllerBase.cs:279). Unit coverage is EntityCsvExporterTests and EntityCsvExporterValueObjectTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Export/).

CorrelationContext

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

  • What it is: the scoped service that holds the correlation ID for the current request, defaulting to the enclosing background hop's correlation ID when one is being delivered, and otherwise to a fresh GUID, until middleware or a restore sets it.
  • Depends on: ICorrelationContext (Level 0, MMCA.Common.Application.Interfaces), the abstraction it implements (CorrelationContext.cs:1, :9), and AmbientOrigin (same namespace), whose Current?.CorrelationId seeds the default (CorrelationContext.cs:13). Uses BCL Guid.
  • 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, and a handler delivered from the outbox or a consumer inherits the originating request's ID instead of minting a disconnected one. [Rubric §3, Clean Architecture]: the concrete holder sits in Infrastructure while every consumer depends on the Application interface.
  • Walkthrough: CorrelationId (CorrelationContext.cs:13) is { get; private set; }, initialized eagerly to AmbientOrigin.Current?.CorrelationId ?? 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:16-20) overwrites it, guarding the input with ArgumentException.ThrowIfNullOrWhiteSpace (:18) 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 ambient-origin default lets a restored background hop continue the trace it came from. The "N" GUID format (32 hex digits, no hyphens) keeps the fallback compact in log lines and headers. Registration is TryAddScoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:299): 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:34-38), 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. The outbox and consumer restore paths read and set it through AmbientOrigin (AmbientOrigin.cs:177, :184).

DataExportControllerBase<TQuery>

MMCA.Common.API · MMCA.Common.API.Controllers.Privacy · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:61 · 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:61-64). Externals: ASP.NET Core MVC (including its IOptions<JsonOptions>), System.Text.Json, Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Options (:6-7), 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] (:59) 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] (:52-55). [Rubric §6, CQRS & Event-Driven Design] covers the [FeatureGate(PrivacyFeatures.DataExport)] (:60, the flag string is "Privacy.DataExport" at MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:12): 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 (:61-63); the latter is re-exposed as the protected CurrentUserService property (:70) so a subclass can reuse it. ExportContentType = "application/json" (:67) is the one media type the package is served as.
    • ExportAsync(UserIdentifierType userId, CancellationToken) (:84-86) is routed by the action-level [HttpGet("{userId}/export")] (:79) only: the route prefix lives on the subclass, so a controller routed at Users serves /Users/{userId}/export (:41-43). The declared responses are 200 with a UserDataExportDTO, plus 401/403/404 as ProblemDetails (:80-83).
    • It reads CurrentUserService.UserId first and, when there is none, short-circuits through HandleFailure with Error.Unauthorized("Privacy.Unauthorized", ...) (:88-92), 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 (:94, declared at :123-126), awaits the handler (:95-96), and maps a failed Result through HandleFailure(result.Errors) (:98-101), which is what turns the handler's own ownership refusal into a 403.
    • On success it serializes the package itself with JsonSerializer.SerializeToUtf8Bytes(export, json) (:111) and returns File(payload, ExportContentType, BuildFileName(...)) (:113). The options are the host's MVC JSON options, resolved per request as HttpContext.RequestServices?.GetService<IOptions<JsonOptions>>()?.Value.JsonSerializerOptions, with JsonSerializerOptions.Web only as the fallback when none resolve (:109-110). The inline comment (:105-108) is explicit about why: Ok(export) would content-negotiate and render inline, and this document exists to be saved, while reusing the MVC options (the host's converters included) keeps the payload byte-shape identical to every other response the API produces.
    • BuildFileName (:137-138) 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 (:128-133).
  • 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:46-49). The route staying on the subclass follows the AuthControllerBase precedent: the app owns its URL space (:41-43).
  • 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:267).

RolesAdminControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers.Administration · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs:61 · Level 10 · class (abstract)

  • What it is: the shipped base controller for role-permission administration: list every role with its compiled and stored permissions, read the role/permission catalog, read one role, and replace one role's stored grants.
  • Depends on: ApiControllerBase (its base, for HandleFailure), the app-supplied IRoleAdministrationService re-exposed as the protected Administration property (:66), and ICurrentUserService, re-exposed as CurrentUserService (:69), taken together as primary-constructor parameters (:61-63).
  • Concept (a closed-set catalog endpoint routed ahead of its own parameterized sibling). [Rubric §1, SOLID]: like UsersAdminControllerBase<TUserDto>, the controller has no rule of its own, only translation to IRoleAdministrationService (DIP). The routing choice on GetCatalogAsync is the notable design point: [HttpGet("catalog")] (:98) is a literal segment that ASP.NET Core routing matches before the [HttpGet("{role}")] template (:116) below it, and the XML remarks on the method say so explicitly (:93-95), which is also why no role can be named "catalog": it would be unreachable through the {role} route. [Rubric §11, Security]: SetPermissionsAsync records who made the change through ResolveChangedBy() (:150-152, method at :162-163), which defaults to the caller's own CurrentUserService.UserId and is virtual so an app can substitute a display name instead.
  • Walkthrough
    • GetAllAsync(cancellationToken) ([HttpGet] at :76, method at :80-84) lists every role via Administration.ListRolesAsync (:83), returning IReadOnlyList<RolePermissionsResponse>.
    • GetCatalogAsync(cancellationToken) ([HttpGet("catalog")] at :98, method at :102-105) returns the closed sets a role editor needs: the roles this surface lists and the permissions a stored grant may name, via Administration.GetCatalogAsync (:105).
    • GetAsync(role, cancellationToken) ([HttpGet("{role}")] at :116, method at :121-125) reads one role's compiled and stored permissions via Administration.GetRoleAsync (:125).
    • SetPermissionsAsync(role, request, cancellationToken) ([HttpPut("{role}/permissions")] at :137, method at :143-152) guards the request (:148), then replaces the role's stored permission grants via Administration.SetStoredPermissionsAsync(role, request.Permissions, ResolveChangedBy(), cancellationToken) (:150-152), returning the role's post-edit state.
    • ResolveChangedBy() (:162-163) is CurrentUserService.UserId?.ToString(CultureInfo.InvariantCulture), or null when the caller cannot be identified.
  • Why it's built this way: the same admin-base pattern as UsersAdminControllerBase<TUserDto>: the HTTP shape ships once in the framework, and an app supplies its IRoleAdministrationService implementation. Keeping ResolveChangedBy virtual rather than hardcoding the user identifier lets an app record a friendlier audit label without touching the write path itself.
  • Where it's used: ADC's AdminRolesController is the current subclass (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AdminRolesController.cs), consumed from the UI at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Roles/RoleList.razor.cs. The framework's own coverage is in AdministrationControllerBaseTests.
  • Caveats / not-in-source: ADR-116 mentions this surface, but the opt-in mechanics it governs live outside the cited span; not determinable from the lines read here.

LegalAcceptanceControllerBase

MMCA.Common.API · MMCA.Common.API.Controllers.Legal · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:45 · Level 10 · class (abstract)

  • What it is: the shipped base controller for the signed-in user's Terms of Service standing: one GET that reads whether the user has accepted the configured current version, and one POST that records the acceptance. An app subclass supplies only the route prefix.
  • Depends on: ApiControllerBase (its base, for HandleFailure), ILegalAcceptanceService, ICurrentUserService (re-exposed as the protected CurrentUserService, LegalAcceptanceControllerBase.cs:51), and IOptions<LegalAcceptanceOptions> (LegalAcceptanceOptions), all taken as primary-constructor parameters (:45-48). It maps through LegalAcceptancePolicy and exchanges LegalAcceptanceDTO and AcceptLegalTermsRequest on the wire, at the route constant LegalAcceptanceRoutes.Action (me/legal-acceptance, LegalAcceptanceRoutes.cs:13).
  • Concept (the configured version is the only truth the controller trusts). [Rubric §11, Security]: the POST does not store what the client sent. LegalAcceptancePolicy.EnsureAcceptsCurrentVersion (:110-111) proves the request names the configured current version, and then the service is called with the configured value (:119-120), with a comment recording that this keeps the stored version byte-identical to configuration (:117-118). [Rubric §1, SOLID]: like the other admin and account bases, the controller holds no rule of its own, only translation to the service and policy (DIP).
  • Walkthrough
    • CurrentTermsVersion (:54) is LegalAcceptancePolicy.ResolveCurrentVersion(options.Value), null when no version is configured.
    • GetLegalAcceptanceAsync(cancellationToken) ([HttpGet(LegalAcceptanceRoutes.Action)] at :59, method at :63) returns Ok(LegalAcceptanceDTO.Evaluate(null, null, null)) when no version is configured (:66-69), a 401 Problem Details failure (Legal.Unauthorized) when UserId is null, (:71-75), and otherwise asks GetForCurrentUserAsync (:77) and returns the standing normalized against the current version through LegalAcceptancePolicy.Normalize (:83).
    • AcceptLegalTermsAsync(request, cancellationToken) ([HttpPost] plus [Idempotent] at :93-94, method at :99) applies the same 401 guard, then the version check described above, then AcceptForCurrentUserAsync with the configured version, and returns the normalized standing. A version mismatch comes back as a failure through HandleFailure, surfaced as a 400 per the declared ProducesResponseType.
  • Why it's built this way: the HTTP shape ships once in the framework and an app contributes its ILegalAcceptanceService implementation, the same pattern as the other base controllers in this group. [Idempotent] on the POST (IdempotentAttribute) makes a retried acceptance replay the first response rather than run twice (see ADR-017). Both actions are virtual, so an app can override one without re-declaring routing.
  • Where it's used: ADC's LegalAcceptanceController is the current subclass and supplies only the route (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/LegalAcceptanceController.cs:24-28); the Common coverage is LegalAcceptanceControllerBaseTests. ADR-116 mentions the surface.
  • Caveats / not-in-source: how the opt-in registration of this controller is wired is outside the cited span and not determinable from the lines read here.

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