Onboarding guide
24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)
What this chapter covers. This is the Identity bounded context of MMCA.ADC, the module that owns who a person is across every ADC surface: web, WebAssembly, and MAUI. It is a leaf context (no upstream module dependencies) but it touches every layer end to end, so this chapter doubles as a compact tour of a full vertical slice built on the framework taught in groups 1 through 15. The single aggregate is the User, and around it sit the credential and refresh-token lifecycle, the role vocabulary, the change-password / change-preferences / avatar use cases, the two privacy use cases that make ADC compliant (data-subject export and erasure), the persistence and EF configuration, the REST controllers, the gRPC contract that lets a peer service ask Identity a question, the integration events that keep the User-to-Speaker link consistent across the service split, and the Blazor profile and user-list UI. The per-type sections follow; this overview shows how the pieces fit and how a request flows through them.
Almost everything here is an instantiation of upstream framework machinery, cross-referenced rather
than re-taught: the Result pattern (G01), the
AuditableAggregateRootEntity<TIdentifierType>
entity chain plus the IAnonymizable and
PiiAttribute governance markers (G02), the outbox
spine and BaseIntegrationEvent /
BaseDomainEvent (G04), the CQRS command/query handler
pipeline (G05), the auth base classes
(AuthenticationServiceBase<TUser>, JWKS,
RoleValue,
HasPermissionAttribute) from the shared auth group (G08),
and the IModule composition system (G14). The lenses
this chapter most strongly embodies are [Rubric §4, Domain-Driven Design] (a behavior-rich aggregate
that guards its own invariants), [Rubric §11, Security] (credential handling, RS256 JWTs,
permission-based authorization, a fail-closed OAuth link gate), and [Rubric §30, Compliance / Privacy /
Data Governance] (the export and erasure flows). The // BR-NN markers referenced below are catalogued
in the ADC business-requirements guide; the privacy promises live in MMCA.ADC/PRIVACY.md.
Projects, one bounded context
The module is split along the standard Clean Architecture layering ([Rubric §3, Clean Architecture]),
each project pinned by a trivial AssemblyReference /
ClassReference anchor pair that Scrutor scanning and the architecture-fitness tests
use to name the assembly. MMCA.ADC.Identity.Domain holds the User aggregate
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:18), the
UserRole value type (UserRole.cs:17), the UserInvariants rule class
(UserInvariants.cs:10), and the UserDeleted /
UserPasswordChanged domain events; it depends only on MMCA.Common.Domain and
MMCA.Common.Shared and knows nothing of EF or ASP.NET. MMCA.ADC.Identity.Application holds the
use-case handlers, the DTO mappers and validators, and the cross-module service implementations.
MMCA.ADC.Identity.Infrastructure holds the
ModuleApplicationDbContext
(MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15), the
UserConfiguration EF mapping, and the
IdentityModuleDbSeeder. MMCA.ADC.Identity.API holds the REST
controllers, the IdentityModule descriptor
(MMCA.ADC.Identity.API/IdentityModule.cs:13), and the
IdentityErrorResources anchor whose .resx siblings translate domain error
codes into the supported languages (IdentityErrorResources.cs:11, ADR-027).
MMCA.ADC.Identity.Shared is the contract package every other layer (including the WebAssembly
client) can reference without dragging in the domain: it carries the DTOs, the
IAttendeeQueryService cross-module interface, the
UserRegistered integration event, and the
IdentityPermissions / IdentitySettings constants. Three
more projects sit outside the module folder: MMCA.ADC.Identity.Contracts (the gRPC adapter),
MMCA.ADC.Identity.Service (the extracted process host), and MMCA.ADC.Identity.UI (the
Blazor pages). The identifier alias for this context is UserIdentifierType = int (a
database-generated identity), while the cross-context LinkedSpeakerId uses
SpeakerIdentifierType = System.Guid.
The User aggregate: credentials, profile, and cross-context links in one root
User (MMCA.ADC.Identity.Domain/Users/User.cs:18) is the only aggregate root in the module,
and it carries more responsibility than most: it is the credential store (PasswordHash plus a
per-user PasswordSalt, both byte[] mapped to varbinary(max), User.cs:34,37), the refresh-token
holder (RefreshToken / RefreshTokenExpiry, rotated by UpdateRefreshToken and cleared by
RevokeRefreshToken, User.cs:234,243), the profile (Email, FirstName, LastName, each marked
[Pii], User.cs:22,26,30), the preference store
(PreferredCulture / PreferredTheme, User.cs:84,87, ADR-027 / ADR-028), the avatar URL holder
(User.cs:96, BR-116a, ADR-045), the optional MAUI device-metadata bag (User.cs:57-75), the
external-OAuth link (LoginProvider / ProviderKey, User.cs:78,81), and the 1:1 cross-context
LinkedSpeakerId pointing at a Conference speaker (User.cs:54, BR-207 / BR-208 / BR-209). Every
property has a private setter, so state changes only through the aggregate's own methods: encapsulation
as a compile-time guarantee ([Rubric §4, Domain-Driven Design], [Rubric §1, SOLID]).
It follows the standard framework shape: a private EF constructor (User.cs:104), a private state
constructor (User.cs:114), and static factory methods returning
Result<T>. Create (User.cs:147) validates every
invariant with Result.Combine(...) before constructing anything (User.cs:156-163), so an invalid
user is unrepresentable; CreateExternal (User.cs:189) builds an OAuth account with empty credential
arrays (User.cs:207). A subtlety worth carrying forward: the factory deliberately does not raise a
registration event. The Id is database-generated ([IdValueGenerated] at User.cs:17, Id set to
default at User.cs:171), so the cross-module UserRegistered is raised by the
application layer only after the insert has executed and a real id exists. The behavior methods each
guard their own rule: ChangePassword re-validates and raises
UserPasswordChanged (User.cs:302-316); UpdatePreferences validates against
the supported-culture allowlist and the light/dark theme values (User.cs:272-285); the Delete()
override revokes the refresh token as a security measure, calls the G02 soft-delete, and raises
UserDeleted (User.cs:348-358).
UserInvariants (UserInvariants.cs:10) is the co-located static rule class whose
methods each return a Result, several of them delegating to
the shared CommonInvariants. Centralizing each
rule as a named, side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric
§14, Testability]), and its const length limits (FirstNameMaxLength = 100,
LastNameMaxLength = 100, EmailMaxLength = 100, DeviceFieldMaxLength = 256,
UserInvariants.cs:13-22) are the same constants UserConfiguration uses for the
EF column widths (UserConfiguration.cs:24,29,34), so the domain rule and the schema cannot drift.
UserRole (UserRole.cs:17) is a value object over the shared
RoleValue base that fixes the ADC role set to three members: Organizer,
Attendee (the registration default), and ContentEditor, a strict capability subset that curates the
session catalog but cannot change event structure, run session selection, or read the user list
(UserRole.cs:20-30). Its IsOrganizer(string?) helper (UserRole.cs:76) does a case-insensitive
compare, because raw JWT role claims may carry any casing, and it is the exact check the delete and
export authorization gates use.
Authentication: a thin subclass over the shared engine
The login / registration / refresh / revocation workflow is not re-implemented here. It lives in
AuthenticationServiceBase<TUser> (G08), which owns
the validate-first flow, the ADR-029 lockout and rate-limit protection, and the BR-205 / BR-206
refresh-token rotation with reuse detection. AuthenticationService
(MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35) is the ADC subclass that fills in
only the context-specific pieces: CreateUser supplies the Attendee default role (BR-45,
AuthenticationService.cs:82-89), CreateAccessToken attaches the speaker_id claim when the user is
linked to a speaker (BR-209, AuthenticationService.cs:92-93 via the SpeakerClaims helper at :228),
OnUserRegisteredAsync raises the UserRegistered integration event
(AuthenticationService.cs:105-110), and ExternalLoginAsync drives the OAuth find-by-provider, else
link-by-email, else create flow (AuthenticationService.cs:123). The EmailExistsAsync override
deliberately passes ignoreQueryFilters: true (AuthenticationService.cs:78-79) so an erased
(soft-deleted) account's email cannot be re-registered.
Two details in that class are worth reading closely. First, atomicity: RegisterAsync wraps the
whole base workflow in UnitOfWork.ExecuteInTransactionAsync (AuthenticationService.cs:57-63), so the
user insert (first save) and the outbox row for UserRegistered (raised in OnUserRegisteredAsync once
the id exists at :107, then a second save at :108) commit together. The event is not fire-and-forget
after the fact, it is captured by the outbox inside the same transaction (ADR-003). The external-login
path does the same through ExternalLoginAsync (:123-132, with the event raised at :213). Second,
the link-by-email gate: linking an external identity to an existing local account on nothing but an
email match would be an account-takeover path through any provider that hands out unverified emails, so
the flow consults IExternalLoginEmailVerifier
(MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11). Its implementation
HttpContextExternalLoginEmailVerifier
(MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17) lives at the API
edge because the assertion lives in the short-lived ExternalLogin cookie principal: it
re-authenticates that scheme and reads the email_verified claim (:32-35), and an absent claim,
absent principal, or non-request context all report unverified. It fails closed, which means GitHub
logins (whose OAuth payload carries no such assertion) never auto-link by design. Identity signs its
tokens with RS256 and publishes the public key at /.well-known/jwks.json; peer services validate
tokens by fetching that document through the Gateway rather than sharing a secret (ADR-004, [Rubric §11,
Security]).
The HTTP surface is equally thin. AuthController
(MMCA.ADC.Identity.API/Controllers/AuthController.cs:25) extends the shared
AuthControllerBase (G12) and adds only what ADC
needs: a register override that captures the client IP for registration rate limiting (BR-213,
AuthController.cs:48), plus PUT password, PUT preferences, and GET preferences
(AuthController.cs:77,104,129) that dispatch ChangePasswordCommand,
ChangePreferencesCommand, and
GetUserPreferencesQuery straight through the
G05 decorator pipeline. OAuthController
(OAuthController.cs:20) is a body-less subclass of
OAuthControllerBase (G12) that drives the
Google/GitHub challenge, callback, complete, single-use-code-exchange flow (tokens never ride the
redirect URL); it is an ADC-only feature, since MMCA.Store uses local credentials only.
UserClaimsController (UserClaimsController.cs:16) reflects the authenticated
JWT's claims back to the client, grouped by claim type (UC-10, :26-38).
UsersController (UsersController.cs:30) hosts the rest: the three avatar
endpoints, the organizer user list, the data export, and the account delete. Its list endpoint is gated
by capability rather than by role name, [HasPermission(IdentityPermissions.UsersRead)]
(UsersController.cs:123), and the identity:users:read grant
(MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:11) is handed to Organizer and Admin in
AddModuleIdentityAPI (MMCA.ADC.Identity.API/DependencyInjection.cs:44-48, ADR-020).
The privacy pair: export and erasure
Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data
Governance] story. DeleteUserHandler
(MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:15) satisfies the
PRIVACY.md §5 "delete within 30 days" erasure promise. After an owner-or-Organizer authorization check
(:26-33) it soft-deletes the row (:41), then calls user.Anonymize() (:52, implemented at
User.cs:371), which irreversibly overwrites the personal fields with placeholders in place rather
than hard-deleting the record. Keeping the row lets cross-context scalar references (bookmarks,
notifications) and the audit trail survive; the replacement email embeds the user id
(deleted-{Id}@anonymized.invalid, User.cs:376) so the unique-email invariant still holds across many
erased accounts, and the operation is idempotent (an already-anonymized user short-circuits at
User.cs:383-386). This is the anonymize-in-place model of ADR-005, backed by the
IAnonymizable marker. Because the avatar photo is
also personal data, the handler captures its blob name before Anonymize nulls the URL and deletes it
from storage after the erasure is persisted (DeleteUserHandler.cs:47,56-61).
ExportUserDataHandler
(MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:26) is the
data-subject access request (PRIVACY.md §7). It is a query handler (it never calls SaveChanges), it
applies the same owner-or-Organizer rule (:38-45), and it projects the user's Identity-owned data into
a UserDataExportDTO (:61-84), deliberately excluding credentials: no
password hash, no salt, no refresh token, no provider key. What makes it instructive is the
cross-service aggregation: it also gathers the Engagement section (bookmarks and submitted session
questions, through
IUserEngagementExportService, :58) and
the Notifications section (inbox rows, through
IUserNotificationExportService, :59), and
it does so best-effort per section. If a peer stays unreachable after the standard Polly resilience
pipeline, the catch block returns a section marked Available = false (:115-121, and the same shape
in the notification twin) and the export still succeeds, so one peer outage never fails the whole
request. That is [Rubric §29, Resilience] and [Rubric §7, Microservices Readiness] applied to a
compliance workflow.
Avatars: the third mutating slice
The avatar trio is a small but complete example of a file-handling slice ([Rubric §11, Security] at the
content boundary, ADR-045). UsersController caps the multipart upload at 2 MB in two
places, declaratively via [RequestSizeLimit(MaxAvatarBytes)] and imperatively via an explicit length
check that returns an Avatar.InvalidUpload validation error (UsersController.cs:39-40,66,77-83,
BR-116a). SetUserAvatarHandler
(MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16) never trusts
the client-declared content type: it sniffs magic bytes through the shared
ImageContentSniffer (:32), re-encodes to a
canonical 256x256 JPEG via IImageProcessor
(:23,52), uploads under a randomized blob name through
IFileStorageService (:60-66), and only then
persists the new URL, deleting the replaced blob after the save so a failure leaks one orphaned image
rather than breaking a live avatar (:74-82). RemoveUserAvatarHandler and
GetUserAvatarHandler are the trivial siblings on the same resource.
Persistence, seeding, and the disabled stub
ModuleApplicationDbContext (ModuleApplicationDbContext.cs:15) is the
abstract, engine-agnostic context declaring the single Users set (:22); the concrete per-engine
class (SQLServerDbContext today) inherits it, and the base
ApplicationDbContext supplies audit stamping,
soft-delete query filters, and outbox / domain-event dispatch via interceptors. Identity owns its own
ADC_Identity database with its own dbo.OutboxMessages, so it never races another service's outbox
(database-per-service, ADR-006). UserConfiguration
(MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12) maps the
Email value object through a value converter (:20-26),
mirrors the invariant length constants onto the columns, ignores the computed FullName and
IsExternalLogin members (:114-115), and pins four indexes that encode business rules as schema
([Rubric §8, Data Architecture]): unique Email (:117), a filtered index on RefreshToken for the
refresh lookup (:119-120), a filtered unique index on LinkedSpeakerId that enforces the 1:1
User-to-Speaker relationship of BR-208 (:122-124), and a filtered unique composite on
(LoginProvider, ProviderKey) for external accounts (:126-128).
SoftDeletedUserValidator
(MMCA.ADC.Identity.Application/Users/SoftDeletedUserValidator.cs:10) implements the shared
ISoftDeletedUserValidator (G08) with a single
filter-bypassing ExistsAsync (:21-24), so the request pipeline's soft-deleted-user middleware can
reject tokens belonging to erased accounts (BR-133).
Seeding is gated, not ambient. IdentityModuleSeeder
(MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:14) returns immediately unless
Seeding:IncludeSampleUsers is set (:28-30, defaulting to false so a production service that sets
nothing seeds nothing), and only then runs IdentityModuleDbSeeder
(MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:16), whose
deliberately weak development credentials are documented in its own remarks (:11-15) and whose three
seed steps each check for an existing email first, making the seeder idempotent (:27-29,37-41). When
the Identity module is disabled in a host, the IdentityModule descriptor registers
the DisabledAttendeeQueryService null-object stub through
RegisterDisabledStubs (IdentityModule.cs:19-20), so a consumer that only needs the attendee list
still composes.
Crossing the service boundary: gRPC and integration events
Identity talks to its peers two ways, and both live in Shared and Contracts so neither side reaches
into the other's domain ([Rubric §7, Microservices Readiness]). Synchronously, the Notification
service needs the set of active attendee user ids; it depends on the
IAttendeeQueryService interface, implemented in-process by
AttendeeQueryService
(MMCA.ADC.Identity.Application/Users/AttendeeQueryService.cs:11), a projected read of ids for users in
the Attendee role (:17-20). Once Identity runs as its own process, the composition root swaps in
AttendeeQueryServiceGrpcAdapter
(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/AttendeeQueryServiceGrpcAdapter.cs:14), which
implements the same C# interface over a generated client and pins a 5-second per-call deadline (:20)
far tighter than the shared resilience budget so a hung peer fails fast rather than stalling a broadcast
notification; AttendeesGrpcService
(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Grpc/AttendeesGrpcService.cs:19) serves the other
end by delegating to the in-process implementation. The swap itself is the Contracts-layer
AddIdentityAttendeeClient (MMCA.ADC.Identity.Contracts/DependencyInjection.cs:14), which uses
Replace rather than TryAdd so it overwrites both the real service and the disabled stub, and which
must run after ModuleLoader.DiscoverAndRegister. Consumer code never changes, only the registration
does (ADR-007, ADR-008). The extracted host itself runs h2c-only for cross-service gRPC, with an
optional HTTP/1.1-only health-probe listener added by KestrelConfiguration when
HealthProbe:Port is configured (MMCA.ADC.Identity.Service/KestrelConfiguration.cs:31-40, ADR-012).
Asynchronously, the User-to-Speaker link is kept consistent by events, not by a cross-database
foreign key. When a user registers, AuthenticationService raises
UserRegistered
(MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:23, a
BaseIntegrationEvent) on the aggregate, and the outbox
carries it to Conference, whose
UserRegisteredHandler runs the speaker
email-match auto-link (BR-207). Conference then publishes
SpeakerLinkedToUser /
SpeakerUnlinkedFromUser back, which
SpeakerLinkedToUserHandler
(MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerLinkedToUserHandler.cs:20) and
SpeakerUnlinkedFromUserHandler consume to set or clear
User.LinkedSpeakerId, so the speaker_id claim appears on the next token issued (eventual
consistency, BR-209). These handlers open their own DI scope (SpeakerLinkedToUserHandler.cs:31), are
idempotent (they return early when the link already matches, :43-46), and log-and-swallow every
non-cancellation exception (:53-56), because re-delivery over the broker is expected. This
event-carried link is what lets the bidirectional User-to-Speaker relationship survive the service split
([Rubric §6, CQRS and Event-Driven], ADR-006 / ADR-008).
The UI edge
The Blazor surface is registered as an IdentityUIModule
(MMCA.ADC.Identity.UI/IdentityUIModule.cs:13) descriptor that contributes two
NavItems as resource keys, "My Profile" for every signed-in
user and "Users" for Organizers (IdentityUIModule.cs:16-19, ADR-027), their routes coming from the
IdentityRoutePaths constants /profile and /users
(IdentityRoutePaths.cs:8-9). The Profile page
(MMCA.ADC.Identity.UI/Pages/Profile/Profile.razor.cs:15) lets an authenticated user change their
password, manage their avatar, and delete their account. It mirrors the server's 2 MB cap client-side
before any upload starts (Profile.razor.cs:25,125), and it accepts an image from either a browser file
input or, on MAUI, the camera and gallery through
IMediaPickerService
(Profile.razor.cs:19,86,88). It talks to the API through the IUserUIService
abstraction implemented by UserService
(MMCA.ADC.Identity.UI/Services/UserService.cs:14), an
AuthenticatedServiceBase subclass that
attaches the bearer token and calls the REST users resource. UserList
(MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16) is the Organizer-only management grid: a
DataGridListPageBase<UserListDTO> with server-side filtering, sorting, and paging on a desktop data
grid, plus a card-based infinite-scroll layout on mobile viewports, the two kept in sync by the shared
ListPageActions helper (UserList.razor.cs:39,76). The whole UI targets WCAG 2.1
AA, and the login, register, and profile flows are covered by axe-core scans in the deploy-gating E2E
suite ([Rubric §21, Accessibility], [Rubric §22, Responsive and Cross-Browser]).
End-to-end: one registration
To see the chapter cooperate, follow a new attendee signing up. AuthController
receives the register POST, captures the client IP for BR-213 rate limiting (AuthController.cs:48),
and calls RegisterAsync on AuthenticationService, which opens one
transaction (AuthenticationService.cs:61) and hands off to the shared G08 engine. The request shape
was already checked by RegisterRequestValidator
(MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12) in the pipeline, so
the engine only has to confirm the email is not taken (query-filter-bypassed, so an erased address stays
reserved), call User.Create(...) with the Attendee role, hash the password, add the aggregate, and
save. Only after that first save, when the EF identity id exists, does OnUserRegisteredAsync raise
UserRegistered and save again (AuthenticationService.cs:105-110); both saves sit
inside the one transaction, so the user row and its outbox row commit atomically (ADR-003). The first
token returned does not yet carry speaker_id. Asynchronously, Conference matches the email to a
speaker and publishes SpeakerLinkedToUser; SpeakerLinkedToUserHandler
sets User.LinkedSpeakerId, and the attendee's next token carries the claim. No password left the
domain in plaintext, no cross-database foreign key was written, no event was hand-dispatched, and the
same code path behaves identically whether Identity runs inside the monolith or as its own service,
which is exactly the property the framework groups (G01 through G15) exist to provide. For the why
behind each choice, ADR-003 (outbox), ADR-004 (JWKS), ADR-005 (soft-delete versus erasure),
ADR-006 / 007 / 008 (database-per-service, gRPC extraction, service topology), ADR-012 (mixed Kestrel
endpoint profile), ADR-020 (permission registry), ADR-027 / ADR-028 (culture and theme), ADR-029 (login
protection), and ADR-045 (file storage and avatars) are the primary references.
AssemblyReference
MMCA.ADC.Identity.{API,Application} ·
MMCA.ADC.Identity.{API,Application}·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/AssemblyReference.cs:5· Level 0 · class (static)
- What it is: the per-layer assembly marker, one static class holding the layer's own
Assemblyand itsAssemblyNamestring. It carries no behavior; it exists so reflection-driven code can name an assembly without a magic string. This unit covers the API and Application copies (the Domain and Infrastructure copies are byte-identical and belong to their own layers).
| Type | File:Line | Notes (what differs) |
|---|---|---|
AssemblyReference (API) |
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/AssemblyReference.cs:5 |
resolves to MMCA.ADC.Identity.API |
AssemblyReference (Application) |
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/AssemblyReference.cs:5 |
resolves to MMCA.ADC.Identity.Application |
- Depends on:
System.Reflectiononly (BCL). No first-party types. - Concept introduced, the assembly marker. The pattern is taught for the framework's own layers in G14; this is its Identity realization.
Assemblyis initialized fromtypeof(AssemblyReference).Assembly(AssemblyReference.cs:7), so it always resolves to the assembly that declares the marker: that is why the type is duplicated per layer instead of shared.[Rubric §15, Best Practices & Code Quality](assesses idiomatic, low-ceremony conventions): atypeofhandle survives a project rename, aAssembly.Load("MMCA.ADC.Identity.Application")string does not. - Walkthrough: two
public static readonlyfields,Assembly(:7) andAssemblyName = Assembly.GetName().Name ?? string.Empty(:8). The null-coalescing guard is there becauseAssemblyName.Nameis declared nullable in the BCL. - Why it's built this way: static readonly fields are computed once at type initialization, so the reflection cost is paid a single time per process rather than at every scan site.
- Where it's used: as the stable handle for assembly-scanning code (Scrutor convention registration, EF configuration discovery, architecture tests). The scanning call itself takes the sibling
ClassReferenceas its generic argument.
ChangePreferencesRequest
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesRequest.cs:10· Level 0 · record (sealed)
- What it is: the inbound payload that updates one user's stored UI preferences, their preferred culture and their preferred theme.
- Depends on: nothing first-party. Two
string?positional parameters, no BCL types beyondrecord. - Concept introduced, the partial-update DTO where
nullmeans "leave unchanged".[Rubric §9, API & Contract Design](assesses unambiguous payload semantics) and[Rubric §27, Internationalization](assesses whether locale is a persisted, first-class user choice rather than a per-request setting). The record is(string? Culture, string? Theme)(ChangePreferencesRequest.cs:10), and the doc comment (:3-9) states the contract: anullfield leaves that preference untouched. That is what lets two independent UI affordances, the app-bar culture switcher (which sends onlyCulture) and the theme toggle (which sends onlyTheme), share one endpoint without clobbering each other. The theme half is also the[Rubric §20, Design System & Theming]story: a persisted light/dark choice rather than a per-tab toggle. - Walkthrough: a single-line positional record (
:10) with no body. No validation attributes: the allow-list check for culture and the light/dark check for theme run in the domain (User.UpdatePreferences), reached throughChangePreferencesHandler. - Why it's built this way: nullable fields give partial-update semantics without a JSON Patch document or per-field "was it set" flags, and keeping the validation in the aggregate means the rules travel with the model rather than with this DTO.
- Where it's used: carried as the
Requestmember ofChangePreferencesCommand, bound from the request body byUsersController.
ClassReference
MMCA.ADC.Identity.{API,Application} ·
MMCA.ADC.Identity.{API,Application}·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/AssemblyReference.cs:11· Level 0 · class
- What it is: an empty, member-less class that exists purely to be a type argument. Generic scanning APIs of the shape
DoSomething<T>()derive the target assembly fromtypeof(T).Assembly, so each layer ships its ownClassReferenceto point such a call at itself.
| Type | File:Line | Notes (what differs) |
|---|---|---|
ClassReference (API) |
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/AssemblyReference.cs:11 |
declared but not used by the API layer's own registration |
ClassReference (Application) |
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/AssemblyReference.cs:11 |
the T in ScanModuleApplicationServices<ClassReference>() |
- Depends on: nothing.
public class ClassReference { }, no base type beyondobject, no members. - Concept introduced: cross-reference
AssemblyReferenceabove. The two solve the same problem from opposite directions:AssemblyReferencehands out anAssemblyvalue,ClassReferencehands out a type that a generic constraint-free method can turn into one. - Walkthrough: the whole declaration is one line (
AssemblyReference.cs:11). It is deliberately notstaticand notsealed, because a static class cannot be used as a generic type argument. - Why it's built this way:
ScanModuleApplicationServices<T>()reads better and refactors more safely than passing anAssemblyargument, and a dedicated empty type avoids accidentally anchoring the scan to some real class that might later move to another project. - Where it's used: the Application copy is the type argument at
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:37(seeDependencyInjectionfor the Application layer). - Caveats / not-in-source: whether the API-layer copy has an active consumer is
Not determinable from sourcewithin this unit; the API registration (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:42-58) does not reference it.
IdentityErrorResources
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Resources·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Resources/IdentityErrorResources.cs:11· Level 0 · class (sealed)
- What it is: an empty "resource anchor" type for the Identity module's localized error messages. It has no members; its only job is to be a
typeof(...)handle that the localization layer uses to find the co-located.resxfiles (ADR-027). - Depends on: nothing first-party. At runtime its
.resxsiblings are loaded throughSystem.Resources/IStringLocalizerFactory(BCL and ASP.NET Core). - Concept introduced, edge error-message localization keyed by error
Code.[Rubric §27, Internationalization](assesses whether user-facing strings, error text included, are translated rather than English-only). ADR-027 localizes failures at the API edge: a domainError'sCode(for example"User.Email.Empty") is the resource key, and the sharedIErrorLocalizerlooks that key up across every registered resource source before the failure is written into the ProblemDetails response. Each module contributes translations additively by registering its own anchor type, so Identity's strings live inIdentityErrorResources.resx/IdentityErrorResources.es.resxrather than in one central framework file. - Walkthrough: the class body is empty (
IdentityErrorResources.cs:11-13); everything worth knowing is in the doc comment (:3-10), which records two design points: keys are the domain errorCode, and runtime-variable messages (those that interpolate a user-supplied value) are deliberately omitted from the.resxso they degrade to their English message with the value intact instead of showing a broken or value-less translation. - Why it's built this way:
AddErrorResources<TResource>()(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:103) builds anIStringLocalizerfromtypeof(TResource)and appends it to the set of error-resource sources; the convention "a.resxnamed after the type, sitting beside it" is what binds the strings, so an empty marker class is exactly enough. - Where it's used: registered at startup by the extracted Identity host,
services.AddErrorResources<IdentityErrorResources>()(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:232).
IExternalLoginEmailVerifier
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11· Level 0 · interface
- What it is: a one-method port that answers a single question about the OAuth login currently in flight: did the external provider explicitly assert that this email address is verified? It is the gate that decides whether an external identity may be auto-linked to an existing local account.
- Depends on: nothing first-party. The single method returns
Task<bool>and takes no arguments, deliberately: the "current external login" is ambient request state, resolved by the implementation, not passed by the caller. - Concept introduced, the account-takeover guard as an explicit port.
[Rubric §11, Security](assesses whether authentication trust decisions are explicit and fail closed) and[Rubric §3, Clean Architecture](assesses dependencies pointing inward). Linking an external identity to a local account on nothing but an email match is a takeover primitive: any provider that hands out unverified email addresses would let an attacker register the victim's address and inherit the victim's account. The verified-email assertion lives in the short-livedExternalLogincookie principal, which is an HTTP concern, so the decision input is declared here as an interface in the Application layer and implemented at the API edge. Application code stays free ofHttpContext, and the security rule stays testable with a two-line fake.[Rubric §1, SOLID]: an interface with exactly one method and exactly one reason to change. - Walkthrough:
Task<bool> IsCurrentExternalLoginEmailVerifiedAsync()(IExternalLoginEmailVerifier.cs:19). The XML comment (:13-18) fixes the semantics precisely:trueonly when the provider explicitly asserts verification (Google'semail_verifiedclaim); providers that assert nothing (GitHub's OAuth flow) yieldfalse. There is no third "unknown" state, unknown is treated as unverified. - Why it's built this way: fail-closed by construction. Because the contract collapses "not verified" and "no assertion" into
false, adding a new provider cannot silently open the auto-link path; it stays closed until someone deliberately maps a verification claim for it. - Where it's used: consumed by
AuthenticationServiceinside the external-login workflow (AuthenticationService.cs:173-182); implemented byHttpContextExternalLoginEmailVerifier, which re-authenticates theExternalLoginscheme and reads theemail_verifiedclaim (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:32-35), returningfalsewhen there is noHttpContext, no principal, or no parseable claim.
DependencyInjection
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:18· Level 2 · class (static)
- What it is: the API-layer composition root for the Identity module. It exposes
AddIdentityModule(...), the single call that registers every layer of the module, plusAddModuleIdentityAPI(), which declares the module's role-to-permission grants and wires the OAuth email-verification gate. - Depends on:
IServiceCollectionandTryAddScoped(Microsoft.Extensions.DependencyInjection);ApplicationSettings; the Application-layerAddModuleIdentityApplication(seeDependencyInjectionfor the Application layer) and the Infrastructure-layerAddModuleIdentityInfrastructure;AuthorizationExtensions'sAddPermissions, plusIdentityPermissionsandRoleNames;IExternalLoginEmailVerifierandHttpContextExternalLoginEmailVerifier. - Concept introduced, the layered DI fan-out via
extension(IServiceCollection).[Rubric §3, Clean Architecture](assesses inward-pointing dependencies and a single composition point per module): the API layer is the only layer that can see all the others, so it owns the aggregate registration. The method hangs offIServiceCollectionthrough the C#extension(IServiceCollection services)block (DependencyInjection.cs:20), the workspace idiom for DI registration (see primer §4).[Rubric §16, Maintainability]: the three-call body mirrors the layering, so registration order matches dependency order and there is exactly one place to look when wiring changes. - Walkthrough
AddIdentityModule(ApplicationSettings)(:27-34) callsAddModuleIdentityApplication(applicationSettings)(:29),AddModuleIdentityInfrastructure()(:30), andAddModuleIdentityAPI()(:31), then returnsservicesfor chaining (:33).AddModuleIdentityAPI()(:42-58) does two things. First it callsAddPermissionsand grants every capability inIdentityPermissionsto bothRoleNames.Organizer(:46) andRoleNames.Admin(:47) via the spread[.. IdentityPermissions.All]; those grants are what back the module's[HasPermission(...)]-gated endpoints. Second it registers the OAuth auto-link gate:AddHttpContextAccessor()(:54) plusTryAddScoped<IExternalLoginEmailVerifier, HttpContextExternalLoginEmailVerifier>()(:55). The inline comment (:50-53) explains the placement: the verified-email assertion lives in the external-login cookie principal, so the verifier is an API-edge concern.- Controllers are not registered here; ASP.NET Core's controller convention discovers them (doc comment
:36-41).
- Why it's built this way: one entry point per module is what
IdentityModulecalls, so module wiring stays discoverable; declaring the role-to-permission grants inside the module that owns the endpoints keeps the capability model co-located with the code it protects instead of in a central authorization file every module must reach into.TryAddScoped(rather thanAddScoped) leaves the door open for a host to pre-register a different verifier. - Where it's used:
AddIdentityModuleis invoked byIdentityModule'sRegister(IdentityModule.cs:23-24) during topological module registration by theModuleLoader.
IdentityModule
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:13· Level 3 · class (sealed)
- What it is: the Identity module's entry point, the concrete
IModulethat theModuleLoaderdiscovers by reflection and registers. Identity is a leaf in the module dependency graph: it declares no prerequisites. - Depends on:
IModule;ApplicationSettings; its ownDependencyInjection'sAddIdentityModule;IAttendeeQueryServiceandDisabledAttendeeQueryServicefrom the Shared layer;Microsoft.Extensions.{Configuration,DependencyInjection}. - Concept introduced, the disabled-module stub. The module contract itself is taught in G14; the Identity-specific lesson is
RegisterDisabledStubs.[Rubric §7, Microservices Readiness](assesses whether modules compose and deploy independently): every ADC host boots the same module assemblies but enables only some of them. A host with Identity disabled still contains consumers that depend onIAttendeeQueryService(Notification needs the attendee id list), so this method registersDisabledAttendeeQueryServiceas a singleton (IdentityModule.cs:19-20), a stub that returns an empty list. DI validation succeeds, the consumer degrades gracefully, and in the extracted topology the composition root later replaces that stub with a gRPC-backed adapter. - Walkthrough: three members, all one-liners.
Name => "Identity"(:16) is the topological-sort key and the value the loader logs.RegisterDisabledStubs(IServiceCollection)(:19-20) registers the stub singleton.Register(IServiceCollection, IConfigurationBuilder, ApplicationSettings)(:23-24) delegates straight toservices.AddIdentityModule(applicationSettings). No dependency-declaration members are overridden, so the interface defaults apply (a leaf). There is deliberately no seeding here: that is a separateIModuleSeeder,IdentityModuleSeeder. - Why it's built this way: the module boundary is what makes each module extractable into its own service host without a rewrite (ADR-007 / ADR-008). In the extracted
MMCA.ADC.Identity.Serviceonly this module is enabled; every other service registers the disabled stub and then overwrites it with a gRPC client. Application code never learns which transport it got. - Where it's used: discovered and registered in Kahn-topological order by the
ModuleLoaderat host startup;RegisterDisabledStubsruns in hosts where the Identity module is not enabled.
ChangePreferencesCommand
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:12· Level 7 · record (sealed)
- What it is: the command that persists one user's culture/theme preferences, the write side of ADR-027 / ADR-028. It pairs the target
UserIdwith the partialChangePreferencesRequestand opts into cache invalidation so a preference change cannot be masked by a stale cached read. - Depends on:
ChangePreferencesRequest; theUserIdentifierTypealias (= int, see primer §2);User(only fortypeof(User).FullName);ICacheInvalidating. - Concept introduced, the cache-invalidating command.
[Rubric §6, CQRS & Event-Driven](assesses commands as explicit, named intentions separated from reads) and[Rubric §12, Performance & Scalability](assesses caching with correct invalidation). The record implementsICacheInvalidating(ChangePreferencesCommand.cs:12) and exposesCachePrefix => $"{typeof(User).FullName}:"(:15). The caching decorator in the command pipeline reads that prefix and evicts every entry under it once the handler succeeds. Deriving the prefix fromtypeof(User).FullNamerather than a literal keeps it in lockstep with the key the user cache actually uses: rename or move the type and the prefix follows. - Walkthrough: a two-parameter positional record,
(UserIdentifierType UserId, ChangePreferencesRequest Request)(:12), plus the single computedCachePrefixproperty (:15).UserIdis supplied by the controller from the authenticated principal, never from the request body;Requestis the partial payload. - Why it's built this way: separating "who" (from the JWT) from "what" (from the body) makes it structurally impossible for a request to target another user's preferences, and expressing invalidation as an interface the command implements keeps eviction a cross-cutting decorator concern instead of handler boilerplate.
- Where it's used: handled by
ChangePreferencesHandler; dispatched byUsersControllerfrom the profile page and the app-bar culture/theme switchers.
AttendeeQueryService
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AttendeeQueryService.cs:11· Level 8 · class (sealed)
- What it is: Identity's in-process implementation of the cross-module
IAttendeeQueryServicecontract. It answers one question, "which user ids hold the Attendee role", and it is the only way another module gets that answer without touching the Identity domain. - Depends on:
IAttendeeQueryService(the Shared-layer contract);IUnitOfWorkand the read side ofIRepository<TEntity, TIdentifierType>;UserandUserRole(Domain); theUserIdentifierTypealias. - Concept introduced, serving data across a module boundary through a Shared-layer contract.
[Rubric §7, Microservices Readiness](assesses whether cross-module needs are met by explicit contracts rather than direct type references) and[Rubric §3, Clean Architecture]. Notification must fan a broadcast out to every attendee, but it must never referenceMMCA.ADC.Identity.Domain. The interface therefore lives inMMCA.ADC.Identity.Shared, the implementation here in Application, and Notification sees only the interface. That indirection is exactly what later allows the same call to be satisfied over gRPC with no change at the call site.[Rubric §12, Performance & Scalability]: the query projects to ids in the database rather than materializing wholeUserrows, so the wire and the heap only ever carry integers. - Walkthrough (primary-constructor injection of
IUnitOfWork,:11)- Read repository (
:16),unitOfWork.GetReadRepository<User, UserIdentifierType>(), the read-only repository facade rather than the mutating one, so the intent is visible in the type. - Projected query (
:17-20),GetProjectedAsync(u => u.Id, u => u.Role == UserRole.Attendee, cancellationToken: cancellationToken). The first lambda is the SELECT projection, the second the WHERE predicate; the global soft-delete query filter is left in force, so erased accounts are excluded automatically (that is what "active users" in the contract means, no explicitIsDeletedtest appears here). - Shape the result (
:22),userIds as IReadOnlyList<UserIdentifierType> ?? [.. userIds]: the repository returnsIReadOnlyCollection<T>, the contract promisesIReadOnlyList<T>, so the cast is attempted first and a collection-expression copy is the fallback. No allocation when the underlying instance is already a list.
- Read repository (
- Why it's built this way: pushing the role predicate and the id projection into the database keeps a broadcast cheap even as the attendee count grows, and relying on the global query filter for soft-delete means this service can never accidentally diverge from the rest of the system's definition of "deleted" (ADR-005).
- Where it's used: registered as the
IAttendeeQueryServiceimplementation by the Application-layerDependencyInjection(DependencyInjection.cs:33); consumed by the Notification module. In the extracted topology the registration is replaced byAttendeeQueryServiceGrpcAdapter, and this class becomes the code behindAttendeesGrpcServiceon the Identity side.
ChangePreferencesHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:13· Level 8 · class (sealed, partial)
- What it is: the command handler that loads the target
User, applies a partial preference update where each omitted field falls back to the stored value, saves, and logs. - Depends on:
ICommandHandler<TCommand, TResult>from the CQRS pipeline;IUnitOfWork;Userand itsUpdatePreferencesmethod;Result/Error;Microsoft.Extensions.Loggingand its[LoggerMessage]source generator. - Concept introduced, the null-coalescing merge plus source-generated logging.
[Rubric §6, CQRS & Event-Driven](a command handler mutates and saves; it does not shape reads) and[Rubric §27, Internationalization](persisting the locale choice is what makes it survive a new session). The merge is one expression (ChangePreferencesHandler.cs:27-29):user.UpdatePreferences(command.Request.Culture ?? user.PreferredCulture, command.Request.Theme ?? user.PreferredTheme). A request carrying onlyCulturere-supplies the current theme, and vice versa, which is precisely why a one-field switcher cannot wipe the other preference. Validation is not the handler's job:User.UpdatePreferencescombines the culture allow-list and light/dark invariants and returns aResultthat this handler simply propagates.[Rubric §13, Observability & Operability]: the success log goes through a[LoggerMessage]-generated method (:39-40), which is why the class must be declaredpartial; the generator emits an allocation-free, strongly-typed log call instead of a boxedILogger.LogInformationinvocation. - Walkthrough (primary constructor
:13-15, body:18-37)- Fetch (
:22-23),unitOfWork.GetRepository<User, UserIdentifierType>()thenGetByIdAsync(command.UserId, cancellationToken). - Not-found guard (
:24-25), anulluser returnsResult.Failure(Error.NotFound.WithSource(nameof(ChangePreferencesHandler)).WithTarget(nameof(User))), the fluent error-tagging builders that let the edge report what was missing without a bespoke error type. - Apply (
:27-29), the null-coalescedUpdatePreferencescall, which returns aResultcarrying any invariant failure. - Persist and log (
:30-34), only whenresult.IsSuccessdoes itawait unitOfWork.SaveChangesAsync(cancellationToken)and emitLogPreferencesChanged(logger, command.UserId). - Return (
:36), the domainresultunchanged, success or failure.
- Fetch (
- Why it's built this way: guarding both the save and the log behind
IsSuccessmeans a rejected culture or theme produces a clean failure (mapped to a 400 at the edge) with no write and, just as important, no misleading "preferences changed" log line for an operator to chase. - Where it's used: discovered by Scrutor convention scanning (one
ICommandHandlerper command) and invoked byUsersController; wrapped by the decorator pipeline, whose caching decorator performs the eviction declared onChangePreferencesCommand. - Caveats / not-in-source:
UpdatePreferencespersists the two columns but raises no domain event, so a preference change writes the row and publishes nothing to the outbox. Whether a given database has the preference columns is an operational fact about applied migrations, not visible in this source file.
SoftDeletedUserValidator
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/SoftDeletedUserValidator.cs:10· Level 8 · class (sealed)
- What it is: Identity's implementation of the framework contract
ISoftDeletedUserValidator(BR-133). It answers "is this user id a soft-deleted account?" so the shared middleware can reject a token that outlived its account. - Depends on:
ISoftDeletedUserValidator(MMCA.Common.Application);IUnitOfWorkandIRepository<TEntity, TIdentifierType>;User; theUserIdentifierTypealias. - Concept introduced, deliberately bypassing the global query filter.
[Rubric §11, Security](assesses whether revoked access is enforced on every request, not just at login) and[Rubric §8, Data Architecture](assesses soft-delete as a first-class concept). Access tokens are self-contained and valid until they expire, so deleting an account does not by itself stop the already-issued token: the framework closes that window with a per-request check. The catch is that a soft-deletedUseris invisible to normal queries, the EF global query filter removes it, so an "is deleted" test written the obvious way would always find nothing. This class therefore passesignoreQueryFilters: true(SoftDeletedUserValidator.cs:23), the one sanctioned place to look past the filter.[Rubric §12, Performance & Scalability]: the check is a singleExistsAsync(anEXISTSprobe) rather than a load-then-inspect, because it runs on every authenticated request. - Walkthrough (primary constructor
:10-11, method:14-25): resolve the repository (:18), thenrepository.ExistsAsync(u => u.Id == userId && u.IsDeleted, ignoreQueryFilters: true, cancellationToken: cancellationToken)(:21-24). The predicate deliberately fuses both conditions so one round trip covers "exists" and "is deleted", as the inline comment (:20) notes. A never-existing id and a live id both returnfalse; only a soft-deleted row returnstrue. - Why it's built this way: the contract lives in
MMCA.Common.Applicationand the implementation here so the framework middleware never references the ADC Identity domain, the same inversion used forIAttendeeQueryService. It also means a host without Identity simply has no implementation registered, and the middleware skips the check rather than failing. - Where it's used: resolved lazily per request by
SoftDeletedUserMiddleware(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:53), which returns HTTP 401 when the answer istrue(:69). Registered by the Application-layerDependencyInjection(DependencyInjection.cs:32).
AuthenticationService
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35· Level 9 · class (sealed)
- What it is: ADC's authentication service. The generic login / registration / refresh / revocation workflow is inherited from
AuthenticationServiceBase<TUser>; this subclass supplies the ADC-specific pieces: the Attendee default role, thespeaker_idclaim, the outbox-atomicUserRegisteredintegration event, and the entire external OAuth login flow. - Depends on:
AuthenticationServiceBase<TUser>andIAuthenticationService;IUnitOfWork,ITokenService,IPasswordHasher,ILoginProtectionService,AuthenticationValidators,TimeProvider(BCL);IExternalLoginEmailVerifier;User,UserRole,UserRegistered,Email,Result/Error;AuthenticationResponseandRegisterRequest. - Concept introduced (1), the template-method base with app-specific hooks.
[Rubric §2, Design Patterns](assesses whether recurring shapes use a named, understood pattern) and[Rubric §16, Maintainability]. The base owns the security-critical sequence: validate first, ADR-029 lockout and registration rate limits, refresh-token rotation with reuse detection (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:134-187). What varies per application is expressed as four abstract hooks and one virtual one, and this class overrides exactly those:FindUntrackedByEmailAsync(AuthenticationService.cs:66-74), an untracked read used by the login path.EmailExistsAsync(:78-79), the registration uniqueness probe, withignoreQueryFilters: trueso a soft-deleted (erased) account's email cannot be re-registered (:77), the same filter-bypass reasoning asSoftDeletedUserValidator.CreateUser(:82-89), which fixes ADC's default role asUserRole.Attendee(BR-45).CreateAccessToken(:92-93), which appends thespeaker_idclaim when the account is linked to a speaker; the claim is built by the privateSpeakerClaimshelper (:228-229), returningnullwhenLinkedSpeakerIdhas no value so the claim is simply absent rather than empty (BR-209).OnUserRegisteredAsync(:105-110), the post-save hook, below.
- Concept introduced (2), the outbox-atomic registration event, and why it needs two saves in one transaction.
[Rubric §6, CQRS & Event-Driven],[Rubric §8, Data Architecture],[Rubric §29, Resilience & Business Continuity].User.Idis a database-generated identity column, so at the moment the aggregate is created the id is still0. The outbox serializes an event's payload at capture time, so raisingUserRegisteredbefore the insert would persistUserId = 0, and the cross-service Conference consumer, which has no access to the Identity database to re-match by email, could never resolve it. The fix is visible in two places:RegisterAsync(:57-63) re-implements the interface member withnewand wraps the base implementation inUnitOfWork.ExecuteInTransactionAsync(token => base.RegisterAsync(request, ipAddress, token), cancellationToken). The interface is re-listed on the class declaration (:44) specifically so this override wins for callers holding anIAuthenticationService, since the base method is not virtual.OnUserRegisteredAsync(:105-110) runs after the base's first save, when the identity value exists: it callsuser.AddDomainEvent(new UserRegistered(user.Id, user.Email, user.FirstName, user.LastName, user.Role))(:107) and saves a second time (:108) so the outbox row is captured. Both saves sit inside the one transaction opened byRegisterAsync, so a crash before commit rolls back user and event together, and after commit the outbox processor guarantees delivery. The class doc comment (:21-33) records that this replaced an earlier second-commitIEventBuspublish whose crash window lost the speaker link permanently.
- Concept introduced (3), the three-way external-login resolution with a takeover guard.
[Rubric §11, Security].ExternalLoginAsync(:123-132) is again a transaction wrapper aroundExternalLoginCoreAsync(:135-223), which resolves the caller into exactly one of three cases:- Known external identity (
:144-149), a tracked lookup byLoginProviderandProviderKey. Found means log in. - Email matches an existing local account (
:157-186), the risky case. Before linking, it awaitsexternalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync()(:173-174) and, when the provider did not assert a verified email, returnsError.Unauthorized("Auth.ExternalEmailNotVerified", ...)(:178-182) telling the user to log in with their password instead. Only a verified assertion reachesexistingUser.LinkExternalProvider(loginProvider, providerKey)(:184). The comment (:167-172) states the threat plainly: without this gate, any provider that hands out unverified emails would let an attacker claim a victim's local account. - Brand new user (
:190-198),User.CreateExternal(...), failure propagated asResult.Failure<AuthenticationResponse>(userResult.Errors)(:193), otherwiseRepository.AddAsyncandisNewUser = true. All three paths then converge (:202-222): mint and store a refresh token withTimeProvider.GetUtcNow().UtcDateTime.Add(RefreshTokenLifetime)(:203), save (:205), and, for a new user only, raiseUserRegisteredand save again (:211-215), the same post-identity pattern as local registration. Finally it mints the access token (:217) and returnsAuthenticationResponsewith the access-token expiry (:219-222).
- Known external identity (
- Why it's built this way: the base class keeps every application on one audited auth workflow, so a fix to lockout or refresh-token reuse detection lands once in
MMCA.Commonrather than per app; the hooks keep ADC's divergences (role, claim, event) small and named. TheUserRegisteredintegration event, rather than an in-process domain event, is a deliberate divergence from MMCA.Store, because Conference runs in a separate process with its own database and can only learn about a registration asynchronously (ADR-003 outbox, ADR-006 database-per-service). The eventual-consistency cost is explicit and documented: the first token issued does not yet carryspeaker_id, and the claim appears on the next refresh once Conference has publishedSpeakerLinkedToUserback. - Where it's used: registered as the
IAuthenticationServiceimplementation by the Application-layerDependencyInjection(DependencyInjection.cs:30); driven byAuthControllerfor local credentials andOAuthControllerfor the social paths. - Caveats / not-in-source: whether
email_verifiedis actually mapped for a given provider is host configuration, not visible here; the verifier's own doc comment (HttpContextExternalLoginEmailVerifier.cs:12-14) records that Google is mapped by a claim action in the service host and that GitHub asserts nothing.
IdentityModuleSeeder
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:14· Level 9 · class (sealed)
- What it is: the Identity module's startup data seeder. It is a thin
IModuleSeederthat checks a configuration gate, resolves its dependencies from the host service provider, and delegates the actual inserts (default Organizer and Attendee accounts) to the Infrastructure-levelIdentityModuleDbSeeder. - Depends on:
IModuleSeeder;IConfigurationandIServiceProvider(Microsoft.Extensions);IUnitOfWork;IPasswordHasher;IdentityModuleDbSeeder. - Concept introduced, the config-gated seeder bridge.
[Rubric §8, Data Architecture](assesses deterministic, repeatable startup state),[Rubric §11, Security], and[Rubric §3, Clean Architecture](the API layer orchestrates, Infrastructure persists). Two design points matter here. First, the gate: the seeded accounts carry deliberately weak, well-known credentials, so seeding is opt-in viaSeeding:IncludeSampleUsers(IdentityModuleSeeder.cs:28) and the method returns immediately when it is false (:29-30).GetValue<bool>defaults tofalsewhen the key is absent, so a production service that configures nothing seeds no accounts at all, exactly the same shape as the conference sample-data gate (comment:24-27). Second, the bridge: this class resolves services from the provider instead of taking them in a constructor, then constructs the DB seeder by hand (:34). - Walkthrough (
:14-36):ModuleName => "Identity"(:17) identifies the seeder to the loader.SeedAsync(IServiceProvider, CancellationToken)(:20-36) resolvesIConfiguration(:22), evaluates the gate (:28-30), then resolvesIUnitOfWork(:32) andIPasswordHasher(:33) withGetRequiredService(a missing registration fails loudly at startup), constructsnew IdentityModuleDbSeeder(unitOfWork, passwordHasher)(:34), and awaits itsSeedAsync(:35).IPasswordHasheris required because the seed data holds plaintext passwords that must be hashed before they touch the database (doc comment:9-13). - Why it's built this way:
IModuleSeeder.SeedAsyncruns inside a scope the loader creates after the host is fully built, so constructor-injecting scoped services would tie the seeder's own lifetime to that scope. Resolving at the boundary keeps the object simple and scope-agnostic. Keeping the EF insert logic in the Infrastructure*DbSeederkeeps the API assembly free of persistence detail and leaves the insert logic independently testable. - Where it's used: discovered through the
IModuleSeederinterface and invoked at host startup after the database initialization strategy has created or migrated the schema.
DependencyInjection
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:16· Level 10 · class (static)
- What it is: the Application-layer registration for Identity. It explicitly binds four services that convention scanning cannot infer, then runs Scrutor scanning to auto-register every handler, mapper, validator, and domain-event handler in the assembly.
- Depends on:
IServiceCollectionandTryAddScoped;ApplicationSettings;IAuthenticationService/AuthenticationService,AuthenticationValidators,ISoftDeletedUserValidator/SoftDeletedUserValidator,IAttendeeQueryService/AttendeeQueryService; theScanModuleApplicationServices<T>helper fromMMCA.Common.Application;ClassReference. - Concept introduced, explicit registration for the ambiguous, convention scanning for the rest.
[Rubric §2, Design Patterns]and[Rubric §16, Maintainability]. Handlers, mappers, and validators follow a one-interface-one-implementation convention, so Scrutor can find them:services.ScanModuleApplicationServices<ClassReference>()(DependencyInjection.cs:37) means adding a new use-case slice needs no DI edit at all. The four services that are not convention-discoverable, because their interfaces live in other assemblies or have more than one plausible implementation, are registered by hand (:30-33).TryAddScopedrather thanAddScopedis the load-bearing detail: a host that has already registered an override (a gRPC-backedIAttendeeQueryService, for instance) keeps it, and the module does not clobber it. - Walkthrough:
AddModuleIdentityApplication(ApplicationSettings)(:26-40) lives inside anextension(IServiceCollection services)block (:18). Body order:_ = applicationSettings;(:28) discards the parameter with a comment marking it reserved for future decorator configuration; thenIAuthenticationServicetoAuthenticationService(:30), theAuthenticationValidatorsparameter object as a concrete registration (:31),ISoftDeletedUserValidatortoSoftDeletedUserValidator(:32),IAttendeeQueryServicetoAttendeeQueryService(:33); then the Scrutor scan (:37) andreturn services(:39). - Why it's built this way: mixing explicit and convention registration keeps the common case zero-ceremony while retaining precise control over the handful of services that need a specific lifetime or an override point. Registering the two cross-boundary implementations (
ISoftDeletedUserValidator,IAttendeeQueryService) here is what closes the inversion those contracts set up: the framework and the Notification module declare the need, Identity satisfies it. - Where it's used: called by the API-layer
DependencyInjection'sAddIdentityModule(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:29), whichIdentityModulecalls in turn. - Caveats / not-in-source: whether
applicationSettingswill ever be consumed isNot determinable from source; today it is only discarded (:28), taken solely to keep the signature uniform across modules.
AssemblyReference
MMCA.ADC.Identity.Domain + MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.{Domain,Infrastructure}· Level 0 · class (static) · two layer instances (table below)
- What it is: the static assembly-marker for the Identity module's Domain and Infrastructure layers. Each is a handle onto its own assembly (
Assembly) plus that assembly's simple name (AssemblyName), used as a stable, string-free anchor for assembly scanning. - Depends on:
System.Reflection.Assembly(BCL) only.
| Type (assembly) | File:Line | Notes |
|---|---|---|
| AssemblyReference (Identity.Domain) | MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/AssemblyReference.cs:5 |
Domain-layer marker; same two fields |
| AssemblyReference (Identity.Infrastructure) | MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:5 |
Infrastructure-layer marker; byte-identical shape, different namespace |
- Concept: the assembly-marker idiom is taught at its first appearance,
AssemblyReference; these are the Identity Domain and Infrastructure instances of the same shape.[Rubric §5, Vertical Slice](assesses feature cohesion and convention-driven wiring): a marker lets Scrutor and the EF configuration scanner target an assembly viatypeof(AssemblyReference).Assemblyrather than a brittle string literal, so adding a slice needs no registration edit. - Walkthrough: in each file, two
static readonlyfields,Assembly = typeof(AssemblyReference).Assembly(line 7) andAssemblyName = Assembly.GetName().Name ?? string.Empty(line 8). No methods. The two declarations differ only in their namespace (...Domainvs...Infrastructure). - Why it's built this way: every module layer ships the identical marker so generic scanning code can be told which assembly to scan without referencing a concrete business type; the layer is identified by which
AssemblyReferenceyou hand it. - Where it's used: the Identity module's EF configuration / seeder assembly scan (Infrastructure marker) and any reflection needing a layer assembly handle; the registration machinery lives in G14, Module System & Composition. The remaining layers (API, Application, Shared, UI) ship their own
AssemblyReference, covered in the sibling parts of this chapter.
ClassReference
MMCA.ADC.Identity.Domain + MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.{Domain,Infrastructure}· Level 0 · class · two layer instances (table below)
- What it is: an empty, non-static companion class (
public class ClassReference { }) that lives beside eachAssemblyReference. It exists so a scanning API that needs an instantiable generic type argument (for example Scrutor'sFromAssemblyOf<T>) has one to reference from the assembly, sinceAssemblyReferenceis static and some APIs reject a static type asT. - Depends on: nothing.
| Type (assembly) | File:Line | Notes |
|---|---|---|
| ClassReference (Identity.Domain) | MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/AssemblyReference.cs:11 |
empty marker, same file as the Domain AssemblyReference |
| ClassReference (Identity.Infrastructure) | MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:11 |
empty marker, same file as the Infrastructure AssemblyReference |
- Concept: cross-reference the marker idiom under
AssemblyReferenceand its first teaching in G17, Conference Domain. - Walkthrough: no members (
public class ClassReference { }, line 11 of each file). - Why it's built this way:
FromAssemblyOf<ClassReference>()needs an instantiableT; this provides one per layer without exposing any behavior. - Where it's used: generic assembly-scan registrations that take a type argument.
DependencyInjection
MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.Infrastructure·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:11· Level 0 · class (static)
- What it is: the Infrastructure-layer DI entry point for the Identity module. Currently a deliberate no-op placeholder:
AddModuleIdentityInfrastructure()returns theIServiceCollectionunchanged (line 20). - Depends on:
Microsoft.Extensions.DependencyInjection.IServiceCollection(NuGet) only. - Concept introduced: the
extension(IServiceCollection)DI-registration idiom (see primer §4).[Rubric §16, Maintainability & Evolvability](assesses uniform, predictable structure): every module layer ships anAddModule{Name}{Layer}()method so the module loader can call them uniformly; an empty one is honest about "nothing to register here yet" rather than absent and surprising. - Walkthrough: a single
extension(IServiceCollection services)block (line 13) exposingpublic IServiceCollection AddModuleIdentityInfrastructure() => services;(line 20). The doc comment (lines 5-10) records why it is empty: Identity has no infrastructure services beyond the EF configurations and seeder, which are discovered automatically via assembly scanning. - Why it's built this way: keeping the method present even when empty means the module-registration pipeline never special-cases Identity; if Identity later needs a typed infrastructure service (a query service, a key store) it is added here without touching the caller.
- Where it's used: invoked from the Identity API layer's
AddIdentityModule(...)alongsideAddModuleIdentityApplicationandAddModuleIdentityAPI; the module/registration machinery is covered in G14, Module System & Composition. - Caveats / not-in-source: the Identity module ships several
DependencyInjectionclasses, one per layer (this Infrastructure one at Level 0, plus the API, Application, and UI-layer ones covered in the sibling parts of this chapter). They share the baredependencyinjectionanchor, which in the assembled chapter resolves to the first occurrence; cross-references in other sections disambiguate by layer in prose.
GetUserAvatarQuery
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarQuery.cs:5· Level 0 · record (sealed)
- What it is: the CQRS query that reads the signed-in user's current avatar state (BR-116a). It carries a single field, the owning
UserId. - Depends on:
UserIdentifierType(the Identityglobal using UserIdentifierType = int;alias, see identifier aliases). - Concept introduced, the user-avatar slice. This is the read end of a small three-operation feature (get / set / remove) that stores a profile photo as an external blob and keeps only its URL on the
Useraggregate. The upload-security machinery is taught in full atSetUserAvatarHandler; this query touches none of it, it just returns whatever URL is stored (or null).[Rubric §9, API & Contract Design](assesses that each operation is a small, single-purpose contract): a dedicated read query keeps the avatar read inside the same CQRS decorator pipeline as every other read rather than bolting a getter onto a service. - Walkthrough: a one-line positional record,
GetUserAvatarQuery(UserIdentifierType UserId)(line 5). No body. The doc comment (lines 3-4) states theUserIdis stamped by the controller from the authenticated principal and is never client-supplied, so a caller can only read their own avatar. - Why it's built this way: modeling the read as a tiny query record pairs it with one handler (
GetUserAvatarHandler) that owns the data access, and the controller-stampedUserIdmakes ownership a property of routing rather than an argument the client controls. - Where it's used: dispatched by the Identity profile/avatar read endpoint; handled by
GetUserAvatarHandler, which returns aUserAvatarDTO.
GetUserPreferencesQuery
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:7· Level 0 · record (sealed)
- What it is: the CQRS query that asks for one user's stored UI preferences (preferred culture and theme). It carries a single field, the target
UserId. - Depends on:
UserIdentifierType(the Identityglobal using UserIdentifierType = int;alias, see identifier aliases). - Concept introduced, the read side of per-user preferences (ADR-027 / ADR-028).
[Rubric §27, Internationalization (i18n)](assesses whether locale is a first-class, persisted user choice rather than a fixed compile-time default): this query is how the app retrieves a user's saved language so the UI can honor it across sessions and devices, the persisted-preference half of the i18n story. The same record also carries the dark/light theme choice, so[Rubric §20, Design System & Theming]applies too: theme is a stored per-user preference, not only a client cookie (ADR-028). The doc comment (GetUserPreferencesQuery.cs:5) names both ADRs. - Walkthrough: a one-line positional record,
GetUserPreferencesQuery(UserIdentifierType UserId)(line 7). No body; the value is the routed user id supplied by the controller. - Why it's built this way: modeling the read as a tiny query record (rather than a method on a service) keeps it inside the CQRS decorator pipeline (logging, caching) like every other read, and pairs it with a single handler (
GetUserPreferencesHandler) that owns the data access. - Where it's used: dispatched by the Identity profile/preferences read endpoint; handled by
GetUserPreferencesHandler, which returns aUserPreferencesResponse.
GetUsersQuery
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetUsers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersQuery.cs:18· Level 0 · record (sealed)
- What it is: the query behind the organizer user-management list (BR-51): optional email/first-name/last-name/role filters plus paging and sort parameters.
- Depends on: nothing first-party (all members are BCL
string?/int). - Concept: the CQRS query record is taught at
IQueryHandler; this is a filter-plus-page-plus-sort read request.[Rubric §9, API & Contract Design](assesses paged, filterable list contracts): the query carries server-side paging (PageNumber/PageSize) and sort (SortColumn/SortDirection) so the list never materializes every user.[Rubric §12, Performance & Scalability]: the page-size cap (max 500, BR-11) is honored downstream in the handler. - Walkthrough: a positional record with eight members (lines 18-26): four nullable filters (
Email,FirstName,LastName,Role),PageNumber = 1,PageSize = 10, and nullableSortColumn/SortDirection. The XML doc (lines 9-17) documents each parameter, including the BR-11 max-500 page size and the CreatedOn/desc defaults the handler applies. - Why it's built this way: a record with defaulted parameters lets a caller request page 1 of 10 with no arguments while still supporting full filter and sort; immutability means the query can flow safely through the decorator pipeline.
- Where it's used: dispatched by the organizer Users endpoint; handled by
GetUsersHandler, producing aPagedCollectionResult<T>ofUserListDTO.
RemoveUserAvatarCommand
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarCommand.cs:8· Level 0 · record (sealed)
- What it is: the CQRS command that removes the signed-in user's avatar photo (BR-116a): it deletes the blob and clears the URL. It carries only the owning
UserId. - Depends on:
UserIdentifierType(the Identityintalias, see identifier aliases). - Concept: the avatar slice is introduced at
GetUserAvatarQuery; this is its delete operation. The doc comment (lines 3-6) records the key contract: the operation is idempotent, removing a non-existent avatar succeeds rather than erroring.[Rubric §9, API & Contract Design](assesses idempotent, predictable mutation semantics): a delete that is safe to repeat lets a client retry without special-casing "already gone". - Walkthrough: a one-line positional record,
RemoveUserAvatarCommand(UserIdentifierType UserId)(line 8). No body. As with the other avatar operations, the doc comment (line 7) notes theUserIdis stamped by the controller from the authenticated principal, never client-supplied. - Why it's built this way: a bare command record keeps the delete inside the command decorator pipeline (validation, logging), and the controller-stamped owner id keeps the operation scoped to self-service.
- Where it's used: handled by
RemoveUserAvatarHandler, which returns a bareResult(no payload).
SetUserAvatarCommand
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarCommand.cs:10· Level 0 · record (sealed)
- What it is: the CQRS command that sets (uploads or replaces) the signed-in user's avatar photo (BR-116a, ADR-045). It carries the owning
UserIdplus the raw uploaded image bytes. - Depends on:
UserIdentifierType(the Identityintalias);System.ReadOnlyMemory<byte>(BCL) for the payload. - Concept: the upload-security pipeline this command feeds is taught at
SetUserAvatarHandler. Worth noting here is the shape of the payload:Contentis aReadOnlyMemory<byte>of the raw uploaded bytes, and the doc comment (lines 3-7) is explicit that the handler validates the true format from the bytes (magic bytes), not the client-declared content type, before re-encoding to a canonical 256x256 JPEG.[Rubric §11, Security](assesses input trust boundaries): carrying raw bytes, not a client-typed stream, forces the trust decision into the handler where the format is sniffed rather than believed. - Walkthrough: a positional record
SetUserAvatarCommand(UserIdentifierType UserId, ReadOnlyMemory<byte> Content)(line 10). No body. TheUserId(doc comment line 8) is controller-stamped;Content(line 9) is the uploaded image. - Why it's built this way: modeling the upload as an immutable command with a value-type byte buffer keeps it inside the command pipeline and hands the handler an owned, read-only view of the bytes to sniff and re-encode.
- Where it's used: handled by
SetUserAvatarHandler, which returns aUserAvatarDTOwith the new URL.
UserPreferencesResponse
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/UserPreferencesResponse.cs:9· Level 0 · record (sealed)
- What it is: the response shape for
GetUserPreferencesQuery: the user's storedCultureandTheme, each nullable. Anullfield means the user has not chosen that preference (the UI then falls back to its default). - Depends on: nothing first-party (two
string?members). - Concept reinforced, persisted UI preferences (ADR-027 / ADR-028).
[Rubric §27, Internationalization (i18n)]: returning the savedCulture(for example "es") lets the app re-apply the user's language on a fresh load instead of defaulting to en-US, and thenull-means-unset convention (doc comment, lines 3-8) cleanly distinguishes "no choice yet" from a real value.[Rubric §20, Design System & Theming]:Theme("light" / "dark") rides the same response, so a returning user gets their dark-mode choice back (ADR-028). - Walkthrough: a positional record
UserPreferencesResponse(string? Culture, string? Theme)(line 9). Both fields nullable; the XML doc documents thenull-means-unchosen semantics for each. - Why it's built this way: a flat two-field record keeps the wire contract minimal and lets the handler map straight from the
Useraggregate'sPreferredCulture/PreferredThemecolumns; nullability is the contract for "unset" rather than a sentinel string. - Where it's used: produced by
GetUserPreferencesHandlerand returned by the Identity preferences endpoint; the client uses it to seed its culture cookie and theme on login. The write side is the preferences-update path onUser(covered in the sibling parts of this chapter).
GetUserAvatarHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarHandler.cs:10· Level 8 · class (sealed)
- What it is: the query handler that loads a user by id and returns their current avatar URL as a
UserAvatarDTO(the URL may be null when no avatar is set), or a NotFound failure if the user does not exist. - Depends on:
IQueryHandler(implemented);IUnitOfWork;User;UserAvatarDTO;Result;Error;GetUserAvatarQuery. - Concept reinforced, the thin CQRS read handler (ADR-014).
[Rubric §6, CQRS & Event-Driven]: it implementsIQueryHandler<GetUserAvatarQuery, Result<UserAvatarDTO>>(lines 10-11) and runs inside the query decorator chain (FeatureGate then Logging then Caching then handler) with no transaction, since it only reads. - Walkthrough
- Primary-constructor injection of
IUnitOfWork(lines 10-11). HandleAsync(lines 14-24): resolvesunitOfWork.GetReadRepository<User, UserIdentifierType>()(the read repository, matching theGetUsersHandlerread-path convention) and callsGetByIdAsync(query.UserId, …)(lines 18-19). If the user isnullit returnsResult.Failure<UserAvatarDTO>(Error.NotFound.WithSource(nameof(GetUserAvatarHandler)).WithTarget(nameof(User)))(line 22), attaching source/target for a traceable error; otherwiseResult.Success(new UserAvatarDTO(user.AvatarUrl))(line 23), which passes the stored URL through unchanged (null included).
- Primary-constructor injection of
- Why it's built this way: using the unit-of-work repository plus a
Resultreturn keeps the read consistent with the rest of the module (no direct DbContext, errors as values not exceptions, ADR-013); aGetByIdAsyncby key is the right shape because it fetches exactly one row. - Where it's used: dispatched for the Identity avatar read endpoint; its
UserAvatarDTOtells the client whether and where to render the profile photo. - Caveats / not-in-source: like
GetUserPreferencesHandler, it resolves the read repository viaGetReadRepository; note that the by-keyGetByIdAsyncstill runs through EF'sFindAsync(which consults the change tracker), so the read-repository choice here is about intent and interface (no write surface on a pure read) rather than a tracking-behavior change.
GetUserPreferencesHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:9· Level 8 · class (sealed)
- What it is: the query handler that loads a user by id and returns their stored culture/theme as a
UserPreferencesResponse, or a NotFound failure if the user does not exist. - Depends on:
IQueryHandler(implemented);IUnitOfWork;User;Result;Error;GetUserPreferencesQuery;UserPreferencesResponse. - Concept reinforced, the thin CQRS read handler (ADR-014) serving persisted preferences (ADR-027 / ADR-028).
[Rubric §27, Internationalization (i18n)]: this handler is the server end of "remember my language", readingUser.PreferredCulturestraight off the aggregate.[Rubric §6, CQRS & Event-Driven]: it implementsIQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>>(lines 9-11) and runs inside the query decorator chain (FeatureGate then Logging then Caching then handler) with no transaction, since it only reads. - Walkthrough
- Primary-constructor injection of
IUnitOfWork(line 9). HandleAsync(lines 13-23): resolves the typed read repositoryunitOfWork.GetReadRepository<User, UserIdentifierType>()(line 17), thenGetByIdAsync(query.UserId, …)(line 18). If the user isnullit returnsResult.Failure<UserPreferencesResponse>(Error.NotFound.WithSource(nameof(GetUserPreferencesHandler)).WithTarget(nameof(User)))(lines 20-21), attaching source/target for a traceable error; otherwiseResult.Success(new UserPreferencesResponse(user.PreferredCulture, user.PreferredTheme))(line 22).
- Primary-constructor injection of
- Why it's built this way: using the unit-of-work repository plus a
Resultreturn keeps the read consistent with the rest of the module (no direct DbContext, errors as values not exceptions, ADR-013); aGetByIdAsyncby key rather than a projection is fine here because it fetches exactly one row. - Where it's used: dispatched for the Identity preferences/profile read endpoint; its
UserPreferencesResponseseeds the client's culture and theme. - Caveats / not-in-source: it resolves
GetReadRepositorylikeGetUsersHandler, but as a by-key single-row read it uses the repository'sGetByIdAsync(EFFindAsync) rather than theTableNoTrackingqueryable path; that is the right shape for one row fetched by key.
GetUsersHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.GetUsers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:15· Level 8 · class (sealed)
- What it is: the query handler for the organizer user list (BR-51). It filters, counts, sorts, pages, and projects
Userrows toUserListDTOentirely at the database level, then returns aPagedCollectionResult<T>. - Depends on:
IQueryHandler(implemented);IUnitOfWork;IQueryableExecutor;IReadRepository<TEntity, TIdentifierType>(viaGetReadRepository);User;UserListDTO;PagedCollectionResult<T>;PaginationMetadata;Result;System.Linq.Expressions(BCL). - Concept introduced, server-side paging/sorting/projection over a no-tracking queryable.
[Rubric §12, Performance & Scalability](assesses that list endpoints push filter/sort/page/projection to the database, never materializing the whole table) and[Rubric §30, Compliance, Privacy & Data Governance](data minimization): the projection selects only the six list columns (UserId,Email,FirstName,LastName,Role,CreatedOn, lines 44-52) so password hash/salt, refresh token, and device fields are never read out of the database.[Rubric §6, CQRS & Event-Driven]: a read handler, no transaction. - Walkthrough
- Primary-constructor injection of
IUnitOfWorkandIQueryableExecutor(lines 15-17); implementsIQueryHandler<GetUsersQuery, Result<PagedCollectionResult<UserListDTO>>>. HandleAsync(lines 20-58): caps page size at 500 (Math.Min(query.PageSize, 500), BR-11, line 25); takes the no-tracking queryablerepository.TableNoTrackingfromGetReadRepository<User, UserIdentifierType>()(lines 27-31); applies filters (line 32); gets the total withqueryableExecutor.CountAsync(aSELECT COUNT, line 35); sorts (line 38); thenSkip/TakeplusSelectintoUserListDTO(lines 41-52) and materializes withqueryableExecutor.ToListAsync(line 54). It wraps the page in aPaginationMetadata(totalCount, pageSize, query.PageNumber)and returnsResult.Success(new PagedCollectionResult<UserListDTO>(paged, metadata))(lines 56-57).ApplyFilters(lines 60-72): adds aWhereper non-null filter (Email/FirstName/LastNameuseContains,Roleuses==);Emailis cast(string)u.Email(line 63) to compare against the value-object-backed column.ApplySorting(lines 74-91): defaults to descending whenSortDirectionis "desc" or blank; aswitchonSortColumn.ToUpperInvariant()picks the key selector, defaulting toCreatedOn(line 85).
- Primary-constructor injection of
- Why it's built this way: doing count, sort, page, and projection through
IQueryableExecutorkeeps the SQL set-based and the wire payload minimal, and routing through the read (no-tracking) repository avoids change-tracking overhead on a pure read; the explicit column projection is the type-level guarantee that sensitive fields never leave the database. - Where it's used: dispatched by the organizer Users endpoint (gated by
IdentityPermissions.UsersRead); itsPagedCollectionResult<T>ofUserListDTOfeeds the user-management grid in the UI.
SetUserAvatarHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16· Level 8 · class (sealed, partial)
- What it is: the command handler that uploads or replaces the signed-in user's avatar (BR-116a, ADR-045). It sniffs the true image format, re-encodes the bytes to a canonical 256x256 JPEG, stores the result under a fresh random blob name, persists the new URL on the
Useraggregate, then best-effort deletes the previous blob. It returns aUserAvatarDTOwith the new URL. - Depends on:
ICommandHandler(implemented);IUnitOfWork;IImageProcessor;IFileStorageService;ImageContentSniffer;User;UserAvatarDTO;Result;Error;Microsoft.Extensions.Logging(ILogger+[LoggerMessage]source generation),System.Guid,System.IO.MemoryStream,System.Uri(BCL). - Concept introduced, safe user-uploaded-image handling (ADR-045). This is the first place the codebase accepts a binary upload from an end user, and it treats those bytes as hostile.
[Rubric §11, Security](assesses input trust boundaries and defense against malicious uploads): the format is decided by magic-byte sniffing (ImageContentSniffer.IsAllowedImage, line 32), never the client-declared content type, and the image is re-encoded rather than stored as received, which the doc comment (lines 10-15) notes strips EXIF metadata and defeats polyglot files (a valid image that is also valid script).[Rubric §13, Observability & Operability]: the success is logged through a compile-time[LoggerMessage]source-generated method (LogAvatarSet, lines 95-96), which is why the class ispartial.[Rubric §30, Compliance, Privacy & Data Governance]: re-encoding also drops geolocation and camera EXIF a user did not intend to publish. - Walkthrough
- Primary-constructor injection of
IUnitOfWork,IImageProcessor,IFileStorageService, and anILogger(lines 16-20); implementsICommandHandler<SetUserAvatarCommand, Result<UserAvatarDTO>>. The canonical edge length isinternal const int AvatarSize = 256(line 23). - Format gate (lines 32-38): if
ImageContentSniffer.IsAllowedImage(command.Content.Span)is false, it returnsError.Validationwith the app-specific code"Avatar.UnsupportedFormat"(line 35). The comment (lines 30-31) notes the shared sniffer lives inMMCA.Common.Applicationwhile the error code and size policy stay app-side. - Load the user (lines 40-46):
GetRepository<User, UserIdentifierType>()thenGetByIdAsync;nullreturnsError.NotFound(lines 44-45). - Normalize (lines 48-58): wraps the bytes in a non-writable
MemoryStreamand callsimageProcessor.NormalizeToSquareJpegAsync(content, AvatarSize, …)(line 52); a failedResult<byte[]>short-circuits with its own errors (lines 55-57). - Upload (lines 60-72): builds a blob name
"{UserId}-{8-hex-suffix}.jpg"from a freshGuid.NewGuid().ToString("N")[..8](lines 60-61), streams the JPEG tofileStorage.UploadAsync(blobName, jpeg, "image/jpeg", …)(line 66); a failed upload short-circuits (lines 69-71). - Persist then clean up (lines 74-83): captures the previous blob name via
TryGetBlobName(user.AvatarUrl)before overwriting, callsuser.SetAvatarUrl(uploaded.Value!.AbsoluteUri)andSaveChangesAsync, then deletes the old blob only after the new URL is committed (the comment, lines 78-79, notes a delete failure leaks at most one orphaned 256px image, never a broken avatar). TryGetBlobName(lines 90-93): astatichelper that pulls the final URL segment as the blob name viaUri.TryCreate+Uri.UnescapeDataString; it is reused byRemoveUserAvatarHandler.
- Primary-constructor injection of
- Why it's built this way: the random blob-name suffix means a replacement never reuses the old URL, so downstream caches and CDNs self-invalidate without an explicit purge (doc comment, lines 12-14). Ordering "persist new URL, then delete old blob" makes the user-visible state the source of truth and turns any storage failure into a harmless orphan rather than a dangling reference. Sniffing plus mandatory re-encoding is the ADR-045 rule that an accepted upload is only ever stored in a shape the server itself produced.
- Where it's used: dispatched by the Identity avatar upload endpoint; its
UserAvatarDTOcarries the new URL back to the client for immediate display. - Caveats / not-in-source: the concrete behavior of
NormalizeToSquareJpegAsync(resize/crop strategy) and of the storage provider live behindIImageProcessorandIFileStorageServicein MMCA.Common; this handler only orchestrates them. The per-upload size limit is enforced upstream (the comment at line 30 notes the size limit is applied outside this handler), not visible in this file.
RemoveUserAvatarHandler
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:14· Level 9 · class (sealed, partial)
- What it is: the command handler that removes the signed-in user's avatar (BR-116a). It clears the stored URL on the
Useraggregate first (the user-visible state), then best-effort deletes the blob. It is idempotent: when no avatar is set it succeeds without touching storage. - Depends on:
ICommandHandler(implemented);IUnitOfWork;IFileStorageService;SetUserAvatarHandler(reuses itsstatic TryGetBlobName, hence Level 9);User;Result;Error;Microsoft.Extensions.Logging([LoggerMessage]source generation). - Concept reinforced, idempotent delete + best-effort cleanup.
[Rubric §9, API & Contract Design]: the operation is safe to repeat (seeRemoveUserAvatarCommand).[Rubric §13, Observability & Operability]: the removal is logged through a source-generated[LoggerMessage]method (LogAvatarRemoved, lines 46-47), so the class ispartial. - Walkthrough
- Primary-constructor injection of
IUnitOfWork,IFileStorageService, and anILogger(lines 14-17); implementsICommandHandler<RemoveUserAvatarCommand, Result>(note the bareResult, no payload). HandleAsync(lines 20-44): loads the user viaGetRepository<User, UserIdentifierType>()+GetByIdAsync;nullreturnsError.NotFound(lines 26-29). It derives the blob name withSetUserAvatarHandler.TryGetBlobName(user.AvatarUrl)(line 31); if that isnull(no avatar set) it returnsResult.Success()immediately, the idempotent no-op (lines 32-35). Otherwise it callsuser.SetAvatarUrl(null)andSaveChangesAsync(lines 37-38), thenfileStorage.DeleteAsync(blobName, …)(line 40), and logs (line 42).
- Primary-constructor injection of
- Why it's built this way: clearing the URL before deleting the blob makes the persisted "no avatar" state authoritative, so a later blob-delete failure leaves an orphan rather than a broken reference (the same ordering rationale as
SetUserAvatarHandler). Reusing that handler'sTryGetBlobNamekeeps the URL-to-blob-name parsing in one place. - Where it's used: dispatched by the Identity avatar removal endpoint; its bare
Resulttells the client the avatar is gone (or was already absent).
AssemblyReference
MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.Infrastructure·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:5· Level 0 · class (static)
- What it is: the static assembly-marker for the Identity module's Infrastructure layer: a handle onto the assembly it lives in (
Assembly) plus that assembly's simple name (AssemblyName), used as a stable, string-free anchor whenever something has to say "scan the Infrastructure assembly of Identity." - Depends on:
System.Reflection.Assembly(BCL) only, imported atAssemblyReference.cs:1. No first-party dependencies, which is why it sits at Level 0. - Concept: the assembly-marker idiom is taught at its first appearance in
AssemblyReference; this is the Identity Infrastructure instance of the same shape, and the module's other layers (Domain, Application, API) each ship their own byte-identical copy covered in the sibling parts of this chapter.[Rubric §5, Vertical Slice]assesses convention-driven wiring and feature cohesion: a marker lets EF-configuration and DI scanners target an assembly throughtypeof(AssemblyReference).Assemblyinstead of a brittle string literal, so adding an entity configuration needs no registration edit.[Rubric §1, SOLID](Dependency Inversion): registration code binds to a deliberate marker rather than totypeof(UserConfiguration).Assembly, so renaming or moving a real type never silently breaks the scan. - Walkthrough: two
public static readonlyfields resolved once at type-initialization.Assembly = typeof(AssemblyReference).Assembly(AssemblyReference.cs:7) is the self-referential handle;AssemblyName = Assembly.GetName().Name ?? string.Empty(AssemblyReference.cs:8) is the simple name with a?? string.Emptyfallback so the field is never null even when the runtime reports no simple name. No methods, no constructor. - Why it's built this way: every module layer ships the identical marker so generic scanning code can be told which assembly to scan without referencing a concrete business type. The layer is identified purely by which
AssemblyReferenceyou hand it, which keeps the layering rules of Clean Architecture intact (the scanner never needs a type reference that would create an upward dependency). - Where it's used: the EF design-time factories pass this exact marker to
AddConfigurationAssembly(...): the per-service Identity migrations project atMMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:25, and the frozen combined-database archive project atMMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer/DesignTimeSQLServerDbContextFactory.cs:29. That is howUserConfigurationis discovered without the migrations host referencing it directly. The general registration machinery lives in G14, Module System and Composition.
ClassReference
MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.Infrastructure·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:11· Level 0 · class
- What it is: the empty, non-static companion to
AssemblyReference, declared in the same file. It exists so a scanning API that needs an instantiable generic type argument has one available from this assembly, since a C# static class cannot be used as a generic type argument. - Depends on: nothing first-party, and nothing from the BCL beyond
object. - Concept: cross-reference the marker idiom taught under
AssemblyReferenceand first introduced in G17, Conference Domain. The companion exists because helpers such asScanModuleApplicationServices<TAssemblyMarker>()constrain their type parameter to a reference type; the Identity Application layer's own copy is passed that way atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:37.[Rubric §33, Developer Experience]assesses how much ceremony the inner loop demands: one conventional token per layer is the entire registration ritual. - Walkthrough:
public class ClassReference { }(AssemblyReference.cs:11), no members. Its only meaningful property is the assembly it belongs to, read by a scanner viatypeof(ClassReference).Assembly. - Why it's built this way: keeping the instantiable anchor separate sidesteps the static-class generic-argument restriction while leaving
AssemblyReferenceimpossible to instantiate by accident. Each layer defines its own copy so it can scan itself by passing its local token. - Where it's used: no call site in ADC currently passes this Infrastructure copy as a type argument; the type-argument scans in the Identity module use the Application-layer copy (
MMCA.ADC.Identity.Application/DependencyInjection.cs:37) and the architecture-fitness suite uses it too (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:40). The Infrastructure copy is present for structural symmetry across layers. - Caveats / not-in-source: whether an Infrastructure-layer generic scan is planned is
Not determinable from source; today the copy is declared and unreferenced.
DependencyInjection
MMCA.ADC.Identity.Infrastructure ·
MMCA.ADC.Identity.Infrastructure·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:11· Level 0 · class (static)
- What it is: the Infrastructure-layer DI entry point for the Identity module. It is a deliberate no-op placeholder today:
AddModuleIdentityInfrastructure()returns theIServiceCollectionunchanged (DependencyInjection.cs:20). - Depends on:
Microsoft.Extensions.DependencyInjection.IServiceCollection(NuGet) only (DependencyInjection.cs:1). No first-party types. - Concept: the
extension(IServiceCollection)registration idiom, taught once in the primer. C# preview extension members let a layer contribute anAddModule{Name}{Layer}()method that reads like a built-inIServiceCollectionAPI.[Rubric §16, Maintainability]assesses uniform, predictable structure: shipping the method even when it registers nothing means the composition root never special-cases Identity, and the empty body is honest about "nothing to register here yet" rather than absent and surprising.[Rubric §3, Clean Architecture]assesses layer discipline: the Infrastructure layer owns its own registration surface, and the API layer composes it rather than reaching into persistence details. - Walkthrough: a single
extension(IServiceCollection services)block (DependencyInjection.cs:13) exposingpublic IServiceCollection AddModuleIdentityInfrastructure() => services;(DependencyInjection.cs:20), an expression body that returns the collection for chaining. The XML doc (DependencyInjection.cs:5-10) records why it is empty: Identity has no module-specific infrastructure services beyond the EF configurations and the seeder, and those are discovered by assembly scanning. That claim matches the layer's contents, which are exactlyModuleApplicationDbContext,IdentityModuleDbSeeder, andUserConfigurationalongside the two marker types above. - Why it's built this way: keeping the method present even when empty means the module-registration pipeline stays uniform across every module and layer; when Identity later needs a typed infrastructure service (a key store, a read-model query service) it is added here and no caller changes.
- Where it's used: invoked from the API layer's
AddIdentityModule(...)atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:30, betweenAddModuleIdentityApplication(applicationSettings)(:29) andAddModuleIdentityAPI()(:31); that composite is in turn called byIdentityModule's registration, theIModulecontract from G14. - Caveats / not-in-source: the Identity module ships one
DependencyInjectionclass per layer (this Infrastructure one plus the API, Application, and UI copies covered in sibling parts of this chapter). They all slug to the baredependencyinjectionanchor, which resolves to the first occurrence in the assembled chapter, so cross-references disambiguate by layer in prose.
IdentityPermissions
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Authorization·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:8· Level 0 · class (static)
- What it is: the capability-permission catalog for the Identity module: string constants that endpoints demand (via
[HasPermission(...)]) instead of hard-coding role names. - Depends on: no first-party types; BCL
IReadOnlyList<string>and a collection expression. - Concept, permission-based authorization over role-based. An endpoint names the capability it needs (
identity:users:read), and the role-to-permission grants are declared once at module registration, so adding a role or re-mapping a capability never touches an endpoint attribute.[Rubric §11, Security]assesses authorization design and least privilege: naming the capability at the endpoint and centralizing grants makes the authorization surface auditable rather than scattered across[Authorize(Roles = ...)]attributes.[Rubric §7, Microservices Readiness]assesses whether the boundary survives extraction: the catalog lives in the module'sSharedproject, so both the in-process host and the standalone Identity service consume the same constants. - Walkthrough:
public const string UsersRead = "identity:users:read"(IdentityPermissions.cs:11) is the single capability today, documented as "list or read all user accounts" for the organizer/admin user-management screens (IdentityPermissions.cs:10).public static IReadOnlyList<string> All { get; }(IdentityPermissions.cs:14) is initialized from a collection expression containingUsersRead(IdentityPermissions.cs:15-17), so a role can be granted the whole capability set in one call. - Why it's built this way: a
namespace:resource:actionstring convention keeps permissions self-describing and greppable, and theAllaccessor keeps the role-grant registration from drifting when a permission is added: the new constant only has to be listed once, insideAll. - Where it's used: demanded by the user-list endpoint on
UsersController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:124,[HasPermission(IdentityPermissions.UsersRead)]), and granted inAddModuleIdentityAPI()wherepermissions.Grant(RoleNames.Organizer, [.. IdentityPermissions.All])and the same call forRoleNames.Adminpopulate thePermissionRegistry(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:46-47).
IdentitySettings
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/IdentitySettings.cs:7· Level 0 · class (sealed)
- What it is: a module-level options object for Identity, declared to bind from the
"Identity"configuration section. It is a one-property options class covering the BR-213 registration throttle. - Depends on: no first-party types; nothing beyond the BCL.
- Concept, module-scoped options with an in-code default.
[Rubric §10, Cross-Cutting Concerns]assesses how configuration is surfaced and layered: an options class turns a business rule into an environment-overridable knob while still carrying a sane value when the configuration file omits the section.[Rubric §15, Best Practices and Code Quality]also applies here in the negative sense described under Caveats: an options type with no binder and no reader is dead configuration surface, and the live knob is elsewhere. - Walkthrough:
public const string SectionName = "Identity"(IdentitySettings.cs:9) is the section key anIConfiguration.GetSection(...)call would use. The single propertypublic int MaxRegistrationsPerIpPerHour { get; init; } = 10(IdentitySettings.cs:15) caps registrations per IP per hour, with the doc comment (IdentitySettings.cs:11-14) attributing it to BR-213 and noting it is set higher in development/test so E2E runs are not rate-limited.init-only keeps a bound instance immutable after startup. The class-level doc comment (IdentitySettings.cs:3-6) says the values come frommodules.identity.jsonor itsDevelopmentoverlay. - Why it's built this way: keeping the
= 10default in code rather than only in JSON means a missing configuration section still yields an enforceable limit, andsealedplusinitmake the options object a safe singleton to share. - Where it's used: nowhere in ADC source today. A repository-wide search for
IdentitySettingsacrossMMCA.ADCreturns only the declaration itself: there is noConfigure<IdentitySettings>(...)binding and no injection ofIOptions<IdentitySettings>. The registration throttle that actually runs is the framework'sLoginProtectionSettingsinMMCA.Common.Infrastructure, which declares the same property name and the same default (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:37) and is read byLoginProtectionService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:82). The AppHost raises that framework knob, not this one, for the Identity service:identityService.WithEnvironment("LoginProtection__MaxRegistrationsPerIpPerHour", "1000")(MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:327, with the BR-213 rationale at:303). - Caveats / not-in-source: treat this type as an unwired duplicate of
LoginProtectionSettings, not as the effective configuration. ChangingMaxRegistrationsPerIpPerHourhere has no runtime effect; theIdentity:MaxRegistrationsPerIpPerHourconfiguration key is not read anywhere. Whether it is a leftover from before the throttle moved into MMCA.Common or a placeholder for a future module-owned setting isNot determinable from source.
KestrelConfiguration
MMCA.ADC.Identity.Service ·
MMCA.ADC.Identity.Service·MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/KestrelConfiguration.cs:11· Level 0 · class (static, internal)
- What it is: the Kestrel endpoint wiring for the standalone Identity service host. It forces HTTP/2-only on cleartext (h2c) for every endpoint and, when the platform injects a health-probe port, adds a second HTTP/1.1-only listener that the Azure Container Apps
httpGetprobes can actually talk to. - Depends on: no first-party types. Externals:
Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols(KestrelConfiguration.cs:1),WebApplicationBuilder, andIConfiguration.GetValue<int?>. - Concept, the transport profile of a cleartext gRPC host. On a cleartext endpoint there is no TLS, therefore no ALPN negotiation, so
Http1AndHttp2effectively degrades to HTTP/1.1 and Kestrel answers gRPC frames withGOAWAY HTTP_1_1_REQUIRED. SettingHttpProtocols.Http2selects h2c prior knowledge, which is what lets cross-service typed gRPC clients (http://identity) connect at all. This is ADR-012 (gRPC-host transport convention) applied to the three REST services; the mixed-endpoint variant used by Notification, where a defaultHttp1AndHttp2endpoint must survive a WebSocket upgrade, is covered in G10.[Rubric §13, Observability and Operability]assesses whether the platform can genuinely observe the app: the probe listener is what allows the ACA probes to reach the real, DB-aware/aliveand/health/readypipeline instead of a bare TCP check (KestrelConfiguration.cs:16-24).[Rubric §7, Microservices Readiness]assesses whether a service is independently deployable: the transport profile is declared by the service host itself, not by a shared ambient default. - Walkthrough: one static method,
ConfigureHttp2WithHealthProbe(WebApplicationBuilder builder)(KestrelConfiguration.cs:27). It null-guards its argument (:29), then callsbuilder.WebHost.ConfigureKestrel(:31). Inside,k.ConfigureEndpointDefaults(o => o.Protocols = HttpProtocols.Http2)(:33) makes h2c the default for every endpoint, including the container'sASPNETCORE_HTTP_PORTSbinding. Then a pattern-match on configuration,builder.Configuration.GetValue<int?>("HealthProbe:Port") is int probePort(:35), gates the probe path: only when the key is present does it re-declare the main endpoint explicitly withk.ListenAnyIP(8080, o => o.Protocols = HttpProtocols.Http2)(:37) and addk.ListenAnyIP(probePort, o => o.Protocols = HttpProtocols.Http1)(:38). The re-declaration is required because any explicitListencall overrides the container's default binding, so 8080 has to be restated alongside the probe port (:20-23). - Why it's built this way: the doc comment (
KestrelConfiguration.cs:14-25) is the rationale of record. TheHealthProbe:Portkey is injected byinfra/main.bicepasHealthProbe__Portand is deliberately absent locally, so Aspire's dynamic ports keep working and co-hosted services cannot collide on a fixed port on one developer machine.MapDefaultEndpointsmaps/health,/aliveand/health/readyon every listener (called atMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:296), so the extra HTTP/1.1 listener serves the real health pipeline, and the probe port stays off the ACA ingress because the platform probes target it directly. The class also exists as a separate file, per its own doc comment (:8-9), soProgram.csstays inside the S1541 cyclomatic-complexity budget the analyzers enforce. - Where it's used: called as the first configuration step of the Identity service host,
KestrelConfiguration.ConfigureHttp2WithHealthProbe(builder)(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:80, with the transport rationale repeated in the comment at:70-79). The Conference and Engagement service hosts ship a byte-identical copy of this class and call it the same way (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/KestrelConfiguration.cs:27fromProgram.cs:83, andMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/KestrelConfiguration.cs:27fromProgram.cs:59); see G20 and G22. - Caveats / not-in-source: the
8080literal and theHealthProbe__Portvalue are set outside this file (the container image's default port andinfra/main.biceprespectively); only the listener declarations are verified here.
IAttendeeQueryService
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IAttendeeQueryService.cs:8· Level 0 · interface
- What it is: the one cross-module read contract Identity publishes. It answers a single question, "which user ids are active attendees?", so the Notification module can fan a broadcast out to every attendee without knowing anything about the
Useraggregate. - Depends on: the
UserIdentifierTypealias only; BCL (Task,IReadOnlyList<T>,CancellationToken). It deliberately references no Identity Domain or Application type. - Concept introduced, the cross-module contract in the
Sharedassembly.[Rubric §7, Microservices Readiness](assesses whether modules talk through narrow, transport-agnostic interfaces that survive extraction into separate processes) and[Rubric §3, Clean Architecture](assesses dependency direction: the consumer depends on an abstraction, not on the producer's internals). The doc comment (IAttendeeQueryService.cs:3-7) states the rule outright: the contract lives inSharedso Notification can call it without depending on the Identity implementation. That one placement decision is what makes three different wirings interchangeable behind the same interface: the in-processAttendeeQueryServicewhen Identity runs in the same host, theDisabledAttendeeQueryServicestub when the module is switched off, and theAttendeeQueryServiceGrpcAdapterwhen Identity runs as its own service. No consumer code changes between those three. - Walkthrough: one member.
GetAttendeeUserIdsAsync(CancellationToken cancellationToken = default)(IAttendeeQueryService.cs:15) returnsTask<IReadOnlyList<UserIdentifierType>>. The return type is deliberately just ids, not user records: the caller needs recipients, not personal data, so the contract carries the minimum ([Rubric §30, Compliance, Privacy & Data Governance], data minimization across a module boundary). - Why it's built this way: a coarse, id-only, async, cancellable method maps cleanly onto a gRPC unary call, which is exactly the extraction path ADR-007 describes. Anything richer (a filtered query object, an
IQueryable) would leak Identity's persistence model across the boundary and would not survive the process split. - Where it's used: consumed by Notification's
AttendeeNotificationRecipientProvider(which bridges it toINotificationRecipientProvider); implemented in-process byAttendeeQueryService, stubbed byDisabledAttendeeQueryService, served over the wire byAttendeesGrpcService, and satisfied remotely byAttendeeQueryServiceGrpcAdapter.
IdentityRoutePaths
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityRoutePaths.cs:6· Level 0 · class (static)
- What it is: the two route strings the Identity UI module owns,
/usersand/profile, published asstatic readonlyfields so the navigation descriptor never hard-codes a URL literal. - Depends on: nothing (no usings in the file).
- Concept reinforced, route constants as the module's public navigation surface.
[Rubric §25, Navigation & Information Architecture](assesses whether routes are declared in one place so menu entries, redirects, and tests cannot drift from the pages themselves) and[Rubric §16, Maintainability]. The pattern is small but load-bearing: the nav items inIdentityUIModulereferenceIdentityRoutePaths.Profile/IdentityRoutePaths.Users(IdentityUIModule.cs:17-18) rather than repeating the strings, so renaming a route is a one-line change here. - Walkthrough:
Users = "/users"(IdentityRoutePaths.cs:8) andProfile = "/profile"(IdentityRoutePaths.cs:9), bothpublic static readonly stringon apublic static class. - Caveats / not-in-source: the
@pagedirectives on theUserListandProfilecomponents still spell their route literally (Razor@pagerequires a compile-time constant, and these arestatic readonlyfields rather thanconst), so this type is the single source of truth for navigation, not for the page routing attribute itself. - Where it's used: only by
IdentityUIModule'sNavItems(IdentityUIModule.cs:17-18) in current source.
UserAvatarDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserAvatarDTO.cs:6· Level 0 · record (sealed)
- What it is: the one-field response body every avatar endpoint returns: the current public avatar URL, or
nullwhen the user has none (BR-116a). - Depends on: nothing first-party; one BCL attribute (
SuppressMessage). - Concept reinforced, the response DTO as a stable wire shape.
[Rubric §9, API & Contract Design](assesses whether endpoints return a named, versionable shape rather than a bare primitive). Returning{ "avatarUrl": ... }instead of a raw string means a later addition (a thumbnail URL, an upload timestamp) is an additive change, not a breaking one. TheCA1054suppression (UserAvatarDTO.cs:5) carries its own justification in source: this is a serialized DTO field, so the URL stays astringon the wire rather than becoming aUri. - Walkthrough: a single positional record,
public sealed record UserAvatarDTO(string? AvatarUrl)(UserAvatarDTO.cs:6). The nullable parameter is the whole contract: "no avatar" is a first-class, non-exceptional state. - Where it's used: produced by the avatar use cases (
GetUserAvatarHandler,SetUserAvatarHandler), returned byUsersController'sme/avatarendpoints, and deserialized client-side byUserServicefor theProfilepage.
UserDataExportBookmarkDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportBookmarkDTO.cs:7· Level 0 · record (sealed)
- What it is: one session-bookmark row inside the Engagement section of a data-subject export: which session the user bookmarked and when.
- Depends on: the
SessionIdentifierTypealias; BCL (DateTime). - Concept introduced, the export row DTO (ids and dates only).
[Rubric §30, Compliance, Privacy & Data Governance](assesses whether a data-subject access / portability request returns the subject's own data, and only that). The doc comment (UserDataExportBookmarkDTO.cs:3-6) ties the shape directly to PRIVACY.md §7. Note what is not here: no session title, no speaker, no other user's activity. The export carries the personal fact ("you bookmarked session X at time T") rather than a denormalized copy of another context's catalog, which keeps the Identity service from becoming an accidental read model of Conference data. - Walkthrough: two
required initmembers,SessionId(UserDataExportBookmarkDTO.cs:10) andCreatedOn, documented as UTC (UserDataExportBookmarkDTO.cs:13).requiredmeans the aggregating handler cannot forget a field, andinitmakes the row immutable once produced (therequired/initimmutability convention from the primer). - Where it's used: built by
ExportUserDataHandlerfrom the Engagement peer's response (ExportUserDataHandler.cs:102) and carried insideUserDataExportEngagementSectionDTO.
UserDataExportNotificationDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportNotificationDTO.cs:7· Level 0 · record (sealed)
- What it is: one notification-inbox row in the Notifications section of a data-subject export: the notification id, its title, and the sent/read timestamps.
- Depends on: the
UserNotificationIdentifierTypealias; BCL (DateTime). - Concept: the same export-row shape
UserDataExportBookmarkDTOintroduces ([Rubric §30, Compliance, Privacy & Data Governance]), with one extra nuance: it does include theTitletext, because a notification's title is content that was addressed to this user, so it is part of their personal data rather than someone else's. - Walkthrough: five members (
UserDataExportNotificationDTO.cs:10-22),required NotificationIdandrequired Title,required SentOn(UTC), plus the optional pairIsRead(a plainbool, defaulting tofalse) andReadOn(a nullableDateTime, null while unread). The two read fields are intentionally notrequired: an unread row simply carries the defaults. - Where it's used: projected by
ExportUserDataHandlerfrom the Notification peer's rows (ExportUserDataHandler.cs:137-139) intoUserDataExportNotificationSectionDTO; the underlying entity isUserNotification.
UserDataExportSubmittedQuestionDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportSubmittedQuestionDTO.cs:7· Level 0 · record (sealed)
- What it is: one submitted session-question row in the Engagement section of a data-subject export: the question id, the session it was asked in, and when it was submitted.
- Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases; BCL (DateTime). - Concept: the same export-row shape as
UserDataExportBookmarkDTO, and the sharpest illustration of its restraint. The doc comment (UserDataExportSubmittedQuestionDTO.cs:3-6) spells the rule out: "ids + submission date only, never other users' data". The question text and its upvote count are omitted, so an export cannot be turned into a scrape of the live Q and A feed ([Rubric §30, Compliance],[Rubric §11, Security]). - Walkthrough: three
required initmembers,QuestionId(UserDataExportSubmittedQuestionDTO.cs:10),SessionId(UserDataExportSubmittedQuestionDTO.cs:13), andCreatedOnin UTC (UserDataExportSubmittedQuestionDTO.cs:16). - Where it's used: carried in
UserDataExportEngagementSectionDTO.SubmittedQuestions, populated byExportUserDataHandler; the source aggregate isSessionQuestionin the Engagement live layer.
UserListDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserListDTO.cs:7· Level 0 · record
- What it is: the row shape for the organizer user list (BR-51): id, email, first/last name, role, and creation date, and nothing else.
- Depends on: the
UserIdentifierTypealias; BCL (DateTime). - Concept introduced, the list projection DTO as a privacy boundary.
[Rubric §8, Data Architecture](assesses whether reads project only the columns a screen needs instead of hydrating whole aggregates) and[Rubric §30, Compliance, Privacy & Data Governance]. The doc comment (UserListDTO.cs:3-6) is explicit that device-specific fields are excluded to protect attendee device privacy: theUseraggregate carriesDeviceId,DeviceModel,DeviceManufacturerand friends, but an organizer browsing the user grid has no business seeing them. Because the projection is built inside the query (GetUsersHandler.cs:44, a.Select(u => new UserListDTO { ... })translated to SQL), the excluded columns are never even read from the database, so this is a privacy boundary and a performance win at once. - Walkthrough: six members (
UserListDTO.cs:10-25).UserId,Email,FirstName,LastName, andRolearerequired init;CreatedOnis a plaininitDateTime.Roleis astring, not theUserRolevalue object: the wire format stays primitive, and the closed-set type is a domain concern. - Why it's built this way: keeping the list DTO separate from
UserDTOlets the grid evolve (sortable columns, an addedCreatedOn) without touching the general-purpose account DTO, and it keeps the list endpoint's payload small enough to page cheaply. - Where it's used: produced by
GetUsersHandlerinside aPagedCollectionResult<T>, returned byUsersController's list endpoint, and consumed client-side throughIUserUIService/UserServiceas theMudDataGridrow type on theUserListpage.
DisabledAttendeeQueryService
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DisabledAttendeeQueryService.cs:7· Level 1 · class (sealed)
- What it is: the no-op stand-in for
IAttendeeQueryServicethat gets registered when the Identity module is switched off in a host. It returns an empty attendee list instead of failing DI. - Depends on:
IAttendeeQueryService, theUserIdentifierTypealias; BCL (Task.FromResult). - Concept introduced, the disabled-module stub (null object).
[Rubric §2, Design Patterns](assesses recognized patterns applied deliberately: this is the Null Object pattern) and[Rubric §7, Microservices Readiness]. The module system lets a host run any subset of modules; a host that disables Identity would otherwise fail to resolve every cross-module Identity interface at startup.IModule.RegisterDisabledStubscloses that hole, andIdentityModuleregisters exactly this type there (IdentityModule.cs:19-20). Crucially the stub lives inShared, the same assembly as the contract, so a host can reference the stub without pulling in Identity's Application or Domain assemblies. - Walkthrough: the whole class is one expression-bodied method.
GetAttendeeUserIdsAsyncreturnsTask.FromResult<IReadOnlyList<UserIdentifierType>>([])(DisabledAttendeeQueryService.cs:10-11): a completed task over an empty collection expression, so there is no allocation-heavy work and noasyncstate machine. - Why it's built this way: returning empty rather than throwing keeps "Identity is not in this host" a configuration fact instead of a runtime error. In the extracted topology the stub is also the safety net:
AddIdentityAttendeeClient()callsservices.Replace(...)to swap in the gRPC adapter (MMCA.ADC.Identity.Contracts/DependencyInjection.cs:47), and its doc comment (DependencyInjection.cs:28-30) notes that the registration it overwrites is this stub. If that replacement were ever skipped, a broadcast would reach nobody rather than crash the Notification host. - Where it's used: registered by
IdentityModule.RegisterDisabledStubs(IdentityModule.cs:20) as a singleton; replaced at startup inMMCA.ADC.Notification.Service/Program.cs:186byAttendeeQueryServiceGrpcAdapter.
HttpContextExternalLoginEmailVerifier
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Authentication·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17· Level 1 · class (sealed)
- What it is: the API-edge implementation of
IExternalLoginEmailVerifier. It re-reads the short-livedExternalLogincookie principal from the current request and reports whether the OAuth provider asserted that the email is verified. - Depends on:
IExternalLoginEmailVerifier(the Application-layer contract it satisfies),ExternalAuthExtensions.ExternalLoginSchemefromMMCA.Common.API.Authentication; ASP.NET Core (IHttpContextAccessor,HttpContext.AuthenticateAsync). - Concept introduced, the fail-closed edge adapter for a security decision.
[Rubric §11, Security](assesses whether authentication decisions are made on evidence the server can verify, and what happens when that evidence is missing) and[Rubric §3, Clean Architecture](assesses the dependency-inversion move: the Application layer declares the question, the API layer answers it using request-scoped state it alone can see). The threat this guards is account takeover by email match:AuthenticationService.ExternalLoginAsyncwill link an external identity to an existing local account, and if it did that on an email string alone, any provider that hands out unverified emails would be a takeover vector (IExternalLoginEmailVerifier.cs:3-10). The verifier exists so the link only happens when the provider explicitly asserts verification. Every uncertain path returnsfalse: noHttpContext, no principal, no claim, or an unparseable claim value all read as unverified. - Walkthrough
EmailVerifiedClaimType(HttpContextExternalLoginEmailVerifier.cs:21): theinternal const string "email_verified"claim type,internalso the module's tests can assert against it without publishing it.IsCurrentExternalLoginEmailVerifiedAsync(HttpContextExternalLoginEmailVerifier.cs:24-36): readshttpContextAccessor.HttpContextand returnsfalseimmediately when there is none (HttpContextExternalLoginEmailVerifier.cs:27-30), which covers any non-request context such as a background job.- It then calls
httpContext.AuthenticateAsync(ExternalAuthExtensions.ExternalLoginScheme)(HttpContextExternalLoginEmailVerifier.cs:32), re-authenticating the same short-lived cookie the sharedOAuthControllerBase.CompleteAsyncjust validated, and pullsemail_verifiedoff the resulting principal (HttpContextExternalLoginEmailVerifier.cs:33). - The final line is the fail-closed gate:
bool.TryParse(claimValue, out var verified) && verified(HttpContextExternalLoginEmailVerifier.cs:35). A missing claim yields anullvalue,TryParsefails, and the method returnsfalse.
- Why it's built this way: the verification assertion only exists inside the external-login cookie principal, which is request state, so the check cannot live in the Application layer without dragging
HttpContextdown there. Inverting it behind an interface keepsAuthenticationServicetestable with a fake verifier. The class doc (HttpContextExternalLoginEmailVerifier.cs:8-16) also records a real provider asymmetry: the Identity service host maps Google's claim through aPostConfigure<GoogleOptions>claim action, while GitHub's OAuth payload carries no such assertion, so GitHub logins report unverified by design and simply do not auto-link. - Where it's used: registered with
TryAddScopedinAddModuleIdentityAPIalongsideAddHttpContextAccessor()(MMCA.ADC.Identity.API/DependencyInjection.cs:54-55); consumed byAuthenticationService's external-login flow. - Caveats / not-in-source: the Google claim-action mapping lives in the Identity service host's
Program.cs, not in this file, and theExternalLogincookie itself is issued by the sharedOAuthControllerBase(seeOAuthControllerBase).
UserDataExportEngagementSectionDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportEngagementSectionDTO.cs:10· Level 1 · record (sealed)
- What it is: the Engagement-owned slice of a data-subject export: the user's session bookmarks and submitted session questions, plus an
Availableflag that says whether the Engagement service could actually be reached. - Depends on:
UserDataExportBookmarkDTO,UserDataExportSubmittedQuestionDTO. - Concept introduced, the partial-availability section (graceful degradation in a composite response).
[Rubric §29, Resilience & Business Continuity](assesses whether a request that fans out to peers degrades instead of failing when one peer is down) and[Rubric §7, Microservices Readiness](assesses that a cross-service aggregate does not turn every peer into a hard dependency). This is the interesting design move in the export: rather than modelling "Engagement is unreachable" as an exception that fails the whole GDPR request, the contract models it as data. The doc comment (UserDataExportEngagementSectionDTO.cs:6-8) states the rule: when the peer stays unreachable after the standard resilience pipeline, the export still succeeds withAvailableset tofalseand the lists empty. The reader of the export can then tell the difference between "you had no bookmarks" and "we could not check", which a bare empty list could never express. - Walkthrough: three members.
required bool Available(UserDataExportEngagementSectionDTO.cs:14) is the only required one, so no producer can construct a section without stating its completeness.Bookmarks(UserDataExportEngagementSectionDTO.cs:17) andSubmittedQuestions(UserDataExportEngagementSectionDTO.cs:20) areIReadOnlyList<T>properties defaulting to an empty collection expression[], which is what makesnew UserDataExportEngagementSectionDTO { Available = false }a legal one-liner on the failure path. - Why it's built this way: the failure default and the required flag together make the degraded case cheap to produce and impossible to produce silently.
ExportUserDataHandlercatches the peer failure, logs a warning, and returns exactly that one-liner (ExportUserDataHandler.cs:120), so a single peer outage never denies a user their portability right. - Where it's used: the
Engagementproperty ofUserDataExportDTO(UserDataExportDTO.cs:77), populated byExportUserDataHandlerviaIUserEngagementExportService(ExportUserDataHandler.cs:89-121).
UserDataExportNotificationSectionDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportNotificationSectionDTO.cs:9· Level 1 · record (sealed)
- What it is: the Notification-owned slice of a data-subject export: the user's inbox rows, newest first, behind the same
Availablecompleteness flag. - Depends on:
UserDataExportNotificationDTO. - Concept: structurally the same partial-availability section
UserDataExportEngagementSectionDTOintroduces, applied to a second peer ([Rubric §29, Resilience & Business Continuity]). Two peers, two independent flags: the Notification service can be down while Engagement answers, and the export still returns everything it managed to gather. - Walkthrough:
required bool Available(UserDataExportNotificationSectionDTO.cs:13) andIReadOnlyList<UserDataExportNotificationDTO> Notifications { get; init; } = [](UserDataExportNotificationSectionDTO.cs:16), documented as newest first. - Where it's used: the
Notificationsproperty ofUserDataExportDTO(UserDataExportDTO.cs:81), populated byExportUserDataHandlerviaIUserNotificationExportService, with the degraded path returningnew UserDataExportNotificationSectionDTO { Available = false }(ExportUserDataHandler.cs:152).
UserDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDTO.cs:8· Level 1 · record
- What it is: the general-purpose account DTO: id, email, first/last name, and role. It is the credential-free projection of the
Useraggregate. - Depends on:
IBaseDTO<TIdentifierType>(the framework DTO contract fromMMCA.Common.Shared.DTOs), theUserIdentifierTypealias. - Concept reinforced, the identified DTO (
IBaseDTO<TIdentifierType>).[Rubric §9, API & Contract Design](assesses a consistent, machine-checkable response shape) and[Rubric §11, Security](assesses that secrets never reach a serialization boundary). ImplementingIBaseDTO<TIdentifierType>(UserDTO.cs:8) is what lets this DTO plug into the framework's generic mapper and service abstractions:Id(UserDTO.cs:11) satisfies the interface. Just as important is the omission, theUserDTOMapperdoc comment (UserDTOMapper.cs:9-11) records thatPasswordHash,PasswordSalt, andRefreshTokenare excluded from the projection, so the DTO is the enforced boundary between an aggregate that holds credentials and anything that can be serialized. - Walkthrough: five
required initmembers (UserDTO.cs:11-23),Id,Email(documented as the login credential, BR-200),FirstName,LastName, andRoleas astring. Because the mapper is a Mapperly source generator, adding a member here changes generated code at build time rather than at runtime (ADR-001, manual/Mapperly mapping). - Why it's built this way:
requiredon every member means the compiler, not a reviewer, catches a mapper that forgets a field, and keeping the type inSharedlets any layer (API, UI, tests) name it without referencing the Domain assembly. - Where it's used: produced by
UserDTOMapper, which implementsIEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>over (User,UserDTO,UserIdentifierType) and converts theEmailvalue object to its string form via a privateEmailToStringhelper (UserDTOMapper.cs:28).
UserDataExportDTO
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportDTO.cs:16· Level 2 · record
- What it is: the full portable export of everything the system holds about one user: the Identity-owned account and device fields, plus the two best-effort cross-service sections. It is the response body of the GDPR/CCPA data-subject access endpoint.
- Depends on:
UserDataExportEngagementSectionDTO,UserDataExportNotificationSectionDTO, theUserIdentifierTypeandSpeakerIdentifierTypealiases; BCL (DateTime). - Concept introduced, the data-portability contract (what goes in, and what is deliberately left out).
[Rubric §30, Compliance, Privacy & Data Governance](assesses whether the right of access and portability is implemented as a real, complete, machine-readable artifact) and[Rubric §11, Security]. The type doc (UserDataExportDTO.cs:3-8) names the exclusions and the reason: the password hash and salt, the refresh token, and the opaque external-provider key are secrets, not portable personal data, so exporting them would create a credential-leak channel out of a privacy feature. What is included is everything a user would recognize as theirs, including the MAUI device metadata (DeviceIdthroughDeviceType,UserDataExportDTO.cs:49-67), which is exactly the blockUserListDTOrefuses to show an organizer: the subject may see their own device data, a third party may not. Theremarksblock (UserDataExportDTO.cs:9-15) records the cross-service aggregation policy and notes it closed the residual on RemediationBacklog #30. - Walkthrough
- Identity account fields (
UserDataExportDTO.cs:19-34):requiredUserId,Email,FirstName,LastName,FullName, andRole. - External-login fields (
UserDataExportDTO.cs:37-40):IsExternalLoginand the provider name only; the comment onLoginProviderrestates that the opaque provider key is intentionally omitted. - Cross-context links and profile (
UserDataExportDTO.cs:43-46): the nullableLinkedSpeakerId(the scalar link to a ConferenceSpeaker) andAvatarUrl(BR-116a). - Device metadata (
UserDataExportDTO.cs:49-67): seven nullable strings reported by the MAUI client. - Audit timestamps (
UserDataExportDTO.cs:70-73):CreatedOnand nullableLastModifiedOn, both UTC, the same audit fields the framework stamps inSaveChangesAsync. - The two nullable sections (
UserDataExportDTO.cs:77,81):EngagementandNotifications. They are nullable rather than required because they are filled by cross-service calls, and each carries its ownAvailableflag for the degraded case.
- Identity account fields (
- Why it's built this way: modelling the export as one flat, versionable record with nested per-service sections keeps the whole subject-access response a single GET, while the per-section availability flags mean a peer outage costs the user completeness, not the request. Together with the erasure path on the
Useraggregate (ADR-005), this is the read half of the privacy pair: export what we hold, then anonymize it on request. - Where it's used: produced by
ExportUserDataHandler(ExportUserDataHandler.cs:61-84) forExportUserDataQuery, returned byUsersController's{userId}/exportendpoint under the handler's owner-or-organizer check.
IdentityUIModule
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:13· Level 3 · class (sealed)
- What it is: the Identity module's UI descriptor. It tells the shared Blazor shell two things: which navigation entries this module contributes, and which assembly to scan for routable components.
- Depends on:
IUIModule(the contract),NavItemandNavSection,IdentityRoutePaths,RoleNames; externals: MudBlazorIcons.Material.Filled, BCLAssembly. - Concept reinforced, the pluggable UI module descriptor.
[Rubric §18, UI Architecture](assesses whether the shell discovers features rather than hard-coding them) and[Rubric §25, Navigation & Information Architecture](assesses that menu structure, role gating, and routes are declared next to the feature that owns them). TheIUIModulecontract is introduced in group-15: a host collects every registered implementation, merges theirNavItemsinto the sidebar, and passes theirAssemblytoAddAdditionalAssembliesso the router can find pages in a Razor Class Library. The effect is that adding the Identity module to a host adds its pages and its menu entries in one registration, with no edit to the shell.[Rubric §11, Security]and[Rubric §27, Internationalization]both show up in the two declarations below. - Walkthrough: two members.
NavItems(IdentityUIModule.cs:15-19), a collection-expression-initializedIReadOnlyList<NavItem>with two entries. "My Profile" points atIdentityRoutePaths.Profilewith aPersonicon inNavSection.Userand no required role, so every signed-in user sees it (IdentityUIModule.cs:17). "Users" points atIdentityRoutePaths.Userswith aSupervisedUserCircleicon, passesRoleNames.Organizeras theRequiredRolepositional argument, and sits inNavSection.Admin(IdentityUIModule.cs:18), so the link is only rendered for organizers. Note that this hides the entry, the actual enforcement is server-side onUsersController; menu gating is UX, not authorization.- Both entries pass
TitleResource: typeof(IdentityUIModule), which per theNavItemcontract turns"Nav.MyProfile"and"Nav.Users"into resource keys resolved against this type's resources at render time, so the menu follows the active culture (ADR-027). Assembly => typeof(IdentityUIModule).Assembly(IdentityUIModule.cs:21): the self-referencing assembly handle used for Blazor route discovery.
- Why it's built this way: a descriptor class is the smallest thing that can carry declarative metadata into DI. Because it is a plain sealed class with no dependencies, it registers as a singleton and costs nothing at runtime, while keeping the sidebar's Identity section owned by the Identity module rather than by the host.
- Where it's used: registered as
IUIModuleinDependencyInjection.AddIdentityUI(MMCA.ADC.Identity.UI/DependencyInjection.cs:32); also theFromAssemblyOf<IdentityUIModule>marker for that method's Scrutor scan (DependencyInjection.cs:23).
DependencyInjection
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:11· Level 4 · class (static)
- What it is: the one-call registration entry point for the Identity UI layer.
AddIdentityUI()wires the module's entity services, its bespoke user service, and itsIdentityUIModuledescriptor into any Blazor host. - Depends on:
IdentityUIModule,IUserUIService/UserService,IEntityService<TEntityDTO, TIdentifierType>andIUIModulefromMMCA.Common.UI; externals:IServiceCollectionand Scrutor'sScan. - Concept reinforced, the
extension(IServiceCollection)registration block.[Rubric §15, Best Practices & Code Quality](assesses idiomatic, current-language composition) and[Rubric §3, Clean Architecture](assesses that each layer owns its own wiring instead of the host reaching into it). This file is the UI-layer instance of the convention used across all four repos: instead of a classicpublic static IServiceCollection AddX(this IServiceCollection services), the method lives inside a C# previewextension(IServiceCollection services)block (DependencyInjection.cs:13) and the receiver is named once for the whole block. Callers see an ordinaryservices.AddIdentityUI(). - Walkthrough:
AddIdentityUI()(DependencyInjection.cs:19-35) does three things and returnsservicesfor chaining.- A Scrutor scan over this assembly registering every
IEntityService<TEntityDTO, TIdentifierType>implementationAsImplementedInterfaceswith a scoped lifetime (DependencyInjection.cs:22-26). Convention over configuration: a new standard CRUD-shaped UI service needs no registration edit. - An explicit
services.AddScoped<IUserUIService, UserService>()(DependencyInjection.cs:29). The comment above it explains why this one is hand-written: users are a custom contract, not anIEntityService, so the scan cannot pick it up (the same asymmetryIUserUIServicedocuments). services.AddSingleton<IUIModule, IdentityUIModule>()(DependencyInjection.cs:32), contributing the nav items and the assembly for component discovery. Singleton is right because the descriptor is immutable metadata.
- A Scrutor scan over this assembly registering every
- Why it's built this way: one host-facing method per module keeps the three hosts symmetric, they each call
AddIdentityUI()and nothing else. The mix of scan plus explicit registration is deliberate: the scan covers the uniform majority, and the one bespoke contract is registered by hand where a reader can see it. - Where it's used: called by all three UI hosts,
MMCA.ADC.UI.Web/Program.cs:70(Blazor Server),MMCA.ADC.UI.Web.Client/Program.cs:54(the WebAssembly client), andMMCA.ADC.UI/MauiProgram.cs:77(MAUI), which is what lets the same Razor Class Library render on web and mobile.
ChangePasswordRequestValidator
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.Validation·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11· Level 1 · class (sealed)
- What it is: the FluentValidation rule set for a self-service password change. It requires the caller to supply a non-empty current password and holds the new password to the shared strong-password policy.
- Depends on: FluentValidation's
AbstractValidator<T>(NuGet),ChangePasswordRequest(the request DTO fromMMCA.Common.Shared.Auth), andStrongPasswordRules<T>fromMMCA.Common.Application.Validation. - Concept reinforced, composable rule sets over copy-pasted rules.
[Rubric §24, Forms, Validation & UX Safety]assesses whether input rules are declared once and applied consistently at every entry point. FluentValidation'sInclude(ChangePasswordRequestValidator.cs:18) splices an entire other validator's rules into this one, so the password-complexity policy lives in exactly one framework type and this validator only states which property it applies to. The validator itself is discovered by assembly scan and executed by the CQRS validating decorator, so the API controller never calls it directly (see the pipeline in 00-primer.md). - Walkthrough
- Constructor (
ChangePasswordRequestValidator.cs:13): the whole type is rules declared in a constructor, which is the FluentValidation idiom. CurrentPassword(lines 15-16):NotEmpty()with the message "Current password is required." and, importantly, an explicitWithErrorCode("User.CurrentPassword.Required"). The stable error code (not the human message) is what the API surfaces and what a client can branch on.NewPassword(line 18):Include(new StrongPasswordRules<ChangePasswordRequest>(x => x.NewPassword))applies the shared complexity policy by passing a property selector.
- Constructor (
- Why it's built this way: the current password gets only a presence check because proving it is correct is a cryptographic operation, not a shape check:
ChangePasswordHandlerverifies it against the stored hash and salt. Validation stays about the shape of the request; authorization and proof-of-knowledge stay in the handler. - Where it's used: resolved by the validating decorator when
ChangePasswordCommandis dispatched fromAuthController.ChangePasswordAsync.
IUserUIService
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI.Services·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/IUserUIService.cs:11· Level 1 · interface
- What it is: the client-side contract the Blazor/MAUI UI uses to talk to the Identity
usersAPI: a paginated organizer user list, account deletion, and the three current-user avatar operations. - Depends on:
UserListDTOand theUserIdentifierTypealias (= int), both fromMMCA.ADC.Identity.Shared.Users. - Concept introduced, the hand-written UI service contract (versus the generic CRUD base).
[Rubric §18, UI Architecture]assesses whether pages depend on abstractions rather than onHttpClientdirectly. Most ADC list pages ride a generic framework CRUD service, but the doc comment (IUserUIService.cs:5-10) states exactly why this one cannot: the users API returnsUserListDTO, which does not implementIBaseDTO<TIdentifierType>, and the resource exposes only list plus delete, not the standard create/update/get-by-id set. Rather than widen the generic base to fit an outlier, the module declares a purpose-built interface.[Rubric §1, SOLID]: this is interface segregation applied to the client layer, the UI depends only on the five operations that actually exist. - Walkthrough
GetPagedAsync(IUserUIService.cs:16-25): every filter (email,firstName,lastName,role) and every paging/sorting argument is optional with a default (pageNumber = 1,pageSize = 10), and the return type is a tuple(IReadOnlyList<UserListDTO> Items, int TotalItems), exactly the shape a MudBlazor server-side grid needs (BR-51).DeleteAsync(UserIdentifierType, CancellationToken)(line 30): returnsbool, with the doc comment recording the server-side rule (owner or Organizer, UC-21).GetMyAvatarUrlAsync(line 33),UploadMyAvatarAsync(line 39),RemoveMyAvatarAsync(line 42): the three "me" operations (BR-116a). Upload takes a rawStreamplusfileNameandcontentType, so the same contract serves both a BlazorInputFileand a MAUI media picker; it returnsstring?(the new public URL) withnullmeaning the server rejected the upload.
- Why it's built this way: keeping the contract in
Identity.UI(not in a shared UI framework package) keeps the outlier local to the module that has the outlier API. Every method carries aCancellationTokenwith a default, which matters in Blazor where a component can be disposed mid-request. - Where it's used: implemented by
UserService; injected intoUserListand the profile/avatar pages.
ListPageActions
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI.Common·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Common/ListPageActions.cs:13· Level 2 · class (static)
- What it is: two static helpers that every ADC organizer list page reuses: reload whichever layout (mobile list or desktop grid) is currently rendered, and run the canonical confirm, delete, toast, reload flow.
- Depends on:
MobileInfiniteScrollList<TItem>and theDeleteConfirmationdialog component (bothMMCA.Common.UI.Components), MudBlazor'sMudDataGrid<T>andISnackbar, and the BCLFunc<>/OperationCanceledException. - Concept introduced, ADC-side shared UI code placed by reference direction.
[Rubric §16, Maintainability]assesses whether duplicated logic gets a single home. The class comment (ListPageActions.cs:6-12) explains an unusual but deliberate placement: this helper is not Identity-specific, yet it lives inIdentity.UIbecause Identity.UI is the root of the ADC module UI reference chain (Conference.UI references Identity.UI, Engagement.UI references Conference.UI), so it is the one ADC location every module UI project can already see. It is not in MMCA.Common because it encodes an ADC page convention, not a framework primitive.[Rubric §22, Responsive & Cross-Browser]: the mobile/desktop split is a genuine dual-render, and this helper is where the "which one is live" branch is centralized. - Walkthrough
ReloadActiveLayoutAsync<TDto>(bool isMobile, MobileInfiniteScrollList<TDto>?, MudDataGrid<TDto>?)(ListPageActions.cs:24-37): both component references are nullable because only one layout is rendered at a time. WhenisMobileand the mobile list exists it callsResetAsync()(line 31), otherwise it callsdataGrid.ReloadServerData()when the grid exists (line 35). If neither is present the call is a silent no-op, which is the right behavior during a render-mode transition.DeleteWithConfirmationAsync(...)(ListPageActions.cs:51-86): guards all five reference arguments withArgumentNullException.ThrowIfNull(lines 60-64), shows the confirmation dialog and returns early unless the result is exactlytrue(confirmed is not true, lines 66-70, so both "cancelled" and "dismissed/null" abort), then runsdeleteAsync(), toastssuccessMessageatSeverity.Success, and awaitsreloadAsync()(lines 74-76).- Error handling (lines 78-85):
OperationCanceledExceptionis caught and deliberately swallowed with a comment naming the two causes (component disposal, InteractiveAuto render-mode transition); any other exception is mapped through the caller-suppliedFunc<Exception, string> errorMessageand toasted atSeverity.Error.
- Why it's built this way: the caller passes in localized strings and a mapping function rather than the helper formatting text itself, so the shared flow stays free of resource lookups while each page keeps its own localized copy (
[Rubric §27, Internationalization]). Distinguishing cancellation from failure is a real UX rule: a disposed component must not flash a red error toast at a user who already navigated away. - Where it's used:
UserListwraps both methods (UserList.razor.cs:38-39and75-83); the Conference and Engagement list pages call the same two helpers.
UserDeleted
MMCA.ADC.Identity.Domain ·
MMCA.ADC.Identity.Domain.Users.DomainEvents·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/DomainEvents/UserDeleted.cs:10· Level 2 · record
- What it is: the in-process domain event raised when a user account is soft-deleted (BR-56). It carries only the user id.
- Depends on:
BaseDomainEvent(base) and theUserIdentifierTypealias. - Concept reinforced, the domain event as a fact, not a command.
[Rubric §6, CQRS & Event-Driven]assesses whether state changes are announced rather than orchestrated inline. The record is past tense and carries the minimum payload; the doc comment (UserDeleted.cs:5-7) frames it as a hook so other bounded contexts can react (cascade cleanup, audit logging) without the aggregate knowing who listens. Domain events (this) differ from integration events (UserRegistered): domain events are dispatched in-process duringSaveChangesAsync, integration events go through the outbox to the broker (ADR-003). - Walkthrough: a one-line
sealed record classwith a single positional parameterUserId(UserDeleted.cs:10-12), inheritingBaseDomainEventwhich supplies the event id and occurrence timestamp. There is no body: everything a handler needs beyond the id it must load itself. - Why it's built this way: shipping only the id (rather than a snapshot of the user) keeps a personal-data-bearing aggregate out of the event stream, which matters because the very next thing that happens to this user is erasure (see
User.Anonymize, ADR-005). - Where it's used: added by
User.Delete()(User.cs:354) and dispatched by the frameworkApplicationDbContextduring save. - Caveats / not-in-source: no handler for this event is registered in the Identity module today; it is a published extension point, not an active flow.
UserPasswordChanged
MMCA.ADC.Identity.Domain ·
MMCA.ADC.Identity.Domain.Users.DomainEvents·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/DomainEvents/UserPasswordChanged.cs:9· Level 2 · record
- What it is: the in-process domain event raised when a user's credentials are replaced. Structurally identical to
UserDeleted. - Depends on:
BaseDomainEventand theUserIdentifierTypealias. - Concept reinforced, minimal-payload domain events (introduced at
UserDeleted).[Rubric §11, Security]: the payload is the user id and nothing else. No hash, no salt, no old or new password ever enters an event, so credential material cannot leak through a logging or auditing subscriber. - Walkthrough:
sealed record class UserPasswordChanged(UserIdentifierType UserId) : BaseDomainEvent(UserPasswordChanged.cs:9-11). One parameter, no body. - Why it's built this way: password change is exactly the kind of event a security-audit or "notify the account owner" feature would subscribe to later, so the aggregate raises it now even without a consumer; adding a subscriber later requires no change to the domain.
- Where it's used: raised inside
User.ChangePasswordonly after the invariant checks pass (User.cs:316). - Caveats / not-in-source: as with
UserDeleted, no subscriber is registered in the Identity module today.
UserRegistered
MMCA.ADC.Identity.Shared ·
MMCA.ADC.Identity.Shared.Users.IntegrationEvents·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:23· Level 3 · record
- What it is: the cross-module integration event announcing that a new user registered. It is the one message that lets Conference react to a registration without referencing anything in Identity.
- Depends on:
BaseIntegrationEvent(base) and theUserIdentifierTypealias. - Concept introduced, the integration event and why it lives in
.Shared.[Rubric §7, Microservices Readiness]assesses whether modules coordinate through published contracts instead of direct references. Note the assembly: this record is inIdentity.Shared, notIdentity.Domain. Domain events (UserDeleted) stay private to the aggregate's assembly; an integration event is a published contract, so it lives in the thinSharedproject that other modules are allowed to reference.[Rubric §6, CQRS & Event-Driven]: it flows Identity to Conference through the outbox and MassTransit broker (ADR-003), so the two services stay decoupled at runtime and each can be down without failing the other's write. - Walkthrough:
sealed record class UserRegistered(UserIdentifierType UserId, string Email, string FirstName, string LastName, string Role) : BaseIntegrationEvent(UserRegistered.cs:23-29). Unlike the domain events, this one carries a payload:Email,FirstName, andLastNameare present because the subscriber needs them for identity matching across a database boundary (there is no cross-database join to fall back on).Rolerecords what the account was assigned at registration time. - Why it's built this way: the doc comment (
UserRegistered.cs:5-16) is unusually explicit about two decisions. First, publication happens after the unit-of-work commit soUserIdis the database-generated identity, not a placeholder zero (see the override inAuthenticationService). Second, it replaced a direct cross-module call (ISpeakerLinkingService.TryAutoLinkSpeakerAsyncfrom Identity into Conference); the event inverts that dependency so Identity stays a leaf module. The Conference-side subscriberUserRegisteredHandlerruns the speaker email-match auto-link (BR-207). Note that ADC deliberately diverges from Store here, which models the same concept as an in-process domain event. - Where it's used: published by
AuthenticationService(both local registration and first-time external OAuth account creation); consumed by Conference'sUserRegisteredHandler. - Caveats / not-in-source: the payload includes PII (email and names) crossing the broker. The retention and encryption posture of the broker itself is infrastructure configuration, not visible in this file.
UserService
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI.Services·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:14· Level 3 · class (sealed)
- What it is: the HTTP implementation of
IUserUIService: it builds the query string, attaches the bearer token, calls theusersendpoints through the Gateway, and deserializes the responses. - Depends on:
AuthenticatedServiceBase(base),ITokenStorageService,IHttpClientFactory,ServiceExceptionHelper,PagedCollectionResult<T>,UserListDTO,UserAvatarDTO, andSystem.Net.Http.Json. - Concept introduced, the authenticated UI HTTP service.
[Rubric §18, UI Architecture]and[Rubric §26, Front-End Security]. The class is a primary-constructorsealed classforwarding both dependencies toAuthenticatedServiceBase(UserService.cs:14-15), which supplies two things every call reuses:CreateAuthenticatedClientAsync()(a client with the stored JWT already attached, so no page ever handles a raw token) andRetryPolicy(a shared transient-fault policy). Every method follows the same four beats: create the authenticated client, execute through the retry policy, translate a non-success response into a domain exception viaServiceExceptionHelper.ThrowIfDomainExceptionAsync, then deserialize. - Walkthrough
Endpoint = "users"(UserService.cs:17): a relative path. All URLs are built withUriKind.Relative, so the base address (the Gateway) is configured once at registration.GetPagedAsync(lines 19-63): assembles aDictionary<string, string?>of every filter and paging argument (lines 30-40), then filters out blank values and URL-encodes each one withUri.EscapeDataStringbefore joining with&(lines 42-44). Skipping blanks keeps the query string minimal; encoding is what makes an email or a name with a space safe. The response is read asPagedCollectionResult<UserListDTO>and flattened to the tuple the grid wants, defaulting to an empty list and0when the body is null (lines 57-62).DeleteAsync(lines 65-80): buildsusers/{id}withstring.Create(CultureInfo.InvariantCulture, ...), which is the culture-safe way to format an id (an analyzer-enforced convention across the codebase), and returnstrueafterEnsureSuccessStatusCode.GetMyAvatarUrlAsync(lines 82-93): the one method that does not throw on failure. A non-success status returnsnull(lines 88-89), because "no avatar" and "could not fetch it" both render the same fallback and neither is worth an error toast.UploadMyAvatarAsync(lines 95-118): deliberately bypassesRetryPolicywith an explicit comment (line 103): the content stream is single-shot, and picker or file-input streams do not rewind, so a retry would post an empty body. It builds aMultipartFormDataContentwith a singlefilepart carrying the caller-supplied content type (lines 104-107).RemoveMyAvatarAsync(lines 120-131): delete through the retry policy, returning the success flag.
- Why it's built this way:
[Rubric §29, Resilience]: retry is applied where it is safe (idempotent GET and DELETE) and withheld where it is not (a stream-backed POST), which is the correct discrimination rather than a blanket policy. UsingIHttpClientFactoryplus a per-callusing var httpClientkeeps handler lifetimes managed by the factory. - Where it's used: registered as the
IUserUIServiceimplementation in the Identity UI DI extension and injected intoUserListand the profile/avatar components.
UserClaimsController
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Controllers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UserClaimsController.cs:16· Level 4 · class (sealed)
- What it is: a single-endpoint diagnostic controller that echoes back the claims carried by the caller's own JWT (UC-10).
- Depends on:
ApiControllerBase(base), ASP.NET Core MVC, andAsp.Versioning. - Concept reinforced, the standard ADC controller attribute stack.
[Rubric §9, API & Contract Design]assesses whether endpoints are consistently declared and documented. Every ADC controller wears the same four class attributes (UserClaimsController.cs:12-15):[ApiController](automatic model-state validation and binding-source inference),[Route("[controller]")](the route is the class name minus the suffix, here/UserClaims),[ApiVersion("1.0")](ADC versions by theapi-versionheader), and[Authorize]. The[ProducesResponseType]pairs on the action (lines 23-24) are what the OpenAPI document is generated from. - Walkthrough:
GetClaims()(UserClaimsController.cs:25) is synchronous because it touches no I/O: everything it returns is already onHttpContext.User, materialized by the JWT bearer handler during authentication. It groups claims by type (line 28) and projects each group into a dictionary value that is a bare string when there is one claim of that type and a list when there are several (lines 31-35). That collapse matters because a JWT legitimately repeats claim types (roles being the usual case), and a naiveToDictionary(c => c.Type, ...)would throw on the duplicate key. - Why it's built this way: the endpoint is a debugging and client-bootstrapping aid: the doc comment (lines 18-21) names the claims a client can expect (
user_id,email,role, optionalspeaker_id), andspeaker_idis exactly the one that appears only after the User-to-Speaker link resolves (BR-209). It returns nothing the caller does not already possess ([Rubric §11, Security]:[Authorize]plus reading onlyHttpContext.Usermeans it is structurally incapable of disclosing another user's data). - Where it's used: called by clients and by manual/E2E diagnostics; routed through the Gateway to the Identity service.
UserList
MMCA.ADC.Identity.UI ·
MMCA.ADC.Identity.UI.Pages.User·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16· Level 4 · class (partial)
- What it is: the code-behind for the organizer user list page: a server-paged, server-sorted, per-column-filterable grid on desktop and an infinite-scroll card list on mobile, with delete (BR-51, UC-21).
- Depends on:
DataGridListPageBase<TDto>(base),IUserUIService,ListPageActions,UserListDTO,MobileInfiniteScrollList<TItem>, theDeleteConfirmationcomponent, and MudBlazor'sMudDataGrid<T>/GridState<T>/GridData<T>. - Concept introduced, the code-behind list page over a framework base.
[Rubric §18, UI Architecture]assesses separation of markup from behavior and reuse of page scaffolding. The page is split into a.razormarkup file and thispartial class, and the class inheritsDataGridListPageBase<UserListDTO>, which supplies the localizerL,Snackbar,IsMobile, filter persistence, and theLoadServerDataAsyncadapter. The subclass therefore contains only what is genuinely user-specific: which service to call, which four columns are filterable, and what to do on delete.[Rubric §23, Front-End Performance]: nothing is loaded client-side and filtered in the browser; paging, sorting, and filtering are all pushed to the API. - Walkthrough
- Base contract (
UserList.razor.cs:18-24):TitleandEntityNameread from the localizerL, andGridRefexposes the_dataGridfield so the base can drive reloads. - Injected service (line 21):
[Inject] private IUserUIService UserService, the interface, never a concrete HTTP type. - Component references (lines 23-29): the desktop grid, the mobile infinite list, and the
DeleteConfirmationdialog._dataGridand_infiniteListare nullable (only one layout renders),_deleteConfirmisdefault!because the dialog is always in the markup. RetryLoadAsync(line 27): the retry action offered by the base's inline error state, a null-safeReloadServerData().SaveFilters/RestoreFilters(lines 32-36): the two overrides that persist the free-text_searchStringacross navigation, so returning to the list keeps the operator's search.LoadServerData(lines 47-64): the desktop fetch. It hands the base a lambda that pulls the four per-column filter values out of MudBlazor's filter dictionary bynameof(UserListDTO.X)(lines 52-55) and forwards them plus page, size, and sort toUserService.GetPagedAsync. The second lambda (lines 60-64) injects the toolbar search box as acontainsfilter onEmail, so the free-text search and the column filters use one code path.FetchMobilePage(lines 67-72): the mobile fetch, simplified to search-on-email only and fixed"Email"ascending sort, because the card layout has no column headers to sort by.DeleteUserAsync(lines 75-83): delegates the whole flow toListPageActions.DeleteWithConfirmationAsync, passing the user's email as the confirmation subject, the delete call, and two localized messages (Snackbar.UserDeleted,Snackbar.DeleteUserFailed).ReloadActiveLayoutAsync(lines 38-39): one line delegating toListPageActions.
- Base contract (
- Why it's built this way:
nameof(UserListDTO.Email)rather than a"Email"literal ties the filter key to the DTO property, so a rename is a compile error rather than a silently dead filter. Delegating delete and reload toListPageActionsmeans the cancellation-swallowing and confirm-first behavior is identical on every ADC list page, which is a[Rubric §24, Forms, Validation & UX Safety]concern: destructive actions always confirm. - Where it's used: routed as the organizer user-management page in the ADC web and MAUI UI; the server side it calls is
UsersController.GetAllAsync, which is gated on theUsersReadpermission.
UserRole
MMCA.ADC.Identity.Domain ·
MMCA.ADC.Identity.Domain.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17· Level 4 · class (sealed)
- What it is: the value object for an ADC user role. It fixes the valid role set (Organizer, Attendee, ContentEditor), parses strings safely, and provides the case-insensitive comparisons every authorization check needs.
- Depends on:
RoleValue(the framework base inMMCA.Common.Shared.Auth),RoleNames(the shared canonical name constants),Result/Error, andSystem.Collections.Frozen.FrozenDictionary. - Concept introduced, the closed-set value object over a bare string.
[Rubric §4, DDD]assesses whether domain concepts get types instead of primitives. A role is stored as a plain string in the database (that is what EF maps), but inside the domain it is aUserRole: the type is the only place the valid set is written down, andFromStringis the only supported way in. The baseRoleValuesupplies value equality, hashing, and validation (UserRole.cs:14-15); this subclass supplies the ADC-specific set.[Rubric §11, Security]: role comparison is a security decision, and getting the case sensitivity wrong is a real vulnerability class, which is why this type providesIsOrganizerrather than letting callers write==. - Walkthrough
- The three canonical instances (
UserRole.cs:20,23,30):Organizer(manages conference master data, BR-41),Attendee(the default for new registrations, BR-45), andContentEditor. TheContentEditordoc comment (lines 25-29) defines it precisely as a strict capability subset of Organizer: it curates sessions, speakers, and categories, but cannot change event structure, rooms, feedback questions, run session selection, or read the user list. AllByValue(lines 32-33): aFrozenDictionary<string, UserRole>built once by the base'sBuildLookup.FrozenDictionaryis the BCL's read-optimized dictionary: built once at type initialization, then faster to read than a regular dictionary for the life of the process, which fits a lookup consulted on many requests and never mutated.- Private constructor (lines 35-38): no caller can invent a fourth role.
FromString(lines 51-58): a dictionary probe returningResult<UserRole>, with a failure carryingError.Invariantcode"User.Role.Invalid". Noterole ?? string.Empty, so a null input is a clean validation failure rather than an exception.IsValid(line 65): the boolean form, used byUserInvariants.EnsureRoleIsValid.IsOrganizer(string?)(line 76):string.Equals(role, Organizer, StringComparison.OrdinalIgnoreCase). The doc comment (lines 67-73) states the trap it exists to prevent: because of the implicitstringconversion, a plain==againstOrganizercompiles but compares ordinally, so a claim of"organizer"would silently fail the check. Raw JWT claim strings can carry any casing, so authorization on a claim must go through this method.- Equality members (lines 78-90) and the implicit
stringconversion plus theToString()named alternate required by analyzer CA2225 (lines 94-98).
- The three canonical instances (
- Why it's built this way: the lookup is the single source of truth, so adding a role is one line plus one
BuildLookupargument, and every validator, parser, and check picks it up. Keeping the implicit string conversion preserves compatibility with the string-typedUser.Rolecolumn and with claim-based code, at the cost of the ordinal-comparison trap thatIsOrganizercloses. - Where it's used:
UserInvariants.EnsureRoleIsValid,User.CreateandCreateExternal(the BR-45 Attendee default),AuthenticationService,DeleteUserHandlerandExportUserDataHandlerfor the owner-or-organizer checks, andIdentityModuleDbSeeder.
UserInvariants
MMCA.ADC.Identity.Domain ·
MMCA.ADC.Identity.Domain.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserInvariants.cs:10· Level 5 · class (static)
- What it is: the rule book for the
Useraggregate: the field-length constants and the per-fieldEnsure...checks that every factory and mutator runs before changing state. - Depends on:
CommonInvariants(the framework primitives),Result/Error,SupportedCultures, andSystem.Net.Mail.MailAddress(BCL). - Concept reinforced, invariants as a separate static companion.
[Rubric §4, DDD]assesses whether business rules live in the domain rather than in handlers or controllers. Pulling the checks out of the entity into a static companion keepsUserreadable (its factory is aResult.Combineof named rules) and makes each rule independently unit-testable. Every method returns aResult, never throws, which is what lets the aggregate accumulate all validation failures in one pass instead of surfacing the first one. - Walkthrough
- Constants (
UserInvariants.cs:13-22):FirstNameMaxLength = 100,LastNameMaxLength = 100,EmailMaxLength = 100,DeviceFieldMaxLength = 256. These are the same constants the EF configuration reads for its column widths, so the schema and the domain cannot drift apart. EnsureEmailIsValid(lines 24-44): the one rule written with early returns rather thanResult.Combine, because the three checks are ordered: not empty, then withinEmailMaxLength, then a real address perMailAddress.TryCreate(line 34). Running the format parse on an empty string would produce a confusing second error, so it short-circuits. Failure code"User.Email.InvalidFormat".EnsureFirstNameIsValid/EnsureLastNameIsValid(lines 46-54):Result.Combineof a not-empty and a max-length check, each with its own stable error code (User.FirstName.Empty,User.FirstName.TooLong, and the LastName equivalents).EnsurePasswordHashIsValid/EnsurePasswordSaltIsValid(lines 56-60): delegate toCommonInvariants.EnsureBytesAreNotEmpty. Note what they do not check: no length, no algorithm. The domain knows credentials must be present, not how they were derived, which stays behindIPasswordHasher.EnsureRoleIsValid(lines 65-72): defers the set membership question toUserRole.IsValid.EnsurePreferredCultureIsValid(lines 77-84):nullis valid (meaning "follow the request default"), otherwise the value must pass theSupportedCultures.IsSupportedallowlist (ADR-027). An allowlist, not a format check, is the security-relevant choice here.EnsurePreferredThemeIsValid(lines 89-98):null,"light", or"dark", comparedOrdinalIgnoreCase(ADR-028).
- Constants (
- Why it's built this way: every rule takes a
sourcestring that is passed asnameof(Create)ornameof(ChangePassword)by the caller, so a failure carries which operation produced it, which is what makes an aggregated error list diagnosable. Sharing the length constants with the EF configuration is the practical mechanism that keeps a 101-character name from being a domain success and a database truncation. - Where it's used:
User.Create,User.CreateExternal,User.UpdatePreferences,User.ChangePassword, and the Identity EF entity configuration (for the column widths).
RegisterRequestValidator
MMCA.ADC.Identity.Application ·
MMCA.ADC.Identity.Application.Users.Validation·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12· Level 6 · class (sealed)
- What it is: the FluentValidation rule set for account registration: email, password strength, both names, and an optional address.
- Depends on:
AbstractValidator<T>(FluentValidation),RegisterRequest, and four shared rule types fromMMCA.Common.Application.Validation:EmailRules<T>,StrongPasswordRules<T>,RequiredStringRules<T>, andAddressValidator. - Concept reinforced, composed rule sets (introduced at
ChangePasswordRequestValidator).[Rubric §24, Forms, Validation & UX Safety]. This validator is almost entirelyIncludecalls: it contributes no rule expressions of its own except the conditional address rule.[Rubric §16, Maintainability]: tightening the password policy is a change in one framework type that both ADC validators inherit at once. - Walkthrough
EmailRules<RegisterRequest>(x => x.Email, "Email", UserInvariants.EmailMaxLength)(RegisterRequestValidator.cs:16): property selector, display name, max length. The limit referencesUserInvariants.EmailMaxLengthdirectly, so the request is rejected at the edge with a field-level message rather than failing later as a domain invariant, and the edge limit cannot drift from the domain constant.StrongPasswordRules<RegisterRequest>(x => x.Password)(line 17): the same complexity policy applied to the new-account password.RequiredStringRulesforFirstNameandLastName(lines 18-19), each with display name and the matchingUserInvariantsconstant (FirstNameMaxLength/LastNameMaxLength).- The address rule (lines 21-23):
RuleFor(x => x.Address).SetValidator(new AddressValidator()!).When(x => x.Address is not null).SetValidatorcomposes a whole child-object validator; the.Whenguard makes address optional, so a registration without one is valid and one with one is fully validated. The!suppresses a nullability warning on the child validator's generic argument.
- Why it's built this way: referencing the
UserInvariantsconstants makes the domain the single source of truth for the limits, so the edge validation and the domain invariant move together; the domain check remains the backstop for any path that bypasses the validator. Validation runs in the pipeline decorator, so the same rules apply whether registration arrives through REST or any future entry point. - Where it's used: executed by the validating decorator on the registration path invoked from
AuthController.RegisterAsyncthroughAuthenticationService. - Caveats / not-in-source: the password policy (via
StrongPasswordRules) has noUserInvariantscounterpart to reference; the length limits are compile-time references to the domain constants, so a change toUserInvariantspropagates here without a matching edit.
User
MMCA.ADC.Identity.Domain ·
MMCA.ADC.Identity.Domain.Users·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:18· Level 6 · class (sealed)
- What it is: the Identity aggregate root. One
Userrow holds the account's identity (email, names), its credentials (hash plus per-user salt), its role, its refresh-token state, optional MAUI device metadata, optional external-login identifiers, UI preferences, an avatar URL, and the optional link to a ConferenceSpeaker. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(base),IAnonymizable,IAuthUser, theEmailvalue object,UserRole,UserInvariants,PiiAttribute,IdValueGeneratedAttribute,UserPasswordChanged,UserDeleted, andResult. - Concept introduced, the aggregate root with a fully encapsulated state surface.
[Rubric §4, DDD]assesses whether entities protect their invariants rather than acting as property bags. Every single property here has aprivate set(User.cs:22-96): the only way to change a user is to call a named domain method.[Rubric §11, Security]: that is what makes it structurally impossible for application code to assignPasswordHashwithout going throughChangePassword's invariant checks, or to setRefreshTokenwithoutUpdateRefreshToken. Two framework contracts are worth naming.IAuthUseris what lets the sharedAuthenticationServiceBase<TUser>operate on this type without knowing it is ADC's user.IAnonymizablemarks the type as supporting right-to-erasure, which is the ADR-005 answer to "how does a soft-delete-everywhere system honor a deletion request".[Rubric §30, Compliance, Privacy & Data Governance]: the[Pii]attribute (User.cs:21,25,29,94) tagsEmail,FirstName,LastName, andAvatarUrlas personal data. The attribute is declarative metadata: it lets tooling and reviewers see the PII inventory on the type itself instead of in a separate document. - Walkthrough
[IdValueGenerated](line 17): declares that the id is database-generated, which the EF configuration honors.- Identity and profile (lines 22-30):
Emailis theEmailvalue object (not a string), and is the canonical identity across all platforms per BR-200. - Credentials (lines 34-37):
byte[] PasswordHashandbyte[] PasswordSalt, mapped tovarbinary(max), with an explicit#pragma warning disable CA1819(lines 32, 38) documenting that the array-returning properties exist for EF mapping. Role(line 41): stored asstringfor EF mapping even though the domain concept isUserRole.- Refresh-token state (lines 44-47): token plus UTC expiry, both nullable,
nullmeaning revoked or never issued (BR-205: 7 days). LinkedSpeakerId(line 54): a nullableSpeakerIdentifierType(aGuid), the Identity half of the 1:1 bidirectional User-to-Speaker link (BR-207/208/209). It is a scalar column, not a foreign key, because Speaker lives in a different database (ADR-006).- Device metadata (lines 57-75): seven nullable MAUI-only fields; the doc comment is explicit that they are analytics metadata and are not used for authentication (BR-201).
- External login (lines 78-81) and the computed
IsExternalLogin(line 99); UI preferences (lines 84-87, ADR-027/028);AvatarUrl(line 96, BR-116a/ADR-045) with a CA1056 suppression explaining it stays a string because EF maps a varchar and DTOs serialize it verbatim; computedFullName(line 102). - Two private constructors (lines 104-128): the parameterless one is EF's materialization constructor (it assigns non-null defaults to satisfy nullability), and the parameterized one is what the factories call.
Create(lines 147-175): the local-account factory. It builds theEmailvalue object and thenResult.Combines six invariant checks (lines 156-163) so all validation failures come back together, returningResult.Failure<User>(result.Errors)on any failure. Its<remarks>(lines 133-139) records a decision worth internalizing: it does not raise a registration domain event, becauseUserRegisteredmust be published afterSaveChangesAsyncso subscribers receive the real database-generated id.CreateExternal(lines 189-215): the OAuth factory. It validates only email and names (there is no password to check), setsPasswordHash/PasswordSaltto empty arrays, defaults the role toUserRole.Attendee, and recordsLoginProvider/ProviderKey.LinkExternalProvider(lines 223-227): attaches an OAuth identity to an existing local account, the "same email logged in via Google" path.UpdateRefreshToken/RevokeRefreshToken(lines 234-247): the rotation and revocation pair (BR-205, BR-216).LinkSpeaker/UnlinkSpeaker(lines 254-262): one-line setters, but named domain operations so the event handlers that call them read as intent (BR-207/209, BR-70).UpdatePreferences(lines 272-285): combines the culture and theme invariants, and only assigns both fields once both pass, so a rejected theme never leaves a half-applied culture.SetAvatarUrl(line 294): deliberately dumb. The doc comment (lines 287-291) states that size, format, and re-encoding validation happen in the upload use case; the domain only records the resulting URL.ChangePassword(lines 302-319): validates the new hash and salt, assigns them, and raisesUserPasswordChanged(line 316) only on success.Delete(lines 348-358):new-shadows the base soft-delete. It revokes the refresh token first (line 350), then callsbase.Delete(), then raisesUserDeletedonly if the base succeeded. Revoking first is the security point: a deleted account's outstanding sessions must die immediately, not at token expiry (BR-56).Anonymize(lines 371-406): the erasure operation. It builds a placeholder emaildeleted-{Id}@anonymized.invalidusingstring.Create(CultureInfo.InvariantCulture, ...)(line 376), and the id is embedded precisely so the unique-email invariant (BR-200) still holds across many erased accounts. It is idempotent: ifEmailalready equals the placeholder there is nothing left to erase and it returns success (lines 383-386). Otherwise it overwrites the email and names with placeholders, empties both credential arrays, nulls all seven device fields, the external-login pair, andAvatarUrl, and revokes the refresh token (lines 388-403).
- Why it's built this way: the split between
Delete(soft-delete,IsDeleted) andAnonymize(destroy the personal data, keep the row) is ADR-005's resolution of a real tension. The row must survive because other bounded contexts hold scalar references toUserId(bookmarks, notifications) and because the audit trail depends on it; the personal data must not survive, because PRIVACY.md promises erasure. Doing both, in that order, satisfies both constraints. The aggregate never touches storage or the blob store:AnonymizenullsAvatarUrlbut the doc comment (lines 89-93) notes the blob itself is deleted by the owning use case, which is the layer that knows about storage (seeDeleteUserHandler). - Where it's used: the single entity of the Identity module's DbContext; loaded and mutated by every Identity handler (
ChangePasswordHandler,DeleteUserHandler,ExportUserDataHandler,SetUserAvatarHandler,GetUsersHandler); projected byUserDTOMapper; mapped by the Identity EF entity configuration; created byAuthenticationServiceandIdentityModuleDbSeeder.
OAuthController
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Controllers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20· Level 7 · class (sealed)
- What it is: the ADC social-login endpoint set (Google, GitHub). It is a body-less controller: the entire OAuth flow lives in the framework base
OAuthControllerBaseand this type exists only to supply the ADC route, version, and constructor wiring. - Depends on:
OAuthControllerBase(base),IAuthenticationService(aliased atOAuthController.cs:6to disambiguate it from the ASP.NET Core type of the same name),ICacheService, andIConfiguration. - Concept introduced, the thin derived controller.
[Rubric §2, Design Patterns]and[Rubric §16, Maintainability]. The class body is a single semicolon (OAuthController.cs:23): a primary-constructor declaration that forwards all three dependencies to the base and declares nothing else. All the endpoints, the challenge, the provider callback, the completion, and the single-use-code exchange, are inherited. The doc comment (lines 10-16) records two things that are not obvious: tokens never ride the redirect URL (they are exchanged for a short-lived single-use code instead, which is whyICacheServiceis a dependency), and the class-level routing and versioning attributes are repeated here because they are not reliably inherited from the base. - Walkthrough:
[Route("auth/oauth")](line 18) is an explicit literal route, not the[controller]token the other controllers use, so the OAuth endpoints sit under the auth path rather than at/OAuth.[ApiController]and[ApiVersion("1.0")](lines 17, 19) complete the standard stack. The primary constructor (lines 20-23) takes the authentication service, the cache service, and configuration, and passes them straight through. - Why it's built this way:
[Rubric §11, Security]: an OAuth callback flow is easy to get subtly wrong (state handling, token leakage through the URL and therefore into browser history and referrer headers), so the algorithm lives once in MMCA.Common and every consuming app inherits the same hardened implementation. External OAuth is an ADC-only feature; Store is local-credential only, and this controller plus the host'sAddExternalAuthProviderscall is the entire ADC-side surface of that difference. - Where it's used: mapped in the Identity service host and exposed through the Gateway; it drives the same
AuthenticationService.ExternalLoginAsyncthat creates or links external accounts. - Caveats / not-in-source: which providers are actually enabled, and their client ids and secrets, come from configuration read by the base and by
AddExternalAuthProviders; nothing in this file determines them.
UsersController
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Controllers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:30· Level 8 · class (sealed)
- What it is: the REST surface for user management and personal-data operations: avatar get/upload/remove for the current user, the organizer user list, the GDPR data export, and account deletion.
- Depends on:
ApiControllerBase(base), six injected handlers (IQueryHandler<in TQuery, TResult>andICommandHandler<in TCommand, TResult>closed overGetUsersQuery,DeleteUserCommand,ExportUserDataQuery,SetUserAvatarCommand,RemoveUserAvatarCommand, andGetUserAvatarQuery),ICurrentUserService,HasPermissionAttribute,IdentityPermissions, andResult/Error. - Concept introduced, the controller as a thin dispatcher over the CQRS pipeline.
[Rubric §6, CQRS & Event-Driven]and[Rubric §3, Clean Architecture]. Every action follows one shape: read the current user id, build the query or command record,await handler.HandleAsync(...), thenresult.IsFailure ? HandleFailure(result.Errors) : <success>. The controller injects handler interfaces, not an application service and not a mediator, so the dependency is visible in the constructor signature and the decorator pipeline (logging, caching, validation, transaction) wraps each one at registration.HandleFailurefromApiControllerBaseis the single placeErrorcodes become HTTP status codes plusProblemDetails, which is why no action here writes a status code for a domain failure.[Rubric §11, Security]shows up in three different mechanisms on one controller, which is worth studying together: class-level[Authorize](line 29) as the baseline; declarative permission gating with[HasPermission(IdentityPermissions.UsersRead)]on the list endpoint (line 124, BR-51); and in-handler authorization for the export and delete endpoints, where the action passescurrentUserService.UserIdandcurrentUserService.Roledown to the handler (lines 161, 183) because "owner or Organizer" is a rule about domain data that an attribute cannot evaluate. - Walkthrough
- Constructor (
UsersController.cs:30-37): six handlers plusICurrentUserService, primary-constructor style. MaxAvatarBytes = 2 * 1024 * 1024(line 40): the 2 MB cap from BR-116a, expressed as an arithmetic constant.GetAvatarAsync(HttpGet("me/avatar"), lines 43-58): resolves the current user id, returnsUnauthorized()when it is null, dispatchesGetUserAvatarQuery. Themeroute pattern takes no id from the URL at all, so a caller can only ever address their own avatar.SetAvatarAsync(HttpPost("me/avatar"), lines 65-101): the most involved action.[RequestSizeLimit(MaxAvatarBytes)](line 66) rejects an oversized body at the Kestrel level before the handler runs; the in-action check (lines 77-83) then rejects a null, empty, or over-capIFormFilewithError.Validation("Avatar.InvalidUpload", ...). Belt and braces, cheap and correct. The stream is copied into a right-sizedMemoryStreamand passed to the handler as abyte[](lines 85-92), withawait usingon both the request stream and the buffer. The doc comment (lines 60-64) records what the handler then does: sniff the real format (jpeg/png/webp) rather than trusting the declared content type, and re-encode to 256x256 JPEG, which is the defense against a disguised-payload upload.RemoveAvatarAsync(HttpDelete("me/avatar"), lines 104-119): dispatchesRemoveUserAvatarCommandand returns204 NoContent; documented as idempotent.GetAllAsync(HttpGet, lines 123-144): the organizer list.[HasPermission(IdentityPermissions.UsersRead)]gates it, and the paging arguments carry[Range(1, int.MaxValue)]data annotations (lines 131-132) so[ApiController]rejectspageNumber=0or a negative page size with a 400 before any code runs. ReturnsPagedCollectionResult<UserListDTO>.ExportAsync(HttpGet("{userId}/export"), lines 148-167): the data-subject access and portability endpoint (PRIVACY.md §7). It passes the target id and the caller's id and role intoExportUserDataQuery, and declares 403 and 404 response types.[Rubric §30, Compliance, Privacy & Data Governance]: export and erasure are the two data-subject rights, and this controller is where both enter the system.DeleteAsync(HttpDelete("{userId}"), lines 170-189): buildsDeleteUserCommandwith the same three-value authorization payload and returns204 NoContenton success.
- Constructor (
- Why it's built this way: keeping the "me" operations on a path with no id (rather than
users/{id}/avatarwith an ownership check) removes an entire class of ownership bug: there is no id to tamper with. Where an id is unavoidable (export, delete), the authorization subject travels on the command into the handler rather than being re-read from ambient context, which keeps the handler pure and unit-testable ([Rubric §14, Testability]). Every action isasyncwith aCancellationTokendefaulted, and everyawaituses.ConfigureAwait(false), the codebase-wide analyzer-enforced convention (ADR-049). - Where it's used: routed through the YARP Gateway to the Identity service; consumed by
UserServicefrom the UI, which is what backsUserListand the avatar components.
AuthController
MMCA.ADC.Identity.API ·
MMCA.ADC.Identity.API.Controllers·MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:25· Level 11 · class (sealed)
- What it is: the ADC authentication endpoint set. It inherits login, registration, refresh, and revocation from the framework
AuthControllerBase, overrides two of them to add ADC-specific rate limiting, and adds three endpoints of its own (change password, get preferences, set preferences). - Depends on:
AuthControllerBase(base),IAuthenticationService,ICurrentUserService, three handlers (ChangePasswordCommand,ChangePreferencesCommand,GetUserPreferencesQuery), the request/response contractsRegisterRequest,LoginRequest,ChangePasswordRequest,ChangePreferencesRequest,AuthenticationResponse,UserPreferencesResponse, and ASP.NET Core'sEnableRateLimiting. - Concept introduced, extending a framework controller by selective override.
[Rubric §1, SOLID](Liskov and open-closed) and[Rubric §11, Security]. This is the richest example in the module of the inherit-then-specialize pattern: the base owns the shared auth endpoints, and ADC overrides exactly the two where its behavior genuinely differs, then adds three endpoints the framework has no opinion about. Two independent throttles guard the credential endpoints and they defend against different attacks: the per-email lockout in the authentication service (BR-212, ADR-029) stops brute force against one account, while[EnableRateLimiting("auth-ip")](lines 39, 63) is a per-IP fixed window that stops password spraying. The comment at lines 57-60 states the reason plainly: the per-email lockout alone cannot throttle one source spraying one password across many emails. - Walkthrough
- Attributes and constructor (
AuthController.cs:22-31): the standard[ApiController]/[Route("[controller]")]/[ApiVersion("1.0")]stack (no class-level[Authorize]here, since login and registration must be anonymous), and a primary constructor forwarding the authentication service and current-user service to the base while keeping the three handlers as its own. RegisterAsync(override, lines 43-54): the reason for the override is one line,HttpContext.Connection.RemoteIpAddress?.ToString()(line 48), passed intoAuthenticationService.RegisterAsyncfor the BR-213 registration rate limit. Reading the client IP requires theHttpContext, which the application layer does not have, so the controller is the correct place to capture it. Returns201 Createdwith theAuthenticationResponseand documents a409 Conflictfor a duplicate email and429for the rate limit.LoginAsync(override, lines 67-70): overridden purely to attach[EnableRateLimiting("auth-ip")]and the429response documentation; the body just callsbase.LoginAsync.ChangePasswordAsync(HttpPut("password"), lines 82-97):[Authorize], readsCurrentUserService.UserIdand returnsUnauthorized()when null, then dispatchesChangePasswordCommandand returns204 NoContent. The doc comment (lines 72-76) records the design choice: the command goes directly to the handler through the decorator pipeline rather than being brokered by the authentication service, so it gets validation, transaction, and cache invalidation like any other command. Note that the user id comes from the token, never from the request body: a caller cannot change someone else's password by changing a field.ChangePreferencesAsync(HttpPut("preferences"), lines 108-123) andGetPreferencesAsync(HttpGet("preferences"), lines 133-146): the ADR-027/ADR-028 culture and theme persistence pair, both scoped to the token's user id. The comments (lines 99-102, 125-128) explain the purpose: preferences follow the user across devices, and a null field leaves that preference unchanged.
- Attributes and constructor (
- Why it's built this way: keeping the login/refresh/revoke algorithm in MMCA.Common means the security-critical token dance is written once and hardened once; ADC's real differences are two rate-limit attributes and one IP capture, and those are the only things overridden. Persisting UI preferences server-side (rather than only in browser storage) is what makes them survive a device change, and routing them through the same CQRS pipeline as every other write means they get the same validation and audit treatment (
[Rubric §27, Internationalization],[Rubric §19, State Management]). - Where it's used: mapped in the Identity service host and fronted by the Gateway's
/Authroute; the tokens it issues are validated by the other three ADC services through JWKS discovery, with no shared secret (ADR-004).
⬅ ADC Engagement Live Layer (Real-Time Polls & Session Q&A) • Index • ADC Application Host, UI Shell & Cross-Module Composition ➡