Onboarding guide
12. API Hosting, Middleware, Idempotency & DTO/Contract Mapping
What this group covers. This is the ASP.NET Core edge of the framework: the layer that turns an
HTTP request into a domain call and turns a Result back
into an HTTP response. Almost everything here lives in MMCA.Common.API (the presentation layer that
sits above Infrastructure in the dependency flow, see primer §1),
with a handful of transport-agnostic collaborators in MMCA.Common.Application
(ICorrelationContext,
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>),
MMCA.Common.Infrastructure (CorrelationContext,
JwtForwardingDelegatingHandler), and MMCA.Common.Shared (the
DTO vocabulary and SupportedCultures). The group has seven interlocking
concerns: the composition root that registers the whole edge; the middleware pipeline every
request flows through, itself expressed as ordered data rather than a hard-coded call sequence; the
error translation that keeps every failure shaped like RFC 9457 Problem Details; the controller
hierarchy that hands a module ready-made CRUD, export, auth, recovery, and service-discovery
endpoints; the write-safety controls (idempotency keys and conditional writes) that make a retried
or racing write predictable; the contract surface (DTO/request mapping, JSON conversion, model
binding, correlation, tenancy, feature gating, output caching); and the well-known endpoints that
make an extracted service self-describing. Read the group as the reusable ASP.NET host a downstream
service (Store, ADC, Helpdesk, or an extracted microservice) drops into place so its own code is
nothing but modules. Its central rubric column is [Rubric §9, API & Contract Design] (consistent,
versioned, standardized contracts and error shapes), with heavy supporting roles for [Rubric §12,
Performance & Scalability], [Rubric §11, Security], [Rubric §13, Observability & Operability], [Rubric §7,
Microservices Readiness], and (since
ADR-027)
[Rubric §27, Internationalization].
The composition root: AddAPI, the builder extensions, and the module-host pair. A host wires the
edge through two static extension classes. DependencyInjection's AddAPI
(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:44) registers MVC
controllers with the global UnhandledResultFailureFilter
(DependencyInjection.cs:49) and ReturnHttpNotAcceptable = false (:48), wires two JSON converters
into the serializer options, the CurrencyJsonConverter
(DependencyInjection.cs:53) and the
EnumerationJsonConverterFactory
(DependencyInjection.cs:58, registered here because a concrete enumeration does not inherit the base
class's [JsonConverter] attribute), adds the XML formatters (DependencyInjection.cs:60), optionally
installs the ModuleControllerFeatureProvider when a
ModulesSettings instance is supplied
(DependencyInjection.cs:62-66), binds IdempotencySettings with
ValidateDataAnnotations().ValidateOnStart() when configuration is supplied
(DependencyInjection.cs:68-74,
ADR-070), registers
the two scoped action filters IdempotencyFilter and
OwnerOrAdminFilter (DependencyInjection.cs:77-78, scoped
because they depend on scoped services), turns on feature management with targeting through
CurrentUserTargetingContextAccessor and the
DisabledFeatureHandler (DependencyInjection.cs:91-93), and registers the
edge error-localization boundary (AddErrorLocalization, DependencyInjection.cs:96 and :107, with
AddErrorResources<TResource> at :122 for each module's own .resx set). Three sibling methods on
the same class complete the picture: AddCommonExceptionHandlers (DependencyInjection.cs:135)
registers the Problem Details service and the five exception handlers in most-specific-first order
(:140-144), AddServerAuthSessionCookie (DependencyInjection.cs:160) registers the Blazor Server
host's SSR cookie reader (CookieTokenReader, :166) plus the
singleton CookieSessionRefresher (:172, singleton so its
in-flight map is shared across requests), and AddModuleHealthChecks (DependencyInjection.cs:188)
turns ModuleLoader discovery results into
module-{Name} health checks, tagged module so /health?tag=module filters them (Healthy for
enabled modules at :192-198, Degraded for disabled ones at :200-207).
WebApplicationBuilderExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:33)
carries the identical builder-side setup every service shares: header-based API versioning through the
api-version header (AddCommonApiVersioning, line 244, reader at line 253, v1.0 assumed when the
header is absent at line 251,
ADR-046), rate limiting
(AddCommonRateLimiting, three overloads at lines 296, 314, and 332), Brotli and Gzip compression at
CompressionLevel.Fastest (AddCommonResponseCompression, line 374, both providers pinned to
Fastest at lines 382-383 and 387-388 because these are dynamic per-request payloads on fractional
vCPUs), OpenAPI (line 403), CORS (line 581,
ADR-082, with the two policy
names as constants at lines 35 and 38 and the allow-any-origin policy reachable only in Development,
lines 593-597), and the two JWT bearer registrations: in-process AddCommonAuthentication (line 538)
for the Identity host and AddForwardedJwtBearer (line 445) for extracted services that validate
against a remote JWKS. Two smaller helpers keep a service host's Program.cs honest.
ModuleHostExtensions's AddModuleHost
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:51) collapses the
settings-bind plus loader construction every module-hosting service repeated verbatim, and answers with
a ModuleHostContext
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:21) carrying the bound
settings, the loader, and RegisterModules (ModuleHostContext.cs:66) as a step the host hands to its
own application pipeline: discovery is deliberately not run inside AddModuleHost, because it has to
sit between AddApplication() and AddApplicationDecorators() at a host-chosen position
(ModuleHostContext.cs:13-19,
ADR-014).
JwtAuthorityExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAuthorityExtensions.cs:15) reads the
authority every non-issuer service validates against and throws when the AppHost never injected it
(GetRequiredJwtAuthority, :35-43), because a host that boots without one answers every
authenticated request with a 401 that looks like a token problem instead of a wiring problem. Only one
DI ordering rule is load-bearing in the whole host, and it belongs to the CQRS pipeline group, not
here: AddApplicationDecorators
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:91) must run last so Scrutor
can decorate handlers that are already registered. The API registrations themselves are
order-independent. This is the [Rubric §9, API & Contract Design] and [Rubric §12, Performance & Scalability]
story: versioning, compression, rate limiting, and CORS are configured once and inherited by every
service instead of copy-pasted per host.
The request pipeline is data, not prose. Middleware order is behavior in ASP.NET Core, so the
framework does not leave it to each host's Program.cs, and it no longer even leaves it as a fixed
sequence of Use... calls.
WebApplicationExtensions's UseCommonMiddlewarePipeline
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:48) and its
Action<MiddlewarePipelineBuilder> overload (WebApplicationExtensions.cs:60) both route through one
private ApplyPipeline that seeds the defaults, lets the host adjust them, validates the result, and
only then applies each step (WebApplicationExtensions.cs:140-147). The steps themselves are
MiddlewarePipelineStep records
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStep.cs:21), each a stable
Name plus an Action<WebApplication> Configure delegate, both null-validated on construction
(MiddlewarePipelineStep.cs:27 and :30); because a step is inert data until someone runs it, the
whole order is assertable without building a host.
MiddlewarePipelineBuilder
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:16) owns the
list: CreateDefault (MiddlewarePipelineBuilder.cs:32) seeds the eighteen framework steps in order
(:34-156) and InsertBefore, InsertAfter, Replace, and Remove (:166, :183, :203, :224)
let a host address any of them by name, which is why
MiddlewarePipelineStepNames
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:14) is public
contract rather than an implementation detail: renaming a constant there is a breaking change, and the
declaration order of those constants (:17-74) is the runtime order. Read it top to bottom and you
have the edge: exception handler, correlation id, request localization, pre-forwarded capture,
forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant
resolution, rate limiting, the soft-deleted-user filter, authorization, output cache, the JWKS and OIDC
discovery endpoints, and finally the controllers. Four of those adjacencies are load-bearing, and
Build (MiddlewarePipelineBuilder.cs:258) re-checks them before a single step is applied
(:259-277): the pre-forwarded capture must run immediately before UseForwardedHeaders or the
captured scheme and host are no longer the ones the connection saw; authentication must run immediately
before tenant resolution because the claim strategy reads HttpContext.User; authentication must
precede the rate limiter (ADR-019)
because GlobalRateLimitPartition keys on the authenticated principal and an unauthenticated pipeline
would see every request as anonymous; and forwarded headers must precede the HTTPS redirect so the
redirect decision reads the proxy-reported scheme. A violation throws InvalidOperationException at
startup with the offending order printed (:325-326 and :340-341), and an invariant binds only when
both of its steps are still present (:320 and :335), so dropping a whole capability stays
legal while reordering a pair does not. Two more decisions are worth internalizing from the default
step list: the HTTPS redirect is wrapped in a UseWhen that skips any request whose content type
starts with application/grpc (MiddlewarePipelineBuilder.cs:91-93), because extracted gRPC services
speak HTTP/2 cleartext (h2c) and a 307 would break the call; and the forwarded-headers step clears the
known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs
(:76-77), which is safe only because the pre-forward scheme and host were already stashed in
HttpContext.Items under the two keys declared at WebApplicationExtensions.cs:24 and :33. Hosts
freeze their own resulting order with the opt-in fitness function
MiddlewarePipelineOrderTestsBase
(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:29), whose
default expectation is the framework list verbatim (:40-57), so a reorder fails a fast unit test
instead of surfacing as an unreachable jwks_uri or a rate cap that never engages
(ADR-079). Alongside
the pipeline, UseCommonRequestLocalization (WebApplicationExtensions.cs:73) builds the culture
options from SupportedCultures
(MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:9: en-US as the
default at line 12 and the full en-US plus es list at line 18, with the qps-Ploc pseudo locale at
line 28 added in Development only, WebApplicationExtensions.cs:80-83) so edge error localization runs
under the caller's culture, and the companion MapCultureEndpoint (WebApplicationExtensions.cs:102)
serves the GET /culture/set switch that Blazor UI hosts map
(ADR-027). Turning order into
inspectable, validated data is [Rubric §12, Performance & Scalability], [Rubric §14, Testability] and
[Rubric §34, Architecture Governance] in one move.
Rate limiting: one always-on partition, one named policy, and an optional shared counter. The
global limiter is active on every request and rejects with 429 above
RateLimitingSettings.GlobalPermitLimit (default 300 requests per minute,
MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:40) per
authenticated user: GlobalRateLimitPartition (WebApplicationBuilderExtensions.cs:80) routes
anonymous traffic down a no-limiter branch (lines 86-89), and health, liveness, /.well-known/*, and
application/grpc traffic bypass the limiter outright (IsRateLimitBypassed, lines 61-65). Because it
deliberately no-ops for anonymous callers and account lockout is per-email, a password spray (one
password, many email addresses) from a single source would otherwise be unthrottled. The framework
closes that gap with the named auth-ip policy (RateLimitPolicyAuthIp,
WebApplicationBuilderExtensions.cs:48), whose partition selector AuthIpRateLimitPartition (line
221) is a per-client-IP window defaulting to 30 requests per minute (RateLimitingSettings.cs:47) and
fails open on an unattributable IP (lines 225-226) rather than collapsing every such request into
one shared bucket, which would throttle the in-process test server to a standstill. Unlike the other
named limiters, this one is not left for each app to attach:
AuthControllerBase carries
[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] on both LoginAsync
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:72) and
RegisterAsync (AuthControllerBase.cs:96), and
PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>
carries it on both recovery endpoints
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:78
and :102), so every consumer inherits spray protection by construction, while RefreshAsync
(AuthControllerBase.cs:117-119) is deliberately left unthrottled because refresh is periodic and
automatic and Blazor Server circuits issue it server-side from one shared host IP. A consumer that
inherits the base without calling AddCommonRateLimiting fails at startup on an unregistered policy,
which is the loud failure rather than the silent one. Two knobs sit on top of that baseline, both
reachable only through the IConfiguration overload (WebApplicationBuilderExtensions.cs:315) that
binds the RateLimiting section. Algorithm (RateLimitingSettings.cs:53) selects the
RateLimitAlgorithm enum
(MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8): FixedWindow
is the default and cheapest but lets a caller spend an allowance twice across a boundary
(RateLimitAlgorithm.cs:10-15), while SlidingWindow divides the same one-minute window into
SegmentsPerWindow segments (default 4, RateLimitingSettings.cs:62) and smooths that burst away at
the cost of one counter per segment. And Distributed (RateLimitingSettings.cs:72) swaps the
in-memory counter for RedisFixedWindowRateLimiter
(MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37), so
a limit means the same thing behind a load balancer as it does on one node. All three choices funnel
through one private factory, CreateLimitedPartition (WebApplicationBuilderExtensions.cs:149), which
takes the Redis path only when an IConnectionMultiplexer is actually registered and otherwise falls
through to the in-memory limiters rather than failing startup (lines 157-178). The Redis limiter is
worth reading for its three deliberate compromises: it stores one INCR counter per partition per
window under rl:{partitionKey}:{unixMinute} (line 130) and gives it a 65 second TTL on the increment
that creates it (line 142), so keys expire themselves and clock skew between instances cannot hand a
partition a fresh allowance mid-window; the increment is not transactional with the permit decision,
so a simultaneous burst can overshoot slightly, accepted in exchange for one round trip per request
(RedisFixedWindowRateLimiter.cs:28); and any Redis fault permits the request and logs at most one
warning per window across the process (lines 147-154), because rate limiting protects capacity and must
never itself become the reason a healthy request is rejected.
RedisRateLimitLease (RedisFixedWindowRateLimiter.cs:169) is the
two-instance lease type it hands out, Acquired and Rejected as shared statics (lines 172 and 175)
so a permitted request allocates nothing. The auth-ip policy stays per-instance whatever
Distributed says (allowDistributed: false, WebApplicationBuilderExtensions.cs:235): per-account
lockout already backs it, and a login throttle that fails open on a Redis outage is a worse trade than
one that stays local (ADR-019 for the
layering, and
ADR-029 for the
brute-force half). This is [Rubric §11, Security] and [Rubric §12, Performance & Scalability] handled
once at the edge.
Correlation, tenancy, and the soft-deleted-user gate: three ambient facts established once.
CorrelationIdMiddleware
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:15) reads the
X-Correlation-ID request header (the constant lives at CorrelationIdMiddleware.cs:18), falling back
to the current W3C trace id then ASP.NET's TraceIdentifier (lines 32-34), writes it onto the scoped
ICorrelationContext
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8, implemented by
CorrelationContext, which self-seeds a GUID when no middleware ever sets one,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CorrelationContext.cs:12), and echoes it
back through Response.OnStarting (CorrelationIdMiddleware.cs:37-41). That single id is what the
CQRS logging decorators read
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:16
and :24) and stamp onto every log scope through the
Command {CommandName} [Module: {ModuleName}] [CorrelationId: {CorrelationId}] scope definition
(LoggingCommandDecorator.cs:69-70), so one request is traceable end to end, and by module.
TenantResolutionMiddleware
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:36) is the
HTTP half of ADR-073: it tries each
configured strategy in order (claim, then header; the
TenantResolutionStrategy.Host
option is defined but deliberately returns null rather than being guessed at, lines 105-121) and
publishes the winner on the scoped
ITenantContext that the persistence query filter, save
interceptor, and per-tenant database routing all read (line 70). Two decisions mirror the
soft-deleted-user middleware below. It is wired unconditionally but inert by default, because
TenancySettings resolves to defaults with
Enabled false in a host that never called AddMultiTenancy (line 62). And it fails closed: with
Tenancy:RequireTenant on, a request that resolves no tenant is rejected at line 83 and answered 400
with an RFC 9457 body naming the claim and header it looked at (lines 133-146), because an unscoped
request would read across every tenant, which is the exact outcome tenancy exists to prevent; an
explicit RequireTenant opt-out lets the request run as a system caller instead (lines 75-81), and
health, liveness, and discovery paths are excluded so probes still answer before any tenant exists
(lines 89-94). SoftDeletedUserMiddleware
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31) enforces
business rule BR-133: an authenticated caller whose account was soft-deleted is rejected with a bare
401 (lines 104 and 145), checked first against a marker cached for 30 seconds
(SoftDeletedUserCache.MarkerDuration,
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29, read at
SoftDeletedUserMiddleware.cs:91 and written at :132) to keep the per-request lookup cheap
(ADR-047). It
resolves ISoftDeletedUserValidator lazily from
RequestServices (line 75) instead of as an InvokeAsync parameter, so a service that does not host
Identity passes the request through rather than 500-ing on every call: an explicit nod to the
[Rubric §7, Microservices Readiness] extraction path. And unlike tenancy it fails open: a cache
read that throws falls back to the validator query (lines 93-99), and a validator query that throws
lets the request continue (lines 118-124), because failing closed would turn any cache or database blip
into a total outage for every authenticated request, while the exposure it buys back is bounded by the
access-token lifetime. All three middlewares are [Rubric §13, Observability & Operability]
(correlation), [Rubric §11, Security] (deleted-account lockout), and [Rubric §8, Data Architecture]
(tenant scoping) concerns handled once at the edge instead of in every controller.
Errors become Problem Details, through two channels and one table. Failures reach the client two
ways. Thrown exceptions are caught by the handler chain registered in AddCommonExceptionHandlers
(DependencyInjection.cs:137-146), evaluated most-specific-first:
OperationCanceledExceptionHandler (499 Client Closed Request,
OperationCanceledExceptionHandler.cs:32), DomainExceptionHandler (400,
DomainExceptionHandler.cs:32), DbUpdateExceptionHandler (409 at line
33 with a deliberately generic detail so database schema names never leak,
DbUpdateExceptionHandler.cs:35-37), ValidationExceptionHandler (400
at line 33 with FluentValidation errors grouped by property name into the errors extension,
ValidationExceptionHandler.cs:46-54), and finally GlobalExceptionHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:23), which is
the 500 catch-all (line 69) plus one special case: a
CrossTenantWriteException is answered
400 with a fixed title and detail that name nothing the exception carries (constants at :29 and
:37-39, handled at :47-65), because echoing a tenant id back tells an unauthorized caller which
tenant owns the row it just tried to write, and it is logged as a warning rather than an error because
a tenant-scoped API refusing an untenanted write is routine. Business failures that travel as
Result.Failure rather than as exceptions are mapped by ApiControllerBase's
HandleFailure
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:35), which falls
back to a 500 when the error list is empty (lines 39-45) and otherwise derives the status from the
most severe error present rather than the first (line 48), so an aggregate built by
Result.Combine cannot be downgraded by error ordering: a 403 or a 500 travelling alongside a
validation error still answers 403 or 500, and equal ranks keep the earliest error (the ranking is
documented at ApiControllerBase.cs:22-30 and lives in
ErrorTypeSeverity in MMCA.Common.Shared, so
the gRPC edge classifies the same aggregate identically). The safety net
UnhandledResultFailureFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:22, an
IAlwaysRunResultFilter) catches any action that accidentally returned a failed Result as a 200
body, logs a warning, and rewrites it as the correct error (lines 28-49). All of those paths converge
on ErrorHttpMapping
(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14), whose
FrozenDictionary<ErrorType, int> (lines 20-31) is the single source of truth mapping each
ErrorType (Validation, Invariant and Failure to 400,
NotFound to 404, Conflict to 409, Unauthorized to 401, Forbidden to 403, UnprocessableEntity to 422,
Unexpected to 500) to a status code, with 400 as the fallback for anything unmapped (lines 37-38) and
the list-taking overload at lines 50-51 applying the severity ranking. Its BuildErrorsExtension (line
61) localizes each Error's human message at the edge
through IErrorLocalizer
(MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/IErrorLocalizer.cs:9), keyed by the
stable Code, leaving Code, Type, Source, and Target verbatim so clients can still branch on
them, and leaving the original English message untouched when no localizer is registered (line 65).
ErrorLocalizer
(MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorLocalizer.cs:11) walks the
registered ErrorResourceSource
(MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorResourceSource.cs:12) list in
registration order (lines 23-30: the framework's own ErrorResources
(MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9) anchor first, then
each module's resources added through AddErrorResources, DependencyInjection.cs:124) and falls back
to the caller's message when no source knows the code (line 32). This is [Rubric §9, API & Contract
Design] (one consistent RFC 9457 shape) meeting the [Rubric §1, SOLID] discipline of never duplicating
the mapping, and [Rubric §27, Internationalization] at the one boundary where a machine-readable code
becomes human prose.
The controller hierarchy: generic CRUD earned by inheritance. A module gets working endpoints by
subclassing one generic base and supplying its type parameters
(ADR-034).
ApiControllerBase is the root: [ApiController], HandleFailure, nothing
else. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:36) adds the
read surface (GetAllAsync at line 106, paged at line 153, lookup at line 371, GetByIdAsync at
line 410) over an
IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>,
with field projection through a fields query parameter, X-Pagination header metadata (line 187),
and a page size clamped to MaxPageSize (resolved per request from
IOptions<ApplicationSettings>,
defaulting to 500, lines 58-64).
AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:28)
extends it with an [Idempotent] POST create (lines 58-59) that returns 201 through
CreatedAtRoute("Get{EntityName}ById", ...) (line 72) and a DELETE that dispatches
DeleteEntityCommand<TEntity, TIdentifierType>
and returns 204 (lines 84-97), and
CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:54) closes
the set with the conditional PUT {id} (attributes at lines 87-89, the six documented response codes
at :90-96, the token read at :102, and a fresh ETag on the way out at :111). The interfaces
IEntityControllerBase<TEntityDTO, TIdentifierType>
(IEntityControllerBase.cs:14) and
IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest>
(IAggregateRootEntityControllerBase.cs:15) describe those shapes for testing and documentation. The
generic constraints tie the tower together: TEntity derives from
AuditableBaseEntity<TIdentifierType>
for reads and from
AuditableAggregateRootEntity<TIdentifierType>
for writes, TEntityDTO implements IBaseDTO<TIdentifierType>, and
TCreateRequest implements ICreateRequest
(IEntityControllerBase.cs:17-18, IAggregateRootEntityControllerBase.cs:20-22,
AggregateRootEntityControllerBase.cs:41-44). Alongside the CRUD tower sit six special-purpose bases:
AuthControllerBase
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:43, anonymous
login, register, and refresh over
IAuthenticationService, lines 67-117, plus the
[Authorize] revoke at line 142 and the multi-device pair GET my-sessions (line 173) and
POST revoke/{sessionId:guid} (line 205), whose current-device flag comes from the access token's own
sid claim so no client state is involved,
ADR-097);
PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43),
a sibling of that base rather than an addition to it because each app's own AuthController
already occupies the single-inheritance chain (it derives straight from ApiControllerBase,
PasswordResetAuthControllerBase.cs:45), serving POST forgot-password (line 75) and
POST reset-password (line 99); both are anonymous by necessity since the caller has lost the
credential, forgot-password always answers 202 whether or not the address exists (line 92) so the
response never reveals which addresses hold accounts, and each app supplies its own command record
through a one-line factory (:61 and :69,
ADR-091);
UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:41),
which subclasses AuthControllerBase purely additively (line 46) and adds the self-service account
endpoints PUT password (line 86), PUT preferences (line 112), and GET preferences (line 138),
taking the two mutation commands as type parameters because each app owns its own command record while
the preferences query is shared (UserAccountAuthControllerBase.cs:48-49);
OAuthControllerBase
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:35, the Google,
GitHub, and Apple external-provider flow at lines 51, 59, and 70, whose single-use exchange code, cached
for two minutes at OAuthControllerBase.cs:46, keeps tokens out of the redirect URL;
ADR-036 and
ADR-043);
DataExportControllerBase<TQuery>
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59),
the data-subject access and portability endpoint
(ADR-076), which serves
GET {userId}/export (line 77) as a real file download rather than an inline body (returned through
File(...) at line 109, with an invariant user-data-{userId}-{yyyyMMdd}.json name derived from the
package's own GeneratedOn at lines 133-134), gated by both an [Authorize] and a [FeatureGate] on
PrivacyFeatures.DataExport (lines 57-58) while the handler
independently enforces owner-or-privileged-role, and abstract only because each app owns its own query
record (CreateQuery, line 119); and ServiceInfoControllerBase
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30), whose
dual-version /ServiceInfo returns ServiceInfoResponse for the deprecated
v1.0 (line 51) and ServiceInfoV2Response for v2.0 (line 54, a superset
adding the supported and deprecated version lists held at lines 32-33), proving the versioning
machinery works across versions ([MapToApiVersion] at lines 40 and 46). All of these carry the same
note: class-level routing and versioning attributes are not reliably inherited, so the per-service
sealed subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §15,
Best Practices & Code Quality] payoff in the presentation layer: a module writes a DTO, a mapper, and a short sealed
subclass, and inherits a fully paged, filterable, exportable, error-mapped REST resource, with
[Rubric §30, Compliance & Data Governance] covered by the DSAR base.
Export is a route, not a content negotiation. EntityControllerBase.ExportAsync
(EntityControllerBase.cs:247-251) streams the same filtered collection the paged endpoint serves as
an RFC 4180 CSV download, page-looping the query service server-side
(ADR-078). It is a distinct path
rather than an Accept: text/csv variant for two reasons stated in the source: the public
output-cache policy varies by query string but not by Accept, so a cached JSON body could be replayed
to a CSV request, and AddAPI sets ReturnHttpNotAcceptable = false, so a negotiation miss would fall
back to JSON silently instead of returning 406 (EntityControllerBase.cs:198-203). The writing half is
CsvWriter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:34), a deliberately
hand-written internal helper: it quotes only when RFC 4180 requires it (WriteField, line 145, with
the trigger set as a SearchValues<char> at line 61), terminates records with CRLF regardless of host
OS (line 48), formats cells invariantly so the same row produces the same bytes on every machine
(FormatCell, line 128), and writes a UTF-8 BOM explicitly (lines 45 and 69, paired with the
preamble-free encoding at line 55) because Excel reads a BOM-less UTF-8 CSV in the machine's ANSI code
page. Two guards run before any byte is written. Columns a CSV cannot represent faithfully, binary
concurrency tokens and every non-string collection property, are computed once per closed controller
type and dropped (UnexportablePropertyNames at EntityControllerBase.cs:639, the type test at
:667-670); value objects and other class-typed properties are deliberately kept, since their
invariant ToString is exactly the cell a reader expects (:635-637). And a caller who names one of
those dropped properties in fields= gets an Error.InvalidEntityField validation failure rather than
a quietly missing column (ValidateExportFields, :685, called at :259). Then the controller opens
a StreamWriter over Response.Body without committing the response (line 277), which is what keeps
the "a failure on page one still returns Problem Details" path honest (line 298), and the row ceiling
is announced up front through the X-Export-Row-Limit header (constant at line 535, default 100,000 at
line 526, overridable per host through MaxExportRows at lines 78-84, written alongside the
Content-Disposition attachment name in BeginExportResponse at lines 713-714) with the truncation
notice written as a final body line (line 349), because headers are frozen the moment the first byte
flushes. Row scoping is a shared hook rather than an export-only one: GetReadSpecificationAsync
(line 597) is what both the list endpoints and the export read, and it returns
GetExportSpecification(), itself null by default (line 626), so an export can no longer drift wider
than the list it mirrors but is unscoped until a controller overrides one of the two. That is a
[Rubric §12, Performance & Scalability] and [Rubric §11, Security] pairing worth reading closely.
Idempotency for safe retries. Write endpoints are made replay-safe by
IdempotentAttribute
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotentAttribute.cs:16), a one-line
ServiceFilterAttribute that resolves the scoped IdempotencyFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:67) from DI
(ADR-017). The filter runs at two
MVC stages. As an IAsyncResourceFilter it runs before model binding, the last point at which the
body can be made replayable, and calls EnableBuffering only when an Idempotency-Key header is
actually present (lines 119-125), so ordinary traffic pays nothing. As an IAsyncActionFilter it does
the real work (line 128): with no key the action simply runs (lines 132-136); with one, it derives the
cache key from the caller's user id claim (or anon: plus the remote IP), the HTTP method, the route
template, and the client-supplied key, joined on newlines and SHA-256 hashed so the key length stays
bounded (BuildCacheKey, lines 485-498). Scoping to the caller stops one user's cached response from
being replayed to another; scoping to method plus route stops one key from colliding across endpoints
that share a cache instance. It also hashes the buffered request body (ComputeRequestBodyHashAsync,
line 182, rewinding the stream on both sides so model binding still sees it) and binds that hash to
the record. The flow is then a lock-free fast path (TryReplayAsync called at line 143), then the
guarded section. The guard is an IDistributedLock
resolved from RequestServices when the host registers one (lines 148-156), because a per-process
lock only serializes duplicates that land on the same replica and both deployed apps run more than
one; a host with no distributed lock falls back to the striped
KeyedSemaphoreStripe (field at line 90, used from line 201)
rather than a per-key semaphore table that would grow unbounded or race on removal. Under the
distributed lock the filter waits LockWait (5 seconds, line 104) for a lease living LockTimeToLive
(30 seconds, line 97), double-checks the cache, and runs the action. Four outcomes are worth
memorizing. A hit replays the stored IdempotencyRecord
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyRecord.cs:14, a status code
plus JSON body plus the request-body hash) with an X-Idempotent-Replay: true header (line 383,
through IdempotencyHeaders), as a bare StatusCodeResult when
the stored body is empty so a replayed 204 does not acquire a content type (lines 384-391). A key
reused with a different payload is answered 422 Unprocessable Entity rather than replayed (line
376 and BodyMismatchResult at 322-333), because replaying would tell the client a genuinely new write
succeeded when nothing ran. A duplicate that cannot take the lock within the wait and finds nothing
cached gets 409 Conflict (lines 263-269 and InFlightDuplicateResult at 301-312), which is
retryable and honest rather than a second execution. And a cache or lock that faults is swallowed:
the request runs without the guarantee and the degradation is counted (lines 255, 364, and 439),
because deduplication is an optimization over an at-least-once client retry and must not become an
outage of every write endpoint. Only 2xx results are stored, and only the two shapes the record can
represent: an ObjectResult or a body-less StatusCodeResult such as NoContent() (BuildRecord,
line 448), for IdempotencySettings.CacheExpirationHours (default 24,
constrained to the range 1 to 168,
MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencySettings.cs:15-16) through
ICacheService (line 430). Replaying a transient 500 for a whole
retention window would defeat the retry the header exists to enable. All three behaviors are
observable: IdempotencyMetrics
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16) publishes
idempotency.replayed (line 36), idempotency.conflict tagged kind=body_mismatch or in_flight
(lines 24, 29, and 41), and idempotency.degraded (line 46) on the MMCA.Common.Idempotency meter
(line 19), so a sustained degraded rate says out loud that deduplication is effectively off. The one
thing the filter cannot do is notice an endpoint that forgot to opt in, which is what
NonIdempotentAttribute
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23) exists
for: it attaches no pipeline stage and changes no behavior, it only records a required Justification
string (line 28), and its sole consumer is the PostActionsDeclareIdempotencyIntent fitness function
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.Idempotency.cs:44,
matching at :72-73), which fails the build unless every POST action carries either [Idempotent] or
this attribute. That is why AuthControllerBase reads the way it does:
RegisterAsync is [Idempotent] (AuthControllerBase.cs:94) while login, refresh, and the two
revoke actions each carry a written reason for staying outside the contract (AuthControllerBase.cs:70,
:116, :143, :206), since replaying a stored token pair would hand a retrying client credentials
the rotation has already invalidated, and replaying a cached 204 would report success for a revoke that
never ran. This is a [Rubric §7, Microservices Readiness], [Rubric §13, Observability & Operability],
[Rubric §34, Architecture Governance] and [Rubric §29, Resilience & Business Continuity] control:
at-least-once retry from a gateway or a flaky client cannot create duplicate resources, and "no
idempotency here" is always a recorded decision rather than an omission nobody noticed.
Conditional writes: the ETag round trip, and only the header. Idempotency keys make a retried
write safe; the concurrency token makes a racing write safe, and the HTTP way to state that
precondition is If-Match
(ADR-035). The token travels in
exactly one place. IConcurrencyAware
(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:15) is the read-side contract:
a byte[] RowVersion declared init (line 19), rendered as the response ETag and echoed back by the
client in its next write, and its own doc states the rule (lines 9-14): update requests carry no token,
a persisted aggregate always has one, and a write that states no precondition is refused rather than
falling back to last-write-wins. ConcurrencyETag
(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:24, taught with the auth and HTTP
primitives in Group 08) is the pure translation layer between the EF Core rowversion byte array and
the wire: Format renders it as the weak tag W/"<base64>" (line 44) and TryParse reads one
back (line 62), with the header names and the * wildcard as constants (:27, :30, :33). Weak is
the honest strength, because a strong tag promises byte-for-byte equality of the representation, which
this cannot promise when the same row version renders differently under a fields= projection
(ConcurrencyETag.cs:13). The read side emits it automatically: EntityControllerBase.GetByIdAsync
calls SetConcurrencyETag (EntityControllerBase.cs:436), which resolves the DTO's RowVersion
property once per closed controller type (line 445, so a DTO with no token costs no per-request
reflection) and writes the header at line 479; the method is protected rather than private (line 471)
so a controller serving a row from a custom read emits the same header instead of re-implementing the
format, which is where the two halves drift apart and a precondition quietly stops working. The write
side is SupportsIfMatchAttribute
(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:49), which,
unlike IdempotentAttribute, needs no scoped service and therefore implements
IAsyncActionFilter directly and needs no DI registration (lines 44-48). Before the action it decodes
the header into HttpContext.Items under a public key (TokenItemKey, line 57, written at line 122),
which the action reads back through the static RequiredToken (line 68); after the action it rewrites
a conflict outcome to 412 Precondition Failed, covering both an ObjectResult 409 and a bare
StatusCodeResult 409 (lines 130-155). Three responses make the contract unambiguous. A missing or
wildcard If-Match is 428 Precondition Required and the action never runs (lines 109-114 and
162-165), so last-write-wins is not reachable by omission. A malformed tag is a 400 rather than
being ignored (lines 116-120 and 174-177), because silently dropping a precondition the client believed
it had set is the one outcome worse than rejecting it. And 412 is used rather than 409 because RFC 9110
reserves it for a precondition the client stated in a header (SupportsIfMatchAttribute.cs:34-39).
CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>
is where all of it lands: [SupportsIfMatch] on the PUT (CrudEntityControllerBase.cs:90), the token
read at :102, and the whole set of outcomes declared as ProducesResponseType at :90-96.
[Rubric §9, API & Contract Design] and [Rubric §8, Data Architecture].
Output caching at the edge, and evicting it across services. Read endpoints that are
user-independent by contract can be cached at the HTTP edge even when the UI attaches a bearer token
to every request, which the built-in ASP.NET policy refuses to do.
PublicEndpointOutputCachePolicy
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35),
registered by name through OutputCacheOptionsExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6, both
overloads at lines 20-21 and 34-35), drops that identity bail-out (lines 68-72), varies by every
query-string key (QueryKeys = "*" at line 81) so search, paging, filtering and field projection each
get their own entry, refuses to store responses that set cookies or are not plain 200s (lines 100-103),
and offers a bypassRoles escape hatch so a privileged caller who receives an elevated payload always
reads fresh, skipping both lookup and storage (lines 71-72 and 112-113,
ADR-040).
The store behind it defaults to per-replica memory, so a tag eviction on one instance leaves every
other instance serving stale bytes. OutputCacheEvictionHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32) closes
that gap by consuming the
OutputCacheEvictionRequested integration
event and calling EvictByTagAsync for each tag against this host's store (lines 38-64); no
MassTransit type appears in it, so the same handler is reachable from the in-process dispatcher and
from the broker (ADR-026). Its two
behaviors are both deliberate: eviction is per-tag best effort, so a store that throws on one tag
is logged and counted rather than rethrown (lines 56-62), because rethrowing would redeliver the
message, re-evict every tag that already succeeded, and eventually dead-letter a message whose only
consequence is a cache entry that expires on its own TTL anyway; and an OperationCanceledException is
explicitly not swallowed (line 56), so host shutdown looks like shutdown rather than an acked
message. The failures are visible through OutputCacheMetrics
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheMetrics.cs:16), which publishes
cache.eviction.failed tagged by cache tag on the MMCA.Common.OutputCache meter (lines 19 and
29-30), the alert target for cross-service cache coherence: a non-zero rate means this host is serving
responses it was told to drop. OutputCacheEvictionExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionExtensions.cs:27) is the
DI half plus the in-process shortcut: AddOutputCacheEvictionHandler (line 111) registers the handler
as a singleton through TryAddEnumerable (lines 115-117) so a host and a module that both ask for the
behavior get one handler rather than a double eviction, while EvictTagsAsync (line 49) and the
log-and-continue TryEvictTagsAsync (line 78) let code already running in this host evict its own tags
without publishing an event to itself. [Rubric §12, Performance & Scalability] with a [Rubric §13,
Observability & Operability] backstop.
The contract surface: mapping, JSON, and query filters. The framework maps between the wire and
the domain by hand, not through a runtime reflection mapper
(ADR-001). Two interfaces in the
Application layer define the shape, both in
MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
(line 14) turns an entity into its DTO and supplies a default interface implementation for the
collection overload (line 27), and
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>
(line 42) turns an incoming request into a domain entity through its factory, returning a
Result so mapping-time validation (a uniqueness check,
for example) is a first-class failure rather than an exception (line 54). Their write-side twin,
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>
(same file, line 79), belongs to the CQRS group because it is what lets one generic update handler own
loading, the concurrency token, and the save while the aggregate keeps owning its invariants
(IEntityDTOMapper.cs:62-74). All three are auto-registered by module assembly scanning. Two edge
helpers finish the contract surface: CurrencyJsonConverter
(MMCA.Common/Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:12)
serializes the Currency value object as its bare ISO
4217 code (lines 29-30) and throws JsonException on a non-string token or an unknown code (lines
17-23), which the framework surfaces as a 400; and QueryFilterModelBinder
(MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24) parses
the filters[Prop].operator= and filters[Prop].value= query-string convention into the
(operator, value) dictionary the paged read and export endpoints hand to the specification layer,
capping one request at MaxFilters = 50 distinct properties (line 34, enforced at lines 61-62,
bounding the per-request reflection work a caller can demand from
QueryFilterService) and discarding entries
missing an operator or a value (line 75). The small shared DTO vocabulary those generics rely on lives
in MMCA.Common.Shared: IBaseDTO<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9, an Id declared with an init
accessor at line 13), BaseLookup<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/BaseLookup.cs:8, a required id at line 12 plus a
required display name at line 15, for dropdowns and autocomplete), and the concurrency contract
IConcurrencyAware described above. Manual mapping keeps the DTO contract
explicit and reviewable: the [Rubric §9, API & Contract Design] and [Rubric §15, Best Practices]
position this codebase takes deliberately.
Feature gating and per-module controller visibility. Three mechanisms let an operator turn surface
area on and off without a code change. DisabledFeatureHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13)
renders a consistent Problem Details 404 when a FeatureGate-protected action is reached while its
flag is off (lines 18-26), so a disabled feature looks like a nonexistent endpoint rather than an error
(ADR-031). A percentage
rollout needs to be sticky per user rather than random per request, which is what
CurrentUserTargetingContextAccessor
(MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:54)
supplies: registered by AddAPI through WithTargeting<...> (DependencyInjection.cs:94), it builds
the targeting context from the current request's principal, taking the user id from the user_id
claim that TokenService emits and falling back to the principal's
name (line 86), and taking the groups from the caller's role claims under all three types the JWT
middleware may produce (ClaimTypes.Role, role, roles, lines 76-82) because
ICurrentUserService is scoped while this accessor is a
singleton (DependencyInjection.cs:86-91). An anonymous request yields an empty context (lines 67-74),
so a targeted feature is simply off for anonymous callers, and the accessor can never fail a request.
At a coarser grain, ModuleControllerFeatureProvider
(MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:28) removes a
disabled module's controllers from MVC discovery entirely, snapshotting the disabled names once (lines
36-39), returning immediately when none are disabled (lines 41-44), and matching a .{ModuleName}.
token against the controller's assembly name or namespace (lines 60-81, the dots on both sides
preventing a Catalogue false positive on Catalog), so a module switched off in configuration
cannot have its routes mapped (they would otherwise 500, since the module's DI services were never
registered). Together these are the feature-flag story extended to the HTTP edge and part of the
[Rubric §7, Microservices Readiness] "one codebase, many deployment shapes" design.
Well-known endpoints, database initialization, and the extraction edge. Several types here exist
only so a module can be lifted out of the monolith into its own service (ADRs
004,
007,
008).
JwksEndpointExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/JwksEndpointExtensions.cs:15) serves
/.well-known/jwks.json (path constant at line 20, mapped at line 33) from
IJwksProvider so extracted services validate tokens against the
issuer's public keys instead of a shared secret, and
OidcDiscoveryEndpointExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OidcDiscoveryEndpointExtensions.cs:22)
serves the minimal discovery document the JWT middleware fetches when AddForwardedJwtBearer sets an
authority: it returns 404 when Jwt:Issuer is not configured (lines 62-66), derives jwks_uri from
that configured issuer rather than from the inbound request (line 76) so issuer and JWKS URI stay
origin-aligned, and disables the camelCase naming policy (line 45) because RFC 8414 field names are
snake_case and jwksUri would not be recognized. AddForwardedJwtBearer itself resolves
RequireHttpsMetadata in three steps, explicit argument, then the
Authentication:JwtBearer:RequireHttpsMetadata key (constant at
WebApplicationBuilderExtensions.cs:56), then "true outside Development"
(WebApplicationBuilderExtensions.cs:458-460); a resolved false outside Development is honored,
because an internal-ingress h2c authority genuinely has no HTTPS metadata, but it is never silent:
InsecureJwtMetadataWarningStartupFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/InsecureJwtMetadataWarningStartupFilter.cs:15)
is registered exactly then (WebApplicationBuilderExtensions.cs:462-466) and logs one startup warning
naming the key (InsecureJwtMetadataWarningStartupFilter.cs:21 with the message at :26-28). It is an
IStartupFilter rather than a log line at registration time for a reason worth remembering: while the
service collection is being built the logging providers are not configured yet, so a warning written
there is dropped (InsecureJwtMetadataWarningStartupFilter.cs:7-13).
JwtForwardingDelegatingHandler
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17) copies
the caller's inbound Authorization header onto outgoing HTTP calls, unless one was already set (lines
27-30) and no-oping when there is no ambient HttpContext (lines 32-36), so distributed authorization
flows through a service-to-service hop without any handler threading the token by hand: the HTTP twin
of JwtForwardingClientInterceptor.
DatabaseInitializationExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21)
initializes every physical data source the host owns: InitializeDatabaseAsync (line 35) validates the
strategy before touching anything (EnsureKnownStrategy, line 47, which accepts only Migrate or
None and otherwise throws naming the valid values, lines 184 and 195), warms the
IEntityDataSourceRegistry so
entity-to-database routing is deterministic before the first repository call (lines 54-56), runs
EnsureCreated for the migration-less Cosmos and SQLite sources up front (lines 70-87, skipping a
SQLite source that does configure a migrations assembly, because creating its tables without an
__EFMigrationsHistory row leaves every migration both pending and un-appliable), then applies the
configured strategy per migrated source (lines 92-102), where None is the production guard that
throws with a per-source breakdown of pending migrations (line 98 into ThrowIfPendingMigrationsAsync
at line 241; ADR-030,
ADR-006), repeats the same
strategy per tenant that keeps its own copy of a source, each in a fresh scope with its tenant set
(line 104 into InitializeTenantDatabasesAsync at line 125,
ADR-073), and finishes by running
the enabled modules' seeders on the default scope only (line 111). Five smaller startup helpers round
out the host: OpenApiEndpointExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:22) maps the
per-version OpenAPI document (MapCommonOpenApi, lines 34-38) and the optional Scalar reference UI
(MapCommonScalarUi, lines 52-56), both outside Production only,
ApiParameterDescriptorBackfillProvider
(MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:43)
fills in the placeholder descriptor MVC leaves null on an unbound route token (lines 65-71) so a
URL-segment-versioned or {tenant}-templated route cannot turn document generation into a 500, running
last by ordering itself at int.MinValue (line 46) and registered exactly once through
TryAddEnumerable however many helpers a host calls
(WebApplicationBuilderExtensions.cs:262, :406, and the helper itself at :418-420),
SignalRExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12) maps
NotificationHub at its configured path when push
notifications are enabled (lines 22-27), MiniProfilerExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9) registers
MiniProfiler with Entity Framework profiling when ApplicationSettings.UseMiniProfiler is set (lines
16-25), and AppAssociationEndpointExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:15) with
AppAssociationOptions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationOptions.cs:9) serve the
Android Digital Asset Links and Apple App Site Association documents (paths at lines 18 and 24, mapped
at lines 42 and 46) that let a mobile OS hand this host's https links to the installed native app
(ADR-043).
ExternalAuthExtensions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:22)
completes the OAuth half of authentication, staying entirely inert when no provider is configured
(lines 47-52), each of Google, GitHub, and Apple gated on its OAuth:{Provider}:ClientId being present
(lines 60-73) and throwing at startup when the matching client secret is missing (lines 98-100 and
110-112). Taken together these are the [Rubric §7, Microservices Readiness], [Rubric §11, Security],
and [Rubric §12, Performance & Scalability] concerns that let the same controller code run identically
in a monolith and in a fleet of extracted services behind a YARP gateway.
Where this group sits. Everything above is the outermost ring. It depends downward on the CQRS
pipeline and query services (Groups 03 and 05), the auth and caching infrastructure (Groups 08 and
09), the persistence factories (Group 07), the integration-event contracts it consumes (Group 04), and
the module system that discovers controllers and drives health checks (Group 14 via
ModuleLoader), and it rests on the Result and
domain primitives (Groups 01 and 02). Nothing inside the framework depends on it: the app hosts and
the gRPC transport group (Group 13) call into it. AssemblyReference and
ClassReference
(MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:8 and :20) are the scanning
anchors that let those callers point at this assembly without naming an incidental type. Read the
group as the framework's HTTP grammar: the reusable edge every downstream service inherits so its own
code stays modules and domain logic, never plumbing.
AssemblyReference
MMCA.Common.API ·
MMCA.Common.API·MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:8· Level 0 · class (static)
- What it is: a tiny static class that exposes the
MMCA.Common.APIassembly 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 dedicatedAssemblyReferencemakes 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 readonlyfields.Assembly(AssemblyReference.cs:11) istypeof(AssemblyReference).Assembly, resolved once at type initialization.AssemblyName(AssemblyReference.cs:14) isAssembly.GetName().Namewith a?? string.Emptyfallback 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.APIassembly, used where a generic constraint or atypeof(...)needs a concrete reference type from this layer rather than anAssemblyinstance. - Depends on: nothing. See AssemblyReference for the full concept;
ClassReferenceis its type-shaped sibling. - Concept: covered under AssemblyReference. Where
AssemblyReference.Assemblyanswers "which assembly",ClassReferenceanswers "give me aclasstoken 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 throughAddGoogle,AddGitHub, andAddApple(:95,:107,:122). First-party, it partners with the app's OAuth controller subclassing OAuthControllerBase, whoseExtractClaimsconsumes 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:AddCommonAuthenticationregisters only the JWT bearer scheme, so without this call the OAuth controller wouldChallengeand read schemes that are not in the authentication pipeline and the flow would fail at runtime (:12-15), yet a host with noOAuthconfiguration 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 itsOAuth:<Provider>:ClientIdbeing 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 mirrorsAddPermissions(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 theOAuthsection (:40) and pullsGoogle:ClientId,GitHub:ClientId, andApple: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 asAddCommonAuthenticationleft 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 byAddCommonAuthentication, it just yields a builder to append schemes onto. - It then adds the
ExternalLogincookie (:58) and each configured provider in turn, each behind its own non-empty check (:60-73), before returningservicesfor chaining (:75). AddExternalLoginCookie(:83-92) configures the bridge cookie: namemmca_external_login(:86),HttpOnly(:87),SameSite=Lax(:90, sufficient because the OAuth round trip returns as a top-level GET navigation, which avoids theSecureplus cross-site cost thatSameSite=Noneimposes,:88-89), and a 10-minute expiry (:91).AddGoogleProvider(:94-104) andAddGitHubProvider(:106-119) each set the client id, require the matchingClientSecretor throw (:98-100,:110-112), setSignInScheme = ExternalLoginScheme, pin a fixedCallbackPath(/auth/callback/googleat:102,/auth/callback/githubat:113), and setSaveTokens = true. GitHub additionally requests theuser:emailscope (:117) because it does not return the email on the default scope and the controller'sClaimTypes.Emaillookup would otherwise fail (:115-116).AddAppleProvider(:121-145) is the one that differs structurally. ItsClientIdis 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, soGenerateClientSecret = true(:130) andTeamId(:131),KeyId(:134), andPrivateKeyPem(:137) are each required-or-throw. The PEM is handed over as a callback returning aReadOnlyMemory<char>(:140). Scheme, callback path (/auth/callback/apple,:143), andSaveTokensmatch the other two.
- The public surface is one C#
- 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 fromAddCommonAuthenticationkeeps 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: thecache_tagtag 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 staticMeterbuilt from it (:21). The type doc carries a warning worth reading once and remembering (:10-14): never create a secondMeterwith 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 aCounter<long>namedcache.eviction.failedwith unit{tag}.RecordEvictionFailure(string cacheTag)(:36-37) adds one with acache_tagtag, so the instrument name and the tag name are spelled in exactly one place.
- Why it's built this way:
internal statickeeps 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 inMMCA.Common.Aspirebecause that package has no reference toMMCA.Common.API(:8-9), and a host exports the instruments by registering the meter, which the Aspire service defaults (ConfigureOpenTelemetry) already do (:6-8). - Where it's used: exactly once, in OutputCacheEvictionHandler's catch block (
OutputCacheEvictionHandler.cs:60), paired with a Warning log naming the same tag.
PublicEndpointOutputCachePolicy
MMCA.Common.API ·
MMCA.Common.API.Caching·MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35· Level 0 · class (sealed)
- What it is: a custom ASP.NET Core
IOutputCachePolicyfor public, user-independent GET/HEAD endpoints that must stay cacheable even when the request carries anAuthorizationheader. 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, andMicrosoft.Extensions.Primitives(StringValues), all atPublicEndpointOutputCachePolicy.cs:1-4. No first-party dependencies; it is registered by OutputCacheOptionsExtensions. - Concept introduced (auth-header-tolerant output caching). The framework UI attaches a Bearer token to every outgoing API request, including reads of
[AllowAnonymous]endpoints whose payload is identical for every caller. Under the default policy those reads bypass the output cache for any signed-in user and land on the database each time (PublicEndpointOutputCachePolicy.cs:12-17).[Rubric §12, Performance & Scalability]assesses whether hot read paths avoid redundant work; this policy is a direct performance lever, letting public reads share one cached entry across authenticated and anonymous callers.[Rubric §11, Security]assesses trust boundaries; the class docs (:24-33) are explicit that a cached response is served verbatim to every subsequent caller, so it must be applied only to identity-independent payloads, and thebypassRolesmechanism exists precisely so a privileged role that receives an elevated payload (for example organizers seeing unpublished rows) is never served or stored from the shared cache. This is the edge tier of the two-tier caching model (ADR-026); the authenticated-read decision itself is ADR-040. - Walkthrough: three fields hold the config,
_expiration,_bypassRoles,_tags(PublicEndpointOutputCachePolicy.cs:37-39). Two constructors: theparams string[] tagsoverload (:44) delegates to the full one with an empty bypass-roles array (:45), and the primary constructor (:54) guards its inputs (ThrowIfLessThanOrEqual(expiration, TimeSpan.Zero), null checks on both arrays,:56-58). All three interface methods are explicitly implemented, so they are reachable only throughIOutputCachePolicy.CacheRequestAsync(:66) computesattemptOutputCachingas "is a GET/HEAD request" AND "is not a bypassed caller" (:71-72), enables output caching, setsAllowCacheLookup/AllowCacheStorageto that flag, allows locking, sets the expiration (:73-77), and (matching the built-in default) varies the cache key by every query-string parameter viaCacheVaryByRules.QueryKeys = "*"(:81), then copies the eviction tags in (:83-84).ServeFromCacheAsync(:90) is a no-op returningValueTask.CompletedTask.ServeResponseAsync(:94) refuses to store any response that set a cookie or returned a non-200 status (:100-104), the same guard the built-in default applies. Two private helpers close it out:IsCacheableRequest(:109, GET or HEAD) andIsBypassedCaller(:112,Array.Exists(_bypassRoles, user.IsInRole)). - Why it's built this way: it mirrors the built-in default policy minus exactly one behavior, the authenticated-request bail-out (
:68-70), so its caching, query-key variance, and cookie/status guards stay identical to what developers already expect. Bypass roles get the default behavior back (no lookup, no storage), which keeps elevated payloads out of the shared cache without disabling caching for everyone. A rawIOutputCachePolicyimplementation inherits none of the default policy's behavior, so every guard is re-implemented here. - Where it's used: registered as a named policy by OutputCacheOptionsExtensions and referenced from controller actions via
[OutputCache(PolicyName = ...)]. Store's Catalog service registers four such policies with no bypass roles (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:149-154); ADC's Conference service registers ten, most of them with an admin bypass-roles array (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:216-244), including two short 60-second policies for the fast-movingNowNextCache(:231) andBookmarkCountsCache(:243). The tags passed here are the same strings OutputCacheEvictionHandler evicts by.
OutputCacheOptionsExtensions
MMCA.Common.API ·
MMCA.Common.API.Caching·MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6· Level 1 · class (static)
- What it is: registration helpers that add named output-cache policies backed by PublicEndpointOutputCachePolicy onto ASP.NET Core's
OutputCacheOptions. - Depends on:
Microsoft.AspNetCore.OutputCaching(OutputCacheOptions,OutputCacheOptionsExtensions.cs:1) and PublicEndpointOutputCachePolicy. - Concept: this is a thin fluent facade over
OutputCacheOptions.AddPolicy, using a C#extension(OutputCacheOptions options)block (OutputCacheOptionsExtensions.cs:8) so the policy registration reads as a first-class option on the options object. See the DI registration extension(T) convention.[Rubric §9, API & Contract Design]is relevant: the helper gives callers a self-documenting, named entry point instead of hand-constructing the policy at each call site. - Walkthrough: two overloads of
AddPublicEndpointPolicy. The first (OutputCacheOptionsExtensions.cs:20-21) takesname,expiration, andparams string[] tagsand registersnew PublicEndpointOutputCachePolicy(expiration, tags). The second (:34-35) adds astring[] bypassRolesparameter before theparams string[] tagsand 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 returnvoid, 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-16and:23-29) instead of being re-derived at each registration. - Where it's used: called during host composition where the app configures
AddOutputCache(...); the registerednameis then referenced by[OutputCache(PolicyName = ...)]on controller actions. Store's Catalog service uses the two-argument form (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:149-154); ADC's Conference service mostly uses the bypass-roles form (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:216-244).
ModuleControllerFeatureProvider
MMCA.Common.API ·
MMCA.Common.API·MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:28· Level 2 · class (sealed)
- What it is: an
IApplicationFeatureProvider<ControllerFeature>that removes controllers belonging to disabled modules from MVC's controller discovery, so a module turned off via configuration exposes no routes. - Depends on: ModulesSettings (the config-bound enabled/disabled map,
ModuleControllerFeatureProvider.cs:4) and the MVC application-parts surface (IApplicationFeatureProvider<ControllerFeature>,ControllerFeature,ApplicationPart,:2-3), plusSystem.Reflectionfor theTypeInfoit inspects (:1). - Concept introduced (module-aware controller discovery). MVC discovers controllers by scanning referenced assemblies. When a host references a module's
APIassembly 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 byIsDisabledModuleController(:46-53). The private static matcher (:60) reads the controller's assembly simple name and namespace, each with a?? string.Emptyfallback (: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.insideMMCA.Store.Catalog.APIor its.Controllersnamespace while avoiding false positives from substrings like "Catalogue" (:69-71). The comparison isOrdinalIgnoreCaseon both the assembly name and the namespace (:74-75), and the loop falls through tofalsewhen nothing matches (:81). - Why it's built this way: matching on the dotted token handles both the
MMCA.{Repo}.{Module}.APIconvention 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)viaConfigureApplicationPartManager(DependencyInjection.cs:64-65), but only when a non-nullModulesSettingsis supplied (DependencyInjection.cs:62); pairs with the module system's disabled-stub registrations so cross-module interfaces stay resolvable.
OutputCacheEvictionHandler
MMCA.Common.API ·
MMCA.Common.API.Caching·MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32· Level 4 · class (sealed, partial)
- What it is: the integration-event handler that drops named tags from this host's output cache when another service says the underlying data changed. It is the consumer half of cross-service output-cache coherence.
- Depends on: IIntegrationEventHandler<in TIntegrationEvent> closed over OutputCacheEvictionRequested (
OutputCacheEvictionHandler.cs:35), ASP.NET Core'sIOutputCacheStore,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): anOperationCanceledExceptionfrom host shutdown propagates, so MassTransit sees the shutdown rather than an acked message. This is the samewhen (ex is not OperationCanceledException)discipline used across the framework's cache-touching code.
- Per-tag best effort (
- Walkthrough: the primary constructor takes
IOutputCacheStore outputCacheStoreandILogger<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 throughoutputCacheStore.EvictByTagAsync(tag, cancellationToken)inside its owntry(: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:
partialplus 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 isRegisterOutputCacheEvictionConsumer()inside the host'sAddBrokerMessagingconfiguration, which wires the genericIntegrationEventConsumer<OutputCacheEvictionRequested>onto this handler; ADC's Conference service calls both (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250and:352). The tags it evicts are the ones passed to OutputCacheOptionsExtensions.AddPublicEndpointPolicy. Framework coverage isOutputCacheEvictionHandlerTests(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
IOutputCacheStoreevicts one tag per call, but a single write usually invalidates several (a speaker edit drops bothconference:speakersandconference). Rather than a private per-controller helper wrapping a run ofEvictByTagAsynccalls (: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.EvictTagsAsyncpropagates the failure. A caller that considers a failed eviction part of its own success uses this one.TryEvictTagsAsyncis 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
TryEvictTagsAsyncis easy to miss and load-bearing: eviction runs underCancellationToken.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
AddOutputCacheEvictionHandlerand 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 throughTryAddEnumerablerather thanAddSingleton, 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 plainAddhere 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 multipleextension(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-guardsstoreandtags(:51-52) and awaitsEvictByTagAsyncfor each tag in the order given (:54-57).TryEvictTagsAsync(ILogger logger, params string[] tags)(:78) adds aloggerguard (:80-82) and routes each eviction throughBestEffort.ExecuteAsyncwith the prefixed operation name, the caller's logger, the eviction as the action, andCancellationToken.None(:86-90).extension(IServiceCollection services)(:95) holdsAddOutputCacheEvictionHandler()(:111). It null-guardsservices(:113), callsservices.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 singletonIOutputCacheStorethe handler evicts through.
- A class-level
- Why it's built this way: the class doc states the pairing rule plainly (
:12-16): this is the DI half,RegisterOutputCacheEvictionConsumer()on the MassTransit bus configurator is the broker half, and a host that wants the behavior calls both. Splitting them is what keeps the handler broker-agnostic, since the DI registration has no messaging dependency at all. Housing the eviction verbs in the same class keeps the write-side helper and the read-side coherence path in one file. - Where it's used: ADC's Conference service calls
services.AddOutputCacheEvictionHandler()(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250) alongside the broker registration (:352). ADC's Conference controllers use the throwing form after a write, for exampleSpeakersController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:315) andSpeakerCategoryItemsController, which evicts three tags in one call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:178); Store's Catalog controllers use the best-effort form, for exampleProductsController(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ProductsController.cs:242) andCategoriesController(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/CategoriesController.cs:168). Framework coverage:OutputCacheEvictTagsTestspins both verbs, including theCancellationToken.Nonerule and the swallow-versus-throw split (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Caching/OutputCacheEvictTagsTests.cs:59-113), andOutputCacheEvictionHandlerTestspins the double-registration behavior (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Caching/OutputCacheEvictionHandlerTests.cs:107-114).
DependencyInjection
MMCA.Common.API ·
MMCA.Common.API·MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:25· Level 11 · class (static)
- What it is: the primary DI entry point for the
MMCA.Common.APIlayer. Using a C#extension(IServiceCollection services)block (DependencyInjection.cs:27) it adds six methods toIServiceCollection:AddAPI,AddErrorLocalization,AddErrorResources<TResource>,AddCommonExceptionHandlers,AddServerAuthSessionCookie, andAddModuleHealthChecks. - Depends on: a broad slice of the API layer plus feature management and localization (
DependencyInjection.cs:1-18). Notable first-party types wired here: CurrencyJsonConverter and the Shared-layer EnumerationJsonConverterFactory (aliased at:18because the Shared and API namespaces both surface converters), UnhandledResultFailureFilter, IdempotencyFilter and IdempotencySettings, OwnerOrAdminFilter, ModuleControllerFeatureProvider, CurrentUserTargetingContextAccessor and DisabledFeatureHandler, IErrorLocalizer/ErrorLocalizer, ErrorResources/ErrorResourceSource, the exception handlers (OperationCanceledExceptionHandler, DomainExceptionHandler, DbUpdateExceptionHandler, ValidationExceptionHandler, GlobalExceptionHandler), CookieTokenReader and ICookieSessionRefresher/CookieSessionRefresher, and ModuleLoader/ModulesSettings. Externals:Microsoft.FeatureManagement(including itsIDisabledFeaturesHandlercontract),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 forModulesSettings/ModuleLoader(:16-17).[Rubric §13, Observability & Operability]and[Rubric §17, DevOps]both apply throughAddModuleHealthChecks(:188), which projects module state into/healthchecks taggedmoduleso/health?tag=modulereports each module's status (:180-181).[Rubric §9, API & Contract Design]is relevant: every parameter is optional and defaulted so a host wires only what it needs. - Walkthrough
AddAPI(ModulesSettings? modulesSettings = null, IConfiguration? configuration = null)(DependencyInjection.cs:44) registers controllers withReturnHttpNotAcceptable = falseand the UnhandledResultFailureFilter global filter (:46-50), adds two JSON converters (:51-59) and the XML DataContract formatters (:60). The second converter is the load-bearing one: concrete enumerations do not inherit the base class's[JsonConverter]attribute becauseSystem.Text.Jsonresolves it withinherit: false, so the factory is registered once here and everyEnumeration<T>serializes byNameacross the whole API surface (:55-58). It then conditionally registers ModuleControllerFeatureProvider whenmodulesSettingsis non-null (:62-66), conditionally binds IdempotencySettings from the config section with data-annotation validation on start (:68-74), and registers the scoped IdempotencyFilter and OwnerOrAdminFilter (scoped because they depend on scoped services such as ICacheService and ICurrentUserService,:76-78). Feature management comes next and is worth reading closely (:80-93):AddHttpContextAccessor()(:90) is called first because it isTryAdd-based and therefore safe to repeat, thenAddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>()(:91-92) registers the feature manager plus the built-in Percentage/TimeWindow/Targeting filters and supplies the targeting audience from the current request's principal, which is what makes a percentage rollout sticky per user instead of random per request; the accessor is a singleton, which is exactly why it readsIHttpContextAccessorrather than the scopedICurrentUserService(comment at:84-89). Finally the singleton DisabledFeatureHandler is bound toIDisabledFeaturesHandler(:93) andAddErrorLocalization()is called (:95-96).AddErrorLocalization()(:107) registers ASP.NET localization (:109), the singleton IErrorLocalizer viaTryAddSingletonso a host can substitute its own (:110), and the framework's own ErrorResources source (:111);AddErrorResources<TResource>()(:122) adds a module's resource anchor as another ErrorResourceSource built from anIStringLocalizerFactory(:124-125). This is the ADR-027 edge error-localization extension point, keyed byError.Code, and modules add their translations additively (:115-121).AddCommonExceptionHandlers()(:135) registers ProblemDetails (adding arequestIdextension fromTraceIdentifier,:137-139) then fiveIExceptionHandlers in specificity order (:140-144):OperationCanceled,DomainException,DbUpdate,Validation, and GlobalExceptionHandler as the catch-all. ASP.NET Core invokes them in registration order and stops at the first that handles the exception, hence most-specific first and the 500 fallback last (:131-132).AddServerAuthSessionCookie(string apiBaseAddress)(:160) wires the SSR-prerender auth path: it guards the address (:162), then addsHttpContextAccessor, memory cache, the scoped CookieTokenReader (:164-166), a namedHttpClientpointed at the internal API base address (:168-169), and the CookieSessionRefresher as a singleton (:172). The singleton is load-bearing: its in-flight map must be shared across requests for single-flight refresh to work (:171). The doc comment is explicit that the address is the internal endpoint, not the browser-facing one (:155-158).AddModuleHealthChecks(ModuleLoader moduleLoader)(:188) adds one health check per module,Healthyfor each enabled module (:192-198) andDegradedfor each disabled one (:200-207), namedmodule-{Name}and taggedmodule. It must run after ModuleLoader'sDiscoverAndRegister(:183-186).
- Why it's built this way: bundling the API-edge concerns behind small, defaulted extension methods lets each host opt into exactly the surface it needs (a JWT-only test host skips
AddServerAuthSessionCookie; a monolith with no disabled modules passes a nullmodulesSettings; a host with no idempotency configuration passes a nullconfigurationand keeps the defaults). The exception-handler ordering, the enumeration converter factory, the targeting accessor's singleton lifetime, and the refresher's singleton lifetime are the four non-obvious, correctness-critical choices, and each carries an inline comment. Error localization is registered automatically byAddAPIso modules only add their own resources additively (ADR-027). - Where it's used: called from every service host's composition (
Program.csof 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 (seeMMCA.Common/CLAUDE.mdDI ordering note). - Caveats / not-in-source: the relative ordering of
AddAPIagainstAddInfrastructure/AddApplicationin a given host is not fixed by this file; onlyAddApplicationDecorators()last is load-bearing.
CsvWriter
MMCA.Common.API ·
MMCA.Common.API.Export·MMCA.Common/Source/Presentation/MMCA.Common.API/Export/CsvWriter.cs:34· Level 0 · class (internal static)
- What it is: a hand-written, minimal RFC 4180 CSV writer. It writes into a caller-supplied
TextWriterone record at a time, so the generic/exportendpoint can stream a file straight to the response body without ever holding the whole result set in memory. - Depends on: nothing first-party. From the BCL:
TextWriter,SearchValues<char>(System.Buffers),UTF8Encoding(System.Text), andCultureInfo.InvariantCulture(System.Globalization). - Concept introduced: a framework declines dependencies its consumers cannot decline.
[Rubric §32, Dependency & Supply-Chain]assesses what a package drags into every downstream application; because MMCA.Common ships under lockstep versioning, a CsvHelper reference here would become a pin in ADC, Store, and Helpdesk that none of them chose. The type doc makes the trade explicit (CsvWriter.cs:12-17): the framework needs exactly three behaviors (quote when required, escape embedded quotes, terminate with CRLF), and that is a page of code with full in-repo test coverage.[Rubric §15, Best Practices & Code Quality]: every formatting decision is invariant, so the same row produces the same bytes on every machine (CsvWriter.cs:18-26). See ADR-078. - Walkthrough
Utf8ByteOrderMark(CsvWriter.cs:45) andLineEnding(CsvWriter.cs:48, the literal"\r\n"regardless of host OS) are the two format constants.Utf8NoPreamble(CsvWriter.cs:55) is aUTF8Encodingconstructed withencoderShouldEmitUTF8Identifier: false, so theStreamWriteran export runs through emits no preamble of its own and the BOM decision lives in exactly one place.MustQuote(CsvWriter.cs:61) is aSearchValues<char>over,"\r\n: the four characters RFC 4180 section 2 says force quoting.SearchValuesis the vectorized-lookup type, so the per-field scan is a span search rather than fourIndexOfpasses ([Rubric §12, Performance & Scalability]).WriteByteOrderMark(writer)(CsvWriter.cs:69) writes the BOM unconditionally. The remarks say why (CsvWriter.cs:39-44): Excel reads a BOM-less UTF-8 CSV in the machine's ANSI code page, turning every accented character into mojibake, and three bytes is cheaper than a setting nobody finds in time.WriteHeader(columns, writer)(CsvWriter.cs:81) andWriteRow(cells, writer)(CsvWriter.cs:105) are the same loop: a comma between fields (CsvWriter.cs:88-91andCsvWriter.cs:112-115), thenLineEnding(CsvWriter.cs:96andCsvWriter.cs:120). The header escapes a column name exactly like a data field (CsvWriter.cs:93), so a column namedfull,namecannot break the record.FormatCell(value)(CsvWriter.cs:128-137) is the type switch that fixes the value contract: null writes empty,stringwrites verbatim,boolwrites lowercasetrue/falseto match the JSON the sibling endpoints emit rather than .NET's capitalizedToString,DateTimeandDateTimeOffsetwrite ISO 8601 round-trip"O"(chosen over the sortable"s"because"O"keeps sub-second precision and the offset, so a parsed value equals the exported one), anything elseIFormattableformats invariantly, and the fallback isConvert.ToString.WriteField(field, writer)(CsvWriter.cs:145) is the only private member: a clean field is written as-is (CsvWriter.cs:147-151), and a field containing anyMustQuotecharacter is wrapped in quotes with embedded quotes doubled (CsvWriter.cs:153-155).
- Why it's built this way: the writer takes a
TextWriterrather 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 typeinternal staticmeans it is a framework implementation detail, not a public surface a consumer can bind to and then be broken by. - Caveats / not-in-source: the writer deliberately does not neutralize spreadsheet formula injection. A cell whose value opens with
=,+,-, or@is written verbatim, and the type doc records the reasoning (CsvWriter.cs:27-32): CSV is treated as a data-faithful format here and prefixing would corrupt legitimate negative numbers, so a host that opens untrusted exports in a spreadsheet is expected to import them as text. ADR-078 records the same decision in the same direction, and names the price (a known spreadsheet risk carried by every consumer). - Where it's used: only by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>
.ExportAsync, which takes the encoding atEntityControllerBase.cs:277, writes the BOM and header atEntityControllerBase.cs:315-316, each data row atEntityControllerBase.cs:753, and the trailing markers atEntityControllerBase.cs:304andEntityControllerBase.cs:349.
DisabledFeatureHandler
MMCA.Common.API ·
MMCA.Common.API.FeatureManagement·MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13· Level 0 · class (sealed)
- What it is: the one-method handler that decides what a
[FeatureGate]-protected controller action returns when its feature flag is off. Instead of ASP.NET Core's default (a bare 404 with no body), it emits an RFC 9457 Problem Details payload so a disabled feature reads the same as any other framework error. - Depends on:
IDisabledFeaturesHandlerandFeatureGateAttributefromMicrosoft.FeatureManagement.Mvc(NuGet);ProblemDetails,ObjectResult, andStatusCodesfrom 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.HandleFailureshape rather than leaking a framework default.[Rubric §6, CQRS & Event-Driven Design]covers concerns applied uniformly across endpoints, and feature flags are exactly that. Note the split: this class gates controller actions decorated with[FeatureGate], while FeatureGateCommandDecorator<TCommand, TResult> gates CQRS handlers one layer deeper. The two surfaces cover the two entry points into a gated capability, which is precisely the dual-surface enforcement ADR-031 records. - Walkthrough:
HandleDisabledFeatures(features, context)(DisabledFeatureHandler.cs:16) setscontext.Resultto anObjectResultwrapping aProblemDetailswithStatus = 404and a fixed title/detail ("Feature not available",DisabledFeatureHandler.cs:18-23), and also sets the outerStatusCode = 404(DisabledFeatureHandler.cs:25) so the response code and the body agree. It returnsTask.CompletedTask(DisabledFeatureHandler.cs:28): the work is synchronous, there is nothing to await. - Why it's built this way: the payload deliberately does not name the disabled feature. An anonymous caller learns only that the endpoint is unavailable, not which flag is off, so the flag set is not enumerable from outside. The
featuresparameter is available but unused for that reason. - Where it's used: registered as the app's
IDisabledFeaturesHandlersingleton insideAddAPI(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:93, immediately afterservices.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>()onDependencyInjection.cs:91-92); invoked byMicrosoft.FeatureManagement.Mvcwhenever a[FeatureGate]action is hit with its flag disabled. In the framework itself that covers DataExportControllerBase<TQuery> whilePrivacyFeatures.DataExportis off (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:58) and the three notification controllers gated onNotificationFeatures.PushNotifications(Controllers/Notifications/DevicesController.cs:23,NotificationInboxController.cs:27,NotificationsController.cs:28).
ServiceInfoResponse
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:51· Level 0 · record (sealed, nested)
- What it is: the v1.0 (minimal) payload returned by the service-info discovery endpoint: just the service name and the API version.
- Depends on: nothing beyond the BCL; a nested positional
recordinside 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 byGetV1()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
recordinside 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 theSupported/Deprecatedarrays the controller holds (ServiceInfoControllerBase.cs:32-33), so the body itself documents the version landscape, the same facts theapi-supported-versions/api-deprecated-versionsheaders carry. - Where it's used: produced by ServiceInfoControllerBase
.GetV2()(ServiceInfoControllerBase.cs:47-48).
ServiceInfoControllerBase
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30· Level 1 · class (abstract)
- What it is: an anonymous, read-only discovery controller that proves the API-versioning machinery works across more than one version. The same
/ServiceInforoute is served by v1.0 (deprecated) and v2.0, selected via theapi-versionheader. - 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, andReportApiVersions = true(set inAddCommonApiVersioningon WebApplicationBuilderExtensions,WebApplicationBuilderExtensions.cs:253, inside the extension member declared atWebApplicationBuilderExtensions.cs:245) so responses carryapi-supported-versions/api-deprecated-versionsheaders (class doc,ServiceInfoControllerBase.cs:6-14). ADR-046 makes the point that a versioning claim which only ever shipsv1.0is untestable; this endpoint is what makes it testable. - Walkthrough
Supported = ["1.0", "2.0"]andDeprecated = ["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
ServiceNameso 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>andTIdentifierType : notnull(IEntityControllerBase.cs:17-18). The members:GetAllAsyncunpaged (IEntityControllerBase.cs:26), withfieldsprojection ([FromQuery]) and the two eager-load flags (IEntityControllerBase.cs:27-30).- the paged
GetAllAsyncoverload (IEntityControllerBase.cs:43), addingsortColumn/sortDirection,[Range(1, int.MaxValue)]-guardedpageNumber/pageSize(IEntityControllerBase.cs:49-50), and aDictionary<string, (string Operator, string Value)>of filters bound by QueryFilterModelBinder (IEntityControllerBase.cs:51). GetAllForLookupAsyncfor id/label dropdown data (IEntityControllerBase.cs:58).GetByIdAsync(IEntityControllerBase.cs:69), whoseincludeFKsdefaults totruefor the single-entity case (IEntityControllerBase.cs:71) while the collection endpoints default it tofalse(IEntityControllerBase.cs:28andIEntityControllerBase.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
ExportAsyncaction 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
- What it is: the read-write extension of IEntityControllerBase<TEntityDTO, TIdentifierType>: it adds
CreateAsyncandDeleteAsyncfor aggregate-root entities. - Depends on: IEntityControllerBase<TEntityDTO, TIdentifierType> (base interface), IBaseDTO<TIdentifierType> and ICreateRequest (constraints).
- Concept: the write half of the segregated controller contract introduced by IEntityControllerBase<TEntityDTO, TIdentifierType>.
[Rubric §1, SOLID]: only aggregate roots get a create/delete surface (TCreateRequest : ICreateRequest,IAggregateRootEntityControllerBase.cs:22), so child-collection controllers that implement only the read interface never expose mutation they should not own.[Rubric §9, API & Contract Design]: create returns the created DTO with a 201, delete returns 204, a consistent verb-to-status contract. - Walkthrough: extends the read interface (
IAggregateRootEntityControllerBase.cs:19) and adds two members:CreateAsync([Required] TCreateRequest request, ...)returning the created DTO with 201 (IAggregateRootEntityControllerBase.cs:28-30), andDeleteAsync(TIdentifierType id, ...)returning 204 No Content (IAggregateRootEntityControllerBase.cs:36-38). Like its base interface it declares no update member: PUT is added one rung higher by CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> as a class member only, for the same "changing a shipped interface breaks every explicit implementer" reason. - Where it's used: implemented by AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>.
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, andProblemDetailsserialization. - 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 byResult.Combinecarries several errors at once, and if the response status came fromerrors[0]then the order in which handlers appended errors would decide whether a caller saw 403 or 400.HandleFailuretherefore hands the whole list toErrorHttpMapping.GetStatusCode(IReadOnlyList<Error>)(ApiControllerBase.cs:48, mapping atMMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:50-51), which resolves the most severe ErrorType present through ErrorTypeSeverity. The ranking is spelled out on the method doc (ApiControllerBase.cs:21-31):Unexpected(500), thenUnauthorized(401),Forbidden(403),Conflict(409),NotFound(404),UnprocessableEntity(422), and finallyInvariant/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 inMMCA.Common.Sharedon purpose (ErrorHttpMapping.cs:45-46), so the gRPC edge classifies the same aggregate identically. Every error still travels in the response; only the status is ranked. - Walkthrough:
HandleFailure(IEnumerable<Error> errors)(ApiControllerBase.cs:35) isprotected 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 atApiControllerBase.cs:47). - Builds a
ProblemDetailswith that status and a fixed title/detail (ApiControllerBase.cs:50-55), attachesExtensions["errors"]viaErrorHttpMapping.BuildErrorsExtension(ApiControllerBase.cs:58), optionally localized through an IErrorLocalizer resolved withGetService(ApiControllerBase.cs:57, so a host without localization simply passesnull), then returnsStatusCode(statusCode, problemDetails)(ApiControllerBase.cs:60).
- Null/empty guard (
- Why it's built this way: one
virtualmethod instead of aswitchin 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 itvirtuallets a subclass (EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>) wrap it with logging without reimplementing the mapping. TheErrorHttpMappingmembers areinternal static(ErrorHttpMapping.cs:37,:50,:61), which is what lets UnhandledResultFailureFilter reuse the same ranked status mapping and the sameerrorsextension array for a failedResultthat an action returned without callingHandleFailure(UnhandledResultFailureFilter.cs:36and:47). The two bodies are deliberately not identical: the filter labels itsProblemDetailsTitle/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 byError.Codeand leavingCode/Type/Source/Targetverbatim so clients can still branch on them (ErrorHttpMapping.cs:56-59for the contract,ErrorHttpMapping.cs:61-69for the projection that honours it). - Where it's used: the root of the controller hierarchy. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, AuthControllerBase, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, DataExportControllerBase<TQuery>, and every module controller derive from it directly or transitively. Framework coverage is
ApiControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/ApiControllerBaseTests.cs).
EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:36· Level 6 · class (abstract)
- What it is: the generic read-only controller that gives any entity five working REST endpoints (
GET /,GET /paged,GET /export,GET /lookup,GET /{id}) with filtering, sorting, pagination, field projection, one shared row-scoping hook, and anETagon the by-id read, by delegating to the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> pipeline. - Depends on: ApiControllerBase (base), IEntityControllerBase<TEntityDTO, TIdentifierType> (implements), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, AuditableBaseEntity<TIdentifierType> (constraint), ApplicationSettings (through
IOptions<T>), CollectionResult<T>, PagedCollectionResult<T>, BaseLookup<TIdentifierType>, QueryFilterModelBinder, Error, CsvWriter, ConcurrencyETag, QueryFieldService, and Specification<TEntity, TIdentifierType>; ASP.NET Core MVC,Asp.Versioning,System.Text.Json,System.Reflection,TimeProvider, andILogger. - Concept introduced: generic controller bases that eliminate CRUD boilerplate.
[Rubric §9, API & Contract Design]covers uniform endpoint conventions;[Rubric §1, SOLID]covers the Open/Closed side, since a new entity controller extends this base rather than re-writing the endpoints. The class-level[ApiController],[Route("[controller]")],[ApiVersion("1.0")](EntityControllerBase.cs:33-35) plus the three generic constraints (EntityControllerBase.cs:43-45) turn the type parameters into the contract: name the entity, the DTO, and the identifier alias, and the routes, the versioning, and the query behavior follow (ADR-034). - Walkthrough
- Primary constructor (
EntityControllerBase.cs:36-41) takes the query service and anILogger, both null-guarded into theQueryService(:47) andLogger(:52) protected properties. MaxPageSize(:58-65) resolvesIOptions<ApplicationSettings>per-request fromHttpContext.RequestServices, falling back to 500 (:63). Per-request resolution means a settings change takes effect without a restart.MaxExportRows(:78-86) does the same for the export ceiling, with the extra rule that a configured value of zero or less is treated as unconfigured (:83-84), because a cap of zero would silently serve every caller a header-only file (:73-77).EntityName(:91) istypeof(TEntity).Name, used in log messages.GetAllAsyncunpaged (:109,[HttpGet]at:106): resolves the read specification (:115), then delegates to the query service withpageNumber: 1andpageSize: MaxPageSize(:122-123), so even the "all" endpoint is capped, then eitherHandleFailureorOk.GetAllAsyncpaged (:157,[HttpGet("paged")]at:153): clamps withMath.Min(pageSize, MaxPageSize)(:168), and on success serializes PaginationMetadata into theX-Paginationresponse header (:187) rather than mixing it into the body,[Rubric §9]again, and[Rubric §12, Performance & Scalability]for the clamp.ExportAsync(:251,[HttpGet("export")]at:247) is covered as its own concept below.GetAllForLookupAsync(:374,[HttpGet("lookup")]at:371): returnsCollectionResult<BaseLookup<TIdentifierType>>, a lightweight id/label pair, with a[Required]nameProperty(:375) choosing the label.GetByIdAsync(:415,[HttpGet("{id}")]at:410):includeFKsdefaults totruefor the single-entity case (:417), and on success it callsSetConcurrencyETag(result.Value)before returning (:436-437).HandleFailureoverride (:505-519): logs the first error at Warning, guarded byLogger.IsEnabled(:508), before delegating to ApiControllerBase.HandleFailure, so the read path gets observability ([Rubric §13, Observability & Operability]) without changing the response mapping. Note it logs the first error while the base ranks the status by the most severe one: the log line is a breadcrumb, not the status decision.- Every read action passes
asTracking: falseto the query service (for example:124), because a read endpoint never mutates what it loaded.
- Primary constructor (
- Concept introduced: one row-scoping hook for every read the controller serves.
[Rubric §11, Security]assesses whether an authorization decision can be reproduced consistently across every path that returns the same rows;[Rubric §15, Best Practices & Code Quality]assesses whether that decision lives in one place.GetReadSpecificationAsync(cancellationToken)(:597-599) returns the Specification<TEntity, TIdentifierType> applied by all five read actions: bothGetAllAsyncoverloads,GetAllForLookupAsync,GetByIdAsync, andExportAsync(each resolves it at:115,:170,:378,:422, and:264). Four properties are worth internalizing, all stated on the hook's own doc (:562-596).- It is asynchronous because row scoping usually is (
:572-579). Scope is rarely a pure function of the current principal: it comes from a query handler, a claim lookup that hits a store, a tenancy read. A synchronous hook forced such a controller to override all five actions by hand purely to get anawaitin before the query. An override with nothing to await returnsValueTask.FromResult(...)and allocates nothing. - It narrows, it never replaces (
:580-586). The query service ANDs the specification with the caller'sfiltersdictionary andfieldsprojection, so a caller can only narrow what the specification already allows; a filter naming excluded rows yields an empty page rather than leaking them (:137-142). - A rejected row is a 404, not a 403 (
:587-591, restated onGetByIdAsyncat:404-409). The specification narrows the query, so the row is simply absent. Answering "forbidden" would confirm the id exists and turn a scoped read into an existence oracle a caller could walk. - The lookup endpoint carries the scope as a predicate (
:364-370).GetAllForLookupAsynchas no specification parameter, so the scope travels asspecification?.Criteria(:382); a null specification passes a null predicate, the unscoped query this endpoint always issued. A dropdown that lists what the list endpoint hides would be an oracle of its own.GetExportSpecification()(:626) is the synchronous half: it returnsnullby default and is whatGetReadSpecificationAsyncreturns by default (:599). A controller that can build its scope without awaiting overrides this one and gets all five actions scoped; a controller that needs anawaitoverrides the async hook instead, and then this one is no longer consulted (:606-613). The remarks are blunt about the consequence of overriding neither on a row-scoped controller (:614-619): it hands every caller the whole table in one request. See ADR-033 for the ownership model this hook reproduces in the query.
- It is asynchronous because row scoping usually is (
- Concept introduced: handing the client a precondition token on the way out.
[Rubric §8, Data Architecture]and[Rubric §9, API & Contract Design].SetConcurrencyETag(object? dto)(:471) emits the read's concurrency token as a weakETagso a client can hand it straight back as anIf-Matchprecondition on the next write (see SupportsIfMatchAttribute) instead of round-tripping it through the request body. Three details make it cheap and honest.RowVersionProperty(:445-449) is resolved once per closed controller type because a DTO's shape cannot change at runtime: the first public instancebyte[]property named exactlyRowVersion. For a DTO with no concurrency token it is null, which makes the whole method a no-op with no per-request reflection at all (:473-474).ReadRowVersion(:488) handles both shapes a served row can take: a typed DTO, read through the cachedPropertyInfo(:490-491), or a shaped dictionary keyed by JSON names when the caller asked for a field projection, probed under bothrowVersionandRowVersion(:493-496).- A DTO without a token gets no header at all (
:476-477). The remark says why (:458-463): absent is the correct answer for a resource that has no version to condition on, and a fabricated tag would invite preconditions the write side cannot honour. The method isprotectedrather than private (:464-469) so a derived controller serving a row from a custom read action emits the identical header instead of re-implementing the reflection and the base64 format.
- Concept introduced: streaming a bulk extract from a paged read.
[Rubric §12, Performance & Scalability]assesses whether a large response is bounded and whether memory grows with the result set;[Rubric §11, Security]covers who may pull a whole table in one request.ExportAsyncanswers the "export what you filtered" request without any new query path (ADR-078):- A dedicated route, not content negotiation (
[HttpGet("export")],:247). The remarks name the two behaviors that make anAccept: text/csvformatter wrong here (:197-204): the public output-cache policy varies by query string but not byAccept, so a cached JSON body could be replayed to a CSV request, andAddAPIsetsReturnHttpNotAcceptable = false(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:48), so a negotiation miss falls back to JSON silently instead of returning 406. A distinct path has neither failure mode. - Field validation before a byte moves (
:259-261):ValidateExportFields(:685) rejects afields=request naming a property the CSV cannot render, as anError.InvalidEntityFieldfailure (:694-698), rather than quietly dropping the column. - Row scoping through the shared hook: the specification is resolved once, before the first page (
:263-264), so the same instance filters every page, and it is the same hook the list endpoints read, so an export can no longer drift wider than the list it mirrors (:229-235). - The page loop (
:280-345) calls the sameQueryService.GetAllAsyncthe paged route uses, atpageSize = Math.Max(1, MaxPageSize)(:267), writing each page out as it materializes. Three termination conditions are distinguished on purpose: a short page is the last page (:333-334), stopping mid-page means rows were left behind (:326-330), and a cap landing exactly on a page boundary consultsPaginationMetadata.TotalItemCountrather than issuing a wasted extra query (:338-342). - Headers first, then body (
BeginExportResponse,:708): content typetext/csv; charset=utf-8(:529, set at:712), aContent-Dispositionattachment whose file name is{controller}-{yyyyMMdd'T'HHmmss'Z'}.csvbuilt invariantly from an injectedTimeProvider(:710,:713,BuildExportFileNameat:724-727, prefix at:547-560), andX-Export-Row-Limit(:535, appended at:714) so a client can tell "exactly at the limit" from "coincidentally that many rows". - Truncation rides in the body. Headers freeze the moment the first body byte flushes, so a truncated export ends with a
# export truncated at N rowsrecord (:347-350, marker at:837-838) and a mid-stream query failure ends with# export incomplete after N rows(:303-304, marker at:843-844) plus a Warning log (LogExportPageFailure,:852). A failure on the FIRST page, before anything was written, still returns Problem Details throughHandleFailure(:297-298), which is exactly what the "constructing the writer sends nothing" comment protects (:274-277). - Column resolution is derived, not invented:
ResolveExportColumns(:774) takes the shaped keys of the first row (:776-778), so the CSV columns for a givenfields=request are the same camelCase JSON names the JSON endpoints emit; an empty result still gets a header row built from the DTO's own properties in declaration order (:780-792).UnexportablePropertyNames(:639-645) drops the properties that cannot render a faithful scalar cell,byte[]/ReadOnlyMemory<byte>concurrency tokens and every collection type exceptstring(IsExportableType,:667-670), computed once per closed controller type since a DTO's shape cannot change at runtime. Value objects and other class-typed properties are deliberately NOT dropped, because a record or value object has a meaningful invariantToString(:634-638).
- A dedicated route, not content negotiation (
- Why it's built this way: the controller stays thin. All filtering/sorting/paging lives in IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, and manual DTO mapping (ADR-001) keeps entities off the wire. The controller only translates HTTP concerns: query strings, headers, status codes. The export reuses the paged read wholesale precisely so it cannot disagree with the grid it was launched from, and the row ceiling (
DefaultMaxExportRows = 100_000,:526, matchingApplicationSettings.MaxExportRows's own default) is a number an operator can reason about rather than an unbounded connection hold. - Caveats / not-in-source: the export carries no attributes of its own. It inherits whatever
[Authorize]or[FeatureGate]the derived controller declares, so a controller that exposespagedanonymously exposesexportanonymously; the symmetry is deliberate and documented (:205-211). The export is also not output-cached. - Where it's used: the base for every read-only module controller, for example ADC's child-collection controllers
SessionSpeakersController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:56) andCategoryItemsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:70). Extended by AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> for entities that also create and delete. The scoping hooks are widely overridden today: the asyncGetReadSpecificationAsyncby ADC'sSpeakersController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:105),SessionsController(SessionsController.cs:77),RoomsController(RoomsController.cs:120) and their child controllers, and by Store's owner-scopedShoppingCartsController(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:206); the synchronousGetExportSpecificationby ADC'sEventsController(EventsController.cs:75) andSessionQuestionAnswersController(SessionQuestionAnswersController.cs:96) and by Store'sOrdersController(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:244). Store'sCustomersControllerdeliberately overrides neither and records why (its list endpoints are Admin-only rather than row-scoped, so there is no ownership specification to reproduce,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:86-100). Framework coverage lives inEntityControllerBaseTests,EntityControllerBaseExportTests,EntityControllerBaseETagTests, andEntityControllerBaseReadSpecificationTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/), the last of which drives both hooks through purpose-built doubles (EntityControllerBaseReadSpecificationTests.cs:356and:377).
OAuthControllerBase
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:35· Level 6 · class (abstract)
- What it is: the base controller for external OAuth2 sign-in (Google, GitHub, Apple). It runs the challenge/callback/complete/exchange dance so a browser or native head can log in through a provider and receive a local JWT pair without ever exposing tokens in a redirect URL.
- Depends on: IAuthenticationService (
ExternalLoginAsync), ICacheService,IConfiguration, ExternalAuthExtensions (the scheme constant,OAuthControllerBase.cs:40), NonIdempotentAttribute, AuthenticationResponse, OAuthCodeExchangeRequest, and Error; the Google, GitHub, and Apple OAuth packages (OAuthControllerBase.cs:3-6) andSystem.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, theRefererheader, or upstream access logs (OAuthControllerBase.cs:127-129).[Rubric §7, Microservices Readiness]: the base is hoisted from the app hosts so every service reuses the identical flow, with the sealed subclass supplying only[Route("auth/oauth")]and versioning (class doc,OAuthControllerBase.cs:30-33). See ADR-036. - Walkthrough
OAuthExchangeCodePrefixand a 2-minuteOAuthExchangeCodeLifetime(OAuthControllerBase.cs:45-46) namespace and time-box the server-side token stash; the short TTL matches the single redirect-then-POST round trip (:41-43).GoogleLogin(:52),GitHubLogin(:60), andAppleLogin(:71) all callChallengeProvider(:272), which stashesreturnUrlinAuthenticationProperties.Itemsand setsRedirectUri = "/auth/oauth/complete"(:276-277). Apple is the newest of the three and needs no special action here: its callback arrives as a cross-site form POST (response_mode=form_postis forced by the name/email scopes) that the middleware handles at/auth/callback/applelike any other provider (:63-68).CompleteAsync(:88): after the middleware handles the provider callback, this reads the external cookie (:91), redirects to/login?error=oauth_failedwhen the ticket did not survive (:93-98), reads the stashedreturnUrlwith aGetStringfallback to"/"rather than the throwingItemsindexer (:100-103), extracts provider claims (ExtractClaims,:185), callsExternalLoginAsyncto find or create the local user and mint tokens (:113-114), signs out the temporary external cookie (:124), then mints a 32-byte hexexchangeCode(:129), stashes the token pair in the cache under it (:130-131), and redirects with only the code (:133, URL built byBuildSuccessRedirectUrlat:136-139).- Name handling is defensive:
ExtractName(:195) prefersGivenName/Surnameclaims and otherwise splits theNameclaim, falling back to("User", "")when there is no usable space-separated name (:209-223), so a provider that returns only a display name still yields a creatable local account. That fallback matters most for Apple, which returns name claims on the first authorization only. - Native heads (ADR-043):
GetAllowedMobileReturnUrl(:247) returns the stashedreturnUrlas the redirect target only when it is an absolute URI whose custom scheme is listed inOAuth:AllowedReturnUrlSchemes; http/https never match (:249-251), so the allowlist cannot become an open redirect, and a missing or empty section (or a test double returningnullfromGetSection) means "no allowlist", the exact pre-ADR-043 behavior (:256-259). Failures route to the same surface throughRedirectError(:235-238), so a native window closes on an error instead of stranding on a web login page. ExchangeAsync(:152):[HttpPost("exchange")]with[NonIdempotent(...)]and[AllowAnonymous](:148-151); the UI swaps the code for the real AuthenticationResponse out-of-band. Because that response is a struct, a cache miss yields a default value rather thannull, so the miss is detected via an emptyAccessToken(:163-169). The code is then removed (:172), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (InvalidCode,:177-183).
- Why it's built this way: carrying tokens in a redirect is the classic OAuth token-leak vector; the single-use code plus a short-lived server-side stash closes it while keeping the client flow a plain redirect and one POST. The
[NonIdempotent]justification onExchangeAsync(:149) records why this one endpoint must stay outside the replay contract: replaying the stored response would defeat the burn, letting a leaked code mint the same tokens again for the whole retention window. TheAppendQueryhelper (:263) deliberately usesOriginalStringrather thanToString(), becauseUrinormalization appends a trailing slash to authority-only URIs (atldevcon://oauth-complete) and native authenticator callback matching can be exact (:265-267). - Caveats / not-in-source: the provider scheme registration and the concrete
ExternalLoginAsyncimplementation live outside this base (ExternalAuthExtensions and the app's IAuthenticationService); this file assumes both are wired. The challenge and complete actions are hidden from OpenAPI with[ApiExplorerSettings(IgnoreApi = true)](:87,:151) because they are browser redirects, not a documented client contract. - Where it's used: subclassed by each app's sealed OAuth controller, today ADC's
OAuthController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20), which adds only the class-level routing and versioning attributes.ExchangeAsyncis 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 isOAuthControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs).
AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:28· Level 7 · class (abstract)
- What it is: the read-plus-create-plus-delete tier of the controller hierarchy. It extends EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (the read endpoints) by adding a
CreateAsync(POST) and aDeleteAsync(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;CreateAsynccarries[Idempotent](AggregateRootEntityControllerBase.cs:60), which wires IdempotencyFilter so a retried POST carrying the sameIdempotency-Keyreplays the original 201 (flaggedX-Idempotent-Replay: true,IdempotencyFilter.cs:40) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-secondLockWait(IdempotencyFilter.cs:104, awaited atIdempotencyFilter.cs:250) is answered with 409 Conflict rather than a replay (IdempotencyFilter.cs:304-309). Because the fitness rule reads attributes withinherit: 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, notablyTEntity : AuditableAggregateRootEntity<TIdentifierType>) enforce at compile time that only aggregate roots reach this create/delete surface. - Walkthrough
- Primary constructor (
AggregateRootEntityControllerBase.cs:28-39): four parameters, wherequeryServiceandloggerare forwarded to the EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> base (:38), pluscreateHandleranddeleteHandler. Theloggeris typedILogger<EntityControllerBase<...>>, not of this class, becauseILogger<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]). CreateHandlerproperty (:48):protected, so a derived controller that overridesCreateAsyncto build a more specific command can still reach the handler.deleteHandlerstays 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 returnsCreatedAtRoute($"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 viaHandleFailure.DeleteAsync(:89-98):[HttpDelete("{id}")](:84); builds a DeleteEntityCommand<TEntity, TIdentifierType>, dispatches it, and returnsNoContent()on success. Delete here means soft-delete: the handler loads the aggregate and calls itsDelete()method, so the domain, not the controller, decides whether the removal is allowed.
- Primary constructor (
- Why it's built this way: splitting the read-only base from the aggregate-root base means a child-collection controller (add/remove associations, not create whole aggregates) can extend the read base without inheriting create/delete it should not expose (
[Rubric §1, SOLID], Interface Segregation), while the actual work stays in injected Application-layer handlers ([Rubric §3, Clean Architecture]) that the CQRS decorator pipeline already wraps with validation, transactions, and cache invalidation (ADR-014). Update is deliberately NOT here: it needs a fifth type parameter, so it lives on CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> instead of changing this type's generic arity. - Where it's used: concrete aggregate controllers in the modules extend this, for example ADC's
EventsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:60) andSessionsController(SessionsController.cs:57), and Store'sCustomersController(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 inAggregateRootEntityControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AggregateRootEntityControllerBaseTests.cs).
CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:54· Level 8 · class (abstract)
- What it is: the top rung of the generic controller chain. It adds the one endpoint AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> leaves out, a conditional
PUT /{id}, completing read, create, update, and delete with no per-entity action bodies at all. - Depends on: AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (base), ICommandHandler<in TCommand, TResult> with UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>, SupportsIfMatchAttribute, IdempotentAttribute, AuditableAggregateRootEntity<TIdentifierType> and ICreateRequest (constraints,
CrudEntityControllerBase.cs:69-72); ASP.NET Core MVC andAsp.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 noUpdateAsyncmember, 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'sIf-Matchheader into the command'sRowVersion, 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 anETagon the read, back asIf-Matchon the write. On success the refreshed token is emitted through the inheritedSetConcurrencyETag(: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 whichqueryService,createHandler,deleteHandler, andloggerare forwarded straight to the aggregate-root base (:66-67); onlyupdateHandleris new. The same narrowly-scoped#pragma warning disable S6672covers the non-covariantILogger<T>category (:63-65). UpdateHandlerproperty (:77):protected, for a derived controller that overridesUpdateAsyncentirely rather than wrapping it.UpdateAsync(:97-113):[HttpPut("{id}")],[Idempotent],[SupportsIfMatch](:87-89), body bound[FromBody, Required](:99). It reads the decoded token withSupportsIfMatchAttribute.RequiredToken(HttpContext)(:102), dispatches an UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> carrying id, request, and row version (:104-106), and on success sets the freshETagand returnsOk(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.
- Class attributes (
- 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
CachePrefixthrough the caching decorator (:38-43). An app that also uses ASP.NET output caching overridesUpdateAsync, awaitsbase.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 aTestCrudControllerdouble (:145, closing the five type parameters at:151). No ADC or Store controller extends it yet; they inherit the four-parameter base and write their own PUT actions, which is exactly the case this base exists to absorb.
CurrentUserTargetingContextAccessor
MMCA.Common.API ·
MMCA.Common.API.FeatureManagement·MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:54· Level 8 · class (sealed)
- What it is: the
ITargetingContextAccessorthat supplies the audience (a user id plus that user's groups) which the feature-managementTargetingfilter evaluates, read from the current HTTP request's principal. - Depends on:
Microsoft.FeatureManagement.FeatureFilters.ITargetingContextAccessorandTargetingContext,Microsoft.AspNetCore.Http.IHttpContextAccessor,System.Security.Claims, and ClaimsPrincipalExtensions for the user-id read (CurrentUserTargetingContextAccessor.cs:1-4). Registered byAddAPI; a sibling of DisabledFeatureHandler in the same folder. - Concept introduced: a percentage rollout that is sticky per user rather than random per request. A
Percentagefeature 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. TheTargetingfilter 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 standardsubclaimTokenServiceemits, read through ClaimsPrincipalExtensions so theClaimTypes.NameIdentifierform the bearer handler maps it to resolves identically, the same readCurrentUserServiceand IdempotencyFilter perform (:15-19, andIdempotencyFilter.cs:487); the principal's name is the fallback for a token carrying neither. Second, the accessor is a singleton (that is the lifetimeWithTargetinggives it), so it cannot take the scopedICurrentUserService; it readsIHttpContextAccessorinstead and re-derives the roles itself, which the doc calls out explicitly (:22-24). - Walkthrough:
GetContextAsync()(:63) readshttpContextAccessor.HttpContext?.User(:65). An unauthenticated or absent principal yields an empty context,UserId = nullandGroups = [](: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 standardClaimTypes.RoleURI when inbound claim mapping is on, or the rawrole/rolesclaim when it is off (:76-82). Finally it builds theTargetingContextwithuser.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.Rolewould 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 workedFeatureManagementconfiguration 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
AddAPIasservices.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>()(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92), immediately afterAddHttpContextAccessor()(:90, which isTryAdd-based and therefore safe to call here as well as elsewhere, the reasoning recorded at:84-89). From there it is consumed only by theTargetingfeature filter, whose verdicts reach the HTTP edge through[FeatureGate]and DisabledFeatureHandler, and the CQRS layer through FeatureGateCommandDecorator<TCommand, TResult>.
AuthControllerBase
MMCA.Common.API ·
MMCA.Common.API.Controllers·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:43· Level 15 · class (abstract)
- What it is: the abstract base for password-based authentication and session management: login, register, refresh, revoke-everywhere, list-my-devices, and revoke-one-device. A downstream module (Identity) inherits it and adds the route prefix, version attribute, and any module-specific endpoints.
- Depends on: ApiControllerBase (base), IAuthenticationService and ICurrentUserService (injected), LoginRequest, RegisterRequest, RefreshTokenRequest, AuthenticationResponse, RefreshSessionSummaryResponse, ClaimsPrincipalExtensions (
FindSessionId), IdempotentAttribute and NonIdempotentAttribute, and theRateLimitPolicyAuthIpconstant on WebApplicationBuilderExtensions;Microsoft.AspNetCore.RateLimitingfor[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 providesvirtualendpoints, a derived controller overrides only what differs), but the load-bearing idea here is[Rubric §11, Security]: anti-spray throttling is on by default.LoginAsyncandRegisterAsynccarry[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)](AuthControllerBase.cs:72and:94), so any consumer inheriting this base gets per-IP protection without opting in. The class doc (AuthControllerBase.cs:20-29) records why: the earlier arrangement shipped the policy in the framework and left each app to attach it, and an app that simply inherited these actions silently had no spray protection at all, because the global limiter deliberately no-ops for anonymous traffic and account lockout is per-email (ADR-019, ADR-029). Every action otherwise shares the same Result-to-ActionResult shape: call the service, checkresult.IsFailure, returnHandleFailure(result.Errors)or the success result. None carries business logic; they are thin HTTP adapters over IAuthenticationService. - Concept introduced: every POST states its idempotency intent. This base is the framework's worked example of the NonIdempotentAttribute rule, and reading its actions together is the fastest way to internalize the distinction.
RegisterAsyncis[Idempotent](:92): a retried registration should replay the original 201 rather than fail on a duplicate email. The others carry[NonIdempotent("...")]with a written harm: login issues a token pair, so a replayed response would hand a retrying client the tokens minted for an earlier call and extend the lifetime of credentials the caller may already have discarded (:68); refresh rotates the refresh token, so a replay would return a token the rotation has already invalidated and hand the client dead credentials (:116); and revocation must reach the store on every call, since a replayed 204 would report success for a revoke that never ran and leave a refresh token live (:143) or a device signed in (:206) after the user asked otherwise. - Walkthrough
- Primary constructor (
:41-43) exposesAuthenticationServiceandCurrentUserServiceasprotectedproperties (:46and:49) so derived controllers can reach them for extra endpoints. ClientIpAddress(:56) andClientUserAgent(:62) are the two request facts every write action forwards to the service. The IP is recorded on the refresh session and drives the registration rate limit, and behind a proxy it is the forwarded value only when the host configuredUseForwardedHeaders(:51-55). The user agent is purely informational, so a device list can name the session a user is looking at; nothing validates against it (:58-61).LoginAsync(:74):[HttpPost("login")],[NonIdempotent],[AllowAnonymous], throttled per IP (:67-70); callsLoginAsync(request, ClientIpAddress, ClientUserAgent, ...)(:78-80) and returnsOkorHandleFailure. The[ProducesResponseType]attributes (:71-73) feed the OpenAPI contract and include the 429 the limiter can produce.RegisterAsync(:99):[HttpPost("register")],[Idempotent], anonymous and throttled (:91-94); returnsStatusCode(StatusCodes.Status201Created, ...)(:109), correctly 201 Created for a new account rather than 200. It isvirtualso a module can override it to inject extra context (:88-89).RefreshAsync(:120):[AllowAnonymous](:117), since exchanging an expired token pair is pre-authentication, and deliberately not throttled (:28-34): refresh is automatic and periodic rather than user-initiated, Blazor Server circuits issue it server-side so every Server-circuit user shares the UI host's IP, and refresh tokens are high-entropy, so brute force is not the threat password spraying is.RevokeAsync(:147):[Authorize](:144); readsCurrentUserService.UserId, returnsUnauthorized()if null (:149-151) as a defensive guard even though[Authorize]should already prevent a null id, then callsRevokeAllSessionsAsyncand returnsNoContent()(:153-159). The endpoint carries no body, so it cannot name the device it is called from: it signs the user out everywhere, which is what a caller with no way to identify its own session should get (:135-140).GetMySessionsAsync(:177):[HttpGet("my-sessions")]plus[Authorize](:173-174), one row per live refresh session, newest first. The "this is the device you are on" flag comes from the access token's ownsidclaim, read withUser.FindSessionId()(:185), so no client state is involved and nothing has to send a refresh token to a read endpoint; a token minted beforesidshipped simply flags no row (:166-170).RevokeSessionAsync(:211):[HttpPost("revoke/{sessionId:guid}")](:205), the per-device counterpart. The session is named in the route rather than by its refresh token, so a client can sign out a device it does not hold the token for, which is the whole point of a device list. Ownership is enforced in the store query, so another account's session id answers 404 exactly as a nonexistent one does, and revoking an already-revoked session answers 204 because a device list is where duplicate clicks come from (:196-202).
- Primary constructor (
- 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 methodsvirtual(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 callingAddCommonRateLimiting()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:297, which registers the"auth-ip"policy at:366-367, constant defined at:47, with anauthIpPermitLimitdefault of 30 requests per minute per IP at:296) fails at startup on an unregistered policy rather than silently serving unthrottled logins (AuthControllerBase.cs:37-41). - Caveats / not-in-source: the per-IP policy partitions on
Connection.RemoteIpAddress(WebApplicationBuilderExtensions.cs:224) and deliberately does not limit when that address is null (in-processTestServer, integration tests):AuthIpRateLimitPartitionreturnsRateLimitPartition.GetNoLimiter("__unknown-ip")in that case (WebApplicationBuilderExtensions.cs:226-227), a fail-open posture matching the global limiter and documented atWebApplicationBuilderExtensions.cs:206-212. - Where it's used: the base of every app's Identity
AuthController, reached today through UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which both ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:30) and Store (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27) extend. Password recovery is deliberately NOT on this chain: it ships as the sibling PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>. The framework's own coverage drives the base through a minimal test double,TestAuthController(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/AuthControllerBaseTests.cs:339), with the throttling asserted separately inAuthControllerBaseRateLimitTests.
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-passwordandPOST 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 theRateLimitPolicyAuthIpconstant on WebApplicationBuilderExtensions; ASP.NET Core MVC,Microsoft.AspNetCore.Authorization, andMicrosoft.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
AuthControlleralready spends it on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>. Recovery therefore ships as its own base that the app routes to the sameAuthprefix ([Route("Auth")]on the concrete controller), soPOST /Auth/forgot-passwordrides the gateway's existing/Authroute with no gateway change (class doc,PasswordResetAuthControllerBase.cs:13-18; the ADC subclass records the same reasoning atMMCA.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.
- Why a sibling controller and not two more actions on the auth base. C# gives a class one base, and each app's
- Walkthrough
- Type parameters and constraints (
:43-47):TForgotPasswordCommand : ICommandWithRequest<ForgotPasswordRequest>andTResetPasswordCommand : 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 areIUserScopedCommand<TRequest>: recovery has no authenticated user to scope to. - The two handlers become
protectedproperties (:50and:53), the same convention the other auth bases follow, so a derived controller can dispatch them itself for an extra endpoint. CreateForgotPasswordCommand(request)(:61) andCreateResetPasswordCommand(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:36and: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 returnsAccepted()on success orHandleFailure(:86-92).ResetPasswordAsync(:107) mirrors it at[HttpPost("reset-password")](:99-102), declaring 204/400/401/429 (:103-106) and returningNoContent()on success (:111-117).
- Type parameters and constraints (
- 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 itsResetPasswordCommandICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:16) while Store's implements onlyICommandWithRequest<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 isPasswordResetAuthControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/PasswordResetAuthControllerBaseTests.cs:23), which drives aTestPasswordResetControllerdouble (: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, andGET 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 andMicrosoft.AspNetCore.Authorization. - Concept introduced: generic-over-the-command deduplication. Two apps wanted the same HTTP surface but not the same command record: ADC's
ChangePasswordCommandalso implements ICacheInvalidating with a cache prefix built from its ownUsertype (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 theIUserScopedCommand<TRequest>abstraction and never on either app's concrete record.[Rubric §2, Design Patterns]: the twoCreate*Commandoverrides are the pattern's primitive operations, and their implementations really are one line each. - Walkthrough
- Type parameters and constraints (
:40-48):TChangePasswordCommand : IUserScopedCommand<ChangePasswordRequest>andTChangePreferencesCommand : 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
protectedproperties (: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) andCreateChangePreferencesCommand(userId, request)(:77-79) are the two abstract factories. Both take aUserIdentifierType, the solution-wide identifier alias, so the base never has to know whether an app's user key is anintor aGuid.ChangePasswordAsync(:91):[HttpPut("password")]plus[Authorize](:86-87). It readsCurrentUserService.UserId, returnsUnauthorized()when null (:95-97), then dispatchesCreateChangePasswordCommand(userId.Value, request)through the handler (:99-101) and returnsNoContent()orHandleFailure. 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.RevokeAsynctakes.
- Type parameters and constraints (
- 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 overrideRegisterAsyncor attach another[EnableRateLimiting]policy app-side, behaves exactly as before. That is what made the consolidation safe to do at all. The alternative (pushing the command records into the framework) would have forced ADC's cache-invalidation behavior onto Store or dropped it from ADC.[Rubric §14, Testability]: because the extension point is two abstract methods rather than a service lookup, the framework can exercise the whole base with a test double supplying trivial commands. - Where it's used: extended by each app's Identity
AuthController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:30) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27), each supplying the two one-line factory overrides. Covered in the framework byUserAccountAuthControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Auth/UserAccountAuthControllerBaseTests.cs:17), which drives the base through aTestUserAccountAuthControllerdouble (: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.resxfamily. 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.resxfamily). - 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
ErrorResourceSourceinstances 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 severalIStringLocalizers as a distinguishableIEnumerable<ErrorResourceSource>(a bareIEnumerable<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 asksIStringLocalizerFactory.Create(typeof(TResource))(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:124-125), so the anchor type is the only thing a module has to supply. - Where it's used: injected as
IEnumerable<ErrorResourceSource>into ErrorLocalizer (ErrorLocalizer.cs:11). Common's own source is registered insideAddErrorLocalization()(DependencyInjection.cs:111), which DependencyInjection'sAddAPIcalls automatically (DependencyInjection.cs:96); modules add theirs by callingAddErrorResources<TResource>()(DependencyInjection.cs:122).
IdempotencyMetrics
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16· Level 0 · class (internal static)
- What it is: the three OpenTelemetry counters that make the idempotency filter observable: how often a stored response was replayed, how often a duplicate was refused, and how often the filter ran without its guarantee because the cache or the lock was unhealthy.
- Depends on:
System.Diagnostics.Metrics(Meter,Counter<long>) from the BCL only. Consumed by IdempotencyFilter; the meter is exported by the Aspire service defaults. - Concept introduced: metering a degradable cross-cutting filter.
[Rubric §13, Observability & Operability]assesses whether an operator can tell that a mechanism is still doing its job. That matters here more than for most filters because IdempotencyFilter is deliberately best-effort: when its cache or its lock faults, it swallows the fault and lets the request run unguarded rather than failing the write. Without a counter that failure mode is invisible, since every request still succeeds, so a Redis outage would silently turn deduplication off across the fleet.idempotency.degradedis 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;Meteris 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), andidempotency.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 eitherConflictKindBodyMismatch = "body_mismatch"(IdempotencyMetrics.cs:24) orConflictKindInFlight = "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 aKeyValuePair(IdempotencyMetrics.cs:61-62), andRecordDegraded()(IdempotencyMetrics.cs:68).
- Why it's built this way:
internal statickeeps 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, becauseMMCA.Common.Aspirehas no reference toMMCA.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:
RecordDegradedon a faulted lock acquisition (IdempotencyFilter.cs:255), a failed cache read (IdempotencyFilter.cs:364) and a failed cache write (IdempotencyFilter.cs:439);RecordConflictfor an in-flight duplicate (IdempotencyFilter.cs:267) and a body mismatch (IdempotencyFilter.cs:374);RecordReplayedon every served replay (IdempotencyFilter.cs:380).
IdempotencyRecord
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyRecord.cs:14· Level 0 · record (sealed, positional)
- What it is: the cached snapshot of an idempotent action's response: the HTTP status code, the JSON-serialized body, and the hash of the request body that produced it. It is what IdempotencyFilter writes after the first successful execution and replays for every duplicate carrying the same key and the same payload.
- Depends on: nothing beyond the BCL; a three-parameter positional
record, all three parameters required. - Concept: introduced fully by IdempotencyFilter; this is the value it persists. Two properties of the shape are load-bearing. First, only status and body are captured, no headers, which is why the replay path re-adds
X-Idempotent-Replayitself (IdempotencyFilter.cs:383) and why a replayed 201 does not carry the originalLocationheader. Second,RequestBodyHashis non-nullable, so there is no "no hash stored" state to fall through: every record carries one and a key replayed with a different payload is always rejected rather than served someone else's response (parameter doc,IdempotencyRecord.cs:9-13). A body-less request hashes the empty payload rather than storing nothing.[Rubric §9, API & Contract Design]assesses whether a persisted contract states its invariants in its shape rather than in a comment; making the hash required is what removes the unchecked path. - Walkthrough:
IdempotencyRecord(int StatusCode, string ResponseBody, string RequestBodyHash)(IdempotencyRecord.cs:14).StatusCodeis the original response's code, defaulting to 200 when anObjectResultcarried none (IdempotencyFilter.cs:453).ResponseBodyis non-nullable: anObjectResultstoresobjectResult.Valueserialized withJsonSerializerOptions.Web(IdempotencyFilter.cs:456-459), while a body-less 2xx such as the 204 fromNoContent()storesstring.Empty(IdempotencyFilter.cs:468), which is the signal the replay path uses to answer with a bare status code instead of a JSON content result.RequestBodyHashis the lowercase hex SHA-256 of the request body (IdempotencyFilter.cs:188-192). - Why it's built this way: keeping the record to three primitives makes it provider-agnostic, so the same artifact round-trips through the in-memory cache or Redis with no serializer coupling. Distinguishing "empty body" from "no body" via the empty string (rather than a nullable) keeps the JSON shape stable across both cases, and the comment at
IdempotencyFilter.cs:463-465names that convention at the one place a body-less result is stored. - Where it's used: read by IdempotencyFilter through ICacheService (
IdempotencyFilter.cs:360), built by itsBuildRecord(IdempotencyFilter.cs:448), and written back with the configured expiration (IdempotencyFilter.cs:435).
IdempotencySettings
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencySettings.cs:9· Level 0 · class (sealed)
- What it is: the options object bound from the
Idempotencyconfiguration section. It carries exactly one knob: how long a cached idempotent response is retained. - Depends on:
System.ComponentModel.DataAnnotationsfor the[Range]attribute; bound throughMicrosoft.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:
SectionNameis apublic static readonly stringequal to"Idempotency"(IdempotencySettings.cs:12), so the binding key is a symbol rather than a magic string at the call site.CacheExpirationHoursdefaults to 24 (IdempotencySettings.cs:16) and is constrained by[Range(1, 168)](IdempotencySettings.cs:15), one hour to one week. The property isinit-only, so the value is fixed once bound. - Why it's built this way:
AddAPIbinds the section only when the caller passes anIConfiguration, and when it does it chains.ValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:70-73), so an out-of-rangeCacheExpirationHoursfails the host at startup rather than at the first idempotent POST.[Rubric §15, Best Practices & Code Quality]: fail fast, loudly, at composition time. RateLimitingSettings follows the same shape one directory over. - Where it's used: resolved as
IOptions<IdempotencySettings>inside IdempotencyFilter at store time (IdempotencyFilter.cs:427-431). Note the deliberateGetService, notGetRequiredService: when the options are absent (theAddAPIoverload called with no configuration) the filter falls back to its hard-coded 24-hourDefaultExpiration(IdempotencyFilter.cs:80) instead of throwing.
IErrorLocalizer
MMCA.Common.API ·
MMCA.Common.API.Localization·MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/IErrorLocalizer.cs:9· Level 0 · interface
- What it is: the contract for localizing a domain Error's human-readable message at the HTTP edge, keyed by its stable machine
Code. Domain, handler, and Result code stays culture-agnostic; only the edge speaks a culture. - Depends on: nothing beyond the BCL. Implemented by ErrorLocalizer; consumed by ErrorHttpMapping.
- Concept introduced: edge localization keyed by a stable code. The domain raises errors carrying a machine
Codesuch as"PhoneNumber.Empty"plus an English message (IErrorLocalizer.cs:15). Translating on theCoderather 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'sfallbackMessageis 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: resolvecodeagainst the current UI culture, otherwise returnfallbackMessage(IErrorLocalizer.cs:11-16). - Why it's built this way: consumers depend on the abstraction while the additive resource-source enumeration stays an implementation detail (see ErrorLocalizer). Registration uses
TryAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:110), so an app that wants a different localization strategy registers its own first and the framework's default steps aside. See ADR-027. - Where it's used: resolved optionally at three edge call sites, via
GetServicerather thanGetRequiredService: ApiControllerBase.HandleFailure(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:57), UnhandledResultFailureFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:46), and the precondition failure path of SupportsIfMatchAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:217). All three hand it to ErrorHttpMapping.BuildErrorsExtension(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:61), which accepts anulllocalizer and leaves messages in English (ErrorHttpMapping.cs:65) while leaving the machineCodeverbatim so clients can still branch on it (ErrorHttpMapping.cs:56-58).
NonIdempotentAttribute
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23· Level 0 · class (sealed)
- What it is: a documentation-only marker declaring that a POST action is deliberately outside the
Idempotency-Keycontract, 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 thePostActionsDeclareIdempotencyIntentfitness 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 theNonIdempotentAttributeNameconstant atArchitectureRules.Idempotency.cs:9so 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), exposingpublic string Justification { get; } = justification;(NonIdempotentAttribute.cs:28). The parameter doc says the reason is required and is meant to be read by the next person who wonders whether the omission was intentional (NonIdempotentAttribute.cs:18-21). - Why it's built this way: the remarks (
NonIdempotentAttribute.cs:11-17) narrow the legitimate cases sharply: this attribute is for actions where replaying a stored response would be actively wrong rather than merely unhelpful (token issuance and revocation, single-use code exchange, anything whose response is only valid for the call that produced it), and IdempotentAttribute is the default everywhere else. Making it a marker rather than a filter is what keeps it free: no pipeline stage, no DI registration, no runtime cost. - Where it's used: in the framework, on the four token-lifecycle actions of AuthControllerBase (login at
AuthControllerBase.cs:70, refresh at:116, revoke at:143, per-session revoke at:206) and on OAuthControllerBase.ExchangeAsync(OAuthControllerBase.cs:150). Each justification names the concrete harm: a replayed login hands a retrying client tokens minted for an earlier call, a replayed refresh returns a token the rotation already invalidated, a replayed revoke reports success for a revocation that never ran, and a replayed exchange defeats the single-use burn on the OAuth code. Downstream the same discipline holds: ADC's session-selection enqueue is opted out because the queue's own pending set already deduplicates and a cached 202 would hide both an already-running refusal and a queue-full rejection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:107), and Store opts its payments action out (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/PaymentsController.cs:58). Consumer repos inherit the rule through IdempotencyConventionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IdempotencyConventionTestsBase.cs).
ErrorLocalizer
MMCA.Common.API ·
MMCA.Common.API.Localization·MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorLocalizer.cs:11· Level 1 · class (sealed, internal)
- What it is: the default IErrorLocalizer. It resolves an error code against an ordered set of registered ErrorResourceSources (Common first, then modules) using the current UI culture, and falls back to the caller's English message when the code is empty or unknown to every source.
- Depends on: IErrorLocalizer (implements), ErrorResourceSource (injected as a collection);
Microsoft.Extensions.Localization.LocalizedString. - Concept introduced: first-match-wins over an ordered source list. The interesting mechanism is how "not found" is detected.
IStringLocalizer's indexer never returnsnull: on a miss it hands back aLocalizedStringwhoseValueis the key itself and whoseResourceNotFoundflag istrue. 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_sourceswith a collection expression (ErrorLocalizer.cs:13).Localize(ErrorLocalizer.cs:16): returnsfallbackMessageimmediately whencodeis null or empty (ErrorLocalizer.cs:18-21); otherwise walks_sourcesin registration order, readingsource.Localizer[code]and returninglocalized.Valueon the first entry where!localized.ResourceNotFound(ErrorLocalizer.cs:23-30); if no source matches it returnsfallbackMessage(ErrorLocalizer.cs:32). The current UI culture is never read explicitly:IStringLocalizerresolves it per call, which is why one singleton can serve requests in different cultures concurrently. - Why it's built this way:
internal sealedkeeps 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 withTryAddSingleton(DependencyInjection.cs:110) is safe precisely because the type holds no per-request state. - Where it's used: registered by
AddErrorLocalization()(DependencyInjection.cs:107-113, which also callsservices.AddLocalization()at:109), and that method is called byAddAPI(DependencyInjection.cs:96); reached at runtime only through the IErrorLocalizer handle that ApiControllerBase, UnhandledResultFailureFilter and SupportsIfMatchAttribute pass into ErrorHttpMapping.
IdempotencyFilter
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:67· Level 4 · class (sealed, partial)
- What it is: the ASP.NET Core filter that gives write operations client-driven idempotency. A client attaches an
Idempotency-Keyheader; 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 bothIAsyncActionFilterandIAsyncResourceFilter(IdempotencyFilter.cs:68), which is the detail that makes body-aware deduplication possible. - Depends on: ICacheService and IDistributedLock, both resolved per request from
RequestServices(IdempotencyFilter.cs:140andIdempotencyFilter.cs:148); IdempotencyRecord; IdempotencySettings viaIOptions<>; 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, andILoggerwith 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.- Double-check locking (doc comment,
IdempotencyFilter.cs:22-32): read the cache with no lock (the common fast path); on a miss take the lock for this key; re-read, because a concurrent duplicate may have finished while this one waited; only then execute and store. Without the lock, two near-simultaneous retries of a slow create both miss and both execute. - The lock must be visible to every replica. A per-process stripe only serializes duplicates that land on the same instance, and both deployed apps run more than one (
IdempotencyFilter.cs:33-38, restated atIdempotencyFilter.cs:219-223), so the primary path uses an IDistributedLock and the stripe is only the fallback for a host that registers none. - The key alone is not enough. A stored response is bound to the request body that produced it, so a key reused with a different payload is refused (422) rather than replayed, which would otherwise silently swallow a genuinely new write (
IdempotencyFilter.cs:43-51).
- Double-check locking (doc comment,
- Walkthrough
- Constants and shared state:
IdempotencyKeyHeaderdelegates toIdempotencyHeaders.IdempotencyKey(IdempotencyFilter.cs:73, defined atMMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19as"Idempotency-Key");CacheKeyPrefixreturns"idempotency:"(IdempotencyFilter.cs:75);DefaultExpirationof 24 hours (IdempotencyFilter.cs:80);LockTimeToLiveof 30 seconds andLockWaitof 5 seconds (IdempotencyFilter.cs:97andIdempotencyFilter.cs:104), sized so a slow action finishes under its own lock while a dead replica does not block a retry for long; andEmptyBodyHash, the SHA-256 of zero bytes (IdempotencyFilter.cs:110-111). KeyLocks(IdempotencyFilter.cs:90) is a static KeyedSemaphoreStripe. The comment above it (IdempotencyFilter.cs:82-89) gives the reason for striping over one-semaphore-per-key: the key embeds a caller-supplied value, so a per-key table either grows without bound or needs an eager removal that races (a removal between another request's lookup and its wait lets a third request create a fresh semaphore, and both then execute concurrently).- Resource stage.
OnResourceExecutionAsync(IdempotencyFilter.cs:119) runs before model binding, the last point at which the body can still be made replayable. It callsRequest.EnableBuffering()only when the header is present (IdempotencyFilter.cs:121-122), so ordinary traffic on an[Idempotent]action pays nothing, then awaitsnext()(IdempotencyFilter.cs:124). - Action stage.
OnActionExecutionAsync(IdempotencyFilter.cs:128): no header means straight through tonext()with nothing else resolved (IdempotencyFilter.cs:131-136), so idempotency is opt-in per request as well as per action. Otherwise it derives the cache key (IdempotencyFilter.cs:138), hashes the body (:139), resolves ICacheService (:140), and tries the lock-free replay (:143). On a miss it picks a lock strategy:GetService<IDistributedLock>()returning null routes to the process-stripe path (:148-153), otherwise the distributed path (:155). The comment at:146-147states the invariant that shapes both paths: the lock has to span execute-and-store, so a duplicate cannot slip in between the action finishing and its response reaching the cache. ReadIdempotencyKey(IdempotencyFilter.cs:163-170) treats a missing or blank header as absent, and both stages call it so they agree on what "has a key" means.ComputeRequestBodyHashAsync(IdempotencyFilter.cs:182-193): a stream that cannot seek was never buffered, so it takesEmptyBodyHashrather than throwing (:185-186), which leaves such a request deduplicated on its key alone. Otherwise it rewinds to 0, hashes withSHA256.HashDataAsync, and rewinds again (:188-190) because model binding still has to read the whole body.ExecuteUnderProcessLockAsync(IdempotencyFilter.cs:199): acquires the stripe honoringRequestAbortedinside ausingso release survives a throw (:206), re-checks the cache (:209), then executes and stores (:212).ExecuteUnderDistributedLockAsync(IdempotencyFilter.cs:238) is where the interesting policy lives, and it separates three outcomes that a naive implementation conflates (rationale in the remarks,:219-236).- The lock backend faults: counted on
idempotency.degraded, logged, and the action runs unguarded (:253-259). There is no holder to wait for, so refusing would turn a cache blip into a write outage. - The wait expires with the lock still held elsewhere: the holder is executing this key right now, so it logs the timeout and re-checks the cache first, and only if still nothing is stored does it record an
in_flightconflict and answer withInFlightDuplicateResult()(:261-273). That helper (:301-310) is a 409ProblemDetailstitled "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 usingon the handle (:275), double-check (:278), then execute and store (:281).
- The lock backend faults: counted on
ExecuteAndStoreAsync(IdempotencyFilter.cs:286-295) is the two-line shared tail of all three paths: awaitnext(), thenTryStoreAsyncthe executed result.TryReplayAsync(IdempotencyFilter.cs:351) serves both the fast path and every double-check, returning whether it short-circuited. A cache read that throws is reported as "nothing stored" after counting a degradation (:358-367). A stored record whoseRequestBodyHashdiffers from this request's hash (an ordinal comparison,:372) yields abody_mismatchconflict andBodyMismatchResult()(:372-378), a 422 whose remarks explain the status choice: 409 is already spent on "still in flight", which is retry-with-the-same-key, while key reuse with a different body is not retryable until the client picks a new key (:317-331). On a genuine hit it counts the replay (:380), appendsX-Idempotent-Replay: true(:383, name fromIdempotencyHeaders.cs:25), and short-circuits with a bareStatusCodeResultwhen the stored body is empty or aContentResultwithapplication/jsonotherwise (:384-391). That split is what makes a replayed 204 look like the original 204 instead of a 204 carrying a content type.TryStoreAsync(IdempotencyFilter.cs:416) builds the record and returns early when it is not cacheable (:423-425), reads the expiration from IdempotencySettings when registered andDefaultExpirationotherwise (:427-431), and swallows a failingSetAsyncafter counting it (:433-441): the action already ran, so failing here would push the client into the very retry the filter exists to deduplicate (:410-414).BuildRecord(IdempotencyFilter.cs:448) decides what is cacheable. AnObjectResultwith a 2xx status stores its value serialized withJsonSerializerOptions.Web(:452-461; theVSTHRD103suppression at:454documents that serializing to a string is correctly synchronous). AStatusCodeResultwith a 2xx status, which is whatNoContent(),OkResultandStatusCode(int)produce, stores the empty string (:466-469). Everything else, including every non-2xx and every redirect or file result, returnsnulland is not stored (:471-472);IsSuccessis the>= 200 and < 300test (:476).BuildCacheKey(IdempotencyFilter.cs:485-499) is the security-relevant part: the subject is the caller's user-id claim viaFindUserIdValue()(:487,MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:26, which readssuband falls back to the mappedNameIdentifier) or, unauthenticated,anon:{remote address}(:488); the route is the attribute route template falling back to the request path (:490-492); those plus the HTTP method and the client key are joined with a newline (:495, a character valid in none of the components, so the tuple cannot be forged), SHA-256 hashed (:496), and emitted asidempotency:{lowercase hex}(:498).- Logging is entirely source-generated
[LoggerMessage]partials with instance forwarders (IdempotencyFilter.cs:501-552): one Information message for a served replay (:501-507) and five Warnings covering body mismatch (:509-514), in-flight duplicate (:516-521), lock-wait timeout (:523-528), cache-read failure (:530-536) and cache-store failure (:538-544), plus lock unavailability (:546-552).[Rubric §13, Observability & Operability]: each degraded path has both a counter and a log line naming the cache key.
- Constants and shared state:
- Why it's built this way: keying on the bare client value would make the key space global, so two callers who happened to pick the same value would share an entry and one user's serialized body could be replayed to another; with services sharing one cache instance that collision reaches across endpoints and services (SECURITY note,
IdempotencyFilter.cs:59-65; ADR-017). Hashing also bounds the stored key length regardless of what a client sends (:478-484). Non-2xx results are deliberately not stored (IdempotencyFilter.cs:400-403) because replaying a failure for the whole retention window would mean a client retrying after a transient 500 keeps receiving that 500 for 24 hours instead of the retry actually executing. The 204 case is explicitly covered rather than skipped (:405-409): skipping it left every command answeringNoContent()with nothing stored, so the body-less writes, the ones most likely to be retried, were the ones idempotency did not actually cover. And the whole cache-and-lock layer is treated as best-effort infrastructure (IdempotencyFilter.cs:52-58): deduplication is an optimization over an at-least-once client retry, so a Redis outage must degrade dedup, not every write endpoint carrying the attribute. - Caveats / not-in-source: the replay restores only the status code and the JSON body. Response headers the original action set (a 201's
Location, for example) are not captured by IdempotencyRecord and are therefore not reproduced; onlyX-Idempotent-Replayis added. - Where it's used: registered scoped in
AddAPIbecause it depends on scoped services (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:77, comment at:76), and attached to actions through IdempotentAttribute. In the framework itself that is the create endpoint on AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (AggregateRootEntityControllerBase.cs:60), the update endpoint on CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> (CrudEntityControllerBase.cs:89, alongside[SupportsIfMatch]), the register endpoint on AuthControllerBase (AuthControllerBase.cs:94), both actions of PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (PasswordResetAuthControllerBase.cs:76and:100), and the send endpoint of NotificationsController (NotificationsController.cs:44). Downstream, ADC marks its Engagement writes (check-ins, bookmarks, live polls, session questions) and its Conference collection writes, and Store marks its cart, order and catalog writes.
IdempotentAttribute
MMCA.Common.API ·
MMCA.Common.API.Idempotency·MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotentAttribute.cs:16· Level 5 · class (sealed)
- What it is: the method-level marker that attaches IdempotencyFilter to a controller action. Putting
[Idempotent]on an action opts it into theIdempotency-Keyreplay behavior; leaving it off means the action is never deduplicated. - Depends on:
ServiceFilterAttributefrom 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 registeredAddScopedand 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, whichAddAPIdoes (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:77); without that registration, resolution fails at request time rather than at startup. It also states the no-op contract: an action carrying the attribute but reached by a request with noIdempotency-Keyheader executes normally (IdempotentAttribute.cs:5-9). - Why it's built this way: opt-in per action is the ADR-017 decision. Nothing is deduplicated unless the action declares it, so adding the attribute is additive and safe, and the client still decides per request whether to send a key at all (a keyless request short-circuits at
IdempotencyFilter.cs:131-136). The flip side, that a POST which should be idempotent but is missing the attribute gets no protection, is closed by a fitness function rather than by inventory discipline: see NonIdempotentAttribute, whosePostActionsDeclareIdempotencyIntentrule 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 withinherit: 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
IApiDescriptionProviderthat walks every API description MVC produced and fills in a placeholderParameterDescriptorwherever 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.ParameterDescriptorandMicrosoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider(ASP.NET Core), nothing first-party. Registered byAddCommonApiVersioning()andAddCommonOpenApi()on WebApplicationBuilderExtensions. - Concept introduced: the API-explorer provider chain as a repair point. MVC builds its OpenAPI input (the
ApiDescriptionlist) by running a chain ofIApiDescriptionProviderinstances;OnProvidersExecutingruns in ascendingOrder,OnProvidersExecutedin 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 thatGET /openapi/{documentName}.jsonmust 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 parametersVersionedApiDescriptionProvidercontributed 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), flattenscontext.Results.SelectMany(description => description.ParameterDescriptions)(:60) and appliesparameter.ParameterDescriptor ??= new ParameterDescriptor { ... }(:65-71). The??=is the load-bearing operator: an existing descriptor (aControllerParameterDescriptor, which carries theParameterInfothat XML-comment lookups prefer) is never replaced, and the comment states that rule explicitly (:62-64). The synthesized replacement takesparameter.Name ?? string.Empty(:67), and the type falls back throughparameter.Type, thenModelMetadata?.ModelType, thentypeof(string)(:68-70).
- Why it's built this way: the class remarks (
ApiParameterDescriptorBackfillProvider.cs:12-42) document the exact failure it guards. MVC leavesParameterDescriptornull for a route-template token with no matching action parameter (normal for[Route("api/v{version:apiVersion}/orders")]or[Route("api/{tenant}/orders")]), whileAsp.Versioning.OpenApi10.2.1 readsarg.ParameterDescriptor.Namewithout a null check (:21-27). The fix is deliberately general (any unbound route token, not just the version token) so a{tenant}or{region}segment is covered by the same guard (:28-33). - Where it's used: registered once through the private
AddApiParameterDescriptorBackfill()helper (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:419-421), which usesTryAddEnumerableso calling bothAddCommonApiVersioning()(WebApplicationBuilderExtensions.cs:245, which calls the helper at:261) andAddCommonOpenApi()(: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
IExceptionHandlerthat turns an EF CoreDbUpdateExceptioninto 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
IExceptionHandlerchain. ASP.NET Core resolves registeredIExceptionHandlerimplementations in registration order and callsTryHandleAsyncon each: returningtrueclaims the exception and stops the chain, returningfalsepasses it on. The framework registers the chain inAddCommonExceptionHandlers()(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:135-146) asOperationCanceled(:140), thenDomain(:141), thenDbUpdate(:142), thenValidation(:143), thenGlobal(:144), and that same method callsAddProblemDetailswith a customization that stamps arequestIdextension fromHttpContext.TraceIdentifieronto every problem document (DependencyInjection.cs:137-139).[Rubric §9, API & Contract Design]assesses whether errors have one uniform, standards-based shape: every handler here writes RFC 9457application/problem+jsonthrough the sameIProblemDetailsService, so controllers never need atry/catch.[Rubric §11, Security]applies to this handler specifically, see below. - Walkthrough:
TryHandleAsync(DbUpdateExceptionHandler.cs:23):- Type-tests with
is not DbUpdateException dbUpdateExceptionand returnsfalse(DbUpdateExceptionHandler.cs:28-29) so the rest of the chain still gets its turn. - Logs the exception at
LogError(:31), then setsStatusCodes.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
ProblemDetailsContexttitled"Database Update Exception"(:39-49) and returnsawait problemDetailsService.TryWriteAsync(context)(:51), so the response body is whatever the configuredProblemDetailsServicewrites.
- Type-tests with
- Why it's built this way: 409 is the honest status for a write rejected by a constraint or a concurrency token, and the split between a rich server-side log and a generic client body is the standard information-disclosure posture.
- Where it's used: registered third in
AddCommonExceptionHandlers()(DependencyInjection.cs:142), ahead of GlobalExceptionHandler. Note that aDbUpdateConcurrencyExceptionon an action carrying SupportsIfMatchAttribute never reaches this handler: that filter converts it to 412 and setsExceptionHandled = true(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:132-137). - Caveats / not-in-source: the class doc comment (
DbUpdateExceptionHandler.cs:12-13) still says "the inner exception message is included in the detail because it typically contains the database-level constraint name". The code does not do that: it writes the generic string at:37. Trust the code; the comment is stale.
OperationCanceledExceptionHandler
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/OperationCanceledExceptionHandler.cs:16· Level 0 · class (sealed)
- What it is: the handler that maps
OperationCanceledException(a client that hung up mid-request) to HTTP 499 Client Closed Request instead of letting it inflate the 500 rate. - Depends on:
IProblemDetailsService,ILogger<OperationCanceledExceptionHandler>; chain concept at DbUpdateExceptionHandler. - Concept:
[Rubric §13, Observability & Operability]assesses whether the signals operators alert on distinguish real faults from normal client behavior. 499 is a non-standard nginx-origin code (class comment,OperationCanceledExceptionHandler.cs:8-13) that monitoring stacks read as "the caller gave up", so cancellations stop looking like server errors on a dashboard. - Walkthrough:
TryHandleAsync(OperationCanceledExceptionHandler.cs:22) type-tests and returnsfalsefor anything else (:27-28), logs atLogWarningwith a client-disconnected message (:30), setsStatusCodes.Status499ClientClosedRequest(:32), and still builds a problem document titled"Operation Canceled Exception"(:33-43) which it writes throughTryWriteAsync(:45). Note thatStatus499ClientClosedRequestis a real constant on ASP.NET Core'sStatusCodes, so the non-standard code is spelled symbolically, not as a magic number. - Why it's built this way: it is registered first in the chain (
MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:140). That position matters: a cancellation propagating out of aCancellationTokenwould otherwise be claimed by nothing until GlobalExceptionHandler turned it into a 500. It is also why everycatchin the cache-touching middleware and limiters filtersOperationCanceledExceptionout explicitly rather than swallowing it (for exampleSoftDeletedUserMiddleware.cs:93andRedisFixedWindowRateLimiter.cs:147). - Where it's used:
AddCommonExceptionHandlers()(DependencyInjection.cs:140); it fires for request-abort propagation from any handler that honors itsCancellationToken. - Caveats / not-in-source: whether the 499 body actually reaches a disconnected client is not determinable from this file; the write is attempted unconditionally at
:45.
QueryFilterModelBinder
MMCA.Common.API ·
MMCA.Common.API.ModelBinders·MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24· Level 0 · class (sealed)
- What it is: a custom
IModelBinderthat parses the structured filter query string (?filters[Name].operator=contains&filters[Name].value=shirt) into aDictionary<string, (string Operator, string Value)>that list endpoints hand to the query layer. - Depends on:
Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderonly. 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 throughMaxFilters, 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 withStringComparer.OrdinalIgnoreCase(:42) so client capitalization does not matter.- It iterates
Request.Query.Keys(:46), skipping anything that failsIsFilterKey(:91-94, prefixfilters[plus suffix].operatoror].value), extracts the bracketed property name withGetFilterPropertyName(:101-109) and the suffix withGetFilterSuffix(: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). TheMaxFilterscheck 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) andTask.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:165for the paged read and:256for the CSV export).
RateLimitAlgorithm
MMCA.Common.API ·
MMCA.Common.API.RateLimiting·MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8· Level 0 · enum
- What it is: a two-value enum selecting the limiting algorithm used by the in-memory rate-limit partitions that
AddCommonRateLimitingregisters. - Depends on: nothing. Read by RateLimitingSettings
.Algorithmand 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 intoSegmentsPerWindowsegments 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 separateDistributedflag 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
.Algorithmdefaults it toFixedWindow(MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:53), andCreateLimitedPartitionbranches on it (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:181) to build either aSlidingWindowRateLimiterOptionspartition (:182-189) or aFixedWindowRateLimiterOptionsone (:192-198).
RedisRateLimitLease
MMCA.Common.API ·
MMCA.Common.API.RateLimiting·MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:169· Level 0 · class (internal sealed)
- What it is: the two-instance lease type RedisFixedWindowRateLimiter hands back: one shared
Acquiredlease and one sharedRejectedlease. - 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
RateLimitLeaseon every request and disposes it afterwards. A lease that carries metadata (a retry-after value, a permit count) has to be allocated per call. This one carries none, so the type declares exactly two immutable instances and returns whichever the decision calls for.[Rubric §12, Performance & Scalability]assesses allocation on a per-request hot path; the doc states the reasoning directly (RedisFixedWindowRateLimiter.cs:164-168): both leases are stateless and carry no metadata, so one shared instance of each serves every request rather than allocating a lease per call. - Walkthrough:
Acquired = new(isAcquired: true)(RedisFixedWindowRateLimiter.cs:172) andRejected = new(isAcquired: false)(:175) are the only two instances, built through a private expression-bodied constructor that sets the single property (:177).IsAcquiredis an override with a get-only auto property (:180).MetadataNamesreturns an empty collection expression (:183), andTryGetMetadataalways nulls its out parameter and returnsfalse(: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, alwaysAcquired) and fromAcquireAsyncCoreon all three of its outcomes: the oversized request (:126,Rejected), the counted decision (:145, either one), and the fail-open path after a Redis fault (:154,Acquired).
ValidationExceptionHandler
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ValidationExceptionHandler.cs:17· Level 0 · class (sealed)
- What it is: the handler that converts FluentValidation's
ValidationExceptioninto a 400 whose problem document carries anerrorsextension 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 atLogWarning(:31, notLogError: an invalid payload is a client mistake, not a system fault), sets 400 (:33), builds the problem document titled"Validation Exception"(:34-44), then groupsvalidationException.ErrorsbyPropertyNameinto aDictionary<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
ModelStateDictionaryserialization front ends already understand. Note the key is the rawPropertyNamefrom FluentValidation, not a camelCased alias, and thatExtensions.Add(:54) would throw on a duplicate key, which cannot happen here because the document was just constructed. - Where it's used: registered fourth (
MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:143), just before the catch-all; it is the exception path that complements the Result-based validation failures returned by ApiControllerBase.
DomainExceptionHandler
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DomainExceptionHandler.cs:16· Level 1 · class (sealed)
- What it is: the handler that translates a DomainException into a 400 Bad Request problem document whose detail is the domain message itself.
- Depends on: DomainException (
MMCA.Common.Shared.Exceptions, imported atDomainExceptionHandler.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 atLogWarning(DomainExceptionHandler.cs:30) rather thanLogError, and it is the one handler that puts the raw exception message into the client-facingDetail(:41). That is safe precisely because aDomainExceptionmessage is authored by the domain layer for humans, unlike an EF or infrastructure message. - Walkthrough:
TryHandleAsync(DomainExceptionHandler.cs:22) type-tests and returnsfalseotherwise (:27-28), logs the warning (:30), setsStatusCodes.Status400BadRequest(:32), and writes a problem document titled"Domain Exception"with the domain message as its detail (:33-45). - Why it's built this way: it sits second in the chain (
MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:141), ahead of the infrastructure handlers, so a business-rule violation never gets mislabeled as a database conflict or an internal error. Remember that the framework's primary path for business failures is the Result pattern; this handler covers the code that still throws. - Where it's used:
AddCommonExceptionHandlers()(DependencyInjection.cs:141).
GlobalExceptionHandler
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:23· Level 1 · class (sealed)
- What it is: the last handler in the
IExceptionHandlerchain. It claims anything the specific handlers declined and answers HTTP 500 as RFC 9457 problem details, with one exception mapped by type on the way: a CrossTenantWriteException is answered 400 instead. - Depends on:
IProblemDetailsServiceandILogger<GlobalExceptionHandler>(primary-constructor injected,GlobalExceptionHandler.cs:23-26), plus CrossTenantWriteException fromMMCA.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 returnsfalseon a type test.[Rubric §11, Security]covers the second half: the cross-tenant detail is deliberately free of everything the exception carries, because echoing a tenant id back tells an unauthorized caller which tenant owns the row it just tried to write (GlobalExceptionHandler.cs:31-36). - Walkthrough:
TryHandleAsync(GlobalExceptionHandler.cs:42) has two paths.CrossTenantWriteTitle(:29) andCrossTenantWriteDetail(:37-39) areinternal conststrings, so the tests assert against the same literals the handler writes.- The tenant-boundary path (
:47-65): aCrossTenantWriteExceptionis logged atLogWarning(:51), because the request was refused exactly as designed and a tenant-scoped API answering 400 to an untenanted write is routine rather than a server fault (:49-50). It setsStatus400BadRequest(:53) and returnsTryWriteAsyncwith the two constants (:54-64). - The catch-all path (
:67-80): logs"Unhandled exception occurred"with the exception atLogError(:67), setsStatusCodes.Status500InternalServerError(:69), and returns the result ofproblemDetailsService.TryWriteAsync(...)(:70-80) with the generic title"Internal Server Error"and a "please try again" detail (:77-78). The exception object is attached to theProblemDetailsContext(:73) so a configured customization can enrich the document (in development, for example) without this class deciding what to expose.
- Why it's built this way: the class doc explains why the cross-tenant mapping lives here rather than in a handler of its own (
GlobalExceptionHandler.cs:13-19):CrossTenantWriteExceptionderives fromInvalidOperationException, so nothing ahead of this handler claims it, while every other save-time invariant failure of that family still ends at the 500. ReturningTryWriteAsync's own boolean rather than a hard-codedtruekeeps the handler honest: if no problem-details writer can serve the request, the framework is told the exception was not fully handled instead of silently swallowing it. Registration order is the only thing that makes it the fallback, so it is registered last (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:144); its own doc says so (DependencyInjection.cs:131-132). - Where it's used:
AddCommonExceptionHandlers()(DependencyInjection.cs:144); every host that calls it inherits the same 500 contract and the same tenant-boundary 400.
RateLimitingSettings
MMCA.Common.API ·
MMCA.Common.API.RateLimiting·MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:21· Level 1 · class (sealed)
- What it is: the options object bound from the
RateLimitingconfiguration section. It carries the four permit counts, the queue depth, the algorithm choice, the sliding-window segment count, and the flag that moves the counter into Redis. - Depends on: RateLimitAlgorithm for the
Algorithmproperty;System.ComponentModel.DataAnnotationsfor the[Range]attributes (RateLimitingSettings.cs:1). Consumed by WebApplicationBuilderExtensions.AddCommonRateLimitingand, 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 parameterizedAddCommonRateLimitingoverload has always used, so the section is optional inappsettings.jsonand 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 apublic static readonly string, the binding key as a symbol rather than a literal repeated at the call site.- Five bounded,
init-only knobs:PermitLimit100 for the opt-in"FixedPolicy"limiter (:27-28,[Range(1, 1_000_000)]),QueueLimit2 for requests queued once"FixedPolicy"or"UserPolicy"saturate (:31-32,[Range(0, 10_000)]),PerUserPermitLimit30 for the opt-in"UserPolicy"limiter (:35-36),GlobalPermitLimit300 per authenticated user for the always-on global limiter (:39-40), andAuthIpPermitLimit30 per client IP for theauth-ippolicy that throttles anonymous authentication attempts (:46-47). Algorithm(:53) defaults toRateLimitAlgorithm.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 isSlidingWindow, and is ignored otherwise (:55-60).Distributed(:72, defaultfalse) 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 anIConnectionMultiplexerin the container, and when none is registered the limiters silently fall back to the in-memory behavior rather than failing startup; and theauth-ippolicy stays in memory either way, because per-account login protection already backs it.
- Why it's built this way: bundling the knobs into one bound object is what let
AddCommonRateLimitinggrow a configuration overload without changing the five-parameter one, and the "same defaults" rule means the two overloads are observationally identical for a host that configures nothing (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:297-298builds an instance from the old parameters). The fall-back-rather-than-fail choice onDistributedis deliberate: a rate limiter that refuses to start is a worse outage than one that limits per instance. TheAuthIpPermitLimitdefault of 30 is itself argued in the overload's parameter doc (WebApplicationBuilderExtensions.cs:291-295): Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP and a tighter cap would throttle real logins. - Where it's used: bound in
AddCommonRateLimiting(IConfiguration)withconfiguration.GetSection(RateLimitingSettings.SectionName).Get<RateLimitingSettings>() ?? new RateLimitingSettings()(WebApplicationBuilderExtensions.cs:319-320), then threaded through every partition selector (GlobalRateLimitPartitionat:79,UserPolicyRateLimitPartitionat:114,AuthIpRateLimitPartitionat:221) and intoCreateLimitedPartition(:148-199), which readsDistributed(:157),Algorithm(:180), andSegmentsPerWindow(:185).
RedisFixedWindowRateLimiter
MMCA.Common.API ·
MMCA.Common.API.RateLimiting·MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37· Level 1 · class (sealed, partial)
- What it is: a
RateLimiterthat 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, andILoggerwith 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.
- Layering (
RedisFixedWindowRateLimiter.cs:15-22): the limiter lives inMMCA.Common.APIrather 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 inMMCA.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. - Storage and precision (
:23-30): one key per partition per window,rl:{partitionKey}:{unixMinute}, incremented withINCRand 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]. - 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].
- Layering (
- Walkthrough
_lastLoggedFailureWindow(RedisFixedWindowRateLimiter.cs:44) is static and initialized to-1: a dead Redis produces one warning a minute for the whole process rather than one per request, and the doc explains that a fault is a property of the connection, which every partition shares (:39-43).- Instance fields hold the connection, partition key, permit limit, logger and clock (
:46-50), plus_lastUsedTimestampseeded fromStopwatch.GetTimestamp()(:52). - The constructor (
:68) guards all four required inputs (:75-78, includingThrowIfLessThan(permitLimit, 1)) and defaultstimeProvidertoTimeProvider.System(:84), which is the substitution point tests use to pin a window (:64-67). IdleDuration(:93-94) reportsStopwatch.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 owningPartitionedRateLimitertable with one entry per user seen since start-up.GetStatistics()(:101) returnsnullon 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 whenpermitCount > _permitLimit(:124-127), compute the window asGetUtcNow().ToUnixTimeSeconds() / 60(:129) and build the invariant-culture key (:130),StringIncrementAsync(:135), and, whencount <= 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) excludesOperationCanceledException, logs at most once per window viaInterlocked.Exchangeon the static field (:149-152), and returnsAcquired.
- Why it's built this way:
partialplus[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 exampleglobal:alice,:58-61) so two policies limiting the same user never share one counter; that scoping is applied inCreateLimitedPartitionas$"{redisScope}:{key}"(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:173). - Where it's used: constructed only inside
CreateLimitedPartition(WebApplicationBuilderExtensions.cs:171-173), and only whenallowDistributed && settings.Distributedis true (:157) and anIConnectionMultiplexerresolves (:163-165); the logger falls back toNullLoggerwhen the request has no service provider (:167-168, which is also whyRequestServicesis read through a nullable local,:159-162). When no multiplexer is registered the code falls through to the in-memory limiters rather than failing startup (:175-177). Theauth-ippolicy passesallowDistributed: false(WebApplicationBuilderExtensions.cs:235), so login throttling never depends on Redis.
ErrorHttpMapping
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14· Level 3 · class (internal static)
- What it is: the single table that maps domain ErrorType values to HTTP status codes, the ranking rule that picks one status for a whole failure, and the builder for the
errorsextension array that problem documents carry. - Depends on: Error, ErrorType, ErrorTypeSeverity, IErrorLocalizer;
System.Collections.Frozenand ASP.NETStatusCodes(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 inMMCA.Common.Shared(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:30) so the gRPC edge classifies the same aggregate identically (ErrorHttpMapping.cs:45-46, and seeMMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:117).[Rubric §27, i18n]applies through the localizer parameter. - Walkthrough
ErrorTypeToStatusCode(ErrorHttpMapping.cs:20-31): aFrozenDictionary<ErrorType, int>withValidationto 400,Invariantto 400,NotFoundto 404,Conflictto 409,Unauthorizedto 401,Forbiddento 403,UnprocessableEntityto 422,Failureto 400, andUnexpectedto 500.FrozenDictionaryis the right structure for a table built once at startup and read on every failed request: it trades slower construction for the fastest lookups (:16-19).GetStatusCode(ErrorType)(:37-38) usesGetValueOrDefault(..., Status400BadRequest), so a future error type falls back to 400 rather than throwing inside an error path.GetStatusCode(IReadOnlyList<Error>)(:50-51) is the overload callers actually use for a failed result: it delegates toErrorTypeSeverity.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 theerrorsarray either way (:40-49).BuildErrorsExtension(IReadOnlyList<Error>, IErrorLocalizer?)(:61-69) projects each error into an anonymous object withCode,Message,Type(stringified),SourceandTarget. OnlyMessageis localized, and only when a localizer was supplied (:65); a null localizer leaves the original English text.Code,Type,SourceandTargetstay verbatim so clients can branch on them regardless of culture (:53-60).
- Why it's built this way:
internalvisibility (: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 stableCode. - Where it's used: ApiControllerBase
.HandleFailurewhen converting a failedResultinto anObjectResult(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:48and:58), and UnhandledResultFailureFilter as the backstop (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:36and:47).
TenantResolutionMiddleware
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:36· Level 3 · class (sealed)
- What it is: middleware that resolves the current request's tenant (from a claim or a header), publishes it on the scoped ITenantContext, and rejects a request that has none when tenancy is required.
- Depends on: ITenantContext, TenancySettings (via
IOptions<T>), andIProblemDetailsService(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 withTenancy:RequireTenant(the default) an unresolved tenant is answered400 Bad Requestrather 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, sinceIOptions<TenancySettings>resolves to defaults withEnabledfalse and the middleware passes every request straight through (:15-20). - Walkthrough
UnresolvedTenantTitle(TenantResolutionMiddleware.cs:39): theinternal constproblem 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.EnabledorIsExcluded(...), callnextand return.IsExcluded(:89-94) matchessettings.EffectiveExcludedPathPrefixeswithStartsWithSegmentsunderOrdinalIgnoreCase, 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 withtenantContext.SetTenant(tenantId)(:70) before the pipeline continues. Resolve(:105-122) walkssettings.EffectiveResolutionOrderand switches on the strategy:Claimreadscontext.User?.FindFirst(settings.ClaimType)?.Value(:111),Headerreadssettings.HeaderName(:112), andHostdeliberately yieldsnull(: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): withRequireTenantfalse 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 readsHttpContext.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 withRequireImmediatelyBefore(Authentication, TenantResolution, ...)(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:265-268), so a host that reorders the pipeline fails at startup rather than misrouting tenants. - Where it's used: the
TenantResolutionstep of the default pipeline (MiddlewarePipelineBuilder.cs:113-119), which WebApplicationExtensions.UseCommonMiddlewarePipeline()applies. It is wired unconditionally and inert by default.
UnhandledResultFailureFilter
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:22· Level 4 · class (sealed, partial)
- What it is: a globally registered
IAlwaysRunResultFilterthat catches an action which returned a failed Result inside anOk(...)(or anyObjectResult) 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;
IAlwaysRunResultFilterruns for every result MVC is about to execute.[Rubric §9, API & Contract Design]assesses whether a domain failure can leak as200 OKwith 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 anObjectResultcarrying a failedResult. Note the pattern matches the baseResult, soResult<T>values are caught too. - Logs at
Warningwith 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, matchingApiControllerBase.HandleFailureexactly (: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
ProblemDetailstitled"Unhandled result failure"whose detail names the defect in plain words (:39-44). - Resolves IErrorLocalizer from
HttpContext.RequestServiceswithGetService(:46, so a host without localization simply passes null) and attaches the localizederrorsextension (:47). - Replaces
context.Resultwith anObjectResult(problemDetails) { StatusCode = statusCode }(:49). OnResultExecuted(:53-55) is intentionally empty: there is nothing to do after the response ran.
- The guard
- 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-constructorloggeris used without an allocation per call. The class ispartialfor that generator. - Where it's used: added as a global MVC filter inside
AddAPI(...)(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:49), so it applies to every controller action in every host.
CorrelationIdMiddleware
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:15· Level 9 · class (sealed)
- What it is: convention-based middleware that establishes one correlation ID per request, publishes it on the scoped ICorrelationContext, and echoes it back to the caller in the
X-Correlation-IDresponse header. - Depends on: ICorrelationContext (injected per invoke, so it is resolved from the request scope) and
System.Diagnostics.Activity(CorrelationIdMiddleware.cs:1-3). - Concept: distributed trace correlation is introduced at ICorrelationContext; this is the type that populates it.
[Rubric §13, Observability & Operability]assesses whether one logical operation can be reconstructed across log entries and services: with this middleware second in the pipeline, every later log line, outbox row and downstream call can carry the same ID. - Walkthrough
HeaderName = "X-Correlation-ID"(CorrelationIdMiddleware.cs:18) is a publicconst, so clients, tests and downstream code all name the header from one place.InvokeAsync(HttpContext, ICorrelationContext)(:27): the second parameter is per-invoke injected, which is how convention-based middleware consumes a scoped service from a singleton middleware instance. Both arguments are null-guarded (:29-30).- The resolution waterfall (
:32-34): the inboundX-Correlation-IDheader wins, elseActivity.Current?.TraceId(the W3C trace ID that OpenTelemetry propagates), else ASP.NET'sHttpContext.TraceIdentifier. A caller-supplied ID is therefore honored, and the ID always exists. correlationContext.SetCorrelationId(correlationId)(:36) publishes it, thencontext.Response.OnStarting(...)(:37-41) registers a callback that writes the response header. Writing it inOnStartingrather than afterawait next(...)is the only safe way: once the response has begun, header writes throw.await next(context)(:43) continues the pipeline.
- Why it's built this way: accepting the client's ID makes cross-system correlation possible when the caller already has a trace, and falling back to
Activity.Currentmeans the correlation ID and the OpenTelemetry trace ID are the same value in a normally instrumented host. - Where it's used: it is the second step of the default pipeline, right after the exception handler (
MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:39-41, followingExceptionHandlerat:34-36), so everything downstream (including the exception handlers above) logs under a known ID. The step is namedMiddlewarePipelineStepNames.CorrelationId(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:19), which is the handle a host uses to insert its own middleware around it. The CQRS logging decorators read the same context.
SoftDeletedUserMiddleware
MMCA.Common.API ·
MMCA.Common.API.Middleware·MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31· Level 9 · class (sealed, partial)
- What it is: middleware that rejects an authenticated request with 401 when the calling user has been soft-deleted (BR-133), using a 30-second cached marker so the check does not cost a database round trip on every request.
- Depends on: ICurrentUserService, ICacheService, ISoftDeletedUserValidator (resolved lazily), and SoftDeletedUserCache for the key and the marker duration (
SoftDeletedUserMiddleware.cs:57-61). - Concept introduced: a fail-open security check, and why.
[Rubric §11, Security]assesses whether revoked principals stop being served, and[Rubric §29, Resilience & Business Continuity]assesses how a dependency outage degrades. The class remarks (SoftDeletedUserMiddleware.cs:16-30) document the trade-off explicitly: this check runs on the hot path of every authenticated request, so failing closed would turn any cache or database blip into a total outage. Failing open is bounded instead, because access tokens live 15 minutes and the deletion already revoked the refresh token, so no new access token can be minted. The residual exposure is one already-issued token's remaining lifetime, and only while a dependency is unhealthy.[Rubric §12, Performance & Scalability]covers the cache: the marker lives 30 seconds (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29), long enough to absorb bursts and short enough that the validator query is authoritative again quickly. - Walkthrough:
InvokeAsync(HttpContext, ICurrentUserService, ICacheService, ILogger<SoftDeletedUserMiddleware>)(SoftDeletedUserMiddleware.cs:57-61), per-invoke injected including the logger (the reason is stated at:52-55):- Anonymous fast path (
:65-73): noUserId, callnextand return. The comment notes this is the common case in extracted services, which see internal gRPC/HTTP traffic with no user. - Validator resolution (
:75-83):context.RequestServices.GetService<ISoftDeletedUserValidator>(). The validator is implemented by the Identity module, so a service that does not host Identity has none registered; lazy resolution lets the middleware no-op there instead of 500-ing every request (remarks at:42-51). This is the "wired unconditionally, inert when not applicable" convention that TenantResolutionMiddleware later copied by name (TenantResolutionMiddleware.cs:16). - Cache read (
:85-100): key fromSoftDeletedUserCache.KeyFor(userId.Value)(:85). A cache exception that is not anOperationCanceledExceptionis logged and treated as a miss (:93-99), andcacheReachablegoes false so the write is skipped too. cachedResult is true(:102-106): the user is known deleted, respond 401 and stop the pipeline.- Cache miss (
:108-148): queryIsUserSoftDeletedAsync(:114-116). If that throws (again excluding cancellation) the request is allowed through with a warning (:118-125), which is the fail-open path. Otherwise the answer is written back to the cache when the cache is reachable, usingSoftDeletedUserCache.MarkerDuration(:127-141, a failed write only costs the next request a lookup), and a deleted user gets 401 (:143-147). - Otherwise
nextruns (:150). - Three
[LoggerMessage]warnings (:153-175) name the failure precisely: cache read, cache write, validator, each stating what the request did next.
- Anonymous fast path (
- Why it's built this way: every
catchfilters outOperationCanceledException(:93,:118,:135), so a client disconnect is never misread as a dependency outage and is left to OperationCanceledExceptionHandler. Returning a bare 401 status with no body (:104,:145) is deliberate: a revoked principal gets no detail to work with. - Where it's used: the
SoftDeletedUserFilterstep of the default pipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:129-131), placed afterRateLimiting(:120-126) and beforeAuthorization(:132-134), so it runs only once a principal exists and before any policy grants access. The step name isMiddlewarePipelineStepNames.SoftDeletedUserFilter(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:58).
ErrorResources
MMCA.Common.API ·
MMCA.Common.API.Resources·MMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9· Level 0 · class (sealed)
- What it is: an empty marker class whose only job is to be a
typeof(...)anchor for the framework's own translation files,ErrorResources.resxandErrorResources.es.resx, which sit beside it inResources/. It has no members:public sealed class ErrorResources;atMMCA.Common/Source/Presentation/MMCA.Common.API/Resources/ErrorResources.cs:9is the entire declaration, written in the body-less semicolon form. - Depends on: nothing, first-party or otherwise. It has no
usingdirectives and no base type. Dependencies point the other way: ErrorResourceSource wraps theIStringLocalizerbuilt 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.resxneeds 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 errorCode("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 theCodea 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 assemblyMMCA.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.
- Registration.
AddErrorLocalization()callsservices.AddLocalization(), registersErrorLocalizerbehindIErrorLocalizerwithTryAddSingleton, and then contributes the framework's own set withservices.AddErrorResources<ErrorResources>()(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:109-111).AddErrorLocalization()is called automatically fromAddAPI(DependencyInjection.cs:96), so no host has to opt in. - Source construction. The generic
AddErrorResources<TResource>()registers a singleton factory that resolvesIStringLocalizerFactory, callsCreate(typeof(TResource)), and wraps the result in an ErrorResourceSource (DependencyInjection.cs:122-126).ErrorResourcesis just the firstTResource; each module passes its own anchor, which is why translations are additive rather than an edit to a framework file. - 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 whoseResourceNotFoundis 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.
- Registration.
- Why it's built this way: see ADR-027 (multi-locale i18n). Localizing at the HTTP edge rather than inside the domain keeps
Error.Codeand 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 oneAddErrorResources<T>()call instead of a merge into shared framework resources. - Where it's used: registered as the framework's own source at
MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111. The text it supplies reaches clients through three call sites that resolveIErrorLocalizerfrom 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.Codehas an entry.
AppAssociationOptions
MMCA.Common.API ·
MMCA.Common.API.Startup.Endpoints·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationOptions.cs:9· Level 0 · class (sealed)
- What it is: the strongly-typed options bag carrying the identifiers a mobile OS needs in order to verify that an installed native app may claim this host's https links. It feeds AppAssociationEndpointExtensions, which serializes it into the two well-known documents (Android Digital Asset Links and the Apple App Site Association).
- Depends on: nothing first-party. BCL only (
IReadOnlyList<string>). - Concept introduced (deep-link / universal-link association). For a Blazor Hybrid app to open a shared web URL directly in the installed native app rather than the browser, the operating system fetches an association document from the URL's host and checks that the installed app's signing identity matches what the document names. This options type is the single source of those identities, so a certificate rotation is a config change and not a code change (the doc comment says exactly that at
AppAssociationOptions.cs:3-8).[Rubric §9, API & Contract Design]assesses whether public contracts are typed and pinned rather than hand-built inline; here the "public contract" is the exact JSON payload Google and Apple parse, and binding its inputs from anAppAssociationconfiguration 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 inassetlinks.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): theTeamID.BundleIDvalue used by both thewebcredentialsand theapplinkssections.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 initgives 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
AppAssociationconfiguration section (with in-code fallbacks) and the applinks patterns hard-coded to the app's routes (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:180-191). The comment there (:180-183) records a trap worth reading: the Release Android head overridesApplicationIdtoivanball.AtlDevCon, so that is the package Digital Asset Links must name, not the Debug-only id. - Caveats / not-in-source: the type performs no validation. Whether a fingerprint or bundle id is the correct one for the shipped app is only observable at install time on the device.
JwtAuthorityExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup.Auth·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/JwtAuthorityExtensions.cs:15· Level 0 · class (static, extension block)
- What it is: a two-member helper that names the one configuration key carrying a service host's JWT/JWKS authority, and reads it with a fail-fast guard. Every extracted service that is not the token issuer calls it immediately before
AddForwardedJwtBearer. - Depends on:
Microsoft.Extensions.Configuration.IConfigurationonly. Its consumer isAddForwardedJwtBeareron 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 hostProgram.csfiles 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'sWithJwksDiscovery(identityService)sets, and that the Azure deployment template sets in production (:17-20).GetRequiredJwtAuthority()(:35), anextension(IConfiguration configuration)member. It null-guards the configuration itself (:37), then reads the key and throws anInvalidOperationExceptionnaming the key and the fix ("Wire .WithJwksDiscovery(identityService) in the AppHost.") when it is absent (:39-42).
- Why it's built this way: the guard is deliberately a null check, not a blank check. A configured-but-empty authority passes through unchanged and is rejected one line later by
AddForwardedJwtBearer's ownArgumentException.ThrowIfNullOrWhiteSpace(WebApplicationBuilderExtensions.cs:453), so the hoist stayed behavior-identical to the five inline guards it replaced; the test that pins this says so directly (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/Auth/JwtAuthorityExtensionsTests.cs:40-45). The key itself is pinned by a second test whose failure message spells out the coupling:WithJwksDiscoverysetsAuthentication__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
AddForwardedJwtBearercall site. ADC: Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:275), Engagement (:175of its ownProgram.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 throughAddCommonAuthenticationinstead (ADR-004). - Caveats / not-in-source: what value the AppHost or the Azure template actually injects is deployment configuration, not code. This type only guarantees that something was injected.
MiddlewarePipelineStep
MMCA.Common.API ·
MMCA.Common.API.Startup.Pipeline·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStep.cs:21· Level 0 · record (sealed, positional)
- What it is: one named step of the shared HTTP edge pipeline: a stable string identifier plus the delegate that registers that step's middleware on a
WebApplication. It is the atom that makes the edge order data rather than a hand-written sequence ofapp.UseX()calls. - Depends on: ASP.NET Core's
WebApplication(through theAction<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 (StepNameson 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 untilUseCommonMiddlewarePipelineruns 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 untilConfigureis 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 withValidated(Name), which callsArgumentException.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 secondValidatedoverload, which null-guards the delegate (:38-42).- The
initaccessors 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:
Configureis invoked exactly once, in pipeline order, at the pointUseCommonMiddlewarePipelineis called, so anything the delegate reads from the host (configuration, environment) is evaluated at configure time and not per request (:16-20).
- Why it's built this way: the validation-in-the-property-initializer idiom is how a positional record enforces invariants without giving up the concise declaration or the value semantics. Value equality also matters here: two steps with the same name and delegate compare equal, which keeps assertions in MiddlewarePipelineBuilder tests simple. See ADR-079, which records the move from an inline sequence to named steps.
- Where it's used: MiddlewarePipelineBuilder
.CreateDefault()constructs eighteen of them (MiddlewarePipelineBuilder.cs:32-157), and a host customizing the pipeline constructs its own to pass toInsertBefore/InsertAfter/Replace. - Caveats / not-in-source: name uniqueness is not enforced here; it is enforced by the builder's
RequireUniqueNameat insertion time (MiddlewarePipelineBuilder.cs:305-313). Constructing two steps with the same name in isolation is legal.
MiddlewarePipelineStepNames
MMCA.Common.API ·
MMCA.Common.API.Startup.Pipeline·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineStepNames.cs:14· Level 0 · class (static, constants)
- What it is: the eighteen well-known step names of the default edge pipeline, as
const stringfields 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 aconstis 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) andForwardedHeaders(:32). The comment on the first (:25-28) states the adjacency: it must run immediately beforeForwardedHeaders, 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 afterAuthenticationbecause the claim strategy readsHttpContext.User.RateLimiting(:56), documented as needing to run afterAuthenticationper ADR-019.SoftDeletedUserFilter(:59),Authorization(:62),OutputCache(:65).JwksEndpoint(:68),OidcDiscoveryEndpoint(:71), andControllers(: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:
constrather thanstatic readonlyso the values are usable in attribute arguments andswitchpatterns, 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; andMiddlewarePipelineOrderTestsBase.ExpectedStepNames(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:38-58) lists all eighteen as the frozen expected order, which each app'sMiddlewarePipelineOrderTestssubclasses.
OpenApiEndpointExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup.Endpoints·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OpenApiEndpointExtensions.cs:22· Level 0 · class (static, extension block)
- What it is: two
extension(WebApplication app)mapping helpers that expose the generated OpenAPI document and an optional interactive reference UI, both outside Production only. - Depends on:
Scalar.AspNetCore(NuGet) for the reference UI andAsp.Versioning'sWithDocumentPerVersion()convention. It pairs withAddCommonOpenApi()on WebApplicationBuilderExtensions, which registers the generator. - Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface).
[Rubric §9, API & Contract Design]assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (OpenApiEndpointExtensions.cs:7-21) is explicit that the guarding happens at two levels: the framework-owned part of the generated document (the versioned naming convention, the unbound-route-token backfill, the generatedProblemDetailserror schema) is diffed against a committed baseline in-repo byOpenApiBaselineTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs), which fails on any change until the baseline is regenerated deliberately in the same pull request, while each consumer's concrete API surface stays the concern of the contract-snapshot tests in that host's integration tier. Mapping outside Production is the security posture[Rubric §11, Security]: these are internal services reached through the Gateway, which does not route the endpoint. - Walkthrough
MapCommonOpenApi()(OpenApiEndpointExtensions.cs:34) calls the built-inMapOpenApi()only when!app.Environment.IsProduction()(:36-39) and chains.WithDocumentPerVersion(), which applies the API-versioning convention so the route resolves one document per discovered API version (/openapi/v1.jsonfor v1.0, doc comment:26-33). It is a no-op in Production and returnsappfor chaining (:41).MapCommonScalarUi()(OpenApiEndpointExtensions.cs:52) is the opt-in developer convenience: it callsMapScalarApiReference()outside Production (:54-57), rendering/scalar/{documentName}. Assets ship inside theScalar.AspNetCorepackage rather than a CDN (:49-50), so it works offline and in CI.
- Why it's built this way: one shared pair of helpers keeps every service's OpenAPI story identical and enforces the "internal spec, not public surface" convention in one place instead of per host. The version-aware document mapping is what keeps the route stable as versions accumulate (ADR-046).
- Where it's used: inside this workspace the only caller is the framework's own probe host,
OpenApiProbeHost(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:40registers,:59maps), which is whatOpenApiBaselineTestsand the ApiParameterDescriptorBackfillProvider tests boot. The ADC and Store service hosts today call the stock ASP.NET pair directly instead. This is the framework offering a convention ahead of the consumers adopting it;OpenApiContractTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/OpenApiContractTestsBase.cs:22) is the base a consumer subclasses once it does.
IBaseDTO<TIdentifierType>
MMCA.Common.Shared ·
MMCA.Common.Shared.DTOs·MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9· Level 0 · interface
- What it is: a one-property marker interface. Every DTO that carries an entity identifier exposes
TIdentifierType Id { get; init; }(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:13). - Depends on: nothing first-party, and nothing external beyond the generic constraint. It lives in
MMCA.Common.Shared, the bottom layer, so a Blazor WebAssembly client and an EF-backed service can both reference it. - Concept introduced (the DTO and the marker/role interface).
[Rubric §9, API & Contract Design]assesses DTOs decoupled from domain entities and stable wire contracts: a DTO (Data Transfer Object) is the shape that crosses the wire, deliberately separate from the domain entity.IBaseDTOlets generic machinery treat any DTO uniformly through itsId, 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
TIdentifierTypewith awhere TIdentifierType : notnullconstraint (IBaseDTO.cs:10); the singleIdisget; init;(IBaseDTO.cs:13), settable at construction and immutable after. Theinit-not-setchoice 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 aGuid-keyed aggregate and anint-keyed one share the same generic pipeline; thenotnullconstraint forbidsIdbeing 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> atMMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:44), and the UI's EntityServiceBase<TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:48). Implemented directly by BaseLookup<TIdentifierType> and by every module DTO in ADC, Store, and Helpdesk (for exampleMMCA.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 responseETag, and the client echoes it back in theIf-Matchheader 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'srowversionis a token the database changes on every update of a row. A read DTO carries the currentRowVersionso 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 toETagtoIf-MatchtoSetOriginalRowVersion.[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 theIf-Matchheader's whole content, so it is never optional. A DTO read from a persisted aggregate always has one (AuditableBaseEntity.RowVersionis non-null), and a write that states no precondition is refused with428 Precondition Requiredrather 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")]onIConcurrencyAware.cs:18: exposing abyte[]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
Sharedmeans 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) andLivePollDTO(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollDTO.cs:8), Store'sOrderDTO(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Orders/OrderDTO.cs:9) andInventoryItemDTO(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Inventory/InventoryItemDTO.cs:9), and Helpdesk'sTicketDTO(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/Tickets/TicketDTO.cs:10). On the server, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETagtests a returned DTO for this interface and writes theETagresponse header (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:471-479, called from:436). On the client, EntityServiceBase<TEntityDTO, TIdentifierType>.ConcurrencyTagOfpattern-matches the same interface to build the outgoingIf-Matchvalue, treating an empty array as no token (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:197-201). - Caveats / not-in-source: the "update requests carry no token" half is a convention this interface cannot enforce by itself; it is pinned by the shipped fitness rule
UpdateRequests_ShouldNotImplement_IConcurrencyAware(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/ConcurrencyConventionTestsBase.cs:14), which each consumer app subclasses.
ICorrelationContext
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8· Level 0 · interface
- What it is: the Application-layer abstraction for "the correlation ID of the work currently in flight": one reader and one writer, resolved per scope.
- Depends on: nothing first-party, BCL
stringonly. 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 forHttpContext, 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 theX-Correlation-IDheader or generated, then carried through the handler pipeline in structured-logging scopes. - Why it's built this way: a two-member interface is small enough that a test or a background host can supply its own holder, and putting the setter here (rather than on the concrete class) keeps middleware depending on the abstraction. ITenantContext is deliberately modelled on it, and says so: one scoped instance per request, populated once at the edge (
MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:11). - Where it's used: registered as
TryAddScoped<ICorrelationContext, CorrelationContext>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:547). Populated by CorrelationIdMiddleware, which takes it as a method-injected parameter ofInvokeAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:27). Consumed by LoggingCommandDecorator<TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:18) and LoggingQueryDecorator<TQuery, TResult> (.../LoggingQueryDecorator.cs:16), which wrap the ID into their log scope for the duration of the pipeline. - Caveats / not-in-source: not every component can use it. The audit-trail interceptor is a singleton and records the ambient
Activitytrace id instead of this scoped value, and says so in its own comment (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:89).
JwtForwardingDelegatingHandler
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Http·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17· Level 0 · class (sealed)
- What it is: an HTTP
DelegatingHandlerthat copies the inboundAuthorizationheader from the currentHttpContextonto 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 ofHttpClient, one concern per handler. - Walkthrough
SendAsync(:22) guards the request (:24), then returns straight tobase.SendAsyncin two cases. First, whenrequest.Headers.Authorizationis 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 structuredAuthenticationHeaderValue(BearerScheme, token)(:45) rather than as a raw string, so the outgoing header is always well-formedBearer <token>with the constant scheme from:19.
- Why it's built this way: the no-op-without-
HttpContextbehavior (:13-14) is what makes the handler safe to attach unconditionally: background work uses its own credentials and is not accidentally given the last request's token. Slicing and re-forming the scheme instead of copying the string verbatim means a malformed or scheme-less inbound value still leaves the client with a valid header. - Where it's used: wired by the framework's typed-service-client helper,
AddTypedServiceClient<TInterface, TImplementation>(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:833), which callsAddHttpContextAccessor()andTryAddTransient<JwtForwardingDelegatingHandler>()(:825-826) and then chains.AddHttpMessageHandler<JwtForwardingDelegatingHandler>()onto the typedHttpClient(:829-831) alongside Aspire service discovery and the standard resilience handler (:834). - Caveats / not-in-source: no shipped app calls
AddTypedServiceClienttoday. 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 atMMCA.Common.Grpc.AddTypedGrpcClientas the preferred service-to-service path (DependencyInjection.cs:822-826), which is the path the cross-service calls in ADC and Store actually take. This handler is therefore live framework code on a currently unused registration path.
AppAssociationEndpointExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup.Endpoints·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/AppAssociationEndpointExtensions.cs:15· Level 1 · class (static, extension block)
- What it is: a mapping helper that serves the two well-known app-association documents from an AppAssociationOptions: Android Digital Asset Links at
/.well-known/assetlinks.jsonand the Apple App Site Association at/.well-known/apple-app-site-association. - Depends on: AppAssociationOptions (Level 0) for every value; ASP.NET
IEndpointRouteBuilderandResults.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) andAppleAppSiteAssociationPath(: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 twoGETs that each returnResults.Json(...), are.AllowAnonymous()and are.ExcludeFromDescription()so they never leak into the OpenAPI document (:42-48).BuildAssetLinks(:54) emits thedelegate_permission/common.handle_all_urlsrelation with the Android package name and the fingerprint list (:56-65).BuildAppleAppSiteAssociation(:68) emits theapplinksdetails block, projecting each configured URL pattern into a{ "/": pattern }component (:78-80), plus thewebcredentialsapps list naming the same app id (:84-87).
- Two path constants:
- Why it's built this way: building the payload once at map time avoids a per-request allocation for a document that never changes
[Rubric §12, Performance & Scalability], and holding the RFC 8615 well-known paths as public constants keeps them from drifting between hosts or between the endpoint and any gateway forwarding rule. - Where it's used: the ADC Blazor web host maps them once at startup (
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:181), which is the host that ships a companion MAUI Hybrid app. Both documents' exact shapes are asserted byAppAssociationEndpointTests(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 activeJsonWebKeySetof 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.JsonWebKeySetandSystem.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
DefaultJwksPathconstant (JwksEndpointExtensions.cs:20) pins the RFC 8615 path.MapJwksEndpoint()(:31) maps a singleGETwhose handler takesHttpContextandIJwksProvideras parameters (:33), callsGetJsonWebKeySet()(:35), serializes withJsonSerializer(:36), setsapplication/json; charset=utf-8explicitly (: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
JwksEndpointstep is unconditional in the default pipeline,MiddlewarePipelineBuilder.cs:141-148); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for/.well-known/*covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (WebApplicationBuilderExtensions.cs:65). - Where it's used: applied as the
JwksEndpointstep of the default pipeline seeded by MiddlewarePipelineBuilder (MiddlewarePipelineBuilder.cs:148), so every host that adopts the shared pipeline serves it; the path it owns is thejwks_urivalue that OidcDiscoveryEndpointExtensions advertises, which is in turn whatAddForwardedJwtBearer(on WebApplicationBuilderExtensions) reaches through OIDC discovery. Its behavior is covered byJwksEndpointTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/JwksEndpointTests.cs).
BaseLookup<TIdentifierType>
MMCA.Common.Shared ·
MMCA.Common.Shared.DTOs·MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/BaseLookup.cs:8· Level 1 · record class
- What it is: a minimal DTO for dropdown and autocomplete lookups: just
IdandName. - 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 returnsBaseLookup<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. BothId(BaseLookup.cs:12) andName(BaseLookup.cs:15) arerequired, so hand-written construction is compile-checked, and record equality gives value semantics for free. - Walkthrough: the type itself is four lines, but its interesting half lives in the repository that projects into it. EFReadRepository<TEntity, TIdentifierType>
.GetAllForLookupAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:217) takes the name of the display property and lets the database do the shaping. The selector is built once per (entity type, property name) pair into a cache (:247-248), bindingIdand the named property into aBaseLookup<TIdentifierType>viaExpression.MemberInit(:262-268). Note the consequence forrequired: an expression-treeMemberInitconstructs the record without the compiler's required-member check, which is legal becauserequiredis a compile-time contract, not a runtime one. Above the repository the shape travels as a CollectionResult<T> through EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.GetAllForLookupAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:374-389). - Why it's built this way: one shared lookup shape means the UI's generic select components bind to a single type regardless of which entity fills them, and projecting inside the SQL query (rather than materializing entities and mapping) keeps a lookup list cheap
[Rubric §12, Performance & Scalability]; caching the compiled expression per property name keeps the reflection cost to the first call. - Where it's used: returned by the lookup path at every layer: the read repository (
EFReadRepository.cs:217), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, the controller base (EntityControllerBase.cs:374), and the UI's IEntityService<TEntityDTO, TIdentifierType>.
InsecureJwtMetadataWarningStartupFilter
MMCA.Common.API ·
MMCA.Common.API.Startup.Auth·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Auth/InsecureJwtMetadataWarningStartupFilter.cs:15· Level 2 · class (internal, sealed, partial)
- What it is: a one-purpose
IStartupFilterthat writes a single warning at host startup whenAddForwardedJwtBearerresolvedRequireHttpsMetadatatofalseoutside 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.RequireHttpsMetadataConfigKeyfor the key name it echoes. - Concept introduced (
IStartupFilteras a "log after the logging providers exist" hook). Registration-time code runs while theIServiceCollectionis still being built, so the logging providers are not configured yet and anything written there is dropped.IStartupFilter.Configureruns 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 returnsnextunchanged (: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 atLogLevel.Warning(:26-28). The message names the config key that produced the value and tells the operator what makes it safe (an internal-ingress cleartext h2c authority) and what to do (record that justification beside the setting in the deployment template).
- Why it's built this way: the filter is registered through
TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, ...>)(WebApplicationBuilderExtensions.cs:464-465), which de-duplicates on implementation type, so a host that callsAddForwardedJwtBearermore than once still gets exactly one warning. Registration is itself conditional (:461): the filter is only added when the resolved value isfalseand the environment is not Development, so a developer's normal loop stays quiet. Source-generated logging avoids boxing and string formatting on a path that runs once, which is the framework's convention rather than a hot-path optimization here. - Where it's used: registered only from
AddForwardedJwtBearer(WebApplicationBuilderExtensions.cs:462-466). Its registration conditions are asserted byForwardedJwtBearerSecurityTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs). In the deployed apps the ADC service hosts document the override explicitly: Azure setsAuthentication:JwtBearer:RequireHttpsMetadatatofalsebecause the authority is the internal-ingress h2c URL (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:270-273), which is precisely the case this filter exists to annotate. - Caveats / not-in-source: nothing in the framework fails a build or a deployment on this warning. Whether an operator acts on it is a process concern, and there is no ADR for this decision in
Website/docs-src/adr/at the time of writing.
OidcDiscoveryEndpointExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup.Endpoints·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Endpoints/OidcDiscoveryEndpointExtensions.cs:22· Level 2 · class (static, extension block)
- What it is: maps a minimal OpenID Connect discovery document at
/.well-known/openid-configuration. It returns just enough for token validation (theissuerandjwks_urifields, 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 returns404. - Depends on: JwksEndpointExtensions
.DefaultJwksPathto compose thejwks_uri(OidcDiscoveryEndpointExtensions.cs:76);IConfigurationforJwt:Issuer;System.Text.Json. - Concept (OIDC discovery as the bootstrap for JWKS-based validation).
[Rubric §7, Microservices Readiness]and[Rubric §11, Security]:AddForwardedJwtBearersets anAuthority, and the JWT bearer middleware fetches{authority}/.well-known/openid-configurationto learn the issuer and the JWKS URL. This endpoint answers that fetch, which is the other half of ADR-004 alongside JwksEndpointExtensions. - Walkthrough
DefaultOidcDiscoveryPathconstant (OidcDiscoveryEndpointExtensions.cs:27).- Three static arrays (
token,public,RS256,:32-34) and anOidcJsonOptionswithPropertyNamingPolicy = null(:43-46). Disabling the naming policy is load-bearing: the field names are already OIDC snake_case per RFC 8414, and camelCasingjwks_uritojwksUriwould leaveOpenIdConnectConfigurationRetrieverunable 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 aGETthat is.AllowAnonymous()(:86) and readsJwt:Issuer(:62); a blank issuer returnsResults.NotFound()(:63-66), safe because no downstream points its authority at a non-Identity host. Otherwise it derivesjwks_urifrom 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-75documents the subtle reasonjwks_uriis built from the configured issuer and not from the request. Aspire/DCP fronts the Identity service on per-launchSettings ports and rewritesHostviaX-Forwarded-Hostto canonical ports that internal callers cannot always reach, so reusing the issuer keeps issuer andjwks_uriorigin-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
OidcDiscoveryEndpointstep of the default pipeline (MiddlewarePipelineBuilder.cs:150-152); consumed by the bearer middleware thatAddForwardedJwtBearerconfigures on WebApplicationBuilderExtensions, which deliberately leavesValidIssuerunset so the issuer comes from this document (WebApplicationBuilderExtensions.cs:489-494). Covered byOidcDiscoveryEndpointTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/OidcDiscoveryEndpointTests.cs). - Caveats / not-in-source: the pre-forwarded scheme and host captured into
HttpContext.Itemsby thePreForwardedCapturestep exist for exactly this endpoint's benefit (WebApplicationExtensions.cs:18-35), but the current handler composesjwks_urifrom the configured issuer only, so those items are not read on this path today.
MiddlewarePipelineBuilder
MMCA.Common.API ·
MMCA.Common.API.Startup.Pipeline·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:16· Level 10 · class (sealed)
- What it is: the mutable, ordered list of MiddlewarePipelineStep values behind
UseCommonMiddlewarePipeline.CreateDefault()seeds the framework's eighteen-step edge pipeline; a host may then insert, replace, or remove steps by name; andBuild()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
UseCommonRequestLocalizationand the two pre-forwardedHttpContext.Itemskeys; 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 unreachablejwks_uri, a tenant that never resolves, a per-user rate cap that never engages) all look like configuration bugs at runtime, so converting them into a startup exception with a named cause is a large operability win. See ADR-079. - Walkthrough
- One field,
_steps(MiddlewarePipelineBuilder.cs:18), and a private constructor (:19), so the only way in isCreateDefault().StepNames(:24) projects the current names in application order, which is what the fitness function asserts on and what error messages print. CreateDefault()(:31-156) seeds the eighteen steps. Reading it top to bottom is the fastest way to learn the edge: exception handler (:34-36), correlation id (:38-40), request localization (:42-47, with the ADR-027 note that this runs early so edge error localization uses the caller's culture), the pre-forwarded scheme and host capture (:49-62), forwarded headers withKnownProxiesandKnownIPNetworkscleared for cloud reverse proxies (:64-80), an HTTPS redirect wrapped inUseWhenthat skipsapplication/grpcso h2c gRPC calls are not 307-redirected (:82-92), response compression (:94-96), routing (:98-100), CORS choosing the development or production policy by environment (:102-106), authentication (:108-110), tenant resolution (:112-118), the rate limiter (:120-126), the soft-deleted-user filter (:128-130), authorization (:132-134), output cache (:136-138), the always-mapped JWKS (:140-147) and OIDC discovery (:149-151) endpoints, and finallyMapControllers()(:153-155). EachConfiguredelegate isstatic, so no closure is allocated per step.- Four mutators, all returning
thisfor chaining and all validating first:InsertBefore(:166),InsertAfter(:183),Replace(:203) andRemove(:224).Replacekeeps the replaced step's position and permits a different name, but rejects a name another step already carries (:208-214). Build()(:257) runs the four checks and returns a defensive copy (:279). Two adjacency checks:PreForwardedCaptureimmediately beforeForwardedHeaders(:259-262) andAuthenticationimmediately beforeTenantResolution(:264-267). Two precedence checks:AuthenticationbeforeRateLimiting(:269-272, ADR-019) andForwardedHeadersbeforeHttpsRedirection(:274-277). Every check carries its rationale string, which is what the exception message prints.- The private helpers hold the guard semantics.
RequireIndexOf(:285) rejects a blank name and, for an unknown one, throws listing every known step (:296-299), which turns a typo into a self-answering error.RequireUniqueName(:304) enforces name uniqueness across the list.RequireImmediatelyBefore(:314) andRequirePrecedes(:329) share one subtle rule: an invariant binds only when both of its steps are still present (:320,:335), so a host that removes a whole capability (both members of a pair) stays legal, while a host that removes only one half is not constrained by a rule that no longer has anything to say.
- One field,
- 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 noWebApplicationbuilt at all, which is what puts the fitness function in the fast unit tier. - Where it's used: only through WebApplicationExtensions
.ApplyPipeline(WebApplicationExtensions.cs:140-151), which bothUseCommonMiddlewarePipelineoverloads 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;MiddlewarePipelineBuilderTestscovers the mutators and the invariants directly. - Caveats / not-in-source: the builder validates order, not semantics. A
Replacethat keeps a step's name but swaps in an unrelated middleware passes every check, because nothing inspects theConfiguredelegate.
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>.resxsibling 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, orAccept-Languagepreference 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 ofAllso the translation-completeness fitness gate does not demand a.qps-Ploc.resxsibling (: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 againstAll;IsPseudoLocale(string?)(SupportedCultures.cs:76-77) tests case-insensitively againstPseudoLocale. 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 returnsDefault(:53-56), an exact case-insensitiveAllmatch wins (:58-62), otherwise the language subtag is matched, so"es-MX"resolves to"es"(:64-66), and anything left over falls toDefault(:68). The subtag split is done by the privateLanguageOf(:83-87), which returns the input unchanged when there is no-, avoiding an allocation for an already-neutral culture. BecausePseudoLocaleis not inAll, this method can never return it (stated at:42).
- Why it's built this way: a single
constplusIReadOnlyListallowlist keeps the localization middleware, the switcher UI, the fitness gate, and the domain guard from drifting apart; separatingPseudoLocalefromAlllets a diagnostic locale ship in Development without polluting the production culture set or the resx-completeness gate.ResolveClosestexists because web heads get language-level fallback for free from request localization'sAccept-Languagematching, 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
.UseCommonRequestLocalizationbuilds the supported list fromAll(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:75), appendsPseudoLocaleonly in Development (:80), and setsDefaultas the default culture (:85); the culture-switch endpoint validates the incoming value withIsSupportedor, when pseudo is allowed,IsPseudoLocale(:107). MmcaCultureBootstrap falls back toDefaultwhen the stored culture is not supported (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:30), and PseudoStringLocalizer activates only underIsPseudoLocale(MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizer.cs:17). The MAUI head is theResolveClosestcaller: 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 allowsnullor anIsSupportedculture (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:144).
MiniProfilerExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9· Level 1 · class (static, extension block)
- What it is: a conditional MiniProfiler registration helper. When ApplicationSettings
.UseMiniProfileris 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 testsapplicationSettings.UseMiniProfiler(:18) and only then callsAddMiniProfiler(...)with a/profilerroute base,PopupShowTimeWithChildren, the dark color scheme (:20-25), and.AddEntityFramework()so EF and SQL timings appear inline. It returnsserviceseither way (:28) so the call chains. - Why it's built this way: gating on a settings flag rather than
#if DEBUGlets 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(...)inMMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.csdoes not invoke it, and no ADC, Store, or HelpdeskProgram.csdoes either; the helper is available for a host that opts in. - Caveats / not-in-source: the helper registers services only. Nothing in this file maps the profiler's own middleware, so a host opting in must also call
UseMiniProfiler()itself.
CorrelationContext
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Context·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/CorrelationContext.cs:9· Level 1 · class (sealed)
- What it is: the scoped service that holds the correlation ID for the current request, defaulting to a fresh GUID when no middleware sets one.
- Depends on: ICorrelationContext (Level 0,
MMCA.Common.Application.Interfaces), the abstraction it implements (CorrelationContext.cs:1,:9). Uses BCLGuidonly. - Concept (where the correlation value actually lives). The pattern itself is introduced at ICorrelationContext; this is its one shipped implementation.
[Rubric §13, Observability & Operability]: the eager default is what makes correlation always-on, so a log line emitted from a path with no HTTP request still carries an ID.[Rubric §3, Clean Architecture]: the concrete holder sits in Infrastructure while every consumer depends on the Application interface. - Walkthrough:
CorrelationId(CorrelationContext.cs:12) is{ get; private set; }, initialized eagerly toGuid.NewGuid().ToString("N")so a value always exists even if no middleware runs (a background processor, a test path, a gRPC call).SetCorrelationId(string)(CorrelationContext.cs:15-19) overwrites it, guarding the input withArgumentException.ThrowIfNullOrWhiteSpace(:17) so a blank header can never wipe the ID. The private setter means the only write path is that one guarded method. - Why it's built this way: a scoped holder with an eager default keeps correlation cheap and unconditional: every code path has an ID without a null check, and inbound requests still adopt the caller's ID for cross-service tracing. The
"N"GUID format (32 hex digits, no hyphens) keeps the value compact in log lines and headers. Registration isTryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:547): scoped, so one instance lives per request, andTryAdd, 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
SetCorrelationIdwith the inboundX-Correlation-IDheader, falling back to the currentActivitytrace ID and then toHttpContext.TraceIdentifier(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:32-36), then echoes the value back on the response throughOnStarting(:37-41). Downstream, LoggingCommandDecorator<TCommand, TResult> and LoggingQueryDecorator<TQuery, TResult> take it as a constructor dependency and wrap the ID into their log scope for the full pipeline duration.
WebApplicationBuilderExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:33· Level 2 · class (static, extension block)
- What it is: the consolidated builder-side registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of WebApplicationExtensions, which owns the runtime pipeline; this one owns what goes into the DI container.
- Depends on: JwtSettings and its JwtSigningAlgorithm;
AddAuthorizationPoliciesfromMMCA.Common.API.Authorization; ApiParameterDescriptorBackfillProvider; RateLimitingSettings, RateLimitAlgorithm and RedisFixedWindowRateLimiter; InsecureJwtMetadataWarningStartupFilter; ASP.NET rate-limiting, compression, CORS andAsp.Versioningprimitives;Microsoft.IdentityModel.TokensandStackExchange.Redis. - Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation).
[Rubric §12, Performance & Scalability](a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer),[Rubric §11, Security](algorithm pinning, HTTPS metadata resolution, per-IP anonymous auth throttling) and[Rubric §9, API & Contract Design](versioning, OpenAPI and compression handled identically across hosts rather than per host). - Walkthrough: the load-bearing members, in file order.
CorsPolicyAllowSpecificOrigins/CorsPolicyAllowAll(WebApplicationBuilderExtensions.cs:36,:38): the two policy names the pipeline chooses between by environment.RateLimitPolicyAuthIp(:47): the named"auth-ip"policy for anonymous authentication attempts. Its comment (:40-46) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled.RequireHttpsMetadataConfigKey(:55, value"Authentication:JwtBearer:RequireHttpsMetadata"): the one configuration key that can override the secure-by-default metadata posture. Its comment (:49-54) narrows the legitimate use to an authority that is genuinely plain HTTP (an internal-ingress h2c service URL) and asks for the justification to be recorded beside the setting.IsRateLimitBypassed(HttpContext)(:61) exempts/health,/alive,/.well-known/*andapplication/grpccontent types (:62-65), all legitimately high-frequency. It isinternalrather than private specifically so the exemption logic is unit-testable throughInternalsVisibleToinstead of only under a request flood (:59-60).GlobalRateLimitPartitionhas two overloads. The permit-count one (:71-72) simply wraps its argument in a RateLimitingSettings and delegates; the settings one (:79) holds the logic: aNoLimiterfor bypassed infrastructure (:81-84) and for unauthenticated callers (:86-89), otherwise a partition keyed byIdentity.Name, then the user-id claim, then the remote IP, then the literal"authenticated"(:91-94), built throughCreateLimitedPartitionwithredisScope: "global",queueLimit: 0andallowDistributed: true(:96-103).UserPolicyRateLimitPartition(:114) is the same shape for the opt-in"UserPolicy"limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (:116-118), withredisScope: "user"and the configured queue limit (:120-127). It was extracted from the inline lambda it used to be so the key selection is unit-testable (:110-113).CreateLimitedPartition(:148) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. WhenallowDistributed && settings.Distributedit resolvesIConnectionMultiplexerthrough a nullable local, becauseHttpContext.RequestServicesis declared non-nullable but is genuinely null outside a request pipeline such as a bareDefaultHttpContextin a unit test (:157-163). With a connection it returns a partition whose factory builds a RedisFixedWindowRateLimiter over$"{redisScope}:{key}"(:170-172), falling back to a null logger when none is registered (:167-168). Without a connection it deliberately falls through to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (:175-177). The in-memory branch honours RateLimitAlgorithm: a sliding window withSegmentsPerWindowsegments (:180-190) or the default fixed window (:192-198), both overTimeSpan.FromMinutes(1)withQueueProcessingOrder.OldestFirst.AuthIpRateLimitPartition(:212permit-count overload,:221settings overload) partitions the"auth-ip"policy on the client IP and returns no limiter at all when the IP is unattributable (:223-226). The remark (:205-211) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-processTestServerand the integration tier to a standstill. It passesallowDistributed: false(:234), so login throttling stays per-instance whateverDistributedsays, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (:143-147).AddCommonApiVersioning()(:244): header-based versioning through theapi-versionreader withAssumeDefaultVersionWhenUnspecifiedandReportApiVersions(:249-253), the API explorer group format'v'VVVandSubstituteApiVersionInUrl(:255-259), then the backfill guard (:261). The comment (:246-248) records thatDefaultApiVersionis deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See ADR-046.AddCommonRateLimitinghas three overloads. The permit-count one (:296) keeps the defaultspermitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30and simply builds a RateLimitingSettings from them (:296-304). TheIConfigurationone (:314) binds theRateLimitingsection, falling back to a default instance when the section is absent (:318-319). The settings one (:332) does the work: rejection status 429 (:338), the always-onGlobalLimiter(:340-341), the opt-in"FixedPolicy"(:346-353,allowDistributed: false),"UserPolicy"(:355), andauth-ip(:365-367). Two comments carry the reasoning:"FixedPolicy"keeps its name whichever algorithm is configured, because it is referenced by name from[EnableRateLimiting]attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (:343-345); and theauth-ippolicy takes the client IP fromConnection.RemoteIpAddress, which the shared pipeline has already resolved fromX-Forwarded-ForbecauseUseForwardedHeadersruns beforeUseRateLimiter(:357-364). The long doc comment on the permit-count overload (:266-295) explains why anonymous traffic is deliberately unlimited and whyauthIpPermitLimitis 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See ADR-019.AddCommonResponseCompression()(:374): Brotli plus Gzip, enabled for HTTPS, both atCompressionLevel.Fastest(:376-388); the comment (:384-386) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads.AddCommonOpenApi()(:403):services.AddApiVersioning().AddOpenApi()(:405) plus the backfill guard (:406). The comment (:393-402) notes the parameterlessAddApiVersioning()only returns the builder, so options configured byAddCommonApiVersioningaccumulate independently of call order. Pair it withMapCommonOpenApi()on OpenApiEndpointExtensions.AddApiParameterDescriptorBackfill()(:418, private) registers ApiParameterDescriptorBackfillProvider viaTryAddEnumerable(:419-420), which de-duplicates on implementation type, so calling bothAddCommonApiVersioningandAddCommonOpenApiinstalls the guard exactly once.AddForwardedJwtBearer(authority, audience, configuration, environment, requireHttpsMetadata = null)(:445) is the extracted-service mode. It validates all four required arguments (:452-455), then resolves the metadata posture in three steps: the explicit argument when it is not null, thenRequireHttpsMetadataConfigKey, thentrueeverywhere except Development (:457-459). When the resolved value isfalseoutside Development it registers InsecureJwtMetadataWarningStartupFilter so the deviation is logged once at startup (:461-465), and finally delegates to the privateAddForwardedJwtBearerCore(:467). Itsauthorityargument is normally the result of JwtAuthorityExtensions.GetRequiredJwtAuthority().AddForwardedJwtBearerCore(:470, private) does the JWT wiring:Authority,AudienceandRequireHttpsMetadata(:478-480), validation parameters that deliberately leaveValidIssuerunset so the middleware takes the issuer from the discovery document (:488-493), andValidAlgorithms = [RsaSha256]as defense against an algorithm-confusion swap (:495-501). It also installs the SignalRaccess_tokenquery-string fallback for/hubs(:505-518) and then callsAddAuthorizationPolicies()(:521).AddCommonAuthentication(IConfiguration)(:538) is the in-process mode: it binds JwtSettings with data-annotation validation on start (:540-543), builds validation parameters throughBuildValidationParameters(:551), wires the same/hubsaccess-token fallback (:556-569), and adds the authorization policies (:572).AddCommonCors(IConfiguration)(:581): the restrictive production policy takes its origins fromCors:AllowedOriginsand allowlists four headers and five methods withAllowCredentials(:585-592); the development any-origin policy sits under a justified#pragma warning disable S5122explaining it is only ever selected when the environment is Development (:593-598). See ADR-082.GetValidatedSigningKey(string)(:609,internal static, outside the extension block) decodes the Base64 HMAC key and throws when it is under 256 bits (:611-616), so a too-short secret fails at startup rather than weakening every token.BuildValidationParameters(JwtSettings)(:628, alsointernal static) branches on JwtSigningAlgorithm: RS256 requiresRsaPublicKeyPemand throws a message that points atAddForwardedJwtBearerwhen it is missing (:632-636), imports the PEM into anRSAheld for the app lifetime (:638-641, with a justified CA2000 suppression) and pins RS256 (:651); the default HS256 path builds aSymmetricSecurityKeyfrom the validated secret and pins HmacSha256 (:655-668).
- Why it's built this way: two authentication entry points are the framework's monolith-to-microservice hinge (ADR-004): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit
ValidAlgorithmspin on both paths is deliberate defense in depth rather than trust in the token header. TakingIConfigurationandIHostEnvironmentas required arguments onAddForwardedJwtBeareris 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 throughCreateLimitedPartitionis what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (:324-328). - Where it's used: every ADC and Store service host calls the builder-side quartet in one block (
AddCommonCors,AddCommonApiVersioning,AddCommonRateLimiting,AddCommonResponseCompression). Identity hosts take the in-process mode while the other services take the forwarded mode, passingbuilder.Configurationandbuilder.Environmentso the framework resolves the metadata posture (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:274-278,MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:177-181). The"auth-ip"policy is applied by attribute on the login and register actions of AuthControllerBase (AuthControllerBase.cs:72and:94), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly byWebApplicationBuilderExtensionsTests,RateLimitPartitionTestsandRateLimitAlgorithmSelectionTests; the metadata resolution byForwardedJwtBearerSecurityTests. - 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. LikewiseDistributedonly takes effect when the host also registers anIConnectionMultiplexer; whether it does is host composition, not this file.
ModuleHostContext
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:21· Level 3 · class (sealed)
- What it is: the return value of
AddModuleHost(see ModuleHostExtensions). It carries the two settings objects a module-hosting service binds once at startup, the ModuleLoader that discovers its modules, and a single method the host registers as a step of its application pipeline to actually run discovery. - Depends on: ModuleLoader, ApplicationSettings and ModulesSettings;
IConfigurationManager,IServiceCollectionandSystem.Reflection.Assemblyfrom 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
AddModuleHostbind 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, betweenAddApplication()andAddApplicationDecorators(), 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 avoid-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 writespipeline.Register(moduleHost.RegisterModules)and cannot get the argument list wrong. - Walkthrough: three captured fields, three read-only properties, one method.
_configuration,_environmentNameand_moduleAssemblies(ModuleHostContext.cs:23-25) are the discovery arguments captured atAddModuleHosttime, so the deferred call needs no state from the caller.- The
internalconstructor (:27-41) means only ModuleHostExtensions in the same assembly can build one; a host receives an instance, it never constructs one. ApplicationSettings(:44) andModulesSettings(:47) expose the bound sections, so the host can pass them onward without re-reading configuration (ADC passesmoduleHost.ModulesSettingsstraight intoAddAPI,MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313).ModuleLoader(:54) exposes the loader thatAddModuleHostregistered as a singleton. Its doc comment names the two later calls that need it:AddModuleHealthChecksafter the pipeline has run discovery, andInitializeDatabaseAsync(:49-53).RegisterModules(IServiceCollection services)(:66) null-guards the collection (:68) then callsModuleLoader.DiscoverAndRegisterwith 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. ExposingRegisterModulesas 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
moduleHostand touches it three times:moduleHost.ModulesSettingsintoAddAPI,pipeline.Register(moduleHost.RegisterModules)insideAddMmcaApplicationPipeline, andmoduleHost.ModuleLoaderintoAddModuleHealthChecks(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313,:348,:364; the Identity, Engagement and Notification services andMMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:131follow 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
RegisterModulesis ever called, or called in the right place. A host that builds aModuleHostContextand 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 drivingRegisterModulesthrough 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 sameProgram.csline is safe in every host. - Walkthrough:
MapNotificationHub()(SignalRExtensions.cs:22) resolvesIOptions<PushNotificationSettings>throughGetService<T>(), which returns null when nothing registered it, and takes?.Value(:24). Only whensettings is { Enabled: true }does it callMapHub<NotificationHub>(settings.HubPath)(:25-28). The doc comment (:16-21) notes it must run afterUseCommonMiddlewarePipeline()so authentication and routing are already in place. - Why it's built this way:
GetServicerather thanGetRequiredService, 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 anaccess_tokenquery-string fallback for/hubson both authentication paths (WebApplicationBuilderExtensions.cs:506-519and:556-569): a WebSocket cannot send anAuthorizationheader. - Where it's used: the ADC Notification service maps it after the shared pipeline (
MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:262, with the pipeline itself at:244); that host reads the hub path from configuration rather than hard-coding it, which the comment there records (:257-260).
ModuleHostExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:22· Level 4 · class (static, extension block)
- What it is: a single
extension(WebApplicationBuilder builder)member,AddModuleHost, that collapses the settings-bind plus ModuleLoader construction every module-hosting service used to repeat verbatim in itsProgram.cs. It returns a ModuleHostContext. - Depends on: ModuleHostContext (which it constructs), ModuleLoader, ApplicationSettings, ModulesSettings and IModule (the interface discovery looks for);
Microsoft.Extensions.Optionsvalidation,IConfiguration,ILogger<T>andSystem.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 eachProgram.csis 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 anullthat 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 forbuilder.Servicesandbuilder.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 throwsInvalidOperationExceptionwhen it is absent (:66-67). The two are not redundant:ValidateOnStartfires when the host starts, while the eagerGet<T>()is what lets the returned context hand the bound settings back to the caller immediately, beforebuilder.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 noApplicationSettingsis not. - Builds the ModuleLoader, attaching the caller's logger through an object initializer only when one was supplied so the loader keeps its own
NullLoggerdefault 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).
- Null-guards the builder and the assembly list (
- Why it's built this way: the
moduleAssembliesparameter is required rather than defaulted to anAppDomainscan, and the doc comment shows the intended call shape (one marker type per module,:31-35). Naming the assemblies explicitly means discovery looks nowhere else, which is what makes a single-module extracted service provably single-module: the ADC Conference host says exactly that in its comment (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:305-307), and the Store Sales host uses the same property to explain why Catalog and Identity reach it only as Contracts projects (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:125-129). The optional logger exists because module discovery runs before the host's real logging pipeline is available, so a host that has bootstrapped a Serilog logger passes one in (:36-42). - Where it's used: every ADC service host (
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:309-311, plus Identity:248, Engagement:209and Notification:187in their own files) and the Store Sales and Catalog hosts (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:131-133), each passingSerilogHostExtensions.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
ModuleLoaderinline and callsDiscoverAndRegisterdirectly (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:97and:104), which is the longhand this extension collapses.
CurrencyJsonConverter
MMCA.Common.API ·
MMCA.Common.API.JsonConverters·MMCA.Common/Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:12· Level 4 · class (sealed)
- What it is: a
System.Text.Json.JsonConverter<Currency>that serializes Currency as its ISO 4217 three-letter code string and deserializes by validating that code throughCurrency.FromCode. - Depends on: Currency (
MMCA.Common.Shared.ValueObjects,CurrencyJsonConverter.cs:3). Extends BCLJsonConverter<T>(CurrencyJsonConverter.cs:12). - Concept (value objects serialize to their natural string form).
[Rubric §9, API & Contract Design]assesses whether the wire contract exposes clean primitives rather than leaking internal object graphs. A domain value object should cross the wire as the compact primitive a client expects ("USD"), not as a nested object with acodeproperty. The converter is also a validation gate at the boundary: malformed input is rejected before model binding completes, so no handler ever sees an invalid currency. - Walkthrough
Read(CurrencyJsonConverter.cs:15) first rejects any non-string token, throwingJsonException("Currency must be a string.")(:17-18), which is what stops a JSON number or object from being coerced.- It then reads the string, coalescing null to empty (
:20), runsCurrency.FromCode(code)(:21), and throwsJsonException($"Invalid currency code: {code}")when the result is a failure (:22-23) before returningresult.Value!(:25). BecauseFromCodereturns a Result, the converter is bridging the Result world into the exception-based contractJsonConverter<T>requires; the thrownJsonExceptionsurfaces as a400 Bad Requestfrom the framework's model binding (doc comment,:9-10). Write(CurrencyJsonConverter.cs:29-30) is a one-liner:writer.WriteStringValue(value.Code).- The type is sealed, holds no state, and has exactly these two methods (
[Rubric §15, Best Practices]: the framework-idiomatic converter pattern).
- Why it's built this way: routing serialization and deserialization through
Currency.FromCodekeeps the single validation gate for currency codes in the value object itself, so the API layer neither duplicates the allowlist nor accepts aCurrencythe domain would reject. - Where it's used: registered globally for MVC in DependencyInjection
.AddAPI, which chains.AddJsonOptions(...)ontoAddControllersand adds an instance to the options converters (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:51-53), so every controller request and response serializesCurrencyas a string uniformly. That registration is also cited as the precedent for the enumeration converter factory added beside it (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:49). - Caveats / not-in-source: there is a second, same-named converter in the Shared layer, CurrencyJsonConverter (
MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:73), attached to the value object by a[JsonConverter]attribute (Currency.cs:13) so that non-MVC paths (cache, outbox, integration events, typedHttpClientcalls) also get string form. The two apply the same input rules and differ only in the exception text (Currency.cs:85quotes the code) and in returning a nullableCurrency?(Currency.cs:76). Which of the two wins for a given payload is aSystem.Text.Jsonconverter-precedence question (an options-registered converter versus a type-level attribute) and is not determinable from this source alone; since their input rules match, the answer does not change what is accepted.
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Mapping·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:14· Level 4 · interface
- What it is: the contract for mapping a domain entity to its DTO. One required method,
MapToDTO(entity), plus a defaultMapToDTOs(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-32is 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 sharedTIdentifierTypeis 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.
- Three generic parameters with matched constraints (
- 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 themAsSelfWithInterfaceswith a scoped lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:202-205), so a new mapper class is wired by existing. - Where it's used: it is a constructor dependency of the whole generic write and read stack: EntityQueryService<TEntity, TEntityDTO, TIdentifierType> takes one and re-exposes it as its
DTOMapperproperty (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:36,:90, declared on the interface atInterfaces/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 exampleMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:14), plus the framework's own PushNotificationDTOMapper (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOMapper.cs:13). - Caveats / not-in-source: mapping is not always the read path. An entity may also have an opt-in IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, scanned beside the mappers (
DependencyInjection.cs:207-213), which pushes the same shaping into SQL for list reads; an entity with no projector keeps materialize-then-map through this interface.
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Mapping·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:42· Level 4 · interface
- What it is: the create-side mapper. It turns an incoming create request into a new domain entity by calling that entity's factory method, and returns a Result so a refused invariant travels back as a failure rather than an exception.
- Depends on: AuditableBaseEntity<TIdentifierType>, the ICreateRequest marker, and Result (
IEntityDTOMapper.cs:43-45,:54). It shares a file with IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, because the file covers both mapping directions. - Concept (the entity factory is the only construction path). The manual-mapping rationale is introduced at IEntityDTOMapper; what this interface adds is a boundary rule.
[Rubric §4, DDD]assesses whether aggregates are constructed through guarded factories that enforce invariants: the mapper does notnewan entity, it callsEntity.Create(...)and hands the resultingResult<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 isTask<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), withTCreateRequest : ICreateRequestas the type-system guard that stops an update request being passed to the create path. The single member isTask<Result<TEntity>> CreateEntityAsync(TCreateRequest request, CancellationToken cancellationToken = default)(:54). A representative implementation is one method: guard the request, thenTask.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 awaitsrequestMapper.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,AsSelfWithInterfacesand scoped (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:217-220). - Where it's used: implemented once per creatable aggregate across the apps (for example
SponsorCreateRequestMapper.cs:11,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestMapper.cs:12), and consumed by the generic create handlers (UseCases/CreateEntityHandlerBase.cs:43,UseCases/CreateEntityHandler.cs:30). - Caveats / not-in-source: this interface has a write-side twin declared in the same file, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> (
IEntityDTOMapper.cs:79-91), which applies a request onto an already-loaded aggregate and returns a bareResultbecause 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-Matchheader, the header is mandatory, and a conflict outcome is answered with412 Precondition Failedrather than409 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, andMicrosoft.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 inHttpContext.ItemsunderTokenItemKey, 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 with428 Precondition Requiredand the action never runs. A malformed tag is a400 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 whatIf-Matchis, 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 implementsIAsyncActionFilterdirectly and requires no DI registration by the host.
- The token never touches the request model (
- Walkthrough
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)](:48);TokenItemKey(:57) is theHttpContext.Itemsslot.RequiredToken(HttpContext)(:68-76) is the action-side accessor: it returns the decodedbyte[], or throwsInvalidOperationExceptionnaming 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), callsTryApplyIfMatch(: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 awaitsnext()(:90) and passes the outcome to the rewrite (:92).TryApplyIfMatch(:104) reads the header (:106-107), then refuses withPreconditionRequiredResultwhen the value is blank or is the wildcard (:109-114), refuses withMalformedIfMatchResultwhenConcurrencyETag.TryParsecannot decode it (:116-120), and otherwise stores the bytes inHttpContext.Itemsand returns true (:122-123).RewriteConflictToPreconditionFailed(:130) handles two shapes. ADbUpdateConcurrencyExceptionis replaced with the 412 result and markedExceptionHandled = true(:132-139), which is what stops DbUpdateExceptionHandler from mapping it to a 409 like any otherDbUpdateException. AnObjectResultalready carrying 409 has both its ownStatusCodeand itsProblemDetails.Statusmoved to 412, leaving the body otherwise intact (:143-150); a bareStatusCodeResult409 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 registeredProblemDetailsFactoryfor the body so the response carries the same diagnostic extensions (traceId) as the rest of the API, falls back to a hand-builtProblemDetailswhen no factory is registered, and attaches the framework's standarderrorsextension through ErrorHttpMapping.BuildErrorsExtensionwith the optional IErrorLocalizer (:216-217). The three call sites differ only in status, title, detail and error: 428Concurrency.PreconditionRequired(:162-171), 400Concurrency.MalformedIfMatch(:174-183), and 412Concurrency.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.Itemsinstead of through the bound model is what removed reflection from the request path entirely: there is no per-type property lookup, and request records stayinit-only as the immutability fitness rules require. - Where it's used: applied across all three consumer apps on writes whose stale-view risk is real: ADC's
EventsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:221,:264,:297),SessionsController(.../SessionsController.cs:319) andSessionQuestionsController(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:134,:160,:186); Store'sOrdersController(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:293,:325,:385, each reading the token withSupportsIfMatchAttribute.RequiredToken(HttpContext)at:306,:338,:401) andInventoryItemsController(.../InventoryItemsController.cs:91,:124); and Helpdesk'sTicketsController(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.API/Controllers/TicketsController.cs:113,:141,:209). ADC pins the convention with aConditionalWriteConventionTestsfitness test that asserts the attribute is present on the endpoints that need it (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Conventions/ConditionalWriteConventionTests.cs:29). The read half of the round trip is EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>.SetConcurrencyETag(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:471-479), which gives the client the tag to send back.
WebApplicationExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:16· Level 10 · class (static, extension block)
- What it is: the
extension(WebApplication app)type every downstream host calls to wire its HTTP edge: the twoUseCommonMiddlewarePipelineoverloads, 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) andPreForwardedHostKey(:33), name theHttpContext.Itemsslots that the pipeline'sPreForwardedCapturestep writes beforeUseForwardedHeadersrewrites scheme and host. The comment on the host key (:24-32) records why: Aspire/DCP injects anX-Forwarded-Hostpointing at the canonical launchSettings URL, which internal callers cannot reach. UseCommonMiddlewarePipeline()(:46) is a one-liner:ApplyPipeline(app, configure: null). Its doc comment (:37-45) states the contract in one sentence worth keeping: the order is data, not prose, named by MiddlewarePipelineStepNames and frozen by theMiddlewarePipelineOrderTestsBasefitness function.UseCommonMiddlewarePipeline(Action<MiddlewarePipelineBuilder> configure)(:58) is the scoped escape hatch: it null-guards the delegate (:60) and routes through the same helper (:61). The XML doc declares both failure modes,ArgumentNullExceptionand theInvalidOperationExceptionan invariant violation raises (:56-57).UseCommonRequestLocalization()(:71) builds the supported list from SupportedCultures.All(:73), appends the pseudo-locale in Development only (:78-81), and sets the default plus both supported and supported-UI culture lists (:84-87). It is itself theRequestLocalizationstep of the default pipeline, and Blazor UI hosts call it explicitly beforeMapRazorComponentsso SSR prerender runs under the right culture (:64-70).MapCultureEndpoint()(:100) maps the anonymousGET /culture/set?culture=&redirectUri=that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (:104,:107), writes the standard ASP.NET culture cookie as non-HttpOnly so the WASM client can read it (:110-121, withSecureconditional on the environment and both deviations justified inline at:109), then local-redirects (:125-126) to force a full reload.ApplyPipeline(:138, private) is the whole application step: seed the defaults (:140), let the host's delegate adjust them if there is one (:141), thenforeachoverbuilder.Build()invoking each step'sConfigurein order (:143-146). Because both public overloads route through here, the zero-argument path is exactly the validated default pipeline (:133-137).
- Two internal constants,
- Why it's built this way: pushing the step list out into MiddlewarePipelineBuilder and keeping only
ApplyPipelinehere is what lets the order be inspected and asserted while the entry point stays a single line in a host'sProgram.cs. Theconfigure-then-Buildsequence 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 exampleMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:377,MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:245,MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:278, andMMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:130. The Blazor UI hosts instead call the localization and culture-endpoint members directly (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:127and:169). - Caveats / not-in-source: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example SignalRExtensions
.MapNotificationHub,MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21). Note also thatMMCA.Common.UIships a differentWebApplicationExtensions(see WebApplicationExtensions in the UI framework chapter); the two share a name and nothing else.
DataExportControllerBase<TQuery>
MMCA.Common.API ·
MMCA.Common.API.Controllers.Privacy·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59· Level 10 · class (abstract)
- What it is: the shipped base controller for the data-subject export endpoint (
GET {route}/{userId}/export, the GDPR/CCPA access and portability request). A subclass supplies its app's query type and a route; the base owns the action, the authorization posture, the feature gate, and the file-download delivery. - Depends on: ApiControllerBase (its base, for
HandleFailureatMMCA.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 onTQuery(DataExportControllerBase.cs:59-62). Externals: ASP.NET Core MVC,System.Text.Json, andMicrosoft.FeatureManagement.Mvc's[FeatureGate]. - Concept (a privacy endpoint shipped as a base class, not a registered controller).
[Rubric §30, Compliance/Privacy/Data Governance]assesses whether legal obligations such as subject access, portability, and erasure are first-class code rather than manual operations: the access/portability half of a DSAR is framework code here, so an app gets it by subclassing rather than by re-implementing the dispatch, the ownership posture, and the delivery format.[Rubric §11, Security]shows up as defence in depth: the class-level[Authorize](:57) demands an authenticated caller, while the handler independently enforces owner-or-privileged-role, so the endpoint cannot leak another subject's data even if a subclass is mounted without its own[Authorize](:49-53).[Rubric §6, CQRS & Event-Driven Design]covers the[FeatureGate(PrivacyFeatures.DataExport)](:58, the flag string is"Privacy.DataExport"atMMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:9): the whole surface stays off, answered by DisabledFeatureHandler's 404, until a host deliberately turns the flag on. - Walkthrough
- The primary constructor takes the app's query handler and ICurrentUserService (
:59-61); the latter is re-exposed as the protectedCurrentUserServiceproperty (:68) so a subclass can reuse it.ExportContentType = "application/json"(:65) is the one media type the package is served as. ExportAsync(UserIdentifierType userId, CancellationToken)(:82-84) is routed by the action-level[HttpGet("{userId}/export")](:77) only: the route prefix lives on the subclass, so a controller routed atUsersserves/Users/{userId}/export(:38-42). The declared responses are 200 with aUserDataExportDTO, plus 401/403/404 asProblemDetails(:78-81).- It reads
CurrentUserService.UserIdfirst and, when there is none, short-circuits throughHandleFailurewithError.Unauthorized("Privacy.Unauthorized", ...)(:86-90), so even the unauthenticated case comes back as RFC 9457 Problem Details rather than a bare status. - It then builds the app's query through the abstract
CreateQuery(userId, currentUserId, CurrentUserService.Role)factory (:92, declared at:119-122), awaits the handler (:93-94), and maps a failed Result throughHandleFailure(result.Errors)(:96-99), which is what turns the handler's own ownership refusal into a 403. - On success it serializes the package itself with
JsonSerializer.SerializeToUtf8Bytes(export, JsonSerializerOptions.Web)(:107) and returnsFile(payload, ExportContentType, BuildFileName(...))(:109). The inline comment (:103-106) is explicit about why:Ok(export)would content-negotiate and render inline, and this document exists to be saved, whileJsonSerializerOptions.Webkeeps the payload byte-shape identical to every other response the API produces. BuildFileName(:133-134) composesuser-data-{userId}-{yyyyMMdd}.jsonwithCultureInfo.InvariantCulture, taking the date from the package's ownUserDataExportDTO.GeneratedOnso the file name and the document always agree, and so a saved file sorts and parses the same in every locale (:124-129).
- The primary constructor takes the app's query handler and ICurrentUserService (
- 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
CreateQueryfactory rather than as a concrete controller added through an application part because the query type is app-owned (each app'sExportUserDataQuerylives in its own Application assembly), and a concrete controller could not construct a type it cannot see (DataExportControllerBase.cs:43-47). The route staying on the subclass follows the AuthControllerBase precedent: the app owns its URL space (:38-42). - Where it's used: both full apps now derive from it, and each subclass is nothing but a route plus a query factory: ADC's
UsersDataExportController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26-35) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersDataExportController.cs:19-29). Both are routed at the literalUsersrather than[controller], and both say why:[Route("[controller]")]would resolve toUsersDataExportand move the published path (ADC:19-22, Store:9-13). The framework's own coverage isDataExportControllerBaseTests, whoseTestDataExportControlleris the third subclass in source (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:270).
DatabaseInitializationExtensions
MMCA.Common.API ·
MMCA.Common.API.Startup·MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21· Level 13 · class (static, extension block)
- What it is: the shared startup routine every downstream host calls once after
app.Build(). It walks each physical data source the host actually uses, creates or migrates that schema according to the configured strategy, repeats the pass for every tenant that keeps its own copy of a source, and finally runs each enabled module's seeder. - Depends on: IEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, DataSourceKey and its DataSource engine enum, PhysicalDataSource, TenantDataSourceTargets / TenantDataSourceTarget, ITenantContext, TenancySettings, ApplicationSettings, and ModuleLoader. Externally it uses only
IServiceProviderscoping,IOptions<T>, and EF Core'sDatabaseFacade(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 singleDatabaseInitStrategystring is the switch between a development host that self-migrates on boot and a production host whose migrations are applied by the deployment pipeline and which must therefore refuse to start if it finds itself behind.[Rubric §13, Observability & Operability]: every failure path here throws a message that names the offending value or the exact pending migrations plus the command to run, so a bad boot is diagnosable from the startup log alone. - Walkthrough: the entire public surface is one extension member on
IServiceProvider(DatabaseInitializationExtensions.cs:23), declared with C#extension(T)syntax like the rest of the framework's DI and startup helpers.InitializeDatabaseAsync(applicationSettings, moduleLoader, cancellationToken)(:35-38) starts by null-guarding all three inputs (:40-42).- Strategy is validated first (
:47).EnsureKnownStrategy(:182) accepts only the ordinal strings"Migrate"and"None"(:184-185) and otherwise throwsUnknownStrategy(:187), whose message names the valid values (:194-195). The comment (:44-46) gives the reason for doing it up front: a misspelled strategy is a configuration mistake, and the host must stop before the code below has already created the migrationless sources. - It then opens a scope (
:49) and warms the entity data-source registry: resolvingIEntityDataSourceRegistryand callingGetPhysicalSourcesInUse()(:54-56) scans the configuration assemblies once so entity-to-database routing is deterministic before the first repository call, replacing a lazy model-building side effect (:51-53). - The migrationless pass (
:70-87) covers Cosmos and SQLite sources in use (:71). A source with an empty connection string is skipped (:75-78), since integration tests may omit one; a source wherePhysicalDataSource.UsesMigrationsis true is also skipped (:80-83); everything left is created outright withEnsureCreatedAsync(:85-86). Two comments carry the load here. The first (:60-65) says this is the only path that creates such a source: without it the first repository call fails. The second (:67-69) explains theUsesMigrationsexclusion: runningEnsureCreatedagainst a SQLite source that does have a migrations assembly writes the tables with no__EFMigrationsHistoryrow, after which every migration is both pending and un-appliable because itsCREATE TABLEhits an existing table.UsesMigrationsitself is the per-engine rule on the source record (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:48-53): SQL Server always, SQLite only with a configuredSqliteMigrationsAssembly, Cosmos never. - The strategy switch (
:92-102) has exactly two live branches."Migrate"delegates toIDbContextFactory.MigrateAsync(:95), which applies pending migrations for every migration-owned source in use (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IDbContextFactory.cs:74-81)."None"callsThrowIfPendingMigrationsAsync(:98). Thedefaultarm rethrowsUnknownStrategy(:101), unreachable in practice because of the guard at:47but kept so the switch is total. - The tenant pass runs next (
:104-105) and module seeding last (:111). Seeding deliberately runs on the default scope only; the comment (:107-110) states the rationale: a seeder writes reference data the application needs to boot, no module declares which of its seeders are tenant-scoped, and running one twice against a shared database is worse than not running it per tenant at all. InitializeTenantDatabasesAsync(:125) resolvesIOptions<TenancySettings>withGetServicerather thanGetRequiredService(:132) and returns immediately when tenancy was never registered or no tenants are configured (:133-136). Otherwise it expands the sources into targets viaTenantDataSourceTargets.Expandand keeps only the ones with a tenant id (:138-139), becauseExpandalso emits the shared, tenant-less target for each source, which the pass above already handled. For each target it opens a fresh scope and callsITenantContext.SetTenantbefore asking that scope for anything (:141-142); the remark (:120-124) is the reason: the scoped context factory binds one physical database per source for the life of a scope, so reusing the outer scope would keep handing back the shared database.- Per tenant it reads
UsesMigrationsoff the shared resolved source (:150), on the grounds stated at:147-149: a tenant's copy is the same schema on a different connection, the migrations assembly is declared once on the source, and a per-tenant override only replaces the connection string. It then applies the same strategy (:152-171): under"Migrate"a migrated source getsMigrateAsyncand anything else getsEnsureCreatedAsync(:155-162), under"None"it defers toThrowIfTenantPendingMigrationsAsync(:166). - The two production rails.
ThrowIfTenantPendingMigrationsAsync(:201) no-ops for a non-migrated source (:207-210), otherwise queriesGetPendingMigrationsAsync(:212) and, when anything is pending, throws naming the tenant target, the migrations, and thedotnet ef database updateremedy (:218-221).ThrowIfPendingMigrationsAsync(:241) short-circuits onIDbContextFactory.HasPendingMigrationsAsync(:247-250), then builds a per-source breakdown by re-querying each key that passesIsMigrationTarget(:252-261) before throwing (:263-265).IsMigrationTarget(:231-233) mirrors the factory's own rule (a source a migrations pipeline owns, minus an optional non-SQL-Server source left without a connection string) so the breakdown names exactly the sources the factory checked, rather than reporting an empty list (:224-228).
- Why it's built this way: one shared init path keeps every service host consistent, so adding a database engine or a tenant changes this file rather than five
Program.csfiles. The"None"strategy is the deploy-time guarantee that an app never serves traffic against an un-migrated database when migrations are the pipeline's job, and validating the strategy string before any side effect keeps a typo from silently degrading into "schema untouched, first query fails". The tenant pass exists because nothing else ever opens a per-tenant database (ADR-073), so without it such a database is never created and never migrated (:116-118). - Where it's used: called once per host after
app.Build(), before the middleware pipeline is wired:MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:374,MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:275, andMMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:127are representative; the ADC and Store service hosts passmoduleHost.ApplicationSettingsandmoduleHost.ModuleLoaderoff ModuleHostContext (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:52). The migrationless-engine loop and the strategy contract are covered byDatabaseInitializationExtensionsTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:42,:95,:143,:184), which exercise SQLite create, SQLite migrate, the"None"failure message, and the unknown-strategy guard. - Caveats / not-in-source: which strategy a given deployment runs is configuration, not code. The framework default is
"Migrate"(MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:43); what any environment overrides it to lives in that app's settings and cannot be read here. The tenant pass is also only as complete asTenancySettings:Expandemits a tenant target only when the tenant declares an override connection string for that source, so a tenant that shares the default database is initialized by the shared pass and never appears in the per-tenant loop.
⬅ Navigation Metadata & Populators (EF-decoupled eager loading) • Index • gRPC & Inter-Service Contracts ➡