Onboarding guide
22. ADC Engagement Module (Session Bookmarks)
What this group covers. Engagement is the ADC bounded context that holds everything an attendee does at the conference beyond browsing the catalog. Four capability families live here, and they are worth naming up front because the chapter title only mentions the oldest one: the session-bookmark aggregate (the personal schedule), the QR badge check-in surface (badges, organizer scanning, the manual fallback, attendee self-service from printed room and sponsor codes, and the attendance rollup), the points economy (an append-only ledger plus an opt-in public leaderboard), and the attendee-facing pages the module contributes to the Blazor UI (feedback forms, the badge, the points screens, and the now-next surface). Around those sit the shared module plumbing: use cases, EF persistence, the REST API, the cross-service gRPC contracts and adapters, and the extracted service host. The module also hosts the conference-day live layer (LivePolls and SessionQuestions), a surface large enough that its aggregates, use cases, controllers, wire payloads and pages are documented on their own in Group 23. What stays in this chapter is everything the rest of the module shares with that layer: the flags in EngagementFeatures, the grants in EngagementPermissions, the outbound publish queue behind ILiveChannelPublishQueue, the two UI helpers the conference-day pages share (LiveChannelSubscription and LiveBroadcastPatch), the browser-side client layer those pages call (ILiveEventUIService, ILivePollUIService and ISessionQuestionUIService with their implementations), the session catalog lookup behind ISessionLookupService, and the LivePolls table shapes (LivePollConfiguration, LivePollOptionConfiguration and LivePollVoteConfiguration), which sit beside the other EF configurations of the one database this module owns. Read Group 02 for the entity primitives and primer §2 for the styles before you go deep here.
Five small aggregates, one shape. Every write in this module lands on an AuditableAggregateRootEntity<TIdentifierType> with private setters, private constructors, and a static Create factory returning Result<T> after a Result.Combine over a matching invariants helper. UserSessionBookmark (MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16) carries two scalar foreign keys, UserId and SessionId (:19, :22), and raises one UserSessionBookmarkChanged event with a DomainEntityState discriminator instead of separate Created and Deleted events (:57, :73, :88, documented in-code as BR-60); its Reactivate() (:68-76) undeletes a soft-deleted row and re-raises the Added variant, which is the domain half of the re-bookmarking rule BR-135. The comment at :55-56 is worth reading before you copy the event: the bookmark's identity is generated by the INSERT, so the event captures a zero id by value and consumers correlate on the user and the session instead. CheckIn (MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:28) is one aggregate for all three scopes of the CheckInScope enum, because the row, the idempotency rule, and the attendance query have the same shape for each and only the required target differs (:13-15); its CheckedInByUserId (:49) is what keeps a self-recorded scan distinguishable from an organizer's, and it is the one aggregate here that raises an integration event straight from its factory (:112). AttendeeBadge (MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:18) holds one opaque Guid credential per user (:24) and raises no domain event at all, since a badge existing is not a fact any other module reacts to (:12-15); Regenerate() (:59) revokes every printed or screenshotted copy, and the credential is minted with Guid.NewGuid() rather than Guid.CreateVersion7() precisely because a v7 value embeds a timestamp and orders monotonically, which is the one property a bearer credential must not have (:65-68). PointsEntry (MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:31) is an append-only ledger row with no mutators, so a total is always the sum of its entries (:12-13), and it snapshots the configured award at earn time (:39-40) so retuning the economy never rewrites history. LeaderboardOptIn (MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:19) stores the display name the attendee explicitly published (:35), which is why serving the board needs no call into Identity, and it keeps EraseDisplayName() (:130) deliberately separate from Delete() (:109): leaving the board and erasing the name you published there are two different promises, and only the second is irreversible. CheckIn and PointsEntry additionally declare IAuditedEntity (CheckIn.cs:28, PointsEntry.cs:31), so their field-level change history is written by the audit-trail interceptor (ADR-075): an attendance assertion about a named person and a prize-bearing ledger are both things you want to be able to prove rather than merely assert.
The bookmark slice, end to end. BookmarksController (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:34) is the thin REST edge: each action injects a handler and delegates, mapping Result failures to RFC 9457 Problem Details through ApiControllerBase. The POST does its own ownership bind first, because the owner arrives in the request body rather than in a query argument: a caller without the Organizer role may only create a bookmark whose UserId equals their user-identifier claim, otherwise the action returns Error.Forbidden (:63-70, ADR-033). It also carries IdempotentAttribute (:50), which is safe here for the reason stated in the doc comment: the request names the (user, session) pair explicitly and a duplicate already answers 409, so replaying a retried star is what the attendee meant (ADR-017). It then routes into CreateBookmarkHandler (MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:16), still the most instructive slice in the module: it asks Conference's ISessionBookmarkValidationService whether the session may be bookmarked at all (:31, BR-49 and BR-91 across the service boundary), returns Error.Conflict on an existing active bookmark (:37-47, BR-21), loads any soft-deleted row through the repository's FindIncludingDeletedAsync (:51-55) and hands the create-or-reactivate decision to the pure BookmarkManagementDomainService behind IBookmarkManagementDomainService (:57), so BR-135 lives in the domain instead of being smeared across the handler. Only a genuinely new entity is added to the repository (:63-66), and the save is wrapped in a catch filtered by the framework's injected IUniqueConstraintViolationDetector (:71), so a request that loses the insert race against the filtered unique index sees the same 409 the pre-check would have produced rather than a raw 500 (:72-83). That detector is the module's shared answer to "the database is the rule, the pre-check is only a courtesy": PointsAwarder, GetOrCreateMyBadgeHandler and SetLeaderboardParticipationHandler inject the identical abstraction. On the read side, GetUserBookmarksHandler (.../GetUserBookmarks/GetUserBookmarksHandler.cs:17) returns a paged, CreatedOn-descending PagedCollectionResult<T> of DTOs, with the page size clamped by PagingMath before anything touches the database and ordering plus OFFSET/FETCH pushed into SQL through IQueryableExecutor; GetBookmarkedSessionIdsHandler (.../GetBookmarkedSessionIds/GetBookmarkedSessionIdsHandler.cs:12) is the lightweight sibling the unified Sessions page turns into per-row filled or empty bookmark icons. Both list endpoints are scoped by the shared OwnerOrAdminFilter applied as a [ServiceFilter] (BookmarksController.cs:84, :105), configured for ADC through OwnerOrAdminFilterOptions in AddModuleEngagementAPI (MMCA.ADC.Engagement.API/DependencyInjection.cs:45-56): ClaimTypes.NameIdentifier as the owner claim, the userId query argument, and the Organizer bypass role. The DELETE keeps a separate inline, database-backed ownership check that returns 404 rather than 403 so it never leaks whether a bookmark exists (BookmarksController.cs:131-148), then runs the generic DeleteEntityCommand<TEntity, TIdentifierType> (:151), which soft-deletes.
Check-in: four ways in, one row out. CheckInsController (MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:39) exposes the whole surface behind [FeatureGate(EngagementFeatures.CheckIn)] and a plain [Authorize] (:33-34). An attendee fetches their own badge at GET my-badge with no permission at all (:46), because GetOrCreateMyBadgeHandler (.../GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:17) reads the identity from the token and mints on first use (:38-70), returning a MyBadgeDTO carrying nothing but the credential (:72-74). The QR itself is formatted by BadgePayload (MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:12), one place that owns both the mmca-adc:badge: prefix (:15) and the parser (:30), so the display side and the check-in handler cannot drift. The organizer paths are gated by [HasPermission(EngagementPermissions.CheckInManage)] (CheckInsController.cs:75, :99, :179): CheckInAttendeeHandler (.../CheckInAttendee/CheckInAttendeeHandler.cs:19) resolves the scanned credential to a badge row and answers a malformed payload and an unknown-but-well-formed credential with the same error, since a scanner that could tell them apart would be an oracle for guessing credentials (:41-56), and ManualCheckInHandler covers the dead-phone fallback. Both then run the shared CheckInProcessor (MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:23), a static helper rather than an injected service so neither handler gains a dependency it does not own (:11-14). The processor is split in two halves on purpose: ExecuteAsync (:109) is the organizer path, which validates the target through Conference and lets the server's answer for the owning event win over the value the scanning client sent (:124-128), while RecordAsync (:58) is the write half every entry point shares, short-circuiting a repeat scan into a success carrying the original timestamp rather than a conflict (:71-75) and otherwise recording exactly one row via the RecordedCheckIn outcome struct (:38). The two self-service flows are separate handlers that reuse only that write half. RecordSponsorVisitHandler (.../RecordSponsorVisit/RecordSponsorVisitHandler.cs:34) and RecordRoomCheckInHandler (.../RecordRoomCheckIn/RecordRoomCheckInHandler.cs:26) take the attendee from the token, never the request, and each carries its own feature flag (CheckInsController.cs:130, :161) so a printed sponsor code and a printed room code can be retired independently. The room flow is the sharpest piece of design here: only the room travels in the request, and the server resolves which session that room is hosting at the request instant plus a configured grace window (RecordRoomCheckInHandler.cs:50-52), so a shared link records nothing outside a session's window and nobody can name a session they are not standing in; an unknown room and an idle room answer identically so the response never confirms which room ids exist (:55-63). GetAttendanceStatsHandler (.../GetAttendanceStats/GetAttendanceStatsHandler.cs:15) closes the loop for organizers, and both of its figures are computed in SQL: a CountAsync for the event scope and a CountByAsync grouped per session, so what crosses the wire is one row per session rather than one per check-in (:27-34). All of this is ADR-072; the camera half of the scanner is ADR-071.
Points: one write path, several adapters. Everything that earns goes through IPointsAwarder / PointsAwarder (MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:29), which takes an (activity, subjectKey) pair and knows nothing about events, sessions or sponsors. It reads the configured value from PointsSettings (MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:12) and treats a configured or missing 0 as a per-rule kill switch, writing nothing rather than a zero-value ledger row (PointsAwarder.cs:43-51); it pre-checks for an existing award purely to make the common duplicate path a clean no-op (:55-64); and it reads the unique-index violation as already awarded, not as a failure to retry (:75-82). The rule that matters is the index, not the code: the unique (UserId, ActivityType, SubjectKey) index in PointsEntryConfiguration (MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:45-48) is simultaneously the idempotency guarantee and the anti-farming rule, because PointsSubjectKeys collapses N questions or N answers in one session onto one subject key. Four adapters feed it. Three are integration-event handlers built on ScopedIntegrationEventHandlerBase<TIntegrationEvent>: AttendeeCheckedInPointsHandler (.../IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:31) maps the wire scope string onto an earn rule and logs-and-skips a scope this build has never heard of rather than dead-lettering a message no retry could fix (:42-46, :65-99), while SessionFeedbackSubmittedPointsHandler and EventFeedbackSubmittedPointsHandler consume events Conference publishes. The fourth, SessionQuestionSubmittedPointsHandler (.../DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51), rides an in-process domain event instead, an explicit at-most-once trade-off documented in the type: a crash between the commit and the dispatch loses one small award and nothing else, and promoting it to an integration event later is a one-file change on each side (:30-39). Its doc comment is also the best worked example in the module of the id-by-value trap described above, and of why every field it needs now comes off the event rather than from a read-back (:21-29). A fifth handler earns nothing at all: UserDeletedPointsHandler (.../IntegrationEventHandlers/UserDeletedPointsHandler.cs:37) consumes Identity's UserDeleted and erases the published leaderboard name, reading with ignoreQueryFilters: true precisely because an attendee who already left the board still has a soft-deleted row carrying the name that must be erased (:53-61).
Reading the points back. PointsController (MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36) is a good study in designing an ownership problem out of existence: nothing on the surface takes a user id, so the three attendee endpoints resolve the caller from the token inside their handlers and there is no ownership check to get wrong (:25-29). GetMyPointsHandler serves the caller's own total and a page of the entries behind it; SetLeaderboardParticipationHandler (.../SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:30) takes the published display name from the caller's token claims server-side, which is why SetLeaderboardParticipationRequest carries only a boolean (:14-19), and rejoining reactivates the soft-deleted opt-in rather than accumulating dead rows (:20-24). GetLeaderboardHandler (.../GetLeaderboard/GetLeaderboardHandler.cs:28) builds the board from the opt-in list and the ledger with no call into Identity (:12-17), projecting the opt-ins to the two columns it needs through the private OptInRow record (:40-43, :85) and summing the ledger with one grouped SumByAsync in the database rather than pulling every row back to fold in memory (:50-59). Ranks are distinct and sequential with an ordinal name tie-break, so repeated reads return the same order (:18-24, :65-77). Only the organizer rollup carries a permission, [HasPermission(EngagementPermissions.PointsViewOverview)] (PointsController.cs:121), and even that view returns activity, points and timestamps without attendee identity (:114-119).
Persistence. Engagement's ModuleApplicationDbContext (MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19) is an abstract context declaring the module's ten internal DbSets (bookmarks, badges, check-ins, the three live-poll sets, the two question sets, the points ledger and the opt-ins, :27-54) and inheriting auditing, soft-delete query filters and domain-event dispatch from the common ApplicationDbContext (:24); the concrete per-engine class (SQLServerDbContext today) sits under it, one instance per physical database (ADR-006). The table shapes are where the module's business rules become enforceable, and every configuration derives from EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>. UserSessionBookmarkConfiguration (.../UserSessionBookmarkConfiguration.cs:17) makes BR-21 a filtered unique index over (UserId, SessionId) restricted to non-deleted rows (:31-34) and adds a SessionId-leading index the composite cannot serve (:36-39). CheckInConfiguration (.../CheckInConfiguration.cs:19) carries three filtered unique indexes, one per scope, with the scope literals kept side by side as named constants because an index filter is SQL and cannot read the enum (:22-26, :48-62); the sponsor one is the once-per-sponsor cap that makes a shared booth link worth nothing past the first scan (:58-62), and two more indexes serve the attendance rollup (:64-69). PointsEntryConfiguration adds the UserId-leading index that makes "my points" a seek (:50-52). The live-poll tables follow the same discipline even though their aggregates are taught in Group 23: LivePollVoteConfiguration (.../LivePolls/LivePollVoteConfiguration.cs:17) makes BR-225, one active vote per (LivePollId, UserId), a filtered unique index through the shared HasSoftDeleteFilter() helper (:34-37), which is the database-level guarantee behind the vote handler's create-or-reactivate dance (:12-15), and adds the (LivePollId, OptionId) index the tallies group by (:39-40). LivePollConfiguration (.../LivePolls/LivePollConfiguration.cs:16) indexes EventId for the Happening Now and organizer manage views (:34-35) and (SessionId, Status) for the session Live page and presenter view, the conference-day hot path that previously scanned (:37-41), and pins the Options navigation to PropertyAccessMode.Field so the encapsulated collection cannot silently stop materializing if the property changes shape (:43-50). LivePollOptionConfiguration (.../LivePolls/LivePollOptionConfiguration.cs:10) is the plain child-entity case: a required text column bounded by the domain's own LivePollInvariants.OptionTextMaxLength and the required FK back to its poll (:18-25). Cross-module identifiers (UserId, EventId, SessionId, SponsorId) are scalar columns throughout, never cross-database foreign keys (CheckInConfiguration.cs:16-17). The module owns ADC_Engagement and is its sole migrator, applying EF migrations idempotently at startup (MMCA.ADC.Engagement.Service/Program.cs:305-310, ADR-030), so events serialized into its own outbox during SaveChangesAsync never race another service's rows (ADR-003).
How Engagement talks to its neighbors. Engagement is both a client and a server on the gRPC mesh (ADR-007, ADR-008). As a server, its in-process BookmarkCountService (MMCA.ADC.Engagement.Application/UserSessionBookmarks/Services/BookmarkCountService.cs:11) answers "how many people bookmarked this session" for Conference's speaker pages, including a batched variant that replaces the caller's per-session fan-out with one grouped COUNT and back-fills a zero for every requested session that has none (:37-46). It goes on the wire through BookmarkCountsGrpcService (MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:24) and is consumed through the client-side BookmarkCountServiceGrpcAdapter (MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14), which re-implements the same C# interface so calling code never knows it crossed a process. Read the trust-boundary note before copying the pattern: that endpoint is mapped deliberately without RequireAuthorization(), because the Conference callers serve [AllowAnonymous] output-cached endpoints and a cache-miss request from an anonymous visitor carries no bearer to forward; the exposure is bounded by the payload (aggregate counts, no PII, no user ids) and by an internal-only container ingress (MMCA.ADC.Engagement.Service/Program.cs:325-334). The identical bridge carries the privacy export, and there the choice flips. UserEngagementExportService (MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:17) projects a user's bookmarks, submitted questions, points ledger, check-in history and leaderboard participation server-side into a UserEngagementExportDTO of ids and dates (:24-81), with soft-deleted rows excluded by the global query filter; check-ins are exported independently of the ledger, because a switched-off rule or a deduped repeat scan means the ledger is no proxy for the attendance record a data subject is entitled to (:48-50). It is served by UserEngagementExportGrpcService and consumed by Identity through UserEngagementExportServiceGrpcAdapter, and that endpoint does carry RequireAuthorization() because it returns personal data keyed by a raw user id (Program.cs:335-341, PRIVACY.md §7, ADR-076, ADR-005). The Contracts-layer DependencyInjection (MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16) is what swaps in-process for remote: each helper registers the typed client and then Replaces (never TryAdds) the existing registration, so it overwrites both the real service and the disabled stub (:45-51), and it must run after ModuleLoader.DiscoverAndRegister (:36-39).
The outbound live-channel path. Engagement never blocks a command on Notification. Handlers enqueue a post-commit broadcast as a LiveChannelPublishWorkItem (MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:10) through ILiveChannelPublishQueue (:21), a three-string record (channel key, event name, pre-serialized payload) carrying no entity and no per-user data. LiveChannelPublishQueue (MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:14) is the single concrete channel, registered once as a singleton and exposed to handlers via the interface (MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57). Read its backpressure contract carefully: the channel is bounded at 1024 items (:18) with BoundedChannelFullMode.DropOldest and SingleReader (:33-40), so Enqueue returns void and never rejects an item (it evicts the oldest rather than refusing the write, :50-58), and a discarded broadcast is observable only through DroppedCount (:47), the itemDropped callback, and a warning log. LiveChannelPublishProcessor (MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30) is the single-reader BackgroundService drain, registered as the module's only hosted service (MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21), forwarding items to ILiveChannelPublisher in FIFO order: one reader preserves per-session ordering, and the swallow is BestEffort's rather than a hand-rolled catch, so a peer that has quietly stopped accepting broadcasts is countable on the besteffort.dispatch.failed meter instead of being a warning nobody alerts on (:21-28, :45). What actually gets published is taught in Group 23.
Module wiring, feature gating, and the extracted host. EngagementModule (MMCA.ADC.Engagement.API/EngagementModule.cs:14) is the IModule entry point discovered by ModuleLoader and registered in topological order; it declares a dependency on Conference with RequiresDependencies => true (:20, :23), and Register is a one-liner into AddEngagementModule (:26-27), which chains the Application, Infrastructure and API registrations in dependency order (MMCA.ADC.Engagement.API/DependencyInjection.cs:26-28). The Application registration is where the module's own services land: the bookmark domain service as a singleton (MMCA.ADC.Engagement.Application/DependencyInjection.cs:38), the two cross-module services scoped (:45, :49), the awarder scoped and registered by hand because it is a plain service the convention scan does not see (:75-83), and everything conventional (handlers, mappers, validators, event handlers) discovered by ScanModuleApplicationServices<ClassReference>() (:87), which is where the per-layer ClassReference marker earns its keep. Each of the four layers declares its own AssemblyReference / ClassReference pair, and the design-time migrations factory reaches for the Infrastructure one, because the IEntityTypeConfiguration classes live in Infrastructure rather than Application. When the module is disabled in a host, RegisterDisabledStubs swaps in DisabledBookmarkCountService and DisabledUserEngagementExportService (EngagementModule.cs:30-34) so a Conference-only or Identity-only host still resolves those interfaces. Every capability has its own kill switch in EngagementFeatures (MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:8): SessionBookmarks, LivePolls, CheckIn, SponsorVisits, RoomCheckIn, Points and SessionQA (:15-58), each turning its controller or route into a 404 without a deploy. Authorization above authentication is capability-based, not role-based: EngagementPermissions (MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:9) declares three capabilities, engagement:live:manage, engagement:checkin:manage and engagement:points:view-overview (:16, :23, :30), granted wholesale to Organizer and Admin in AddModuleEngagementAPI (MMCA.ADC.Engagement.API/DependencyInjection.cs:58-62, ADR-020). MMCA.ADC.Engagement.Service boots exactly this one module and is worth reading top to bottom as the reference shape for an extracted host. Kestrel is configured in one line, builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) (Program.cs:67), the shared helper from KestrelEndpointExtensions: Http2-only on cleartext so cross-service gRPC clients negotiate h2c by prior knowledge, plus the optional HTTP/1.1-only health-probe listener (ADR-012). PointsSettings and CheckInSettings are bound here rather than in the module, because the module registration takes no IConfiguration, and both are deliberately not ValidateOnStart: a missing section binds working defaults and an explicit 0 is the documented kill switch, so no value an organizer sets should stop this host from booting mid-conference (:122-133, a considered divergence from ADR-070). The scheduler and audit trail are wired next (:193, :197, ADR-074, ADR-075), and EngagementErrorResources contributes the module's error-code translations (:216, ADR-027). Composition itself runs through AddMmcaApplicationPipeline, a single ordered chain that registers the modules first and then, because Conference is disabled in this process, re-points Engagement's two Conference dependencies at gRPC clients, swaps the framework's no-op live-channel publisher for the Notification adapter, and finally wires broker messaging (:278-288, ADR-014 closes and seals the decorator pipeline at the end of it). This host is not publisher-only: the points game consumes four integration events, AttendeeCheckedIn, SessionFeedbackSubmitted, EventFeedbackSubmitted and UserDeleted (:285-288). AttendeeCheckedIn is the novel one: it is published by this same service (the CheckIn factory raises it and the outbox captures it in the check-in transaction), so this is ADC's first broker self-consumption, which MassTransit's type topology supports with no special casing (:268-271). SelfHttpWarmupTask (MMCA.ADC.Engagement.Service/SelfHttpWarmupTask.cs:23) extends SelfHttpWarmupTaskBase and holds /health/ready not-ready until the routing, auth and middleware pipeline has been JITted (Program.cs:153-157, ADR-025); the two gRPC endpoints are mapped last (:333, :341).
The UI surfaces. EngagementUIModule (MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17) implements IUIModule to contribute the module's Blazor assembly (:33), the invisible LiveEventListener layout component (:31), and six nav items declared as resource keys with the organizer ones carrying a RequiredRole so the shared NavMenu hides them from attendees (:21-29, ADR-027); their targets live in EngagementRoutePaths (MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:8), which also holds the two deep-link-only self-service routes that deliberately contribute no nav item at all (:29-32). The pages divide by actor. Attendees get MyBadge (Pages/CheckIn/MyBadge.razor.cs:15), which renders the opaque payload beside the account name as plain text so a human can confirm the badge belongs to its holder (:10-13), MyPoints, and the two QR landing pages SponsorVisit (Pages/Sponsors/SponsorVisit.razor.cs:21) and RoomCheckIn, which post the write themselves so the whole interaction is one scan with nothing to press; both keep the write off the prerender pass and behind a once-per-instance guard (SponsorVisit.razor.cs:48-55) and branch their on-screen state off the server's stable error code carried back by SelfCheckInOutcome<TResult> (Services/SelfCheckInOutcome.cs:20) and named in CheckInErrorCodes (Services/CheckInErrorCodes.cs:8), because two of the three refusals share the same 404 status. Organizers get CheckInScan (Pages/CheckIn/CheckInScan.razor.cs:18), which loops the shared scanner where a camera exists and falls back to the AttendeeSearchPanel name and email search everywhere else (:12-16, :38), OrganizerAttendance and OrganizerPointsOverview. The two feedback pages, EventFeedback (Pages/Feedback/EventFeedback.razor.cs:19) and SessionFeedback (Pages/Feedback/SessionFeedback.razor.cs:18), render a dynamic question form whose definitions and answers belong to Conference, with per-question values held in a FeedbackAnswerModel (Pages/Feedback/FeedbackAnswerModel.cs:23) whose DataAnnotations are the single declaration of the 4000-character free-text rule (:30-34). Their submit paths differ, and the difference is deliberate: the event form posts one upsert per answered question and reports exactly how much was saved when a call fails (EventFeedback.razor.cs:183-202, BR-107), while the session form posts the whole form in one call the server applies atomically (SessionFeedback.razor.cs:226, Services/IFeedbackUIService.cs:57-61). Behind the pages sit ordinary authenticated HTTP clients built on AuthenticatedServiceBase: BookmarkService and the cross-module SessionBookmarkUIService (which also feeds SessionReminderCoordinator and the pure SessionReminderPlanner, whose SessionReminder records carry a stable notification id so rescheduling replaces rather than duplicates, Services/SessionReminderPlanner.cs:13, :29-38, ADR-042), CheckInService, PointsService, AttendeeLookupService (whose by-identifier lookup is answered from a roster snapshot because the Identity users endpoint filters by name and email but cannot be queried by identifier, Services/AttendeeLookupService.cs:10-15), and NowNextService behind INowNextService, which mirrors the Conference API's now-next wire shape locally rather than referencing Conference.Shared for one payload (Services/INowNextService.cs:6-9). The conference-day pages taught in Group 23 get their client layer from here too, and it is the same shape. LivePollUIService (MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:15) and SessionQuestionUIService (Services/SessionLive/SessionQuestionUIService.cs:15) are AuthenticatedServiceBase clients over the Gateway's livepolls and sessionquestions routes (:19 in each), and their contracts ILivePollUIService (Services/SessionLive/ILivePollUIService.cs:15) and ISessionQuestionUIService (Services/SessionLive/ISessionQuestionUIService.cs:15) both open with the rule that keeps a live page calm: every member answers with a Result, so a server refusal is data the page renders rather than an exception, and only the caller's own cancellation still propagates (ILivePollUIService.cs:10-13, ISessionQuestionUIService.cs:10-13). Both also say in the contract that the API enforces the manage and moderation rights regardless of what the UI chooses to render (ILivePollUIService.cs:7-9, ISessionQuestionUIService.cs:7-9), which is the only safe way to write a doc comment about a permission a browser can be told to ignore. LiveEventService behind ILiveEventUIService (Services/SessionLive/LiveEventService.cs:14, Services/SessionLive/ILiveEventUIService.cs:7) resolves the currently-live-or-next published event from the Conference API and computes its window with the same math the backend enforces, then degrades to null on any API failure so the live layer simply stays dormant instead of erroring a page (LiveEventService.cs:7-12, :33-36); the LiveEventContext record it returns (Services/SessionLive/LiveEventContext.cs:13) answers IsLiveAt and ToEventLocal for the pages, and its zone id always resolves because the Conference write path guards it (:20-31). SessionLiveUIService (Services/SessionLive/SessionLiveUIService.cs:10) is the smallest type in the chapter and the clearest one about module boundaries: it implements the ISessionLiveUIService extension point with a single line returning Engagement's own route (:13-14), so a Conference session page lights up its Live button only when this module is present. SessionLookupService behind ISessionLookupService (Services/Lookups/SessionLookupService.cs:13, Services/Lookups/ISessionLookupService.cs:19) is the enrichment client that turns session ids into the titles and times the bookmark surfaces display, projecting the Conference page into a session-keyed dictionary of the small SessionInfo record (ISessionLookupService.cs:9, SessionLookupService.cs:33-43); its contract carries the usage rule as well as the signature, because the catalog call is cheap to misuse: GetAllAsync is for a page that genuinely needs every session (the reminder planner, which schedules against every bookmarked one), and a single-session page must call GetByIdAsync rather than transfer the catalog to label one row (ISessionLookupService.cs:21-28). The comment above the fetch is worth keeping when you copy it: the base sessions endpoint takes no page size and always serves one page capped at the framework's 500-row maximum, which covers a conference catalog (SessionLookupService.cs:24-25). One more UI type is easy to miss and easy to get wrong: CurrentEventNotificationScopeProvider (Services/CurrentEventNotificationScopeProvider.cs:29) scopes ADC's notifications to event:{EventId}, caching a resolved key for five minutes because the bell polls every 30 seconds (:40), and it never answers null: on INotificationScopeProvider a null key means unscoped, which would widen an attendee's inbox to every event, so an instance that has never resolved an event answers with a well-formed key no row carries (:18-24, :34-38), failing to an empty inbox rather than to everyone's.
Rubric lens. This module is a compact end-to-end illustration of most of Part A. Five small aggregates whose rules live in factories and invariants are the [Rubric §4, Domain-Driven Design] story; the controller-handler-domain-service split and the one-slice-per-folder layout are [Rubric §5, Vertical Slice] and [Rubric §6, CQRS and Event-Driven], with the points adapters showing both dispatch styles (broker integration events and an in-process domain event) side by side and stating the trade-off in code. The gRPC adapters, disabled stubs and Replace-based composition are [Rubric §7, Microservices Readiness] and [Rubric §9, API and Contract Design]; the per-service database, the filtered unique indexes carrying idempotency, anti-farming and one-vote-per-poll, and the pushed-down grouped COUNT and SUM in the attendance and leaderboard reads are [Rubric §8, Data Architecture] and [Rubric §12, Performance and Scalability]. Security ([Rubric §11]) is the recurring theme rather than a bolt-on: identity taken from the token instead of the body on every self-service path, the 404-not-403 delete, indistinguishable answers for a bad payload and an unknown credential, server-resolved room sessions, a bearer credential minted from a non-monotonic Guid, and one documented unauthenticated-by-necessity gRPC endpoint. The drop-oldest publish queue with its dropped-count meter is [Rubric §29, Resilience] and [Rubric §13, Observability and Operability], as is the live client layer's Result-only surface and its dormant-rather-than-broken answer when the event lookup fails; per-capability feature flags plus a retunable points economy with per-rule kill switches are [Rubric §12, Performance & Scalability] and [Rubric §17, DevOps]; the export projection and the leaderboard name erasure are [Rubric §30, Compliance and Privacy]. The pages extend the story into Part B ([Rubric §18, UI Architecture], [Rubric §24, Forms/Validation/UX Safety] for the single-declaration answer model, and [Rubric §25, Navigation and IA] for the deep-link-only QR routes). Relevant ADRs, roughly in the order they surface above: ADR-075 (audit trail), ADR-033 (owner-or-admin filter), ADR-017 (request idempotency), ADR-001 (Mapperly mapping), ADR-072 and ADR-071 (badge check-in, points, and the scanner capability), ADR-006 and ADR-030 (database-per-service and sole migrator), ADR-003 (outbox), ADR-007 and ADR-008 (gRPC extraction and topology), ADR-076 and ADR-005 (data-subject export, soft-delete versus erasure), ADR-039 (best-effort live publish), ADR-020 (permission registry), ADR-014 (the sealed decorator pipeline the host closes), ADR-012 (Kestrel endpoint profile), ADR-070 (fail-fast configuration, and this module's documented exception to it), ADR-074 (scheduler), ADR-025 (warm-up and readiness), ADR-027 (resource-key navigation and error translations), and ADR-042 (device capabilities and reminder dispatch).
AssemblyReference
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:5· Level 0 · class (static)One byte-identical copy per Engagement layer. This section covers the API and Application copies; the Domain, Infrastructure, Shared, and UI copies are the same type in another part of this chapter.
What it is: a tiny static class that exposes the assembly it lives in (
Assembly) plus that assembly's simple name (AssemblyName), so anything that needs to say "scan the assembly this type belongs to" has a compiled, refactor-safe anchor instead of a namespace string.Type File:Line Notes (what differs) AssemblyReference(API)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.API(:3)AssemblyReference(Application)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.Application(:3)Nothing else differs: both files are eleven lines long and declare the same two fields at the same line numbers.
Depends on:
System.Reflection.Assembly(BCL) only, imported at line 1 of each file. No first-party dependencies, which is why both copies sit at Level 0.Concept, assembly-marker types for convention scanning. The framework-level explanation lives with the shared copy in AssemblyReference; these are the Engagement-local instances of the same idiom.
[Rubric §2, Design Patterns]assesses whether recurring problems are solved with a recognized idiom: handing anAssemblyto a scanner through a purpose-built token, rather than through some incidental business class, is that idiom.[Rubric §1, SOLID](Dependency Inversion) applies too: registration and configuration code depends on this deliberate marker, not ontypeof(SomeHandler).Assembly, so renaming or moving a real type never silently breaks a scan.Walkthrough: two
public static readonlyfields, resolved once at type initialization and identical in both copies.Assembly(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:7,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:7) capturestypeof(AssemblyReference).Assembly.AssemblyName(line 8 of each file) readsAssembly.GetName().Name ?? string.Empty, the null-coalesce keeping the field non-null under nullable-reference-type analysis even if the runtime reports no simple name. There are no methods and no other state.Why it's built this way: each layer can be scanned from inside itself without a cross-layer reference, and a compiled
typeofcannot go stale the way a hardcoded assembly-name string would. The Engagement module declares the pair once per scannable layer, which is why the same two type names recur across the chapter.Where it's used: the convention scan the Application layer runs takes the companion ClassReference rather than the static class, because a static class cannot be a generic type argument (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88). The static handle is the shape used where anAssemblyvalue is passed by hand, for example the design-time EF factories and the architecture-fitness map.Caveats / not-in-source: neither of these two copies has a reader inside the Engagement API or Application projects themselves; they exist so that every layer carries the same anchor.
ClassReference
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:11· Level 0 · classOne byte-identical copy per Engagement layer. This section covers the API and Application copies.
What it is: the non-static companion to AssemblyReference, an empty instantiable class declared at the bottom of the same file. It supplies an assembly anchor for the APIs that need a generic type argument, which a static class can never satisfy.
Type File:Line Notes (what differs) ClassReference(API)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.APIClassReference(Application)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.ApplicationDepends on: nothing first-party, and nothing from the BCL beyond
object.Concept: the companion half of the marker pattern, introduced at framework level under ClassReference. C# forbids a static class as a generic type argument, and the registration helper the modules use takes its marker as a type parameter, so a second, non-static token fills that slot without weakening
AssemblyReference's static-ness.[Rubric §33, Developer Experience]assesses how much ceremony the inner loop costs: one conventional token per layer is the whole of it.Walkthrough:
public class ClassReference { }at line 11 of both files. No members, no behavior; its only meaningful property is the assembly it belongs to, read astypeof(ClassReference).Assemblyby a scanner.Why it's built this way: keeping the two forms separate sidesteps the static-class generic-argument restriction while leaving AssemblyReference impossible to instantiate by accident. Every layer declares its own copy so it scans itself with a local token.
Where it's used: the Application-layer copy is the one with a call site:
services.ScanModuleApplicationServices<ClassReference>()in the Application DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), which registers domain event handlers, DTO and request mappers, command and query handlers, and validators by convention.Caveats / not-in-source: the API-layer copy has no reader in the repo; it exists to keep the per-layer convention uniform.
EngagementErrorResources
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Resources·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Resources/EngagementErrorResources.cs:9· Level 0 · class (sealed)
- What it is: an empty sealed class that serves as the resource anchor type for the Engagement module's localized error-code translations. It carries no members; its job is to name the
.resxfamily that sits beside it. - Depends on: nothing first-party in code. Its behavioral partners are the shared ErrorLocalizer infrastructure (through the IErrorLocalizer contract) and the
AddErrorResources<T>()host registration helper, both named in the type's doc comment. - Concept, resource-anchor types for per-module error localization.
[Rubric §27, Internationalization]assesses whether user-facing strings are externalized and culture-resolved rather than hard-coded. The doc comment (EngagementErrorResources.cs:3-8) states the mechanism: the type's.resxsiblings (EngagementErrorResources.resxfor the invariant culture,EngagementErrorResources.es.resxfor Spanish) are keyed by a domain error'sCode, and the sharedIErrorLocalizerresolves a failing Result's code to a translated message once the host registers this type viaAddErrorResources<EngagementErrorResources>(). That is ADR-027 applied per module: the class is empty because a resource anchor only needs a stable full name from which the resource base name is computed. - Walkthrough:
public sealed class EngagementErrorResourceswith an empty body (EngagementErrorResources.cs:9-11), no members.sealedbecause it is never meant to be subclassed; it exists purely as atypeofhandle for the resource manager. - Why it's built this way: one anchor per module keeps error strings out of a single monolithic resource file, so each bounded context owns its own translation table (ADR-027). The type lives in the API layer because that is where errors become HTTP responses.
- Where it's used: registered by the Engagement service host through
AddErrorResources<EngagementErrorResources>(); read by the shared ErrorLocalizer when mapping a domain error code to a message. The codes it translates are the ones the handlers raise, for exampleCheckIns.ForbiddenandCheckIns.BadgeConflictin GetOrCreateMyBadgeHandler andCheckIns.EventNotPublishedin CheckInProcessor. - Caveats / not-in-source: the
.resxfiles themselves and the host registration call live outside this.csfile; only the anchor type is verified here, the rest is quoted from its doc comment.
GetAttendanceStatsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetAttendanceStats·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsQuery.cs:8· Level 0 · record (sealed)
- What it is: the read request behind the organizer dashboard's attendance rollup for one event: how many people arrived at the event, plus a count for each session that has at least one session-scoped check-in.
- Depends on: the
EventIdentifierTypealias only (an identifier type alias linked solution-wide, see primer §4). Paired with GetAttendanceStatsHandler through the query contract, and answered as an AttendanceStatsDTO. - Concept, the CQRS query as an immutable record. This is a vertical-slice read: the query type and its handler live in the same
UseCases/GetAttendanceStats/folder.[Rubric §6, CQRS & Event-Driven]assesses whether reads and writes are modeled as distinct messages; this is a pure read, so the pipeline runs it without a transaction.[Rubric §5, Vertical Slice]assesses feature-folder cohesion, which the layout embodies.record classgives value equality and immutability for free, so the message is safe to pass and compare. - Walkthrough:
public sealed record class GetAttendanceStatsQuery(EventIdentifierType EventId)(GetAttendanceStatsQuery.cs:8), one positional parameter and no body. The XML doc (GetAttendanceStatsQuery.cs:3-7) fixes the contract: an event-scoped arrival count plus a per-session count for every session that has at least one session-scoped check-in. - Why it's built this way: the whole request is one event id, so the record is a one-line declaration; naming the message explicitly still lets the query pipeline decorate it uniformly and keeps the read intent greppable.
- Where it's used: constructed by CheckInsController on
GET /api/checkins/stats(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:191) from a[FromQuery, Required] EventIdentifierType eventIdargument (CheckInsController.cs:187), on an endpoint gated by[HasPermission(EngagementPermissions.CheckInManage)](CheckInsController.cs:183), and dispatched through the injected IQueryHandler<in TQuery, TResult> (CheckInsController.cs:43).
GetOrCreateMyBadgeCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetOrCreateMyBadge·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeCommand.cs:11· Level 0 · record (sealed)
- What it is: the request for "give me my badge", a parameterless command. The attendee it applies to is never stated in the message: the handler reads the caller from the token.
- Depends on: nothing. It has no members at all. Paired with GetOrCreateMyBadgeHandler, which answers with a MyBadgeDTO.
- Concept introduced, the deliberately empty message as a security property.
[Rubric §11, Security]assesses whether authorization decisions are made from trusted inputs rather than from client-supplied ones. The doc comment says exactly why the record is empty (GetOrCreateMyBadgeCommand.cs:5-9): the attendee is the caller, read from the token by the handler, and carrying a user id in the body would make "whose badge do I get" a client-supplied value, which is precisely the input an attendee could change to obtain someone else's credential. Removing the parameter removes the attack rather than validating it away.[Rubric §9, API & Contract Design]shows in the pairing with the route: the endpoint isGET /api/checkins/my-badgewith no id segment and no body (CheckInsController,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:50), so the contract has nowhere to put an identity even if a caller wanted to. - Concept, a command that reads. It is declared a command, not a query, because the first call for an attendee writes a new badge row; see the rationale under GetOrCreateMyBadgeHandler.
- Walkthrough:
public sealed record class GetOrCreateMyBadgeCommand;(GetOrCreateMyBadgeCommand.cs:11). One line, no positional parameters, no body. - Why it's built this way: an empty message still buys the uniform dispatch, decoration, and naming of the CQRS pipeline while carrying no attack surface. The alternative (a
UserIdparameter validated against the token) would give the same behavior with a check that can be forgotten. - Where it's used: constructed by
CheckInsController.GetMyBadgeAsync(CheckInsController.cs:57, method at:49) and dispatched through the injected ICommandHandler<in TCommand, TResult> (CheckInsController.cs:40).
RecordedCheckIn
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:38· Level 0 · record struct (internal readonly)
- What it is: the two-field outcome of a check-in write, nested inside CheckInProcessor: whether a check-in for the same target already existed, and the timestamp that applies (the original one when it did, the newly written one when it did not).
- Depends on: nothing first-party. Only
boolandDateTimeOffsetfrom the BCL. It is produced byCheckInProcessor.RecordAsyncand consumed by the three writers that call it. - Concept introduced, the small internal result type that keeps a shared helper honest. A
RecordAsyncthat returned only a timestamp would force each caller to re-derive "was this a repeat", and one that returned a full DTO would force every caller into the same response shape even though the room path, the sponsor path, and the organizer path each answer with a different DTO. This record struct is the middle ground: it carries exactly the two facts the shared write half knows, and each caller maps them into its own contract.[Rubric §9, API & Contract Design]assesses whether a contract carries what it means and nothing more, which is what two named components buy here (AlreadyRecorded,RecordedOn) over abool/DateTimeOffsettuple.[Rubric §12, Performance & Scalability]is the reason it is areadonly record structrather than a class: the value is created on every check-in, has no identity, and is never mutated, so it stays on the stack.[Rubric §15, Best Practices & Code Quality]:internalkeeps it a module-private detail, so no API-layer contract can accidentally start depending on it. - Walkthrough: one declaration,
internal readonly record struct RecordedCheckIn(bool AlreadyRecorded, DateTimeOffset RecordedOn)(CheckInProcessor.cs:38), with the per-parameter docs directly above it (CheckInProcessor.cs:35-37):AlreadyRecordedis true when a prior check-in for the same target was found, andRecordedOnis that prior check-in's timestamp, or the timestamp of the one just written. Both construction sites are insideRecordAsync: the short-circuit path buildsnew RecordedCheckIn(AlreadyRecorded: true, existing.CheckedInOn)(CheckInProcessor.cs:74) and the write path buildsnew RecordedCheckIn(AlreadyRecorded: false, checkIn.CheckedInOn)(CheckInProcessor.cs:92). Both use named arguments, so the boolean at the call site says what it means. - Why it's built this way: idempotency is the whole point of the check-in write (a repeat scan must not double count), so the outcome type has to make "this was a repeat" a first-class fact rather than something a caller infers. Returning the original timestamp on a repeat is what lets a UI show the attendee when they actually checked in rather than when they rescanned.
- Where it's used: returned as
Result<RecordedCheckIn>fromCheckInProcessor.RecordAsync(CheckInProcessor.cs:58). Three call sites unwrap it: CheckInProcessor's ownExecuteAsyncmaps it onto a CheckInResultDTO (CheckInProcessor.cs:143-149), RecordRoomCheckInHandler maps it onto aRoomCheckInResultDTOand suppresses its log line whenAlreadyRecordedis true (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:87-97), and RecordSponsorVisitHandler does the same into aSponsorVisitResultDTO(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:85-95).
DependencyInjection
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:15· Level 1 · class (static, extension block)
- What it is: the Engagement module's API-layer composition root. It exposes two extension methods on
IServiceCollection:AddEngagementModule(...)chains the three layer registrations into one call, andAddModuleEngagementAPI()configures the shared ownership-authorization filter with ADC's vocabulary and declares the module's role-to-permission grants. - Depends on: ApplicationSettings (passed straight through), the Application and Infrastructure registration extensions (
AddModuleEngagementApplicationon the Application-layer DependencyInjection andAddModuleEngagementInfrastructure), the shared OwnerOrAdminFilterOptions / OwnerOrAdminFilter, the auth vocabulary RoleNames and EngagementPermissions (the latter fed into the IPermissionRegistry throughAddPermissions), andSystem.Security.Claims.ClaimTypesfrom the BCL (DependencyInjection.cs:1). - Concept, C#
extension(T)DI blocks composing a module top-down. The class body is anextension(IServiceCollection services)block (DependencyInjection.cs:17), the codebase-wide registration idiom (introduced in the primer): the members inside read as instance methods on anyIServiceCollection.[Rubric §3, Clean Architecture]assesses whether layering is honored at composition time;AddEngagementModuleregisters Application, then Infrastructure, then API in dependency order (DependencyInjection.cs:26-28), so nothing binds before its dependencies exist.[Rubric §11, Security]assesses whether authorization is centralized and declarative: rather than hand-roll an ownership check,AddModuleEngagementAPIreuses MMCA.Common's OwnerOrAdminFilter and supplies only ADC's field names (ADR-033), then wires the module's permission grants through the shared registry (ADR-020) so the[HasPermission(...)]-gated endpoints resolve. - Walkthrough
AddEngagementModule(ApplicationSettings applicationSettings)(DependencyInjection.cs:24) callsAddModuleEngagementApplication(applicationSettings)(line 26),AddModuleEngagementInfrastructure()(line 27), thenAddModuleEngagementAPI()(line 28), and returnsservicesfor chaining (line 30). This is the single method EngagementModule.Registerinvokes.AddModuleEngagementAPI()(DependencyInjection.cs:43) does two things.- Configures OwnerOrAdminFilterOptions (
DependencyInjection.cs:45-56) withOwnerClaimType = ClaimTypes.NameIdentifier(line 53),BypassRole = RoleNames.Organizer(line 54, the role that skips the ownership check), andOwnerParameterName = "userId"(line 55, the query argument the Bookmarks list endpoints bind). The comment above those three lines (DependencyInjection.cs:47-52) is the part worth reading twice: the token carries the user id insubonly, but the JWT bearer handler maps inboundsubontoClaimTypes.NameIdentifier, soNameIdentifieris the claim type the principal actually carries by the time this filter runs; the rawsubform survives only where an identity is built straight from a token's claims (the Blazor client-side state provider), and those readers go through the claims-principal extensions instead of this filter. The filter itself is registered upstream by Common'sAddAPI; this call injects only the module's vocabulary. - Calls
AddPermissions(...)(DependencyInjection.cs:58) and grants bothRoleNames.Organizer(line 60) andRoleNames.Admin(line 61) the full EngagementPermissions.Allset through a collection spread. The doc comment (DependencyInjection.cs:33-41) records the complement: attendee-facing endpoints stay on a plain[Authorize]that carries no capability rather than on a permission gate. That is the split you see on CheckInsController, where the organizer stats endpoint carries[HasPermission(EngagementPermissions.CheckInManage)](CheckInsController.cs:183) while the attendee's own-badge endpoint carries no permission attribute at all (CheckInsController.cs:50).
- Configures OwnerOrAdminFilterOptions (
- Why it's built this way: one
AddEngagementModuleentry point keeps the module's wiring in a single call the module loader can invoke; delegating ownership enforcement to a configured shared filter means that logic is written and tested once in Common (ADR-033); and declaring role grants in one place keeps the permission model auditable (ADR-020). The doc comment (DependencyInjection.cs:33-41) is the authoritative note on which ADC value maps to which option. - Where it's used:
AddEngagementModuleis called by EngagementModule.Register;AddModuleEngagementAPIruns inside it. The configured OwnerOrAdminFilter guards the list endpoints on BookmarksController; the granted permissions gate the[HasPermission(...)]endpoints on CheckInsController and the live-layer controllers covered in group-23. - Caveats / not-in-source:
AddModuleEngagementInfrastructurelives in the Infrastructure project and is covered by that layer's section, not here.
EngagementModule
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:14· Level 5 · class (sealed)
- What it is: the IModule implementation for the Engagement bounded context, the type ModuleLoader discovers by reflection and uses to slot Engagement into the application in dependency order.
- Depends on: IModule (the contract), ApplicationSettings, the API-layer DependencyInjection extension (
AddEngagementModule), and the two disabled-stub pairs it registers when Engagement is not co-hosted: IBookmarkCountService / DisabledBookmarkCountService and IUserEngagementExportService / DisabledUserEngagementExportService. - Concept, the module descriptor and its declared dependency on Conference.
[Rubric §7, Microservices Readiness]assesses whether a modular monolith can be split into services without a rewrite; a module that declares its dependencies and can register disabled stubs for modules that are not co-hosted is exactly that boundary. Engagement namesConferenceas a dependency (EngagementModule.cs:20) and setsRequiresDependencies => true(EngagementModule.cs:23), so the loader orders Conference before Engagement in its topological (Kahn) sort. When Engagement runs as its own service, Conference is not in-process and the cross-module contracts are satisfied by gRPC clients instead (ADR-007 / ADR-008); conversely, in a host where Engagement is off,RegisterDisabledStubssupplies no-op implementations so a dependent module's calls into Engagement's bookmark count and data-subject export still resolve. - Walkthrough: five members implementing IModule.
Name => "Engagement"(EngagementModule.cs:17): the module's identity in the loader's graph.Dependencies => ["Conference"](EngagementModule.cs:20): the modules that must register first.RequiresDependencies => true(EngagementModule.cs:23): declares the Conference dependency mandatory, not optional.Register(...)(EngagementModule.cs:26-27): an expression body delegating the whole wiring toservices.AddEngagementModule(applicationSettings)in the API DependencyInjection. Note the signature also receives anIConfigurationBuilder, which this module does not use.RegisterDisabledStubs(...)(EngagementModule.cs:30-34): registers two singletons for hosts where Engagement is off,IBookmarkCountServiceas DisabledBookmarkCountService (line 32) andIUserEngagementExportServiceas DisabledUserEngagementExportService (line 33).
- Why it's built this way: keeping the module's shape (name, dependencies, register, disabled stubs) behind one contract lets the loader treat every module uniformly, and lets a module run in-process or as an extracted service without changing its business code (ADR-007 / ADR-008). The doc comment (
EngagementModule.cs:10-13) records that the module depends on Conference for session data. - Where it's used: instantiated and invoked by ModuleLoader during host startup; its
Registerroutes into the API DependencyInjection.
GetOrCreateMyBadgeHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetOrCreateMyBadge·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:18· Level 9 · class (sealed, partial)
- What it is: the handler behind "show me my badge". It reads the caller's AttendeeBadge, mints one if this is the first call, and returns only the badge credential as a MyBadgeDTO.
- Depends on: IUnitOfWork, ICurrentUserService, IUniqueConstraintViolationDetector, and
ILogger<GetOrCreateMyBadgeHandler>, all injected through the primary constructor (GetOrCreateMyBadgeHandler.cs:18-22). It reads and writes the AttendeeBadge aggregate through an IRepository<TEntity, TIdentifierType> (narrowed to IEntityQuerier<TEntity, TIdentifierType> for the read helper), uses the framework'sRequireUserIdcaller guard, and returns Result / Error. It implements ICommandHandler<in TCommand, TResult> closed overResult<MyBadgeDTO>(GetOrCreateMyBadgeHandler.cs:22). - Concept introduced, get-or-create as a command, and losing the insert race on purpose. Three design decisions carry this handler.
- It is a command, not a query, and the class doc says why (
GetOrCreateMyBadgeHandler.cs:12-17): the first call writes, because an attendee opening their badge for the first time gets a credential created on the spot, and every later call returns that same credential, so a badge printed or screenshotted yesterday still scans today.[Rubric §6, CQRS & Event-Driven]assesses whether the read/write split reflects actual effects rather than the verb in the method name; classifying a lazily-minting read as a command is that distinction taken seriously, and it puts the call on the transactional side of the decorator pipeline. - The read-then-insert is a race, and the race is handled rather than prevented. The unique index on
AttendeeBadge.UserId(AttendeeBadgeConfiguration,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:31-32, with the comment at:29-30naming this handler as the reason it exists) is the actual guarantee; the handler's pre-check is only an optimization.[Rubric §8, Data Architecture]assesses whether invariants live where they can be enforced, and one-badge-per-attendee lives in the database.[Rubric §29, Resilience & Business Continuity]shows in the recovery: the comment at the catch site (GetOrCreateMyBadgeHandler.cs:56-58) states that the promise is one stable credential per attendee, so the winner's badge is returned rather than a failure the caller would retry into. - Recognizing that collision is delegated, not hand-rolled. The injected IUniqueConstraintViolationDetector answers "was this save rejected for violating a unique constraint" without the Application layer naming a provider type; its own remarks (
MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUniqueConstraintViolationDetector.cs:9-30) record why the question is declared in Application and answered in Infrastructure, whereSqlException.Number2601 and 2627 are reachable, and note that a miss is safe rather than silent because an unclassified exception simply propagates.[Rubric §3, Clean Architecture]assesses whether the dependency rule holds at the awkward points, and this is one of them. [Rubric §11, Security]runs through the whole method: the identity comes fromcurrentUserService.RequireUserId(...)(GetOrCreateMyBadgeHandler.cs:29), never from the (empty) GetOrCreateMyBadgeCommand, and the response carries only the credential (line 74) because, as the comment says (lines 72-73), the DTO carries nothing that identifies the attendee, so the QR code built from it stays opaque to anyone who scans it.
- It is a command, not a query, and the class doc says why (
- Walkthrough
HandleAsync(GetOrCreateMyBadgeHandler.cs:25-27) starts with the caller guardcurrentUserService.RequireUserId("CheckIns.Forbidden")(line 28), the shared extension that collapses the read-then-null-check-then-fail block every per-user handler used to repeat (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35-46); the module supplies only the error code, because the framework cannot know it. A failure returns its errors unchanged (GetOrCreateMyBadgeHandler.cs:30-33), which is anErrorType.Forbiddencarrying the generic "Access denied." message.- It unwraps the caller id (line 34), resolves the badge repository (line 35), and reads the caller's badge through the private
ReadBadgeAsynchelper (line 36). - If no badge exists (line 38),
AttendeeBadge.Create(userId)runs (line 40); a failed factory result is returned as-is (GetOrCreateMyBadgeHandler.cs:42-43), otherwise the new badge is unwrapped (line 44). - The insert is wrapped in
try(GetOrCreateMyBadgeHandler.cs:47-53):AddAsync(line 48),SaveChangesAsync(line 49), then the information-levelLogBadgeIssued(line 51). - The
catch (Exception exception) when (uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception))filter (line 53) is the race path: it logsLogConcurrentDuplicateat debug (line 58), re-reads the badge (line 60), and only if the re-read still finds nothing returnsError.Conflictwith codeCheckIns.BadgeConflict, sourcenameof(GetOrCreateMyBadgeHandler), and targetnameof(MyBadgeDTO.Credential)(GetOrCreateMyBadgeHandler.cs:64-68). - The single exit returns
Result.Success(new MyBadgeDTO { Credential = badge.Credential })(line 74), so a first-call mint and a repeat call answer with the identical shape. ReadBadgeAsync(GetOrCreateMyBadgeHandler.cs:78-85) callsFirstOrDefaultAsync(b => b.UserId == userId, asTracking: false, ...)(lines 81-84): the read is untracked because it either returns an existing badge unchanged or is followed by a fresh insert. Its parameter is typed as the read-only IEntityQuerier<TEntity, TIdentifierType> (line 78), so the helper cannot write even though the caller passes it a full repository.- Two source-generated log methods close the file:
LogBadgeIssuedat information (GetOrCreateMyBadgeHandler.cs:87-88) andLogConcurrentDuplicateat debug (GetOrCreateMyBadgeHandler.cs:90-91).[Rubric §13, Observability & Operability]: the race is logged at debug because it is expected and benign, while a genuine issuance is worth an information-level record.
- Why it's built this way: minting on demand means no badge table has to be pre-populated for every registered user, and returning the same credential forever means a printed or screenshotted badge keeps working. Treating the unique-constraint failure as "someone else already created what I wanted" turns the only concurrency hazard into a re-read, so the endpoint stays idempotent from the caller's point of view.
- Where it's used: dispatched from
CheckInsController.GetMyBadgeAsynconGET /api/checkins/my-badge(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:50-57); registered by the convention scan in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88).
CheckInProcessor
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:23· Level 11 · class (internal static)
- What it is: the check-in core every check-in entry point runs: validate the target, short-circuit a repeat check-in, otherwise record one. It is split into a write half (
RecordAsync, shared by all four writers) and an organizer-path half (ExecuteAsync, which resolves the target through Conference first), plus one shared error factory. - Depends on: IUnitOfWork, Conference's IEventLiveValidationService, the CheckIn aggregate and its CheckInScope discriminator, CheckInResultDTO, the read surface IEntityQuerier<TEntity, TIdentifierType> obtained through
GetRepository, and Result / Error. Externals:System.Linq.Expressions(CheckInProcessor.cs:1) andTimeProvider. - Concept introduced, the static collaborator-passing helper.
[Rubric §1, SOLID],[Rubric §14, Testability]. The class doc (CheckInProcessor.cs:10-22) explains the shape: it is a static helper rather than an injected service so no handler gains a dependency it does not own. Each caller passes the collaborators it already holds (IUnitOfWork, the validation service, aTimeProvider), which keeps the rule in one place without adding an interface, a registration, and a mock to every test. The cost is that it cannot be substituted in a unit test: a test of CheckInAttendeeHandler exercises this code too, so the handler tests are where its branches are covered. - Concept, idempotency as an application rule with a database backstop.
[Rubric §6, CQRS & Event-Driven],[Rubric §29, Resilience & Business Continuity]. TheRecordAsyncremarks (CheckInProcessor.cs:43-48) state the full model: a repeat scan reports the original check-in instead of writing a second row or publishing a second integration event; the filtered unique indexes from CheckInConfiguration are the race backstop for two concurrent scans of the same badge or booth (the losing save fails rather than duplicating), and the broker retry that follows lands on this same no-op path. Those indexes are one per scope:(UserId, EventId)(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:49-51),(UserId, SessionId)(:54-56), and(UserId, SponsorId)(:60-62), eachIsUnique()with a soft-delete filter plus a scope filter, which is exactly the three-way splitFindExistingAsyncmirrors in LINQ. - Concept, the caller's event id is not trusted for a session check-in.
[Rubric §8, Data Architecture].ExecuteAsynctakes the resolved event from the validation result, not from the request (CheckInProcessor.cs:128), and the comment above it (CheckInProcessor.cs:124-127) gives the failure mode it prevents: a stale event context on the organizer's screen would file the attendance under the wrong event, where the rollup GetAttendanceStatsHandler runs would never find it again. - Walkthrough
EventNotPublished(string source)(CheckInProcessor.cs:29-33): the shared "the owning event is not published" failure,Error.Validationwith codeCheckIns.EventNotPublishedand targetnameof(CheckIn.EventId).sourceis a parameter (documented at:26-28) so the error still names the entry point the caller used, which is why the two attendee-path handlers can raise the identical error from their own code.RecordedCheckIn(CheckInProcessor.cs:38): the nested outcome type, covered in its own section above.RecordAsync(...)(CheckInProcessor.cs:58-93): nine parameters, no state. It resolves the writable repository (line 69), looks for an existing check-in for the same target (line 71) and returnsAlreadyRecorded: truewith the original timestamp when it finds one (CheckInProcessor.cs:72-75); otherwise it callsCheckIn.Create(...)withtimeProvider.GetUtcNow()(CheckInProcessor.cs:77-84), returns the factory's errors on failure (lines 85-86), adds and saves (lines 89-90), and returnsAlreadyRecorded: false(line 92). Note it takes bothsessionIdandsponsorIdas nullable parameters, so all three scopes go through this one write.ExecuteAsync(...)(CheckInProcessor.cs:109-150): the organizer path. It validates the target first (lines 120-122), takesresolvedEventIdfrom the validation result (line 128), delegates the write toRecordAsyncwithsponsorId: null(lines 130-139), propagates a write failure (lines 140-141), and maps the outcome onto a CheckInResultDTO carryingUserId,AlreadyCheckedIn, andCheckedInOn(CheckInProcessor.cs:143-149).ValidateTargetAsync(...)(CheckInProcessor.cs:161-188): for a session scope it callsGetSessionLiveInfoAsyncand returns the session's ownEventId(lines 168-178); otherwiseGetEventLiveInfoAsyncand returns the requested event id (lines 180-187). Both paths run the published check. The doc comment (CheckInProcessor.cs:152-160) records a deliberate v1 omission: the live window is not enforced, because an organizer scanning badges at the door or in the room works ahead of and behind the published clock, so a window check would reject legitimate check-ins; only the published and eligible facts gate the write.EnsureEventIsPublished(bool isPublished)(CheckInProcessor.cs:190-193): success, orEventNotPublished(nameof(CheckInProcessor)).FindExistingAsync(...)(CheckInProcessor.cs:201-217): aswitchexpression picks the scope-specific predicate (lines 210-215) and hands it toFirstOrDefaultAsyncwithasTracking: false(line 216), correct here because the existing row is only inspected, never mutated. The three predicate builders areSameSession(line 219),SameSponsor(line 222), andSameEvent(line 225); each pinsc.Scopeas well as the target id, so a read can never cross scopes. The doc (CheckInProcessor.cs:195-200) ties them back to the filtered unique indexes they mirror.
- Why it's built this way: pulling the flow out of the handlers means the organizer scan path, the manual fallback, the room QR path, and the sponsor booth path cannot drift apart in their idempotency, timestamp, or event-resolution behavior, which for an attendance record is the difference between a defensible number and a guess.
[Rubric §15, Best Practices & Code Quality]. SplittingRecordAsyncout ofExecuteAsyncis what lets the two attendee-driven paths share the write while doing their own target lookup, since each asks Conference a different question (CheckInProcessor.cs:15-21). - Where it's used:
ExecuteAsyncby CheckInAttendeeHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:60) and ManualCheckInHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInHandler.cs:40).RecordAsyncandEventNotPublisheddirectly by RecordRoomCheckInHandler (RecordRoomCheckInHandler.cs:74and:67) and RecordSponsorVisitHandler (RecordSponsorVisitHandler.cs:72and:64), which resolve their own target first and then share this write half. - Caveats / not-in-source:
ExecuteAsynctakes nosponsorIdandValidateTargetAsynchas only a session branch and an event branch, so a sponsor visit cannot be recorded through the organizer path even thoughCheckInScopeadmits it; the sponsor scope reachesRecordAsynconly through RecordSponsorVisitHandler, whose own comment cites the once-per-sponsor filtered index as its race backstop (RecordSponsorVisitHandler.cs:68-70). ThesessionId!.Valuedereference atCheckInProcessor.cs:170is guarded only by the caller passing a session id together with a session scope, not by anything in this file.
GetAttendanceStatsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetAttendanceStats·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:16· Level 11 · class (sealed)
- What it is: the query handler for GetAttendanceStatsQuery. It answers the organizer dashboard with one event-scoped arrival count plus one row per session that has check-ins, as an AttendanceStatsDTO.
- Depends on: IUnitOfWork through its primary constructor (
GetAttendanceStatsHandler.cs:16). It reads the CheckIn aggregate through an IReadRepository<TEntity, TIdentifierType>, filters on CheckInScope, and builds SessionAttendanceDTO rows inside an AttendanceStatsDTO. It implements IQueryHandler<in TQuery, TResult> (GetAttendanceStatsHandler.cs:17). - Concept introduced, the persistence-neutral grouped count.
[Rubric §12, Performance & Scalability]assesses whether a read pulls only what it needs. The class doc (GetAttendanceStatsHandler.cs:10-15) is explicit: both figures are read through the focused repository surface and both are computed in SQL, aCOUNTfor the event scope and a groupedCOUNTper session, so what crosses the wire is one row per session rather than one per check-in. The member that makes this possible isCountByAsync, declared on the shared querier contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:167-172); its remarks (IRepository.cs:156-161) name the problem it removes, that the Application layer references no EF Core and therefore has noIQueryableto group, which would otherwise force a handler to project every matching row out of the database and fold it in memory.[Rubric §3, Clean Architecture]is the reason the fix took that shape: the aggregate is expressed as a persistence-neutral member rather than by letting the handler see the provider.[Rubric §8, Data Architecture]shows in how the two scopes are distinguished: a singleCheckIntable holds both event arrivals and session attendance, separated by theScopediscriminator rather than by two tables, and CheckInConfiguration indexesEventIdandSessionIdfor exactly this read (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:64-69).[Rubric §6, CQRS & Event-Driven]: this is a pure read, resolved throughGetReadRepository(line 25) rather than the writable repository, so nothing enters the change tracker. - Walkthrough
HandleAsync(GetAttendanceStatsHandler.cs:20-22) null-guards the query withArgumentNullException.ThrowIfNull(query)(line 23).- It resolves the read repository for CheckIn (line 25).
- The event figure is a server-side count:
CountAsync(c => c.EventId == query.EventId && c.Scope == CheckInScope.Event, cancellationToken)(GetAttendanceStatsHandler.cs:28-30), which translates to a SQLCOUNTand never materializes a row. - The session figures are a server-side
GROUP BY:CountByAsync(c => c.SessionId!.Value, c => c.EventId == query.EventId && c.Scope == CheckInScope.Session && c.SessionId != null, cancellationToken)(GetAttendanceStatsHandler.cs:32-35) returns anIReadOnlyDictionary<SessionIdentifierType, int>holding one entry per session that has at least one session-scoped check-in of this event. The!= nullfilter is what makes theSessionId!.Valuekey selector safe. - The rollup is built with a collection expression (
GetAttendanceStatsHandler.cs:37-42):OrderBy(pair => pair.Key)(line 39) makes the output order deterministic (a dictionary has none), and each pair becomes aSessionAttendanceDTO { SessionId = pair.Key, Count = pair.Value }(line 40). - It returns
Result.Success(new AttendanceStatsDTO { EventId = ..., EventAttendance = ..., SessionAttendance = ... })(GetAttendanceStatsHandler.cs:44-49). There is no failure branch: with a valid event id the query always succeeds, and an event with no check-ins simply yields zero and an empty list.
- Why it's built this way: pushing both aggregates into SQL keeps the response size proportional to the number of sessions rather than to the number of attendees, which is the difference that matters on a conference day; sorting by key in memory afterwards costs nothing and means two calls with the same data return rows in the same order, which matters for a dashboard and for snapshot-style tests.
- Where it's used: dispatched from
CheckInsController.GetAttendanceStatsAsynconGET /api/checkins/stats?eventId=(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:182-194), an endpoint gated by[HasPermission(EngagementPermissions.CheckInManage)](CheckInsController.cs:183); registered by the convention scan in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88).
DependencyInjection
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:29· Level 12 · class (static, extension block)
- What it is: the Engagement module's Application-layer registration. Its single extension method
AddModuleEngagementApplication(...)registers the module's domain service, its per-aggregate services (bookmarks, the two live-layer aggregates, and the points awarder), and the live-channel publish queue explicitly, then convention-scans the assembly for everything uniform (handlers, mappers, validators, event handlers). - Depends on: ApplicationSettings (accepted but not yet consumed), the Engagement services it registers (IBookmarkManagementDomainService / BookmarkManagementDomainService, IBookmarkCountService / BookmarkCountService, IUserEngagementExportService / UserEngagementExportService, ILiveChannelPublishQueue / LiveChannelPublishQueue, IPointsAwarder / PointsAwarder), the shared generic services (INavigationPopulator<in TEntity> / NullNavigationPopulator<TEntity>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> / EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, the DeleteEntityCommand<TEntity, TIdentifierType> / DeleteEntityHandler<TEntity, TIdentifierType> pairing bound through ICommandHandler<in TCommand, TResult>), the live-layer types it also wires (LivePoll, LivePollVote, LivePollOption, LivePollNavigationPopulator, LivePollOptionNavigationPopulator, LivePollResultsBuilder, DeleteLivePollHandler, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder), and the ClassReference marker.
- Concept, explicit registration plus convention scanning, with idempotent
TryAdd.[Rubric §2, Design Patterns]assesses recognized composition idioms: this method mixes hand-written registrations (for services that need a specific lifetime or a closed generic the scanner cannot infer) with one convention scan for the many uniform types.[Rubric §33, Developer Experience]shows inScanModuleApplicationServices<ClassReference>()(DependencyInjection.cs:88): a new use case is picked up as soon as its handler exists, with no registration edit, which is why neither GetOrCreateMyBadgeHandler nor GetAttendanceStatsHandler appears by name here. Every explicit line usesTryAdd*rather thanAdd*, so a registration is a no-op when the same service was already contributed elsewhere, which keeps module wiring composable across hosts.[Rubric §15, Best Practices & Code Quality]: the comments in this file carry the reasoning that the call sites cannot, notably why the queue is registered twice and why two aggregates deliberately get no populator. - Walkthrough
AddModuleEngagementApplication(ApplicationSettings applicationSettings)(DependencyInjection.cs:33, inside theextension(IServiceCollection services)block at line 30) opens with_ = applicationSettings;and the comment "Reserved for future use" (line 34), acknowledging the parameter is part of the uniform module signature but unused today.- Domain service:
TryAddSingleton<IBookmarkManagementDomainService, BookmarkManagementDomainService>()(line 37). - Per-aggregate services for UserSessionBookmark: a
NullNavigationPopulator(line 40, the bookmark aggregate needs no navigation loading), anEntityQueryServiceover UserSessionBookmarkDTO for its read surface (line 41), and aDeleteEntityHandlerbound toICommandHandler<DeleteEntityCommand<UserSessionBookmark, ...>, Result>(line 42), so delete reuses the framework's generic handler. - Cross-module services:
TryAddScoped<IBookmarkCountService, BookmarkCountService>()(line 45), the count Conference consumes, andTryAddScoped<IUserEngagementExportService, UserEngagementExportService>()(line 49), the Engagement half of the data-subject export that Identity aggregates across services (the comment at lines 47-48 cites PRIVACY.md §7,[Rubric §30, Compliance/Privacy/Data Governance]). - Live-channel publish queue (
DependencyInjection.cs:56-57): the concrete LiveChannelPublishQueue is registered as a singleton (line 55) and ILiveChannelPublishQueue is registered as a factory resolving that same instance (line 56). The double registration is deliberate and the comment above it (lines 51-54) explains why: handlers enqueue post-commit broadcasts instead of awaiting the gRPC publish inline, and the Infrastructure-hosted drain needs the concrete type's reader while handlers only need the interface, so both must share one instance. - Live-layer LivePoll aggregate (
DependencyInjection.cs:60-69): a real LivePollNavigationPopulator (line 59, unlike the bookmark aggregate this one has navigations to load), aNullNavigationPopulatorfor LivePollVote (line 60), a LivePollOptionNavigationPopulator for LivePollOption (line 64, whose comment at lines 62-63 notes options are normally hydrated through the poll's own child descriptor and that this covers the option's back-reference on the manual path), anEntityQueryServiceover LivePollDTO (line 66), the module's own DeleteLivePollHandler bound to the generic delete command rather than the framework handler (line 67), and the LivePollResultsBuilder projection helper (line 68). - Live-layer SessionQuestion aggregate (
DependencyInjection.cs:72-74):NullNavigationPopulators forSessionQuestion(line 71) and SessionQuestionUpvote (line 72), plus the SessionQuestionViewBuilder (line 73). - Points (gamification):
TryAddScoped<IPointsAwarder, PointsAwarder>()(line 83). The comment above it (lines 75-82) records two things worth reading: there is one write path into the ledger, so the check-in and feedback integration-event handlers and the session-question domain-event handler all resolve this from their own scope and hand it an activity plus subject-key pair; and it is registered by hand because it is a plain service rather than a handler, so the convention scan below does not see it. The same comment states that PointsEntry and LeaderboardOptIn deliberately get NOINavigationPopulator, since neither has a navigation to populate, exactly like the CheckIn and AttendeeBadge aggregates this module already queries through the repository without one. services.ScanModuleApplicationServices<ClassReference>()(line 87): convention-scans this assembly for domain-event handlers, DTO and request mappers, command and query handlers, and validators (comment atDependencyInjection.cs:86-87), then the method returnsservicesfor chaining (line 89).
- Why it's built this way: explicit
TryAddregistrations pin the lifetimes and closed generics the scanner cannot infer, while the single scan removes the per-handler registration ceremony; together they keep module wiring short and additive. Passing the assembly marker ClassReference rather than a business type keeps the scan decoupled from any specific handler. The live-layer aggregates are registered here even though their behavior is taught in group-23, because a module registers all of its aggregates from one Application-layer entry point. - Where it's used: called by
AddEngagementModulein the API DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:26), which is in turn invoked by EngagementModule.Registerduring host startup.
GetBookmarkedSessionIdsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetBookmarkedSessionIds·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetBookmarkedSessionIds/GetBookmarkedSessionIdsQuery.cs:5· Level 0 · record (sealed)
- What it is: the read request for "which sessions has this user bookmarked", a single-parameter query carrying only the
UserId. Its handler answers with a map from session id to bookmark id, so a listing page can render per-row bookmark state. - Depends on: the
UserIdentifierTypealias (an identifier type alias linked solution-wide, see primer §2); paired with GetBookmarkedSessionIdsHandler through the CQRS query contract. - Concept, the CQRS query as an immutable record. This is a vertical-slice read: the query type and its handler live in the same
UseCases/GetBookmarkedSessionIds/folder.[Rubric §6, CQRS & Event-Driven]assesses whether reads and writes are modeled as distinct messages; this is a pure read, so it carries no validator and no transaction (the query pipeline skips both, see the decorator order in IQueryHandler<in TQuery, TResult>).[Rubric §5, Vertical Slice]assesses feature-folder cohesion, which this layout embodies. The record shape gives value equality and immutability for free, so the query is a safe, comparable message. - Walkthrough:
public sealed record GetBookmarkedSessionIdsQuery(UserIdentifierType UserId)(GetBookmarkedSessionIdsQuery.cs:5), one positional parameter and no body. The XML doc (GetBookmarkedSessionIdsQuery.cs:3-4) states the intent: the set of session ids the user has bookmarked, keyed by session id to bookmark id. - Why it's built this way: a dedicated query type keeps the read intent explicit and lets the IQueryHandler<in TQuery, TResult> pipeline decorate it uniformly; carrying just the
UserIdkeeps the request minimal. - Where it's used: constructed by BookmarksController's
GET /api/bookmarks/session-idsaction from theuserIdquery argument (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:113) and dispatched to GetBookmarkedSessionIdsHandler; the resulting map drives the star indicator per row on the unified Sessions page. The record's property round-trip is pinned byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetBookmarkedSessionIdsHandlerTests.cs:39.
GetLeaderboardQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardQuery.cs:12· Level 0 · record (sealed)
- What it is: the read request for the public points leaderboard. It is a parameterless record: the board has no page, no filter, and no caller-chosen size.
- Depends on: nothing. It is paired with GetLeaderboardHandler through the CQRS query contract, and the board length it implies comes from PointsSettings, not from the message.
- Concept, the empty message as a security decision. A query with no parameters looks like an oversight until you read why it has none: the doc comment (
GetLeaderboardQuery.cs:5-10) records that the board is a single fixed-size list whose length comes from configuration (Points:LeaderboardSize), so "there is no parameter through which a caller could ask for more of the board than the organizer chose to publish".[Rubric §11, Security]assesses whether inputs that widen a disclosure exist at all; the cheapest way to make a knob untamperable is not to expose it.[Rubric §9, API & Contract Design]assesses whether a contract says what it means: an empty record is an honest statement that this read has exactly one shape.[Rubric §30, Compliance/Privacy/Data Governance]is in scope because what the board publishes is attendee display names, which is why the size question is a privacy question and not just a paging one. - Walkthrough:
public sealed record class GetLeaderboardQuery;(GetLeaderboardQuery.cs:12), a declaration with no positional parameters and no body. Callers writenew GetLeaderboardQuery(). - Why it's built this way: keeping the size in configuration rather than in the message means an operator can shorten or lengthen the published board without a deploy and without a client being able to override it; keeping the type (rather than passing no message at all) keeps the read on the same IQueryHandler<in TQuery, TResult> pipeline as every other query.
- Where it's used: constructed by PointsController's
GET /api/points/leaderboardaction (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:82), whose own doc restates the fixed-length rule (PointsController.cs:66-74), and handled by GetLeaderboardHandler. The controller test asserts the action takes no parameter other than the cancellation token (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:114).
GetMyPointsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetMyPoints·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsQuery.cs:14· Level 0 · record (sealed)
- What it is: the read request for the calling attendee's own points: their running total, their leaderboard status, and one page of the ledger entries behind the total. It carries paging arguments and nothing else.
- Depends on: nothing first-party. Paired with GetMyPointsHandler, which produces a MyPointsDTO.
- Concept, the deliberately absent user id. The most instructive thing about this record is a parameter it does not have. The doc comment (
GetMyPointsQuery.cs:6-10) states it plainly: the attendee is the caller, read from the token by the handler, because "a user id in the query would make 'whose points do I get' a client-supplied value, which is exactly the input an attendee could change to read someone else's ledger".[Rubric §11, Security]assesses whether authorization decisions depend on trusted inputs: an identity that is never accepted from the client cannot be forged by the client, which is a stronger guarantee than any check placed after it. Contrast GetUserBookmarksQuery, which does carry aUserIdand therefore needs theOwnerOrAdminFilterat the API edge to compare it against the caller's claim; this query removes the need for that comparison by removing the parameter. - Walkthrough:
public sealed record class GetMyPointsQuery(int PageNumber = 1, int PageSize = 20)(GetMyPointsQuery.cs:14). Two positional parameters, both defaulted, sonew GetMyPointsQuery()is the common call. The per-parameter docs (GetMyPointsQuery.cs:12-13) record where the safety lives: page numbers below 1 are treated as page 1, and the page size is "clamped into 1 to 100 by the handler", not by the record. - Why it's built this way: defaults keep the ordinary call trivial, and pushing the clamping into the handler means the ceiling holds for every caller including tests and any future in-process dispatcher, not only for requests that passed through the controller's
[Range]attributes (PointsController.cs:54-55). - Where it's used: constructed by PointsController's
GET /api/points/meaction from thepageNumber/pageSizequery arguments (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:59); the controller test asserts the arguments arrive on the query unchanged and that no user id is passed (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:43), and a separate reflection test asserts that no action on the controller takes a user id from the caller at all (PointsControllerTests.cs:316). Handled by GetMyPointsHandler.
GetPointsOverviewQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewQuery.cs:8· Level 0 · record (sealed)
- What it is: the read request for the organizer rollup of the points game: participation, payout, the per-activity breakdown, and a tail of recent activity. Its one parameter says how long that tail should be.
- Depends on: nothing first-party. Paired with GetPointsOverviewHandler, which produces a PointsOverviewDTO.
- Concept: the same single-purpose query record introduced under GetBookmarkedSessionIdsQuery, here with one bounded knob.
[Rubric §12, Performance & Scalability]assesses whether a caller can ask for an unbounded amount of data:RecentCountlooks unbounded on the record, and the doc comment (GetPointsOverviewQuery.cs:7) points at where it is not, "clamped into 1 to 100 by the handler". Note what the query cannot ask for: there is no attendee filter, because the rollup is deliberately identity-free (see GetPointsOverviewHandler). - Walkthrough:
public sealed record class GetPointsOverviewQuery(int RecentCount = 20)(GetPointsOverviewQuery.cs:8), one defaulted positional parameter. The summary (GetPointsOverviewQuery.cs:3-6) enumerates the four figures the rollup returns. - Why it's built this way: one query type per organizer screen keeps the read explicit, and defaulting
RecentCountmeans the dashboard's ordinary call carries no arguments at all. - Where it's used: constructed by PointsController's
GET /api/points/overviewaction (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:130), which is the one Points endpoint behind[HasPermission(EngagementPermissions.PointsViewOverview)](PointsController.cs:121, ADR-020); the controller tests pin the pass-through ofrecentCount(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:209) and the permission gate (PointsControllerTests.cs:293).
GetUserBookmarksQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetUserBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetUserBookmarks/GetUserBookmarksQuery.cs:8· Level 0 · record (sealed)
- What it is: the read request for a user's bookmarked sessions as a paged list, with an optional event filter. Unlike GetBookmarkedSessionIdsQuery, which returns a lightweight id map, this query yields full bookmark DTOs plus pagination metadata.
- Depends on: the
UserIdentifierTypeandEventIdentifierTypealiases (see primer §2); paired with GetUserBookmarksHandler. - Concept, a paged query with defaulted parameters.
[Rubric §9, API & Contract Design]assesses whether list endpoints expose paging and filtering as first-class inputs; this query does, defaultingPageNumberto 1 andPageSizeto 10 so a caller can omit both.[Rubric §12, Performance & Scalability]assesses whether list reads are bounded: the doc comment (GetUserBookmarksQuery.cs:7) records the 500-item ceiling from BR-11, and the handler, not the record, enforces it. The optionalEventIdentifierType?filter (BR-58,GetUserBookmarksQuery.cs:5) is resolved through a Session lookup that lives in another module, which is why the handler reaches into Conference. - Walkthrough:
public sealed record GetUserBookmarksQuery(UserIdentifierType UserId, EventIdentifierType? EventId, int PageNumber = 1, int PageSize = 10)(GetUserBookmarksQuery.cs:8-12). Four positional parameters: the requiredUserId, the nullableEventIdfilter, and the two paging knobs with defaults. The XML doc (GetUserBookmarksQuery.cs:3-7) ties each parameter to its business rule. - Why it's built this way: defaulted paging parameters keep the common call simple while still allowing explicit paging; making
EventIdnullable models "all events" as the absence of a filter rather than as a magic value. TheUserIdis a real parameter here (unlike on GetMyPointsQuery) because an Organizer is allowed to read another attendee's bookmark list, which is exactly why the endpoint needs an ownership filter at the edge. - Where it's used: constructed by BookmarksController's
GET /api/bookmarksaction (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:95) behind[ServiceFilter(typeof(OwnerOrAdminFilter))](BookmarksController.cs:85, ADR-033); handled by GetUserBookmarksHandler, which returns a PagedCollectionResult<T> of UserSessionBookmarkDTO.
LiveChannelPublishWorkItem
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:10· Level 0 · record (sealed)
- What it is: one pending best-effort live-channel broadcast, captured as an immutable three-field record. It is exactly the argument tuple a drain worker will later hand to
ILiveChannelPublisher.PublishAsync. - Depends on: nothing but
string. It is deliberately primitive-only so the item can sit in a queue with no entity, noDbContext, and no scope attached. Consumed by ILiveChannelPublishQueue and, on the other side, ILiveChannelPublisher. - Concept, the deferred-work message.
[Rubric §12, Performance & Scalability]assesses whether slow out-of-process work is kept off the request path: representing a broadcast as data (rather than as an in-flightawait) is what makes deferral possible at all.[Rubric §29, Resilience & Business Continuity]assesses failure containment: because the payload is pre-serialized intoPayloadJsonbefore enqueueing, the drain never needs the originating scope, its entities, or itsDbContext, so a slow publish cannot pin request-scoped resources. The doc comment (ILiveChannelPublishQueue.cs:3-9) ties the type to BR-229 and ADR-039. - Walkthrough:
public sealed record LiveChannelPublishWorkItem(string ChannelKey, string EventName, string PayloadJson)(ILiveChannelPublishQueue.cs:10).ChannelKeyis the session or event channel key (the doc points at LivePollChannel,ILiveChannelPublishQueue.cs:7),EventNameis the channel event name such aspoll.results-changed(ILiveChannelPublishQueue.cs:8), andPayloadJsonis the pre-serialized broadcast body, which the doc states never carries per-user data (ILiveChannelPublishQueue.cs:9). - Why it's built this way: a broadcast fans out to every subscriber of a channel, so the payload must be user-agnostic; encoding that rule in the doc of a single-purpose record keeps it visible at every construction site. Serializing eagerly also means the queue holds a small, immutable string triple rather than a live object graph.
- Where it's used: constructed by the live-layer command handlers after their commit, notably OpenLivePollHandler (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:110), CloseLivePollHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:95), SubmitQuestionHandler (SubmitQuestionHandler.cs:142,:157), and ModerateQuestionHandler (ModerateQuestionHandler.cs:140,:154), plus the two post-commit domain-event handlers LivePollVoteChangedHandler (LivePollVoteChangedHandler.cs:79) and SessionQuestionUpvoteChangedHandler (SessionQuestionUpvoteChangedHandler.cs:80); drained by LiveChannelPublishProcessor.
OptInRow
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:85· Level 0 · record (private sealed, nested)
- What it is: the projection shape for one opted-in attendee inside GetLeaderboardHandler: the attendee's id and the display name they published when they joined the board.
- Depends on: the
UserIdentifierTypealias;string(BCL). Nothing else, which is the point. - Concept, the private projection record. A projection read needs a target type, and the choice of target is a design decision. Using an anonymous type (as GetBookmarkedSessionIdsHandler does) is fine when the shape is consumed one line later; naming the shape pays off when it is threaded through several LINQ stages, because the name documents what the two columns mean. Declaring it
privateand nested keeps it invisible outside the handler, so it can never be mistaken for a contract: nothing about it is a promise to any caller.[Rubric §12, Performance & Scalability]assesses reading only the columns you need, which is what a projection record makes explicit at the call site.[Rubric §15, Best Practices & Code Quality]: the type sits inside the only file entitled to change it. - Walkthrough:
private sealed record class OptInRow(UserIdentifierType UserId, string DisplayName)(GetLeaderboardHandler.cs:85), two positional members, with per-parameter docs at:83-84naming them as the attendee who joined the board and the name they published at opt-in. - Why it's built this way:
recordgives value semantics for free andsealedplusprivatekeeps the shape local. TheDisplayNamecarried here is the snapshot taken at opt-in, which is why the board can be rendered with no call into Identity at all. - Where it's used: the
selectargument of the LeaderboardOptIn projection read (GetLeaderboardHandler.cs:41), then read again for the user-id list that scopes the ledger sum (:48), for the ordering (:68-69), and for the final DTO projection (:71-76). It is the only projection row in this handler: the points side is summed by the database rather than projected, see GetLeaderboardHandler.
ILiveChannelPublishQueue
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:21· Level 1 · interface
- What it is: the one-method boundary between a command handler and the live-channel broadcast path. Handlers hand a LiveChannelPublishWorkItem to this queue instead of awaiting a gRPC publish inline.
- Depends on: LiveChannelPublishWorkItem only. Its implementation is LiveChannelPublishQueue and its drain is LiveChannelPublishProcessor, which forwards to ILiveChannelPublisher.
- Concept introduced, the non-blocking hand-off that decouples a hot path from a remote peer. The failure mode this exists to prevent is stated in the doc comment (
ILiveChannelPublishQueue.cs:12-20): a hung (not refused) Notification peer would otherwise stall a user request for as long as the publish takes, because an inlineawaitinherits the peer's latency. Enqueueing instead makes the handler's cost bounded and synchronous.[Rubric §29, Resilience & Business Continuity]assesses how a dependency's degradation propagates: here it cannot propagate at all, since a failed publish (including a full queue) is logged by the caller or drain and never fails the command (ILiveChannelPublishQueue.cs:18-19).[Rubric §1, SOLID](ISP and DIP): the interface exposes exactly one method, and the Application layer depends on this abstraction rather than on the transport, so the concrete channel and the hosted drain both live outside the use-case code.[Rubric §13, Observability & Operability]shows in how the contract keeps a discard observable without burdening the caller: it hands callers nothing to branch on and points at the implementation's dropped-count warning instead (ILiveChannelPublishQueue.cs:24-26). - Walkthrough: a single member.
void Enqueue(LiveChannelPublishWorkItem workItem)(ILiveChannelPublishQueue.cs:30) performs a non-blocking enqueue. Its doc (ILiveChannelPublishQueue.cs:23-27) pins the contract precisely: the queue never rejects an item, because under backpressure it discards the OLDEST pending broadcast to make room, so there is nothing for a caller to branch on, and discards surface through the implementation's dropped-count warning rather than through a return value. A null work item throwsArgumentNullException(ILiveChannelPublishQueue.cs:29). There is no async variant, so the call cannot suspend the handler. - Why it's built this way: ADR-039 makes live-channel push best-effort, so the publish must be a side channel rather than a step of the command. The doc comment also records the ordering guarantee the design buys: a single-reader drain forwards items in FIFO order, which preserves per-session event ordering (
ILiveChannelPublishQueue.cs:15-17). Keeping the interface in the Application layer, with the drain in Infrastructure, keeps the transport dependency pointing inward only (Clean Architecture,[Rubric §3]). - Where it's used: constructor-injected into all four live-layer command handlers, OpenLivePollHandler (
OpenLivePollHandler.cs:23), CloseLivePollHandler (CloseLivePollHandler.cs:22), SubmitQuestionHandler (SubmitQuestionHandler.cs:29), and ModerateQuestionHandler (ModerateQuestionHandler.cs:26), plus the two post-commit domain-event handlers (LivePollVoteChangedHandler.cs:40,SessionQuestionUpvoteChangedHandler.cs:41); registered against the singleton implementation in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:57).
OverviewRow
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:102· Level 1 · record (private sealed, nested)
- What it is: the projection shape GetPointsOverviewHandler reads the ledger into: one row per points entry, carrying the six fields every figure in the organizer rollup is computed from.
- Depends on: the
UserIdentifierTypeandPointsEntryIdentifierTypealiases, and PointsActivityType (which is why it sits at Level 1 rather than 0);DateTimeandstring(BCL). - Concept, the internal shape that carries more than the output does. This row deliberately holds a field that never leaves the handler. Its doc says so (
GetPointsOverviewHandler.cs:92-95): the attendee id "is present here so the distinct participant count can be computed, and is dropped before anything leaves the handler".[Rubric §30, Compliance/Privacy/Data Governance]assesses data minimization at the boundary rather than in the query: you cannot count distinct participants without reading who they are, so the id is read, aggregated, and then discarded when the rows are projected into the identity-free PointsEntryDTO. Keeping the wider shapeprivateis what makes that discard structural instead of a convention someone must remember.[Rubric §3, Clean Architecture]: the internal read model is not the published contract, and here they are two different types on purpose. - Walkthrough:
private sealed record class OverviewRow(UserIdentifierType UserId, PointsEntryIdentifierType Id, PointsActivityType ActivityType, int Points, string SubjectKey, DateTime OccurredOnUtc)(GetPointsOverviewHandler.cs:102-108), each member documented at:96-101. - Why it's built this way: one projected pass over the ledger feeds all four rollup figures, so the row is the union of what those figures need rather than the intersection; the narrowing to a public shape happens once, at the end.
- Where it's used: the
selectargument of the single PointsEntry read (GetPointsOverviewHandler.cs:42-48), then the source for the per-activity grouping (:54-62), the recent tail (:69-80), the distinct participant count (:85), and the total payout (:86).
LiveChannelPublishQueue
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:14· Level 2 · class (sealed, partial)
- What it is: the in-process implementation of ILiveChannelPublishQueue, a bounded
System.Threading.Channelschannel holding up to 1024 pending broadcasts, plus the reader handle the hosted drain consumes. - Depends on: ILiveChannelPublishQueue and LiveChannelPublishWorkItem first-party;
System.Threading.Channels(Channel,BoundedChannelOptions,BoundedChannelFullMode,ChannelReader<T>),ArgumentNullExceptionandInterlockedfrom the BCL, andILogger<LiveChannelPublishQueue>for the dropped-broadcast warning (LiveChannelPublishQueue.cs:1-2,:21). - Concept introduced, bounded channels and an explicit backpressure policy. An unbounded queue turns a slow consumer into a memory leak, so the capacity is fixed at
1024(LiveChannelPublishQueue.cs:18). The interesting design choice is what happens when it fills:BoundedChannelFullMode.DropOldest(LiveChannelPublishQueue.cs:36), so a full queue discards the oldest item and still accepts the new one, which is why the write can never be refused andEnqueuehas no outcome to return. The doc states the reasoning (LiveChannelPublishQueue.cs:6-13): live channel events are ephemeral, so under sustained backpressure the freshest broadcast is worth more than the oldest, and the request path must never block.[Rubric §12, Performance & Scalability]assesses bounded resource use under load, which the capacity plus drop policy gives.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation: a slow Notification peer degrades to stale-but-recent broadcasts, not to a stalled or ballooning host. The inline comment sizes the capacity against the observed conference-day load of roughly 67 concurrent users (LiveChannelPublishQueue.cs:16-17), which is[Rubric §31, Cost/FinOps]thinking applied to memory: generous for the real load, not for an imagined one. - Walkthrough
private const int Capacity = 1024(LiveChannelPublishQueue.cs:18), the bound, with the sizing rationale in the comment above it.- The
_channelfield (LiveChannelPublishQueue.cs:20) is created in the constructor byChannel.CreateBounded<LiveChannelPublishWorkItem>(LiveChannelPublishQueue.cs:33-40) with three options:FullMode = BoundedChannelFullMode.DropOldest(line 36),SingleReader = true(line 37, matching the one hosted drain, which is what makes delivery FIFO and per-session order preserving), andSingleWriter = false(line 38, because many concurrent request threads enqueue), plus theitemDropped: OnItemDroppedcallback (line 40). The comment above the call (:30-32) explains that this callback is the only way aDropOldestdrop is observable, sinceTryWritealways succeeds under this mode. public ChannelReader<LiveChannelPublishWorkItem> Reader => _channel.Reader(LiveChannelPublishQueue.cs:44) exposes only the read side to the drain worker; the writer side stays private, so nothing outside this class can bypassEnqueue.public long DroppedCount => Interlocked.Read(ref _droppedCount)(LiveChannelPublishQueue.cs:47) is the running total of broadcasts discarded under backpressure since startup.public void Enqueue(LiveChannelPublishWorkItem workItem)(LiveChannelPublishQueue.cs:55-59) null-guards withArgumentNullException.ThrowIfNull(workItem)(line 57), then calls_channel.Writer.TryWrite(workItem)(line 58) without inspecting the result: underDropOldestthe write evicts to make room rather than refusing, and the writer is never completed, soTryWritecannot fail (LiveChannelPublishQueue.cs:50-54). It never blocks, which is the whole point of the contract.OnItemDropped(LiveChannelPublishQueue.cs:61-65) is the only place a discard becomes visible: it increments_droppedCount(line 63) and emits the source-generatedLogDroppedwarning naming the channel key, the event name, and the running total (LiveChannelPublishQueue.cs:67-70).
- Why it's built this way:
Channel<T>gives a lock-free, allocation-light producer/consumer queue in the BCL, so no third-party dependency is needed for a hand-off this simple. TheReaderproperty is why the class is registered twice in DI (once as the concrete type, once as the interface,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57): handlers only need the interface, but the drain needs the concrete reader, and both must resolve to the same singleton instance. The class ispartialbecauseLogDroppedis a[LoggerMessage]source-generated method. - Where it's used: registered as a singleton in the Application-layer DependencyInjection (
DependencyInjection.cs:55-56); itsReaderis consumed by LiveChannelPublishProcessor in the Infrastructure layer, which is added withAddHostedService<LiveChannelPublishProcessor>()(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21).
GetBookmarkedSessionIdsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetBookmarkedSessionIds·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetBookmarkedSessionIds/GetBookmarkedSessionIdsHandler.cs:12· Level 8 · class (sealed)
- What it is: the query handler that answers GetBookmarkedSessionIdsQuery, returning a read-only dictionary mapping each of a user's bookmarked session ids to its bookmark id. This is the lightweight lookup a listing page uses to decide which rows render as bookmarked, and which bookmark id a delete would target.
- Depends on: IUnitOfWork (constructor-injected,
GetBookmarkedSessionIdsHandler.cs:12-13), the UserSessionBookmark aggregate it reads, the IReadRepository<TEntity, TIdentifierType> it obtains from it, and Result. It implements IQueryHandler<in TQuery, TResult>. - Concept, a projection read that never materializes the entity.
[Rubric §12, Performance & Scalability]assesses whether reads pull only the columns they need; this handler projects, so the SQL selects two columns rather than whole rows. It resolves a read repository throughGetReadRepository(GetBookmarkedSessionIdsHandler.cs:20) rather than the writable one, which signals intent and is also the DI-safe way to obtain a repository: injecting IRepository<TEntity, TIdentifierType> directly into a handler is the trap the persistence chapter warns about.[Rubric §6, CQRS & Event-Driven]and[Rubric §5, Vertical Slice]: the handler sits beside its query in the same use-case folder and returns a Result rather than throwing. - Walkthrough
- The primary constructor injects IUnitOfWork (
GetBookmarkedSessionIdsHandler.cs:12-13); the class closes the query-handler interface overResult<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>>(:13). HandleAsync(GetBookmarkedSessionIdsHandler.cs:16-18) obtains the read repository for UserSessionBookmark (line 20), then callsGetProjectedAsyncwithselect: b => new { b.SessionId, b.Id }andwhere: b => b.UserId == query.UserId(:22-25), so only the two ids for that user come back. The projection target is an anonymous type here rather than a named record, because it is consumed on the very next line.- It materializes the pairs into a dictionary keyed by
SessionIdwith valueId(GetBookmarkedSessionIdsHandler.cs:27) and wraps it inResult.Success<IReadOnlyDictionary<...>>(line 29). There is no failure branch: this read cannot fail, so the Result is always a success.
- The primary constructor injects IUnitOfWork (
- Why it's built this way: the caller only needs "is this session bookmarked, and under which bookmark id", so projecting to a two-field shape and returning a dictionary avoids loading full aggregates and gives O(1) per-row lookups while rendering. The doc comment (
GetBookmarkedSessionIdsHandler.cs:8-11) names the consumer explicitly: the unified Sessions page's per-row star state. Soft-deleted bookmarks are excluded by the global query filter, so a removed bookmark simply stops appearing without a predicate here. - Where it's used: dispatched from BookmarksController's
GET /api/bookmarks/session-idsaction (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:112-114) through the IQueryHandler<in TQuery, TResult> pipeline. Covered byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetBookmarkedSessionIdsHandlerTests.cs:13, which asserts the read repository is the one requested. - Caveats / not-in-source: the anonymous projection target is why that test verifies the repository call rather than the returned map; the test's own comment records that a mocked
GetProjectedAsynccannot produce an anonymous type (GetBookmarkedSessionIdsHandlerTests.cs:20-21).
GetPointsOverviewHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:25· Level 8 · class (sealed)
- What it is: the query handler behind the organizer points dashboard. One read of the ledger produces four figures: how many attendees are playing, how many points the game has paid out, the per-activity breakdown, and the most recent awards.
- Depends on: IUnitOfWork (primary constructor,
GetPointsOverviewHandler.cs:25), the PointsEntry aggregate, its own OverviewRow projection, and the published shapes PointsOverviewDTO, PointsActivityTotalDTO, PointsEntryDTO, plus PointsActivityType. Implements IQueryHandler<in TQuery, TResult> and returns Result. - Concept, the in-memory fold and the reason it is not a
GROUP BY. The class doc states the constraint and the trade-off (GetPointsOverviewHandler.cs:18-21): the Application layer has no EF Core reference, so a SQLGROUP BY"is not available to it", and at conference scale (about 100 attendees) one projected pass over the entries is cheaper than the round trips a per-figure query set would cost, with a materialised rollup named as the scale lever if the population ever changes that.[Rubric §3, Clean Architecture]assesses whether the inner layers stay free of infrastructure: the missingGROUP BYis a direct consequence of that rule, and the code says so rather than pretending the shape is accidental.[Rubric §12, Performance & Scalability]assesses whether the chosen shape is sized to the real workload (the observed conference-day load, not an imagined one).[Rubric §30, Compliance/Privacy/Data Governance]assesses what an operator surface exposes: the recent tail is projected into PointsEntryDTO, which carries no user id, so "an organizer sees THAT points were earned and for what, never by whom" (GetPointsOverviewHandler.cs:13-15). - Walkthrough
private const int MaxRecentCount = 100(GetPointsOverviewHandler.cs:29) is the ceiling the handler enforces "regardless of what the caller asks for" (:28).HandleAsync(GetPointsOverviewHandler.cs:32-34) null-guards the query (line 36) and clamps the tail length withMath.Clamp(query.RecentCount, 1, MaxRecentCount)(line 38), so both a zero and anint.MaxValuerequest land inside 1 to 100.- One read:
GetReadRepository<PointsEntry, PointsEntryIdentifierType>()(line 40) thenGetProjectedAsyncselecting an OverviewRow per entry withasTracking: false(:41-50). There is nowhere, because the rollup is over every attendee. - The per-activity breakdown groups the rows by
ActivityType, orders by the enum key, and sums points and counts per group into PointsActivityTotalDTO (GetPointsOverviewHandler.cs:52-63). - The recent tail orders newest-first with the entry id as the tie-break, takes
recentCount, and projects into PointsEntryDTO (:67-81). The comment (:65-66) explains the tie-break: entries stamped in the same instant keep a stable order "rather than shuffling between refreshes of the organizer dashboard". - The result assembles
ParticipantCountfromrows.Select(row => row.UserId).Distinct().Count()(line 85) andTotalPointsAwardedfromrows.Sum(row => row.Points)(line 86), then returnsResult.Success(...)over the PointsOverviewDTO (:83-89).
- Why it's built this way: the participant count is the only figure that needs attendee identity, and it needs it only as a cardinality, so reading the id into a private row and never projecting it outward gives the organizer the number without the names. Doing all four aggregations over one materialized list keeps the database round trips at one regardless of how many figures the dashboard grows.
- Where it's used: resolved by PointsController (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:40) and invoked from the permission-gated overview endpoint (PointsController.cs:129-131). Its behavior is pinned byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetPointsOverviewHandlerTests.cs, including the distinct-attendee count (:17), the empty-ledger zeros (:35), the per-activity rollup (:65), the recent tie-break (:116), and the tail clamp theory (:135).
GetMyPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetMyPoints·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:27· Level 9 · class (sealed)
- What it is: the query handler for GetMyPointsQuery. It reads the calling attendee's own ledger, sums it into a running total, reports whether they are on the leaderboard, and returns one page of entries.
- Depends on: IUnitOfWork and ICurrentUserService (primary constructor,
GetMyPointsHandler.cs:27-29), the PointsEntry and LeaderboardOptIn aggregates, PagingMath, and the MyPointsDTO / PointsEntryDTO shapes. Returns a Result and can produce an Error. Externals: none beyond the BCL. - Concept, scoping a read to the token instead of to a parameter. The class doc states the guarantee in its first sentence (
GetMyPointsHandler.cs:13-15): the read is always scoped to the token's user id, "nothing in the query can widen it, so there is no parameter an attendee could tamper with to read another attendee's ledger".[Rubric §11, Security]assesses whether authorization is enforced where the data is fetched rather than only at the edge; here the filter predicate itself is built from the caller identity (:63), so even a mis-configured route cannot widen the result set.[Rubric §8, Data Architecture]assesses whether derived values are stored or computed: the doc (:15-22) explains that the total is summed on read rather than maintained on a user row, because "a materialised total can drift from its ledger, and a summed one cannot", with the materialised total named as the first scale lever if the population grows.[Rubric §12, Performance & Scalability]is the counterweight the same paragraph acknowledges: at about 100 attendees with a few dozen entries each, the sum is a trivial read. - Walkthrough
private const int MaxPageSize = 100(GetMyPointsHandler.cs:32), the ceiling the handler enforces whatever the caller asks for (:30).HandleAsync(GetMyPointsHandler.cs:35-37) null-guards the query (line 38), then resolves the caller with the shared guardcurrentUserService.RequireUserId("Points.Forbidden")(line 40). That extension member lives in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35) and collapses the read-then-null-check-then-fail block every per-user handler repeats: it returnsResult.Success(userId)when the token carries a user, and otherwise one Error built from the module's code, the default message"Access denied.", andErrorType.Forbidden(CurrentUserServiceExtensions.cs:43-45). The module supplies only the code, because the code names the module and the framework cannot know it (CurrentUserServiceExtensions.cs:21-24).- A failure short-circuits:
return Result.Failure<MyPointsDTO>(caller.Errors)(GetMyPointsHandler.cs:42-45), which the controller renders as a 403 Problem Details response (PointsController.cs:52). On success the identifier is unwrapped (line 46). - Paging is clamped, not trusted:
PagingMath.Clamp(query.PageNumber, query.PageSize, MaxPageSize)returns a(skip, take)pair (GetMyPointsHandler.cs:52). The comment (:48-50) records exactly what the helper buys: it floors a non-positive page number or size and range-checks the offset in 64-bit, so a hostile page number nearint.MaxValue"yields the empty page it holds instead of a negative skip" (the helper itself returns(0, 0)in that case,MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:42). - The ledger read projects straight into PointsEntryDTO with
where: entry => entry.UserId == userIdandasTracking: false(GetMyPointsHandler.cs:55-66). - A second read pulls the caller's leaderboard display names (
GetMyPointsHandler.cs:70-75) and takes the first (:76). The comment (:67-68) notes that this read relies on the default query filter, so an opt-in the attendee soft-deleted by leaving the board correctly reports them as not on the leaderboard. - The page is ordered newest-first with the entry id as the tie-break, then skipped and taken in memory (
GetMyPointsHandler.cs:81-88); the comment (:78-79) gives the concrete reason (one badge scan fanning out into awards stamped in the same instant). - The result (
GetMyPointsHandler.cs:90-98) setsTotalfromentries.Sum(...)over every entry rather than the page (the inline comment at:91-92makes that explicit),IsOnLeaderboardfrom whether a display name was found,LeaderboardDisplayName, andEntries.
- Why it's built this way: reading the caller from ICurrentUserService rather than from the message is what lets the endpoint skip an ownership filter entirely; routing that read through the shared
RequireUserIdguard keeps every module's denied-caller failure the same shape while leaving the error code module-specific; and clamping through the shared PagingMath rather than a localMath.Minkeeps the arithmetic (including the 64-bit offset check) in one tested place used by every paged read in the codebase. - Where it's used: resolved by PointsController (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:37) and invoked fromGET /api/points/me(PointsController.cs:58-60). Its paging and total semantics are pinned byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetMyPointsHandlerTests.cs: the total sums every entry even when one page is returned (:23), the page walk neither repeats nor drops (:76), hostile paging arguments clamp instead of throwing (:102), a page number beyond the ledger returns the empty page that page holds (:120), no current user returns forbidden (:141), and the read never returns another attendee's entries (:153). - Caveats / not-in-source: the ordering, skip, and take happen after materialization, so the read pulls all of the caller's entries and pages them in memory. That is the intended trade for a per-attendee ledger at conference scale (the class doc's scale note,
GetMyPointsHandler.cs:16-23), not an oversight, but it is a different shape from the server-side paging in GetUserBookmarksHandler.
GetUserBookmarksHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetUserBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetUserBookmarks/GetUserBookmarksHandler.cs:17· Level 9 · class (sealed)
- What it is: the query handler for GetUserBookmarksQuery. It returns a page of a user's bookmarked sessions as DTOs, newest first, with an optional event filter resolved across the module boundary.
- Depends on: IUnitOfWork, the cross-module ISessionBookmarkValidationService, IQueryableExecutor, and UserSessionBookmarkDTOMapper (all primary-constructor injected,
GetUserBookmarksHandler.cs:17-21), plus PagingMath. It reads the UserSessionBookmark aggregate, produces UserSessionBookmarkDTOs, and returns a PagedCollectionResult<T> carrying PaginationMetadata. Externals:System.Linq.Expressions(:1). - Concept, server-side paging plus a cross-module lookup instead of a join.
[Rubric §12, Performance & Scalability]assesses whether counting, ordering, and paging happen in the database rather than in memory; every step here does.[Rubric §7, Microservices Readiness]assesses cross-service reads: the event filter cannot be a SQL join because sessions live in the Conference database (ADR-006), so the handler fetches the event's session ids through ISessionBookmarkValidationService (a gRPC client once extracted, ADR-007) and filters on that id set.[Rubric §29, Resilience & Business Continuity]shows in how a Conference outage is handled: the code comment (GetUserBookmarksHandler.cs:40-42) states that a failure across the gRPC boundary propagates as a Result failure "so the request degrades gracefully instead of a raw 500". - Walkthrough
HandleAsync(GetUserBookmarksHandler.cs:24-26) first clamps the page:PagingMath.Clamp(query.PageNumber, query.PageSize, 500)(line 31) is the BR-11 cap mechanism, and the comment above it (:28-30) records that it also floors a non-positive page number or size and range-checks the offset in 64-bit.- It resolves the repository (line 33) and declares an
Expression<Func<UserSessionBookmark, bool>> filter(line 36) so the predicate is built once and used by both the count and the page query. - When
query.EventId.HasValue(line 38) it callssessionValidationService.GetSessionIdsByEventAsync(...)across the module boundary (:43-44), returnsResult.Failurewith the propagated errors if that call failed (:45-46), materializes the ids into a list (line 48), and filters bookmarks to that user and that session-id set (line 50, BR-58). Otherwise the filter is justb => b.UserId == query.UserId(line 54). - It counts server-side with
bookmarkRepo.CountAsync(filter, cancellationToken)(line 58, translated to SQLCOUNT), then orders and pages server-side by pushingTableNoTracking.Where(filter).OrderByDescending(b => b.CreatedOn).ThenByDescending(b => b.Id).Skip(skip).Take(take)through IQueryableExecutor.ToListAsync(:63-70, translated to SQLORDER BYplusOFFSET/FETCH). TheIdtie-break is load-bearing and commented as such (:60-62):OFFSET/FETCHneeds a total row order, or bookmarks created in the same instant "would otherwise repeat or vanish across pages". - It maps the page with UserSessionBookmarkDTOMapper
.MapToDTOs(line 72), buildsnew PaginationMetadata(totalCount, take, Math.Max(query.PageNumber, 1))(line 73) using the clamped take and a floored page number so the metadata describes what was actually returned, and returnsResult.Success(new PagedCollectionResult<UserSessionBookmarkDTO>(dtos, metadata))(line 75).
- Why it's built this way: pushing count, order, skip, and take into the database keeps the read bounded and cheap even for a user with many bookmarks; resolving the event filter through a service interface (never a cross-database foreign key) is what makes the module independently deployable (ADR-006 and ADR-007).
TableNoTrackingkeeps the paged read off the change tracker, and the whole read still honors the soft-delete query filter, so a deleted bookmark simply is not in the page or the count. - Where it's used: dispatched from BookmarksController's
GET /api/bookmarksaction (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:94-96), whose ownership is guarded by theOwnerOrAdminFilterconfigured for the module (BookmarksController.cs:85, ADR-033). Pinned byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetUserBookmarksHandlerTests.cs, which covers the event-filter path (:140), the failure propagation when the session lookup fails (:161), the over-large page size (:199,:310), the page number beyond the reachable offset (:230), and the negative page number and page size (:250,:269). - Caveats / not-in-source: whether ISessionBookmarkValidationService resolves to an in-process implementation or a gRPC client depends on the host composition, not on this file. Note also that this handler takes the writable repository via
GetRepository(GetUserBookmarksHandler.cs:33) even though it only reads, unlike GetBookmarkedSessionIdsHandler, which usesGetReadRepository; the read itself still goes throughTableNoTracking.
GetLeaderboardHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:28· Level 10 · class (sealed)
- What it is: the query handler that builds the public leaderboard: the opted-in attendees with the highest points totals, ranked, truncated to the configured board length.
- Depends on: IUnitOfWork and
IOptions<PointsSettings>(primary constructor,GetLeaderboardHandler.cs:28-30), the LeaderboardOptIn and PointsEntry aggregates, its private projection OptInRow, and the published LeaderboardEntryDTO. Implements IQueryHandler<in TQuery, TResult>, returns Result. Externals:Microsoft.Extensions.Options(:1),StringComparer.Ordinal(BCL). - Concept, publishing a name only if its owner published it. The class doc (
GetLeaderboardHandler.cs:13-16) is the design statement worth reading twice: only attendees who opted in appear, the name shown is "the snapshot they published at opt-in", and the handler "makes no call into Identity and reads no user record", so an attendee who never opted in is absent rather than present under a name they did not choose.[Rubric §30, Compliance/Privacy/Data Governance]assesses consent as a structural property rather than a checkbox: the board is built from the opt-in table outward, so non-participation is the default and cannot be undone by a bug in a filter.[Rubric §11, Security]assesses minimizing what a public surface can reveal: no Identity call means no user record is even in reach of this code path. A second design note (:19-23) covers ranking: ties keep distinct sequential ranks (1, 2, 3) rather than competition ranking (1, 1, 3), because the board is a fixed-length list of positions, and the tie-break is the published name compared ordinally so the order is "deterministic and repeatable across reads rather than dependent on whatever order the database returned".[Rubric §15, Best Practices & Code Quality]shows in that ordinal comparison: a culture-sensitive comparison would make the rendered order depend on the server's locale. - Concept, pushing the aggregate into the database with
SumByAsync. The points side of this read is not folded in memory.SumByAsync(declared on the querier contract atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:184, which IReadRepository<TEntity, TIdentifierType> composes atIRepository.cs:330-331) is the persistence-neutral way to ask for aGROUP BYwithSUM, so only one aggregate row per attendee crosses the wire instead of every ledger row. Its EF implementation groups with an element selector (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:165-172) so the caller's expression tree reaches the provider intact.[Rubric §3, Clean Architecture]is what makes this member necessary at all: the Application layer references no EF Core, so a handler that wants a grouped aggregate has noIQueryableof its own to group (IRepository.cs:156-161).[Rubric §12, Performance & Scalability]: the contract returnsdecimal, so anintpoints column widens on the way in and narrows on the way out (GetLeaderboardHandler.cs:52-53,:61), which the code comment notes no ledger total can overflow. - Walkthrough
HandleAsync(GetLeaderboardHandler.cs:33-35) reads the opt-ins first: a read repository for LeaderboardOptIn (line 39), thenGetProjectedAsyncinto OptInRow withasTracking: false(:40-43). The comment above it (:37-38) records why there is no "is active" predicate: the default query filter excludes soft-deleted rows, so leaving the board removes the attendee from this read for free.- An empty opt-in list short-circuits to an empty successful board (
GetLeaderboardHandler.cs:45-46), so a conference where nobody opted in costs one query rather than two. - The user ids are collected (line 48) and used to scope the ledger aggregate: a read repository for PointsEntry (line 54) then
SumByAsync(entry => entry.UserId, entry => entry.Points, where: entry => userIds.Contains(entry.UserId), ...)(:55-59). The comment (:50-53) states both halves of the reasoning: only the opted-in attendees' entries are summed, and the sum happens in the database, so "one grouped SUM comes back per attendee rather than every ledger row for the fold to add up". - The
decimalsums are narrowed into aninttotals dictionary (GetLeaderboardHandler.cs:61). var boardSize = Math.Max(settings.Value.LeaderboardSize, 0)(line 63) floors a misconfigured negative size at zero, so a bad configuration value publishes nothing instead of throwing insideTake. The setting itself defaults to 10 (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:40).- The board is composed with a collection expression (
GetLeaderboardHandler.cs:65-77): order by total descending usingtotals.GetValueOrDefault(...)(line 68, so an opted-in attendee with no entries scores zero rather than being dropped), then byDisplayNamewithStringComparer.Ordinal(line 69),Take(boardSize)(line 70), and project each into a LeaderboardEntryDTO whoseRankis the positionalindex + 1(:71-76). return Result.Success(board)(line 79).
- Why it's built this way: one narrow projected read for the opt-ins plus one grouped database aggregate for the totals keeps the round trips at two and keeps the ledger itself on the server; the opt-in table (not the ledger) drives the outer loop, which is what makes the consent rule structural. Reading the length from PointsSettings rather than from GetLeaderboardQuery is what keeps the published board size an organizer decision.
- Where it's used: resolved by PointsController (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:38) and invoked from the parameterlessGET /api/points/leaderboardendpoint (PointsController.cs:81-83). Its rules are pinned test by test inMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetLeaderboardHandlerTests.cs: a points-holder who never opted in is excluded (:18), an attendee who left the board is excluded (:33), nobody opted in yields an empty board (:47), ranks start at one in total order (:59), ties break on the ordinal published name (:78), an opted-in attendee with no points sits at zero (:94), the configured size truncates (:108), a zero or negative board size publishes nothing (:121,:135), and the name served is the one snapshotted at opt-in (:150). - Caveats / not-in-source: the aggregate is scoped with
userIds.Contains(...)over a list built in memory, so the number of parameters in the translated SQL grows with the opted-in population. That is bounded by the conference-scale assumption stated in the class doc rather than by anything in this file. The final ordering, truncation, and ranking still happen in memory over one row per opted-in attendee.
AssemblyReference
MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Domain,MMCA.ADC.Engagement.Infrastructure·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5· Level 0 · class (static)One byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies together; the API and Application copies are covered earlier in this chapter.
What it is: a two-field static class whose only job is to hand out the
Assemblyit was compiled into, plus that assembly's simple name, so anything that needs to say "scan the assembly this type lives in" gets a compiled anchor instead of a hand-typed assembly-name string.Type File:Line Notes (what differs) AssemblyReference(Domain)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.Domain(:3); no consumer anywhere in the repo todayAssemblyReference(Infrastructure)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.Infrastructure(:3); this is the copy the design-time migrations factory consumesThe Engagement module carries four copies of this file (API, Application, Domain, Infrastructure). Each is eleven lines long and declares the same two fields at lines 7 and 8; only the namespace on line 3 differs. What actually distinguishes them is which assembly
typeof(AssemblyReference).Assemblyresolves to, and therefore which call sites can use them.Depends on:
System.Reflection(BCL), imported at line 1 of each file. Nothing first-party, which is why both copies sit at Level 0 even though one of them lives in the module's outermost non-presentation layer.Concept: the framework-level explanation of assembly-marker types lives with the shared copy in AssemblyReference; these are the Engagement Domain and Infrastructure instances of that idiom.
[Rubric §2, Design Patterns]assesses whether a recurring problem is solved with a recognized, uniform idiom rather than an ad-hoc one each time: every layer of every ADC module exposes the same pair of fields, so "point a scanner at this layer" has exactly one spelling across the repo.[Rubric §1, SOLID](Dependency Inversion) is the other half: the migrations host depends on a marker declared for that purpose, not on some real EF configuration class it happens to know about, so renaming or deleting an entity configuration can never silently empty a scan.Walkthrough: two
public static readonlyfields per copy, both resolved once at type initialization.Assembly(AssemblyReference.cs:7) istypeof(AssemblyReference).Assembly. Because the marker is declared inside its own project, the Infrastructure copy resolves to theMMCA.ADC.Engagement.Infrastructureassembly, the one holding the tenEntityConfigurationclasses underMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/, and the Domain copy resolves to the assembly holding the module's aggregates.AssemblyName(AssemblyReference.cs:8) isAssembly.GetName().Name ?? string.Empty. The null-coalesce is what keeps the field non-null under nullable-reference-type analysis, sinceAssemblyName.Nameis declared nullable even though a loaded assembly always reports one.
Why it's built this way: a
typeofcannot go stale the way a string literal can, and because each layer declares its own copy, a scan can be aimed at one layer without that layer's consumers taking a reference to any other. The four-copy shape is deliberate duplication of a token, not duplicated logic: there is no behavior here to keep in sync, which is also why an unused copy costs nothing.Where it's used: the Infrastructure copy is the one the EF Core design-time factory points at when it tells the module's SQLServerDbContext where to find entity configurations:
options.AddConfigurationAssembly(typeof(MMCA.ADC.Engagement.Infrastructure.AssemblyReference).Assembly)atMMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:45, the per-service migrations project that owns theADC_Engagementdatabase (ADR-006). That call site names the type through its full namespace, which is why the Infrastructure copy specifically is load-bearing: swapping in the Domain copy would point the scan at an assembly with no configurations in it. There are four such per-service migrations projects underMMCA.ADC/Source/Hosting/(Conference, Engagement, Identity, Notification), one per database.Caveats / not-in-source: the Domain copy has no consumer at all. The architecture-fitness map does not use either marker:
MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:44pins the Domain assembly with a real aggregate type (typeof(Engagement.Domain.UserSessionBookmarks.UserSessionBookmark).Assembly) and:46pins Infrastructure withAssembly.Load("MMCA.ADC.Engagement.Infrastructure"), a name string. So the marker's only production consumer today is the one design-time factory line above.
ClassReference
MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Domain,MMCA.ADC.Engagement.Infrastructure·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11· Level 0 · classOne byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies; the API and Application copies are covered earlier in this chapter.
What it is: an empty, instantiable class declared at the bottom of the same file as the static marker above, existing purely so the assembly can be named in a generic type argument.
Type File:Line Notes (what differs) ClassReference(Domain)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.Domain(:3); declared for symmetry, no call site todayClassReference(Infrastructure)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.Infrastructure(:3); declared for symmetry, no call site todayDepends on: nothing.
public class ClassReference { }is the whole declaration in both files.Concept: the companion half of the marker pattern, introduced at framework level under ClassReference. C# forbids a static class as a generic type argument, so a registration helper shaped as
Scan<TAssemblyMarker>()cannot acceptAssemblyReference; the empty non-static class fills that slot without weakening the static one.[Rubric §33, Developer Experience]assesses how much ceremony the inner loop costs: because every layer of every module carries the identical pair, a developer adding a module writes the same two lines and gets both call shapes for free.Walkthrough: no members. The only meaningful property of either copy is the assembly it belongs to, which a scanner reads as
typeof(ClassReference).Assemblyinside the generic helper.Why it's built this way: co-locating both markers in one file makes the pair a single copy-paste unit when a new layer is created, and keeps the "which one do I pass?" answer purely mechanical (static handle for
typeof(...).Assembly, empty class for<TMarker>).Where it's used: neither of these two copies is referenced anywhere in the repository. The generic-marker call site in the Engagement module is the Application copy,
services.ScanModuleApplicationServices<ClassReference>()atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, which is what discovers the module's domain-event handlers, mappers, command/query handlers, and validators by convention. The Domain layer has nothing to scan for, and the Infrastructure layer registers its one hosted service explicitly instead (see DependencyInjection below).Caveats / not-in-source: both copies are unreferenced. They exist so the marker file is identical in every layer, not because something consumes them; nothing in the build fails if either is deleted. Read nothing here as saying they are wired up.
EngagementFeatures
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:8· Level 0 · class (static)
- What it is: the module's seven feature-flag names as
const strings. Each constant is simultaneously the key read from the"FeatureManagement"configuration section and the argument to a[FeatureGate(...)]attribute on a controller, so a flag flip in configuration turns a whole surface off without a code change. - Depends on: nothing at all, first-party or external. The file has no
usingdirectives (EngagementFeatures.cs:1is the namespace declaration). That is what lets Shared-layer callers, API controllers, and configuration authors all name the same string without any of them referencing each other. - Concept, module-scoped feature flags as compile-time constants. The sibling explanation for the pattern lives with ConferenceFeatures; this section covers what is specific to Engagement. Two mechanisms meet here. First,
const string(notstatic readonly) means the value is inlined at the use site, which is why it can be used in an attribute argument and in an[InlineData]in a test. Second, the runtime behavior of a disabled flag is not the default fromMicrosoft.FeatureManagement.Mvc: MMCA.Common registers DisabledFeatureHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13), which sets an RFC 9457ProblemDetailsbody withStatus = 404and the title"Feature not available"(:18-26). So a gated route answers 404 in the same envelope as every other failure from ApiControllerBase, and the endpoint reads to an outside caller as simply not existing rather than as forbidden.[Rubric §9, API & Contract Design]assesses whether concerns like this are applied uniformly at one point rather than re-implemented per endpoint: the gate is a single attribute on a controller class, and the disabled response shape is one handler in the framework.[Rubric §11, Security]shows in the choice of 404 over 403: a disabled surface leaks nothing about whether the capability exists.[Rubric §29, Resilience and Business Continuity]applies to the conference-day intent recorded in the doc comments: these are kill switches an organizer can throw mid-event. - Walkthrough: seven constants, all prefixed
Engagement.so keys stay unambiguous in a shared configuration file.SessionBookmarks = "Engagement.SessionBookmarks"(EngagementFeatures.cs:15) gates create, list, and delete for the personal schedule; applied at BookmarksController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:32).LivePolls = "Engagement.LivePolls"(:22) gates the conference-day live layer (author, open, close, vote, results); applied at LivePollsController (LivePollsController.cs:43).CheckIn = "Engagement.CheckIn"(:29) gates QR badge check-in (my badge, scan, manual, attendance stats); applied at CheckInsController (CheckInsController.cs:37).SponsorVisits = "Engagement.SponsorVisits"(:37) gates the attendee-facing sponsor booth-visit endpoint (CheckInsController.cs:134). The doc comment (:31-36) names the operational contrast worth remembering: this flag makes every printed sponsor QR inert, while setting thePoints:SponsorVisitaward (20 points atMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:31) to zero is the softer switch that keeps the visit recorded but stops the award.RoomCheckIn = "Engagement.RoomCheckIn"(:44) gates the attendee-facing room self check-in endpoint (CheckInsController.cs:165). The comment (:39-43) states why it is not folded intoSponsorVisits: the two surfaces are printed and retired independently, so each needs its own switch.Points = "Engagement.Points"(:51) gates the whole points surface (my points, leaderboard, opt-in, organizer overview); applied at PointsController (PointsController.cs:34).SessionQA = "Engagement.SessionQA"(:58) gates session Q&A (submit, list, moderate, upvote); applied at SessionQuestionsController (SessionQuestionsController.cs:35).- Note the nesting on
CheckInsController: the class carries[FeatureGate(EngagementFeatures.CheckIn)](:33) and two of its actions carry a second gate (:130,:161), so those two routes need both flags on. TurningCheckInoff retires the badge surface and the two attendee self-serve routes together; turningSponsorVisitsoff retires only the booth game.
- Why it's built this way: ADR-031 is the decision record. Flag granularity here follows a printed-artifact rule rather than a code-structure rule: each constant corresponds to something an organizer might need to retire on its own during a live event, which is why sponsor visits and room check-in are separate constants even though they share a controller. Keeping the constants in the Shared assembly (not the API assembly) is what lets tests and other layers name a flag without taking a controller dependency.
- Where it's used: as
[FeatureGate(...)]arguments on the five Engagement controllers listed above, and as configuration keys. All seven appear in the standalone service host's"FeatureManagement"section, all set totrue:MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:17-25. The constants are also asserted directly in the API tests, for exampleEngagementFeatures.PointsatMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:283and the per-action gate theory atMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/CheckInsControllerTests.cs:477-478, which pins thatRecordSponsorVisitAsynccarriesSponsorVisitsandRecordRoomCheckInAsynccarriesRoomCheckIn. - Caveats / not-in-source: the class doc comment (
EngagementFeatures.cs:3-7) says the constants are used with[FeatureGate]attributes and theIFeatureGatedmarker interface (:6). The attribute half is real; the marker half is not exercised by this module. No Engagement command implements IFeatureGated today (the only ADC implementor is Conference'sRefreshFromSessionizeCommand,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13), so every Engagement flag is enforced at the HTTP boundary only, never inside the CQRS pipeline. Separately, the"FeatureManagement"section shown above is the per-service host's configuration; how the flags resolve in any other composition depends on that host's configuration and is not determinable from these files.
EngagementPermissions
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Authorization·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:9· Level 0 · class (static)
- What it is: the module's capability vocabulary: three permission strings plus an
Alllist. Endpoints are gated on these capabilities rather than on role names, so "who can do what" is decided in one registration call instead of being spelled out across controllers. - Depends on: nothing first-party; only
IReadOnlyList<string>and a collection expression from the BCL. The consumers are elsewhere: HasPermissionAttribute on the controllers and IPermissionRegistry behind the grants. - Concept, permission-based authorization instead of role checks. The framework machinery (the attribute, the registry,
Grant) is taught under PermissionRegistry and decided in ADR-020; the module-specific idea worth teaching here is the shape of the vocabulary. The class doc (EngagementPermissions.cs:4-7) makes two commitments: endpoints require capabilities, not roles, and the values are stable string identifiers because they may end up in tokens or logs. That second point is why these are not an enum: the wire and the log are part of the contract.[Rubric §11, Security]assesses whether authorization is expressed as least-privilege capability rather than as an identity shortcut, and whether the grant surface is small enough to audit; three constants and oneGrantcall per role is that surface.[Rubric §9, API and Contract Design]applies to the naming:engagement:live:manageismodule:resource:action, so a permission is self-describing wherever it surfaces.[Rubric §15, Best Practices & Code Quality]shows inAll: adding a fourth capability grants it to organizers and admins automatically, because the grants spread the list rather than enumerating members. - Walkthrough: three constants and a derived list. Each constant's doc comment records not just what it grants but what deliberately does not need it, which is the more useful half.
LiveManage = "engagement:live:manage"(EngagementPermissions.cs:16): author, open, close, and delete live polls, and (in Wave 2) moderate session Q&A. The comment (:11-15) records the important exclusion: speakers get session-scoped rights through the speaker-assignment check in the handlers, not through this permission, so holding it is an organizer-level capability rather than the only way to act on a poll.CheckInManage = "engagement:checkin:manage"(:23): scan an attendee badge, record a manual check-in, read the attendance rollup. The comment (:18-22) records why fetching your own badge is authenticated-only: an attendee needs no capability to see their own QR.PointsViewOverview = "engagement:points:view-overview"(:30): read the organizer points rollup (participation, payouts per activity, recent activity). Again the comment (:25-29) states the boundary: an attendee's own points and the opt-in leaderboard are authenticated-only, and only the organizer rollup is gated.All(:33-38) is apublic static IReadOnlyList<string>initialized from a collection expression listing the three constants. It is a get-only property over an immutable-facing interface, so a caller can enumerate it but not append to it.
- Why it's built this way: the three-capability split follows the same rule as the flag list, but along a different axis: a capability exists where an organizer-only action would otherwise have to be spelled as a role check inside a controller. Everything an attendee does to their own data stays on plain authenticated access plus the ownership filter, which is why there is no
engagement:bookmarks:*capability at all: bookmark ownership is enforced by OwnerOrAdminFilter (ADR-033), configured with ADC's ownership vocabulary in the module's own API registration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:45-56), not by a permission. - Where it's used: granted in
AddModuleEngagementAPI(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58-62), wherepermissions.Grant(RoleNames.Organizer, [.. EngagementPermissions.All])(:60) and the identicalRoleNames.Adminline (:61) are the module's entire role-to-permission mapping. Consumed as attribute arguments atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:121(PointsViewOverview),LivePollsController.cs:150and:168(LiveManage), andCheckInsController.cs:79,:99,:179(CheckInManage). Pinned byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Authorization/EngagementPermissionGrantsTests.cs, which builds the real registry fromAddModuleEngagementAPI()(:20) and asserts that organizers (:25) and admins (:37) hold every capability while attendees (:58) and content editors (:70) hold none. - Caveats / not-in-source: whether a given caller actually presents the Organizer or Admin role depends on token issuance in the Identity module, not on anything in this file. The
LiveManagecomment says Wave 2 extends it to session Q&A moderation; the moderation actions themselves live on SessionQuestionsController and are covered in the live-layer chapter.
CheckInResultDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInResultDTO.cs:8· Level 0 · record class
- What it is: the response body of both organizer check-in endpoints, who the badge resolved to, whether this was a repeat, and when the check-in was recorded.
- Depends on: the
UserIdentifierTypealias (module-specificglobal using, see primer §2). Externals: BCLDateTimeOffset. - Concept, a repeat is a success, not a conflict.
[Rubric §9, API & Contract Design]assesses how a write's idempotent semantics are expressed to clients. The obvious design answers 409 on a second scan; this one answers 200 withAlreadyCheckedIn = true(CheckInResultDTO.cs:3-7). The reason is operational: the organizer scanning a lanyard needs the attendee's identity on screen either way, and at a door a second scan is a normal event rather than a client error to correct. Encoding the repeat as data on a success means the calling UI has one code path, and the status code stops carrying business meaning. CheckInsController states the same rule at its POST (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:64-70). - Walkthrough (
CheckInResultDTO.cs:8-18): threerequiredinitmembers.UserId(line 11) is the attendee the credential resolved to, which is the whole point of the scan response, since the organizer scans an opaque code and needs a person back.AlreadyCheckedIn(line 14) is the repeat flag.CheckedInOn(line 17) is the recorded time and, on a repeat, deliberately the original time rather than "now", so the UI can say "already checked in at ...". CheckInProcessor fills it from the existing row on the repeat branch (.../CheckIns/Services/CheckInProcessor.cs:62-67) and from the new entity otherwise (:78-83). - Why it's built this way:
required+initmakes every field non-optional at construction and the value immutable afterwards, so no handler can return a partially filled outcome. The type carries no session or event id because the caller supplied those; only the facts the server discovered travel back. - Where it's used: returned by CheckInAttendeeHandler and ManualCheckInHandler through CheckInProcessor, surfaced by CheckInsController on
POSTandPOST manual(CheckInsController.cs:73,:89), and consumed client-side by ICheckInUIService/CheckInService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/ICheckInUIService.cs:27,:33) into CheckInScan's result list (CheckInScan.razor.cs:212).
CheckInScope
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInScope.cs:11· Level 0 · enum
- What it is: what a single check-in row attests to, arrival at the event, attendance of one session, or a visit to one sponsor booth.
- Depends on: nothing. It is the discriminator every other type in this family branches on.
- Concept, one aggregate with a scope discriminator, and why the numbers are frozen.
[Rubric §4, DDD]assesses whether the model reflects the domain's real distinctions rather than inventing tables per surface, and[Rubric §8, Data Architecture]how storage encodes those distinctions. The three scopes share a row shape, an idempotency rule and an attendance query, and differ only in which target is required, so they live on one CheckIn aggregate rather than three (CheckInScope.cs:3-5). The cost is one persistence constraint that is easy to miss: the uniqueness rules are filtered unique indexes, and an index filter is SQL, so it cannot read the enum. CheckInConfiguration therefore names the numbers literally,"[Scope] = 0","[Scope] = 1","[Scope] = 2"(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:22-26), and pairs each with its target: unique on(UserId, EventId)for the event scope (:49-51), on(UserId, SessionId)for the session scope (:54-56), on(UserId, SponsorId)for the sponsor scope (:60-62). That is why the type doc calls the numbers load-bearing (CheckInScope.cs:6-9): a scope is added, never renumbered, or the shipped indexes silently start guarding the wrong rows. - Walkthrough (
CheckInScope.cs:11-24):Event = 0(line 13), arrival at the event itself, documented as kept for a future info-desk or points-activation use rather than as the working door process;Session = 1(16), the scope the conference-day scanning flow actually uses;Sponsor = 2(23), a booth visit recorded by the attendee themselves after scanning a printed deep-link QR, which is the one scope where the recording user and the visiting user are the same person. - Why it's built this way: the conference runs door and arrival check-in through TicketLeap, so
Eventis deliberately not a door process here, a point CheckInsController repeats where a reader is most likely to assume otherwise (CheckInsController.cs:27-31). Keeping the unused scope in the enum costs one number and keeps the model honest about what a check-in can mean. - Where it's used: on the CheckIn entity and in every predicate that finds an existing row (CheckInProcessor
.cs:146-148, RecordRoomCheckInHandler.cs:83, RecordSponsorVisitHandler.cs:81); in the request DTOs CheckInAttendeeRequest and ManualCheckInRequest; in the attendance query (GetAttendanceStatsHandler.cs:28,:33); and as the toggle value on CheckInScan, which offers onlySessionandEvent(CheckInScan.razor:40-41), since a sponsor visit is self-recorded and never scanned by an organizer. - Caveats / not-in-source: the enum never travels on the integration event; see CheckInScopeNames for the wire form and why.
CheckInSettings
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInSettings.cs:11· Level 0 · sealed class
- What it is: the module's check-in policy options, bound from the
"CheckIns"configuration section. Today it holds exactly one knob, the room self check-in grace window. - Depends on: no first-party types. Externals: the options pattern (
Microsoft.Extensions.Options, bound at the host). - Concept, the options class as a policy boundary.
[Rubric §17, DevOps & Deployment]assesses whether configuration is typed and injected rather than read ad hoc, and[Rubric §7, Microservices Readiness]whether a module owns its own policy. Both are visible in one decision here: the grace window lives in Engagement, not Conference. Conference answers "which session is this room hosting"; this module decides "how early does that count" and passes its own number into the cross-module call (CheckInSettings.cs:6-9, and RecordRoomCheckInHandler.cs:48-52). Had the number lived in Conference, retuning check-in behavior would mean redeploying a service that has no stake in it. - Walkthrough (
CheckInSettings.cs:11-22):SectionName(line 14) is the const"CheckIns", kept next to the type so the binding call cannot mistype it.RoomCheckInGraceMinutes(line 21) is aninit-onlyintdefaulting to 15, with the rationale in its own doc comment: attendees scan while the previous talk is clearing out, so a zero-grace window would reject the whole pre-session queue. - Why it's built this way: the binding is deliberately not
ValidateOnStart. The Engagement service host states the reason inline: a missing"CheckIns"section binds the grace window to a working default, which is not a misconfiguration worth refusing to boot over mid-conference (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:150-155). The default living on the property, rather than in an appsettings file, is what makes that stance safe.[Rubric §29, Resilience & Business Continuity]: the failure mode of bad configuration here is a slightly wrong window, not a dead host. - Where it's used: registered with
services.AddOptions<CheckInSettings>().Bind(...)(Program.cs:154-155) and injected asIOptions<CheckInSettings>into RecordRoomCheckInHandler (.cs:28), which passessettings.Value.RoomCheckInGraceMinutesto IEventLiveValidationService.GetCurrentRoomSessionInfoAsync(.cs:51-53). No other handler reads it. - Caveats / not-in-source: covered by
MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/CheckInSettingsTests.cs. Whether a deployed environment overrides the 15-minute default is a configuration fact, not a source fact: not determinable from this file.
SessionAttendanceDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Attendance·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Attendance/SessionAttendanceDTO.cs:6· Level 0 · record class
- What it is: one row of the attendance rollup, a session and how many attendees are checked in to it.
- Depends on: the
SessionIdentifierTypealias. Externals: none. - Concept: a leaf row type of a composed read model; the rollup concept itself is taught at AttendanceStatsDTO.
[Rubric §9, API & Contract Design]: the row carries an identifier and a count, no session title, so the reporting surface joins names itself rather than making the Engagement service reach into Conference for display text on every rollup. - Walkthrough (
SessionAttendanceDTO.cs:6-13): tworequiredinitmembers,SessionId(line 9) andCount(line 12). Rows are produced by grouping aSessionId-only projection in memory and ordering by session identifier (GetAttendanceStatsHandler.cs:37-44), which is why AttendanceStatsDTO documents the list as ordered by session identifier. - Where it's used: only inside AttendanceStatsDTO
.SessionAttendance; the OrganizerAttendance page turns the list into display rows viaBuildSessionRowsAsync(.../UI/Pages/CheckIn/OrganizerAttendance.razor.cs:75), which is where session titles are attached (SessionAttendanceRow). - Caveats / not-in-source: sessions with zero check-ins never appear, since the projection only sees recorded rows.
AttendanceStatsDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Attendance·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Attendance/AttendanceStatsDTO.cs:7· Level 1 · record class
- What it is: one event's attendance rollup for the organizer dashboard, the event-scoped arrival count plus one SessionAttendanceDTO row per session that has at least one session-scoped check-in.
- Depends on: SessionAttendanceDTO and the
EventIdentifierTypealias. Externals: BCLIReadOnlyList<T>. - Concept, the purpose-built read model.
[Rubric §6, CQRS & Event-Driven]assesses whether reads are served by shapes designed for the question being asked rather than by generic entity lists, and[Rubric §12, Performance & Scalability]whether the work sits where it is cheapest. This DTO is the answer to exactly one screen. GetAttendanceStatsHandler builds it from two focused repository calls on the read repository (unitOfWork.GetReadRepository<CheckIn, ...>,.cs:25): aCountAsyncfor the event scope (:27-29), and aSessionId-only projection filtered in SQL by event, scope and a present session id (:31-35), grouped and counted in memory (:37-44). NoCheckInentity is ever materialized, and the projection stays small by construction, one small value per recorded check-in of the event (.cs:9-14). - Walkthrough (
AttendanceStatsDTO.cs:7-17): threerequiredinitmembers.EventId(line 10) echoes the queried event.EventAttendance(13) is the count of distinct attendees at the event scope, which is distinct by construction because the filtered unique index permits one event-scoped row per attendee (CheckInConfiguration.cs:49-51).SessionAttendance(16) is the ordered per-session list. - Why it's built this way: session titles are not in this payload, so Engagement answers the rollup without a synchronous Conference read on a dashboard refresh, which keeps the cross-service dependency off the hot path (ADR-007, ADR-008). The page joins names once it has the list.
- Where it's used: returned by GetAttendanceStatsHandler for a GetAttendanceStatsQuery, surfaced by
GET stats?eventId=on CheckInsController (.cs:154-161) behind theCheckInManagepermission (:157); fetched client-side by CheckInService (.../UI/Services/CheckInService.cs:87) and held by the OrganizerAttendance page (.../UI/Pages/CheckIn/OrganizerAttendance.razor.cs:29). - Caveats / not-in-source: sponsor-scoped visits are counted in neither figure; the query filters on
CheckInScope.EventandCheckInScope.Sessiononly (GetAttendanceStatsHandler.cs:29,:33).
CheckInAttendeeRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInAttendeeRequest.cs:8· Level 1 · record class
- What it is: the organizer scanning request, a scanned or typed badge credential plus what the check-in attests to and its target.
- Depends on: CheckInScope, BadgePayload (which parses the
Credentialstring), and theEventIdentifierType/SessionIdentifierTypealiases. - Concept, the scope-conditional request.
[Rubric §24, Forms/Validation/UX Safety]and[Rubric §9, API & Contract Design]assess whether input rules are declared once and enforced before business logic runs. This DTO carries a discriminator and two possible targets, which is exactly the shape where "which field is required" gets re-litigated in every handler. It is stated once instead, in CheckInAttendeeRequestValidator:Credentialnot empty (CheckIn.Credential.Required),ScopeIsInEnum(CheckIn.Scope.Invalid),EventIdnon-default (CheckIn.EventId.Required), andSessionIdNotNullonly.When(x => x.Scope == CheckInScope.Session)(.../CheckInAttendee/CheckInAttendeeRequestValidator.cs:11-34). The comment at line 28 is the rule worth memorizing: the event stays required for both scopes, only the session is scope-conditional. Every rule attaches a stableWithErrorCodeso clients branch on a code rather than parse prose. - Walkthrough (
CheckInAttendeeRequest.cs:8-21):Credential(line 9), the raw scanned text, deliberately astringrather than aGuidso a prefixed payload and a hand-typed bare code both bind, with BadgePayload.TryExtractCredentialdoing the parsing server-side (CheckInAttendeeHandler.cs:41);Scope(12);EventId(15), required for both scopes; and the nullableSessionId(18), required when the scope isSession. - Why it's built this way: the event id the client sends is not the last word. For a session check-in CheckInProcessor takes the owning event back from Conference and lets that value win, because a stale event context on the organizer's screen would otherwise file the attendance under an event where the rollup would never find it again (
CheckInProcessor.cs:47-51). This is a good illustration of a request field being accepted for routing but not trusted as truth. - Where it's used: bound by
POSTon CheckInsController (.cs:67-74) behind theCheckInManagepermission (:68), handled by CheckInAttendeeHandler, and built client-side by CheckInScan (.../UI/Pages/CheckIn/CheckInScan.razor.cs:151-156), which sendsSessionIdonly when the scope toggle is onSession.
CheckInDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInDTO.cs:8· Level 1 · record class
- What it is: the standard read projection of a recorded CheckIn row, the module's generic DTO for the entity as opposed to the purpose-built outcome and rollup shapes above.
- Depends on: IBaseDTO<TIdentifierType> (implemented interface,
CheckInDTO.cs:1,:8), CheckInScope, and theCheckInIdentifierType/UserIdentifierType/EventIdentifierType/SessionIdentifierTypealiases. Externals: BCLDateTimeOffset. - Concept, the entity DTO contract.
[Rubric §3, Clean Architecture]assesses whether the domain entity stays behind a boundary, and[Rubric §9, API & Contract Design]whether transferable shapes are uniform across a codebase. Implementing IBaseDTO<TIdentifierType> is what plugs this record into the framework's generic mapping and API machinery: it promises anIdof the entity's identifier type, which is the single assumption the shared base classes make. The mapper is generated, not hand-written, by Mapperly: CheckInDTOMapper is a[Mapper]-annotated partial implementingIEntityDTOMapper<CheckIn, CheckInDTO, CheckInIdentifierType>withMapToDTOleftpartialfor the source generator and only the collection overload written by hand (.../CheckIns/DTOs/CheckInDTOMapper.cs:11-23). See ADR-001 for the mapping stance. - Walkthrough (
CheckInDTO.cs:8-30):Id(line 11, theIBaseDTOmember),UserId(14),Scope(17),EventId(20), the nullableSessionId(23, set only for session-scoped rows),CheckedInByUserId(26, the user who recorded it), andCheckedInOn(29). Allrequiredinitexcept the nullable session id. - Why it's built this way: keeping a conventional entity DTO next to the specialized ones means the entity has a standard projection available the moment a generic read surface is needed, without a handler inventing a new shape under time pressure.
- Where it's used: nothing but CheckInDTOMapper references it in production code today (repo-wide search over
MMCA.ADCfinds the type inCheckInDTO.csand the mapper only). None of the check-in endpoints return it: CheckInsController returns MyBadgeDTO, CheckInResultDTO, SponsorVisitResultDTO, RoomCheckInResultDTO and AttendanceStatsDTO (.cs:35-40), and the privacy export uses its own UserEngagementCheckInExportDTO. - Caveats / not-in-source: the DTO omits
SponsorId, which the entity carries and CheckInConfiguration indexes (.cs:60-62), so a sponsor-scoped row projected through this DTO would lose its target. Why the pairing is kept while unused is not stated in source: not determinable from source.
ManualCheckInRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/ManualCheckInRequest.cs:7· Level 1 · record class
- What it is: the organizer's manual check-in fallback body, used when a badge cannot be scanned (no camera on the head, a dead phone, or an attendee found by name search instead).
- Depends on: CheckInScope and the
UserIdentifierType/EventIdentifierType/SessionIdentifierTypealiases. - Concept: structurally the twin of CheckInAttendeeRequest with the credential replaced by a
UserId; see that section for the scope-conditional validation concept. The two validators are line-for-line parallel apart from the first rule, ManualCheckInRequestValidator opening withUserIdNotEqual(default(UserIdentifierType))and error codeCheckIn.UserId.Required(.../ManualCheckIn/ManualCheckInRequestValidator.cs:13-16) where the scan validator requires a non-empty credential. - Walkthrough (
ManualCheckInRequest.cs:7-20):UserId(line 10),Scope(13),EventId(16), nullableSessionId(19). - Why it's built this way: keeping the fallback as its own DTO and endpoint (rather than an optional-credential variant of the scan request) is what makes the two paths independently authorizable and independently auditable, while CheckInProcessor keeps the actual rules stated once for both (
CheckInProcessor.cs:10-15).[Rubric §15, Best Practices & Code Quality]: two thin entry points over one shared core beats one entry point with a mode flag. - Where it's used: bound by
POST manualon CheckInsController (.cs:87-94) behind theCheckInManagepermission (:88), handled by ManualCheckInHandler, and built by CheckInScan's fallback path (CheckInScan.razor.cs:186-191).
DependencyInjection
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:9· Level 4 · class (static)One
DependencyInjectionper Engagement layer. This section is the Infrastructure layer's; the API, Application, UI, and Contracts layers each have their own, covered elsewhere in this chapter.
- What it is: the Engagement Infrastructure layer's registration extension. It is the smallest of the module's composition roots: one method that registers one hosted service.
- Depends on:
IServiceCollectionandAddHostedService(Microsoft.Extensions.DependencyInjection,DependencyInjection.cs:1) and LiveChannelPublishProcessor from the layer's ownLivenamespace (:2,:21). - Concept: this is the standard
extension(IServiceCollection)registration idiom used throughout the codebase, taught once in the primer under C# extension(T) types; theextension(IServiceCollection services)block atDependencyInjection.cs:11is what makesAddModuleEngagementInfrastructure()read as an instance method on the collection. What is worth teaching here is the division of labor the file records. Its doc comment (:13-18) is explicit that the queue itself is registered by the Application layer and only the drain worker is registered here, and that is exactly what the code does:MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57registers LiveChannelPublishQueue as a singleton and aliases ILiveChannelPublishQueue onto the same instance, while this file registers only the reader.[Rubric §3, Clean Architecture]assesses whether the dependency direction survives composition: the producer side of that boundary (the queue and its interface) is an Application-layer concern that command handlers enqueue to, and the consumer side (aBackgroundServicethat resolves ILiveChannelPublisher out of a scope) is infrastructure, so the split is not arbitrary tidiness.[Rubric §5, Vertical Slice]applies to the whole per-layer pattern: each layer of the slice declares its own wiring and the host calls one method.[Rubric §12, Performance and Scalability]is the reason the worker exists at all: the queue takes the broadcast off the command hot path, so a slow Notification peer cannot lengthen a poll-vote request. - Walkthrough: one method, two statements.
public IServiceCollection AddModuleEngagementInfrastructure()(DependencyInjection.cs:19) sits inside theextension(IServiceCollection services)block (:11).services.AddHostedService<LiveChannelPublishProcessor>()(:21) is the only registration.AddHostedServicemeans the host starts it with the application and stops it on shutdown; the processor is aBackgroundService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30-33) whoseExecuteAsyncdrains the queue withawait foreach (var workItem in queue.Reader.ReadAllAsync(stoppingToken)...)(:41). Because it is registered exactly once there is one reader, which is what preserves per-session FIFO ordering (:11-14); the processor resolves the publisher per item from a fresh scope (:50-51) because the gRPC adapter is registered scoped, and wraps each publish in BestEffort.ExecuteAsync(:45) so a down peer is counted on thebesteffort.dispatch.failedmeter (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:108) instead of crashing the host, which is the posture the class doc records atLiveChannelPublishProcessor.cs:21-28.return services(:23) for fluent chaining, matching every other registration extension in the repo.
- Why it's built this way: registering the drain here rather than in the Application layer keeps the hosted-service dependency (and the scope factory, and the publisher resolution) out of the layer that command handlers compile against, which is what lets the Application project stay free of hosting concerns. Keeping the method to a single registration is itself the design statement: this module's infrastructure has no repositories, no adapters, and no clients to register, only entity configurations that are discovered by assembly scan (see AssemblyReference above) and this one worker. ADR-039 is the decision record for the live-channel push path the worker serves.
- Where it's used: called exactly once, from the module's API-layer composition:
services.AddModuleEngagementInfrastructure()atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:27, the middle of the three-lineAddEngagementModule(ApplicationSettings)chain (Application at:26, Infrastructure at:27, API at:28). That method in turn is what EngagementModule.Registercalls (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:26-27), so the drain worker starts in any host where the module loader registers Engagement. - Caveats / not-in-source: nothing in this file makes the registration conditional, so the drain worker starts even in a host where every Engagement feature flag is off. That is harmless (the channel simply never receives work items), but it is worth noting that the flags in EngagementFeatures gate HTTP routes, not hosted services.
CheckInScopeNames
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInScopeNames.cs:8· Level 9 · static class
- What it is: the wire names for CheckInScope, plus the one function that maps an enum value to its name. It is what the AttendeeCheckedIn integration event carries instead of the enum.
- Depends on: CheckInScope. Externals: none.
- Concept, strings on the wire, enums in the model.
[Rubric §6, CQRS & Event-Driven]assesses how state changes are announced across a boundary, and[Rubric §7, Microservices Readiness]whether that announcement survives independent deployment of producer and consumer. An enum serialized as a number is a version trap: consumers deserialize whatever integer arrives, so adding or reordering a member changes the meaning of already-published messages, and a consumer built before the change reads the new value as a member it thinks it knows. Sending the name makes a new scope purely additive: an old consumer sees an unrecognized string and can log-and-skip, which is exactly what the points handler does (CheckInScopeNames.cs:3-7, and AttendeeCheckedIn.cs:10-13). Note the deliberate asymmetry with persistence: the database stores the numbers (and the filtered unique indexes hard-code them, CheckInConfiguration.cs:22-26) because it is one deployment unit with the code, while the broker carries strings because it is not. Integration events and the outbox are taught in Group 04 and ADR-003. - Walkthrough (
CheckInScopeNames.cs:8-29): three consts,Event(line 11),Session(14),Sponsor(17), each string equal to its member name.ToName(CheckInScope scope)(lines 22-28) is aswitchexpression over the three known members with a fallback ofscope.ToString()(line 27), so a member added to the enum without a const here still produces its name rather than a number or a throw. - Why it's built this way: the mapping is a static class rather than an extension or a converter because both ends need the raw constants, not just the conversion. The producing side calls
ToNameonce, inside the CheckIn factory, so the event is raised in the same transaction as the row and the outbox captures both (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:112-119). The consuming side compares against the constants withStringComparison.Ordinal, never parses back to the enum. - Where it's used: CheckIn
.Createbuilds the AttendeeCheckedIn payload withCheckInScopeNames.ToName(checkIn.Scope)(CheckIn.cs:114); AttendeeCheckedInPointsHandler maps each name to an earn rule,Eventto an event-check-in award,Sessionto a session award only when a session id is present,Sponsorto a sponsor award only when a sponsor id is present, and anything else to a warning-logged no-op (.../Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:68-97, warning at:99). - Caveats / not-in-source: there is no
TryParsecounterpart, so nothing in source converts a wire name back to a CheckInScope; consumers branch on the string. Covered byMMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/CheckInScopeNamesTests.cs.
BadgePayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Badges·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:12· Level 0 · static class
- What it is: the one definition of what a badge QR code contains, a namespaced prefix plus an opaque credential, shared by the display side that encodes it and the check-in side that decodes it.
- Depends on: no first-party types. Externals: BCL
Guid,CultureInfo.InvariantCulture, and[NotNullWhen(true)]fromSystem.Diagnostics.CodeAnalysis(BadgePayload.cs:1-2). - Concept, the shared wire format as code, not convention.
[Rubric §9, API & Contract Design]assesses whether a payload contract is stated once and enforced, rather than re-implemented per caller. Two independent surfaces touch this format, the attendee's badge page (which builds the string it renders as a QR) and the organizer's scan path (which parses whatever the camera read), and they live in different layers. Putting the prefix and both directions in one static class means neither can drift: the encoder cannot invent a prefix the decoder does not strip.[Rubric §11, Security]assesses whether untrusted input is validated at the boundary and whether payloads leak more than they must. Both apply here. The payload is deliberately opaque: it carries a credential, never a user id, a name or an email, so a photographed badge tells the photographer nothing, and the server remains the sole authority on which attendee a credential belongs to (BadgePayload.cs:6-11). Scanned text is treated as hostile: parsing isTry-shaped and every ill-formed input returnsfalserather than throwing (BadgePayload.cs:22-29). - Walkthrough:
Prefix(line 15) is the const"mmca-adc:badge:". Its job is namespacing: a scanner pointed at a conference lanyard also sees vendor QRs, Wi-Fi codes and session deep links, and the prefix is what tells this one apart.Format(Guid credential)(line 20) concatenates the prefix with the credential in the"D"format underCultureInfo.InvariantCulture, so the encoded text is byte-identical regardless of the device's locale.TryExtractCredential(string? payload, out Guid credential)(lines 30-46) is the decoder and the guard in one. It zeroes the out parameter first (32), rejects null/blank (33-36), trims (38), strips the prefix case-insensitively when present (39-42), and finally requires both a parseableGuidand a non-empty one (44-45). Two details are load-bearing. The prefix is optional on the way in, so an organizer can type a bare credential when a camera fails, andcredential != Guid.Emptycloses the hole where the literal all-zeros GUID would otherwise parse as a valid badge.
- Why it's built this way: a badge has to survive a printed lanyard, a phone screenshot and a hand-typed fallback, so the format is plain text with one prefix rather than a signed or encrypted token. The security posture is that the credential itself is the only secret, and the server checks it; the accepted trade-off (a badge image is shareable) is the same one the sponsor deep link documents at RecordSponsorVisitHandler (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:25-32). - Where it's used: MyBadge renders
BadgePayload.Format(badge.Credential)into its QR (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/MyBadge.razor.cs:51); CheckInScan callsTryExtractCredentialto reject unrelated codes before it spends a round trip (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:128); and CheckInAttendeeHandler calls the same method server-side on the value that actually arrives (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:43). The client check is a UX shortcut; the server check is the one that counts. - Caveats / not-in-source: nothing in the payload is signed or expiring, so replay protection comes entirely from the idempotency rule downstream (see CheckInProcessor) and from the organizer holding the scanner. Covered by
MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/BadgePayloadTests.cs.
MyBadgeDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Badges·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Badges/MyBadgeDTO.cs:7· Level 0 · record class
- What it is: the caller's own badge as the API returns it, a single opaque credential and nothing else.
- Depends on: no first-party types. Externals: BCL
Guid. - Concept, minimum disclosure by shape.
[Rubric §11, Security]assesses whether a response carries only what the caller needs, and[Rubric §30, Compliance/Privacy/Data Governance]whether personal data is kept out of artifacts that get photographed and shared. A badge QR is printed, screenshotted and held up to strangers' cameras, so the DTO deliberately holds no name, no email and no user id (MyBadgeDTO.cs:3-6). The QR is built client-side from this credential by BadgePayload.Format, so the resolution from credential to attendee happens only on the server. The endpoint reinforces the same rule from the other side: the identity comes from the token, never the request, so no caller can ask for someone else's credential (CheckInsController.cs:42-44). - Walkthrough (
MyBadgeDTO.cs:7-11): onerequiredinitmember,Credential(line 10), theGuidminted on the AttendeeBadge aggregate. - Why it's built this way: a record with one field looks like ceremony over a bare
Guid, but it gives the endpoint a JSON object it can extend (an expiry, a display hint) without breaking clients that already parse the body, which a bare scalar response would not. - Where it's used: returned by GetOrCreateMyBadgeHandler (
.../GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:74, note the comment at:72-73making the disclosure rule explicit) viaGET my-badgeon CheckInsController (.cs:45-48); read client-side by CheckInService (.../UI/Services/CheckInService.cs:37) and rendered by MyBadge. - Caveats / not-in-source: the DTO has no expiry field, and the handler's contract is one stable credential per attendee for the life of the badge (
GetOrCreateMyBadgeHandler.cs:11-16), so a badge screenshotted yesterday still scans today. Rotation is not modeled anywhere in this type.
RoomCheckInRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Rooms·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Rooms/RoomCheckInRequest.cs:8· Level 0 · record class
- What it is: the body of the attendee's room self check-in, one room id and nothing more.
- Depends on: the
RoomIdentifierTypealias. Externals: none. - Concept, the anti-abuse property is what the DTO leaves out.
[Rubric §11, Security]assesses whether a client can assert facts it should not be trusted with. Two values are conspicuously absent: the attendee (taken from the token) and the session (resolved server-side from the room plus the configured grace window). Because the session is never client-supplied, nobody can claim attendance at a session they are not standing in, and a shared room link records nothing outside that session's window (RecordRoomCheckInHandler.cs:12-16). This is the same "the request carries the least it can" discipline as SponsorVisitRequest; a request DTO's security value is often in the fields it refuses to accept. - Walkthrough (
RoomCheckInRequest.cs:8-12): onerequiredinitmember,RoomId(line 11), the room whose printed QR was scanned. RoomCheckInRequestValidator adds the only structural rule,GreaterThan(0)with error codeCheckIn.RoomId.Required(.../RecordRoomCheckIn/RoomCheckInRequestValidator.cs:11-15), running in the validating decorator before the handler (see the CQRS decorator pipeline). - Why it's built this way: the request doubles as the command type, so the controller hands the deserialized body straight to
ICommandHandler<RoomCheckInRequest, Result<RoomCheckInResultDTO>>(CheckInsController.cs:44,:143-147) with no request-to-command translation layer.[Rubric §5, Vertical Slice]: the DTO, its validator and its handler sit in one use-case folder. - Where it's used: posted to
POST room-visits(CheckInsController.cs:142), which is gated by theRoomCheckInfeature flag (:139) and requires authentication but no organizer permission; built client-side by CheckInService.RecordRoomCheckInAsync(.../UI/Services/CheckInService.cs:65) from the RoomCheckIn page.
RoomCheckInResultDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.Rooms·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Rooms/RoomCheckInResultDTO.cs:9· Level 0 · record class
- What it is: the outcome of a room self check-in, naming back the session the server resolved plus the same repeat flag and timestamp the organizer paths use.
- Depends on: the
SessionIdentifierTypealias. Externals: BCLDateTimeOffset,string. - Concept, echo back what the server decided.
[Rubric §9, API & Contract Design]. Because RoomCheckInRequest deliberately does not name a session, the attendee has no way to know what they were checked into unless the response says so.SessionIdplusSessionTitleclose that loop in one round trip, which is the same one-round-trip reasoning SponsorVisitResultDTO applies to the sponsor name. The repeat semantics are inherited verbatim from CheckInResultDTO, and the type doc adds the case that only exists here: an organizer may have already scanned this attendee into the same session at the door, soAlreadyCheckedIncovers a repeat by any path (RoomCheckInResultDTO.cs:3-8). - Walkthrough (
RoomCheckInResultDTO.cs:9-22): fourrequiredinitmembers.SessionId(line 12) andSessionTitle(15) come from Conference's room lookup (RoomSessionInfo, consumed at RecordRoomCheckInHandler.cs:66,:92-93).AlreadyCheckedIn(18) is set on the branch that finds an existing session-scoped row for the caller (.cs:87-97), using the same predicate the organizer paths use so a self check-in and a prior door scan collapse onto one row (.cs:78-83).CheckedInOn(21) carries the original time on that branch. - Why it's built this way: the flow writes an ordinary
Session-scoped CheckIn; onlyCheckedInByUserIddiffers, it is the attendee rather than an organizer (RecordRoomCheckInHandler.cs:19-24). Keeping the row identical is what lets the points award, the attendance rollup and the export posture apply unchanged, and it is why this DTO differs from CheckInResultDTO only in the fields the attendee needs to see. - Where it's used: returned by RecordRoomCheckInHandler (
.cs:90-96,:115-121) throughPOST room-visits(CheckInsController.cs:144); wrapped client-side in a SelfCheckInOutcome<TResult> by CheckInService (.../UI/Services/CheckInService.cs:62-65) and rendered by the RoomCheckIn page (.../UI/Pages/Rooms/RoomCheckIn.razor.cs:36). - Caveats / not-in-source: when no session is running in the room the caller gets a 404
CheckIns.NoCurrentSessioninstead of this DTO, and an unknown room answers identically on purpose so the response cannot be used to probe which room ids exist (RecordRoomCheckInHandler.cs:58-65,:124-128).
SponsorVisitRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.SponsorVisits·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/SponsorVisits/SponsorVisitRequest.cs:7· Level 0 · record class
- What it is: the body of a sponsor booth visit, one sponsor id.
- Depends on: the
SponsorIdentifierTypealias. Externals: none. - Concept: the same "only the target travels" shape as RoomCheckInRequest.
[Rubric §11, Security]: the attendee is always the authenticated caller, so this endpoint can only ever record the caller's own visit, and a client cannot file a visit for someone else (SponsorVisitRequest.cs:3-6, and the endpoint's own statement at CheckInsController.cs:104-112). - Walkthrough (
SponsorVisitRequest.cs:7-11): onerequiredinitmember,SponsorId(line 10). SponsorVisitRequestValidator appliesGreaterThan(0)with error codeCheckIn.SponsorId.Required(.../RecordSponsorVisit/SponsorVisitRequestValidator.cs:11-15). - Where it's used: posted to
POST sponsor-visits(CheckInsController.cs:117), gated by theSponsorVisitsfeature flag (:114), authenticated but with no organizer permission; built client-side by CheckInService.RecordSponsorVisitAsync(.../UI/Services/CheckInService.cs:56) from the SponsorVisit landing page, and handled by RecordSponsorVisitHandler.
SponsorVisitResultDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.SponsorVisits·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/SponsorVisits/SponsorVisitResultDTO.cs:12· Level 0 · record class
- What it is: the outcome of a sponsor booth visit, the sponsor (id and display name), whether the attendee had already visited, and when.
- Depends on: the
SponsorIdentifierTypealias, and mirrors CheckInResultDTO's repeat contract. Externals: BCLDateTimeOffset,string. - Concept, denormalize one field to save a round trip.
[Rubric §12, Performance & Scalability]assesses whether a surface's data needs are met with the fewest calls, and[Rubric §9, API & Contract Design]how much a response should carry.SponsorNamerides back on the DTO precisely so the landing page can render a confirmation card without a follow-up Conference lookup (SponsorVisitResultDTO.cs:7-10). The handler already holds the name from the validation call, so including it costs nothing on the server and removes a whole request from a phone on conference Wi-Fi. The repeat semantics are the same as everywhere else in this family: a second scan is a success carryingAlreadyVisited, not a 409 (.cs:3-6). - Walkthrough (
SponsorVisitResultDTO.cs:12-25): fourrequiredinitmembers,SponsorId(line 15),SponsorName(18),AlreadyVisited(21),VisitedOn(24). RecordSponsorVisitHandler fills the name from SponsorLiveInfo on both branches (.cs:88-94for the repeat,:114-120for the new visit), and the repeat branch carries the originalCheckedInOn. - Why it's built this way: the visit row is an ordinary
Sponsor-scoped CheckIn whoseCheckedInByUserIdequals itsUserId, which is what keeps a self-recorded visit distinguishable from an organizer scan on the same table (RecordSponsorVisitHandler.cs:13-17). The uniqueness backstop for two concurrent scans is the filtered unique index on(UserId, SponsorId) WHERE [Scope] = 2(.cs:75-77, index at CheckInConfiguration.cs:60-62). - Where it's used: returned by RecordSponsorVisitHandler through
POST sponsor-visits(CheckInsController.cs:119); wrapped in a SelfCheckInOutcome<TResult> by CheckInService (.../UI/Services/CheckInService.cs:53-56) and rendered by the SponsorVisit page (.../UI/Pages/Sponsors/SponsorVisit.razor.cs:37).
UserEngagementBookmarkExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementBookmarkExportDTO.cs:7· Level 0 · record (sealed)
- What it is: one session-bookmark row inside a user's Engagement data export: which session they saved and when.
- Depends on: the
SessionIdentifierTypealias (solution-wideglobal using, see primer §2). It is a member ofUserEngagementExportDTO. - Concept, ids and dates only.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a data-subject access path returns the subject's own data and nothing else. This record is the smallest expression of that rule: a bookmark is a personal-schedule entry, so the export carries the session id and the creation date, never the session's title, speaker, or any content owned by somebody else (UserEngagementBookmarkExportDTO.cs:4-5). The concept is taught once onUserEngagementExportDTO; the other export rows follow the same rule. - Walkthrough (
UserEngagementBookmarkExportDTO.cs:7-14):required SessionIdentifierType SessionId(line 10) andrequired DateTime CreatedOn(line 13). Both arerequired, so a projection that forgets a column is a compile error rather than a silently empty export field. - Where it's used: projected server-side from
UserSessionBookmarkbyUserEngagementExportService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:25-28); serialized byUserEngagementExportGrpcService(UserEngagementExportGrpcService.cs:46-50) and rehydrated from the wire byUserEngagementExportServiceGrpcAdapter(UserEngagementExportServiceGrpcAdapter.cs:41-45).
UserEngagementSubmittedQuestionExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementSubmittedQuestionExportDTO.cs:7· Level 0 · record (sealed)
- What it is: one submitted session question inside a user's Engagement data export: the question id, the session it was asked in, and the submission date.
- Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:11). It is a member ofUserEngagementExportDTO. - Concept, cross-reference: the ids-and-dates rule is the one introduced on
UserEngagementBookmarkExportDTO. The extra decision here is that the question text stays out of the summary by design (UserEngagementSubmittedQuestionExportDTO.cs:4-5), even though the subject authored it.[Rubric §30, Compliance, Privacy & Data Governance]: the export is a portable summary of the footprint, not a content dump, and a question's text is visible in the live layer alongside other attendees' questions. - Walkthrough (
UserEngagementSubmittedQuestionExportDTO.cs:7-17):required SessionQuestionIdentifierType QuestionId(line 10),required SessionIdentifierType SessionId(line 13),required DateTime CreatedOn(line 16). - Where it's used: projected from
SessionQuestionbyUserEngagementExportService(UserEngagementExportService.cs:30-34).
UserEngagementCheckInExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementCheckInExportDTO.cs:11· Level 1 · record (sealed)
- What it is: one check-in record inside a user's Engagement data export: what the attendee was checked in to, under which scope, and when.
- Depends on:
CheckInScope(imported fromMMCA.ADC.Engagement.Shared.CheckIns,UserEngagementCheckInExportDTO.cs:1) and theEventIdentifierType/SessionIdentifierType/SponsorIdentifierTypealiases. - Concept, attendance is not a projection of the ledger.
[Rubric §30, Compliance, Privacy & Data Governance]assesses completeness of a data-subject access response. It would be tempting to derive attendance from the points entries, and it would be wrong:PointsAwarderwrites nothing when a rule is configured to 0 (PointsAwarder.cs:43-51) and treats a repeat scan as a clean no-op (PointsAwarder.cs:55-64), so a check-in that earned no points still leaves a distinct attendance record the subject is entitled to see. The export therefore readsCheckIndirectly, and the reason is written both on this record (UserEngagementCheckInExportDTO.cs:6-9) and at the call site (UserEngagementExportService.cs:48-50). - Walkthrough (
UserEngagementCheckInExportDTO.cs:11-27):required CheckInScope Scope(line 14), the discriminator;required EventIdentifierType EventId(line 17), always set for every scope;SessionIdentifierType? SessionId(line 20) andSponsorIdentifierType? SponsorId(line 23), each nullable and set only for the matching scope;required DateTimeOffset CheckedInOn(line 26). Note this is the one export row that usesDateTimeOffsetrather thanDateTime, matching the aggregate's own timestamp (UserEngagementExportService.cs:59). - Why it's built this way: one flattened record with nullable scope-specific ids beats three parallel collections, because the export document then reads as a single chronological attendance history regardless of scope, and the reader disambiguates on
Scope. - Where it's used: projected from
CheckInbyUserEngagementExportService(UserEngagementExportService.cs:51-62); carried onUserEngagementExportDTO.CheckIns; mapped across the wire byUserEngagementExportGrpcService(UserEngagementExportGrpcService.cs:66-81) and back byUserEngagementExportServiceGrpcAdapter(UserEngagementExportServiceGrpcAdapter.cs:61-72). The nullable ids are what the two proto3 mapping comments are about: an absent target travels as0and is mapped back tonullon arrival (UserEngagementExportGrpcService.cs:73-76,UserEngagementExportServiceGrpcAdapter.cs:68-70).
UserEngagementPointsEntryExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementPointsEntryExportDTO.cs:9· Level 1 · record (sealed)
- What it is: one awarded points entry inside a user's Engagement data export: what was earned, what it was earned on, and when.
- Depends on:
PointsActivityType(imported fromMMCA.ADC.Engagement.Shared.Points,UserEngagementPointsEntryExportDTO.cs:1) andPointsSubjectKeysfor the meaning ofSubjectKey. - Concept, cross-reference: the export-row rule is the one taught on
UserEngagementBookmarkExportDTO. Worth contrasting with its API twin: this record deliberately drops the entryIdthatPointsEntryDTOcarries (an export is portable data, not a handle the subject can call back with) and reports the auditCreatedOnrather thanOccurredOnUtc.[Rubric §30, Compliance, Privacy & Data Governance]. - Walkthrough (
UserEngagementPointsEntryExportDTO.cs:9-22):required PointsActivityType ActivityType(line 12),required int Points(line 15),SubjectKeydefaulted tostring.Empty(line 18),required DateTime CreatedOn(line 21).SubjectKeyis the one non-requiredmember, so a row projected without it still reads as an empty string rather than a null. - Where it's used: projected from
PointsEntrybyUserEngagementExportService(UserEngagementExportService.cs:36-46); carried onUserEngagementExportDTO.PointsEntries; mapped over the wire byUserEngagementExportGrpcService(UserEngagementExportGrpcService.cs:57-65) and back byUserEngagementExportServiceGrpcAdapter(UserEngagementExportServiceGrpcAdapter.cs:52-60).
UserEngagementExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:9· Level 2 · record (sealed)
- What it is: the whole portable export of the personal data the Engagement module holds for one user: bookmarks, submitted questions, the points ledger, check-in history, and leaderboard participation.
- Depends on:
UserEngagementBookmarkExportDTO,UserEngagementSubmittedQuestionExportDTO,UserEngagementPointsEntryExportDTO,UserEngagementCheckInExportDTO. - Concept introduced, the per-module export document.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a real data-subject access and portability path exists rather than a policy promise. Identity owns the aggregated user export document but owns none of Engagement's data, so each module publishes its own export shape and its own read contract (IUserEngagementExportService); Identity'sEngagementUserDataExportSectionstitches this document into the whole (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:30-45).[Rubric §7, Microservices Readiness]: because the document lives in*.Sharedand the contract is an interface, the aggregation works identically whether Engagement is in-process or a separate service. - Concept, "active footprint" is the export's definition of current. Every collection is read through the EF global soft-delete filter, so soft-deleted rows are excluded (
UserEngagementExportService.cs:14-15). The visible consequence is stated on the service: an opt-in the user has since left reports as not on the leaderboard rather than as a historical membership. That is the erasure model of ADR-005 showing through on the access path. - Walkthrough (
UserEngagementExportDTO.cs:9-31):Bookmarks(line 12),SubmittedQuestions(line 15),PointsEntries(line 18), allIReadOnlyList<...>defaulted to[];CheckIns(line 24), carried separately from the ledger for the reason given onUserEngagementCheckInExportDTO(UserEngagementExportDTO.cs:20-23);IsOnLeaderboard(line 27) andLeaderboardDisplayName(line 30), the latter null when the user never opted in. Every collection defaulting to[]is what makes an empty document (the disabled-module stub, or a user with no Engagement footprint) safe to enumerate. - Why it's built this way: ids plus dates keep the document cheap to build and cheap to ship across a service boundary, and the "no content authored by other users" rule (
UserEngagementExportDTO.cs:4-8) means the export can be handed to the subject without a redaction pass. The nullableLeaderboardDisplayNameis a documented contract the gRPC adapter honors explicitly: proto3 scalars carry no null, so the server emits the empty string and the adapter maps it back to null (UserEngagementExportServiceGrpcAdapter.cs:75-80). - Where it's used: built by
UserEngagementExportService(UserEngagementExportService.cs:73-81); returned byIUserEngagementExportService; serialized byUserEngagementExportGrpcServiceand rebuilt byUserEngagementExportServiceGrpcAdapter(UserEngagementExportServiceGrpcAdapter.cs:39-81); consumed by Identity'sExportUserDataHandlerthrough its Engagement section.
IUserEngagementExportService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/IUserEngagementExportService.cs:14· Level 3 · interface
- What it is: the cross-module contract for reading one user's Engagement-owned personal data. One method, one document out.
- Depends on:
UserEngagementExportDTO, theUserIdentifierTypealias, andServiceContractAttributefromMMCA.Common.Shared.Abstractions(IUserEngagementExportService.cs:1). - Concept, the extraction-ready cross-module interface.
[Rubric §7, Microservices Readiness]assesses whether modules talk through explicit contracts that survive being pulled apart, and[Rubric §3, Clean Architecture]assesses dependency direction. The interface lives in*.Shared, the only Engagement assembly a foreign module is allowed to reference, so Identity's export handler depends on a contract and never on Engagement's domain entities or DbContext. The doc comment names the pattern explicitly (IUserEngagementExportService.cs:5-12): it is the same shape asIBookmarkCountService, an in-process implementation inside the Engagement service and a gRPC adapter inMMCA.ADC.Engagement.Contractseverywhere else (ADR-007, ADR-008). - Concept introduced in this chapter,
[ServiceContract]as an enforced marker. The interface carries[ServiceContract](IUserEngagementExportService.cs:13), the same markerIBookmarkCountServicecarries (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:10). It is not documentation: an architecture fitness rule scans every mapped assembly for types wearing it and fails the build if such a type depends on the producing service's Domain, Application, or Infrastructure (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:3-12).[Rubric §34, Architecture Governance & Documentation]assesses whether an architectural rule is asserted or merely written down; this one is a test (ADR-015). The attribute also carries an optionalVersiondefaulting to"v1"(ServiceContractAttribute.cs:34-37), which this interface leaves at the default. - Walkthrough (
IUserEngagementExportService.cs:14-24): one member,Task<UserEngagementExportDTO> GetUserEngagementExportAsync(UserIdentifierType userId, CancellationToken cancellationToken)(line 23). Two details are deliberate. TheCancellationTokenhas no default, so every caller passes one through explicitly. The return is a bareTask<T>rather than aResult<T>: there is no expected business failure here (an unknown user simply yields an empty document), so a transport fault is an exception, which is exactly what lets the aggregating handler degrade that one section instead of failing the whole export (EngagementUserDataExportSection.cs:12-16). - Why it's built this way: a single coarse method (rather than one per collection) keeps the wire contract to one round trip and one proto message, and keeps the interface stable as the export grows: the points ledger, check-ins, and leaderboard participation were all added to
UserEngagementExportDTOwithout touching this signature. - Where it's used: implemented in-process by
UserEngagementExportService, stubbed byDisabledUserEngagementExportService, served over the wire byUserEngagementExportGrpcService, and satisfied remotely byUserEngagementExportServiceGrpcAdapter. The consumer is Identity'sEngagementUserDataExportSection(EngagementUserDataExportSection.cs:19), behindExportUserDataHandler. - Caveats / not-in-source: the method-level doc says "session bookmarks and submitted session questions" (
IUserEngagementExportService.cs:17-19), which is narrower than what the contract now returns; the type-level summary (:5-12) and the implementation are current. The narrower phrasing is stale doc text, not a behavior difference.
DisabledUserEngagementExportService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:7· Level 4 · class (sealed, internal)
- What it is: the stand-in implementation of
IUserEngagementExportServiceregistered when the Engagement module is switched off in a host. It returns an empty export document. - Depends on:
IUserEngagementExportService,UserEngagementExportDTO. - Concept introduced, the disabled-module stub (Null Object).
[Rubric §2, Design Patterns]assesses deliberate use of known patterns; this is the Null Object, an implementation that satisfies the contract while doing nothing, so consumers never branch on "is the module present".[Rubric §7, Microservices Readiness]and[Rubric §15, Best Practices & Code Quality]: the module system lets each host enable a subset of modules, and a host that runs Identity without Engagement must still be able to resolveIUserEngagementExportService.EngagementModuleregisters this stub from itsRegisterDisabledStubshook (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:30-34), alongsideDisabledBookmarkCountServicefor the other cross-module contract. In the extracted topology the stub is then displaced: the Contracts DI facade usesReplace(notTryAdd) so the gRPC adapter wins over whichever binding is present, and the inline comment says exactly that (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:76-85). - Walkthrough (
DisabledUserEngagementExportService.cs:7-12): one method (line 10),=> Task.FromResult(new UserEngagementExportDTO()). No async state machine, no I/O, and no null: because every collection onUserEngagementExportDTOdefaults to[], the caller enumerates an empty export rather than guarding for null. Both parameters are ignored by design. The class isinternal, so nothing outside the Shared assembly can take a dependency on the stub itself; consumers only ever see the interface. - Why it's built this way: returning an empty document rather than throwing means Identity's export still succeeds in a host where Engagement is absent, and the subject gets a document with no Engagement content instead of a failed request. The registration is a singleton (
EngagementModule.cs:33), which is safe precisely because the type is stateless. - Where it's used: registered by
EngagementModule.RegisterDisabledStubs(EngagementModule.cs:33); replaced byUserEngagementExportServiceGrpcAdapterthrough theAddEngagementUserExportClienthelper, which registers the adapter as scoped (DependencyInjection.cs:76-85).
LeaderboardEntryDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/LeaderboardEntryDTO.cs:7· Level 0 · record (sealed)
- What it is: one row of the public points leaderboard: a rank, a display name, and a total. It is the entire wire shape of
GET /api/points/leaderboard. - Depends on: nothing first-party. Three primitives only (
int,string,int). - Concept, the opt-in projection as a privacy boundary.
[Rubric §11, Security]assesses whether a surface exposes only what the actor consented to expose, and[Rubric §30, Compliance, Privacy & Data Governance]assesses whether personal data flows are deliberate. The board is the one Engagement surface that shows one attendee's data to another attendee, and this record is what makes that safe: it carries no user id, no email, and no name from Identity.DisplayName(LeaderboardEntryDTO.cs:13) is the snapshot the attendee published when they opted in, andGetLeaderboardHandlerbuilds the board purely from theLeaderboardOptInrows plus the ledger, making no call into Identity (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:12-17). An attendee who never opted in is absent rather than present under a name they did not choose. - Walkthrough (
LeaderboardEntryDTO.cs:7-17):Rank(line 10), anintstarting at 1;DisplayName(line 13), defaulted tostring.Emptyso the record is never constructed into a null name;Points(line 16). All three areinit-only, so a board row is immutable once the handler has built it. - Why it's built this way:
Rankis carried on the wire rather than inferred from list position because the handler assigns distinct sequential ranks (1, 2, 3) rather than competition ranking (1, 1, 3), with the published name compared ordinally as the tie-break (GetLeaderboardHandler.cs:18-24,:65-77). That decision belongs to the server, so the client renders the number it is given instead of recomputing a different one. - Where it's used: returned by
PointsController.GetLeaderboardAsync(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:78), produced byGetLeaderboardHandler(GetLeaderboardHandler.cs:71-76), and consumed client-side byPointsService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:43-56) for theMyPointspage.
PointsActivityType
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsActivityType.cs:18· Level 0 · enum
- What it is: the closed set of things an attendee can do to earn points. Six values, each an earn rule.
- Depends on: nothing. It is consumed by
PointsEntry,PointsSettings, and every points DTO in this chapter. - Concept, the enum whose numbers are a persisted contract.
[Rubric §8, Data Architecture]assesses whether stored values carry deliberate, stable semantics. This enum is not a UI label set: the numeric value is written to theActivityTypecolumn of every ledger row and is one third of the unique index(UserId, ActivityType, SubjectKey)that makes an award idempotent (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:45-48). Renumbering a member would silently re-point historical rows at a different rule, which is exactly what the type documentation forbids (PointsActivityType.cs:4-5).[Rubric §15, Best Practices & Code Quality]: the constraint is written into the type rather than left as tribal knowledge. - Concept, reserving 0 instead of defining it. Numbering starts at 1 on purpose so that a defaulted column or a payload that never set the field cannot read as a real earn rule (
PointsActivityType.cs:11-15). That is a deliberate violation of the analyzer rule "enums should have zero value", so the file carries a targetedSuppressMessagefor CA1008 whoseJustificationstates the reason inline (PointsActivityType.cs:17).[Rubric §15, Best Practices & Code Quality]assesses whether analyzer deviations are justified in place rather than blanket-disabled; this is the pattern to copy. The invariant that rejects the unset value isPointsEntryInvariants.EnsureActivityTypeIsDefined, which callsEnum.IsDefined(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntryInvariants.cs:52-55). - Walkthrough (
PointsActivityType.cs:20-36):EventCheckIn = 1(line 21), the optional info-desk activation scan;SessionCheckIn = 2(line 24), the primary conference-day earn mechanic;SessionFeedback = 3(line 27);EventFeedback = 4(line 30);QuestionAsked = 5(line 33), awarded once per session rather than once per question;SponsorVisit = 6(line 36), awarded once per sponsor from the sponsor's printed QR. - Why it's built this way: the doc remarks record a domain fact the numbering alone would not convey (
PointsActivityType.cs:6-10): the conference runs door and arrival check-in through TicketLeap, soEventCheckInis an optional activation scan and not a door process, which is whySessionCheckInis the mechanic the economy is tuned around (see the values inPointsSettings). - Where it's used: stored on
PointsEntry; switched over byPointsSettings.GetPointsFor; passed toIPointsAwarder.AwardAsyncby every points handler (AttendeeCheckedInPointsHandler,SessionQuestionSubmittedPointsHandler,SessionFeedbackSubmittedPointsHandler,EventFeedbackSubmittedPointsHandler); carried onPointsEntryDTO,PointsActivityTotalDTO, andUserEngagementPointsEntryExportDTO. It also crosses the gRPC boundary as a bareintin both directions, and both sides carry a comment saying the numeric stability is what makes the cast lossless (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:59-61,MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:54-56).
SetLeaderboardParticipationRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/SetLeaderboardParticipationRequest.cs:8· Level 0 · record (sealed)
- What it is: the request body for joining or leaving the public leaderboard. One boolean, nothing else.
- Depends on: nothing first-party.
- Concept, the request shape as the security control.
[Rubric §11, Security]assesses whether a surface can be misused by a caller who controls the payload, and[Rubric §9, API & Contract Design]assesses contracts that make the wrong call impossible to express. The published display name is resolved from the caller's token principal server-side bySetLeaderboardParticipationHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:65) and is deliberately absent from this record (SetLeaderboardParticipationRequest.cs:4-6). ADisplayNameproperty here would let any caller publish a name they do not own on the one surface whose purpose is showing names to other attendees. The endpoint likewise takes no user id, so there is no ownership argument to check and none to get wrong (PointsController.cs:90-98). - Walkthrough (
SetLeaderboardParticipationRequest.cs:8-12): a singlebool Participate { get; init; }(line 11).truejoins,falseleaves, and the handler branches on exactly that (SetLeaderboardParticipationHandler.cs:51-53). - Why it's built this way: making it a record rather than a bare
boolparameter leaves room for the request to grow (a future opt-in preference) without changing the route shape, and it matches theICommandHandler<TCommand, TResult>signature the controller dispatches to (PointsController.cs:39). - Where it's used: bound by
PointsController.SetLeaderboardParticipationAsyncfrom the body ofPUT /api/points/me/leaderboard-participation(PointsController.cs:99-112); constructed client-side byPointsService(PointsService.cs:67); handled bySetLeaderboardParticipationHandler.
CreateBookmarkRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.UserSessionBookmarks·MMCA.ADC.Engagement.Shared/UserSessionBookmarks/CreateBookmarkRequest.cs:6· Level 0 · record
- What it is: the two-field request body for "bookmark this session for this user". It is simultaneously the JSON contract of the
POST /bookmarksendpoint and the CQRS command object the handler consumes; Engagement declares no separate internal command type for the create use case. - Depends on: no first-party types. It uses the two module-scoped identifier aliases
UserIdentifierTypeandSessionIdentifierType(the alias convention is taught in the primer), which is why the type sits at Level 0. - Concept introduced, request-as-command.
[Rubric §9, API & Contract Design]assesses whether the wire contract is explicit and stable;[Rubric §6, CQRS & Event-Driven]assesses whether writes flow through a command object and a single handler. Here the two collapse into one type on purpose:BookmarksControllerinjectsICommandHandler<CreateBookmarkRequest, Result<UserSessionBookmarkDTO>>(MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:35) and passes the deserialized body straight intocreateHandler.HandleAsync(request, cancellationToken)(BookmarksController.cs:72), so there is no request-to-command mapping step to keep in sync. The trade-off is that the public contract and the command shape can no longer diverge, which is exactly what the module wants for a request this small. - Walkthrough: two members, both
requiredandinit-only.UserId(CreateBookmarkRequest.cs:9) names the owner of the bookmark;SessionId(CreateBookmarkRequest.cs:12) names the session being saved.requiredmeans neither construction nor JSON deserialization can leave a member unset, andinitfreezes the instance after construction, so no decorator in the CQRS pipeline can mutate a command mid-flight. - Why it's built this way: keeping the payload to two identifiers pushes every other concern to a dedicated place: shape validation to
CreateBookmarkRequestValidator(MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkRequestValidator.cs:9), business rules (session validity, duplicate detection) toCreateBookmarkHandler(.../Create/CreateBookmarkHandler.cs:23), and authorization to the controller. BecauseUserIdtravels in the body rather than being inferred server-side, the controller binds it to the caller: a non-Organizer whose user-identifier claim does not equalrequest.UserIdgets aForbiddenfailure (BookmarksController.cs:58-70), the create-side counterpart of theOwnerOrAdminFilteron the list endpoints (ADR-033).[Rubric §11, Security]: without that check any authenticated user could create bookmarks owned by someone else. Note also the[Idempotent]attribute on the action (BookmarksController.cs:50), justified in the action's own doc comment (:45-47): because the body names the (user, session) pair explicitly and a duplicate already answers 409, replaying a retried star is what the attendee meant. - Where it's used: the API entry point
CreateAsync(BookmarksController.cs:54-55); the UI contractIBookmarkUIService(MMCA.ADC.Engagement.UI/Services/Bookmarks/IBookmarkUIService.cs:15); andSessionBookmarkUIService, which constructs one inline when a star is toggled on a session list (MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:69).
IBookmarkCountService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.UserSessionBookmarks·MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:11· Level 0 · interface
- What it is: the cross-module read contract that lets Conference ask "how many people bookmarked this session?" without referencing an Engagement domain entity, an Engagement DbContext, or an Engagement package beyond
.Shared. - Depends on: nothing first-party beyond the
SessionIdentifierTypealias and theServiceContractAttributemarker it carries (IBookmarkCountService.cs:1,:10); BCLTask,IReadOnlyDictionary,IReadOnlyCollection,CancellationToken. - Concept introduced, the extraction-ready cross-module interface.
[Rubric §7, Microservices Readiness]assesses whether modules talk through contracts that survive a process split;[Rubric §1, SOLID](Dependency Inversion) assesses whether the consumer depends on an abstraction rather than a concrete collaborator. This interface is the canonical example in ADC: it has three different implementations that a host picks between with no consumer change at all. In-process it isBookmarkCountService(registered withTryAddScopedatMMCA.ADC.Engagement.Application/DependencyInjection.cs:46); when Engagement is switched off in a host it isDisabledBookmarkCountService(MMCA.ADC.Engagement.API/EngagementModule.cs:32); and when Engagement runs as its own process it isBookmarkCountServiceGrpcAdapter, swapped in withReplacebyAddEngagementBookmarkCountClient()(MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:49, called fromMMCA.ADC.Conference.Service/Program.cs:350). That is ADR-007 and ADR-008 made concrete: the C# call site never learns which one it got. The[ServiceContract]marker is what declares this intent in code rather than in a comment. - Walkthrough: two methods, both taking an explicit (non-defaulted)
CancellationTokenso every implementation, including the gRPC one, threads cancellation.GetBookmarkCountForSessionAsync(sessionId, cancellationToken)(IBookmarkCountService.cs:19) returns a bareintfor one session.GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken)(IBookmarkCountService.cs:28-30) is the batched form, and its contract is spelled out in the doc comment (IBookmarkCountService.cs:22-23): every requested session id is present in the result, and a session with no bookmarks maps to0. That sentence is a real contract, not decoration: it is what forces the disabled stub to project zeros rather than return an empty map.[Rubric §12, Performance & Scalability]: the batch method exists so the speaker dashboard resolves N sessions in one round trip instead of N, which matters most when the call is a network hop rather than a local query. - Why it's built this way: returning plain
int/IReadOnlyDictionaryrather thanResult<T>keeps the contract trivially projectable onto a proto message; a fault surfaces as an exception (over gRPC, a status the caller's resilience pipeline can react to) instead of a failure payload each caller must unwrap. Living in.Sharedis what keeps the reference direction legal: Conference references Engagement's contract assembly only, which the Conference service project spells out in a comment on its ownProjectReference(MMCA.ADC.Conference.Service/MMCA.ADC.Conference.Service.csproj:26). - Where it's used: Conference's
GetSessionBookmarkCountHandlerand the batchedGetSessionBookmarkCountsHandler; on the producing side it is whatBookmarkCountsGrpcServicepublishes over the wire.
PointsActivityTotalDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsActivityTotalDTO.cs:7· Level 1 · record (sealed)
- What it is: the rollup of one earn rule across every attendee: how much it paid out and how many times it fired.
- Depends on:
PointsActivityType. It is a member ofPointsOverviewDTO. - Concept, the two-number rollup.
[Rubric §9, API & Contract Design]assesses whether a read model answers the question its consumer actually asks. Total points alone would not tell an organizer whether a mechanic was popular or merely expensive, so the row carries both the payout and the count:SessionCheckInpaying 4,000 across 400 awards andEventCheckInpaying 4,000 across 160 awards are different facts about the room.[Rubric §30, Compliance, Privacy & Data Governance]: the rollup is aggregate by construction, so the organizer view carries no attendee identity. - Walkthrough (
PointsActivityTotalDTO.cs:7-17):ActivityType(line 10),TotalPoints(line 13),AwardCount(line 16), allinit-only. - Where it's used: built by
GetPointsOverviewHandlerby grouping the projected ledger rows by activity and ordering by the enum value (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:52-63), carried onPointsOverviewDTO.PerActivity, rendered byOrganizerPointsOverview.
PointsEntryDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsEntryDTO.cs:7· Level 1 · record (sealed)
- What it is: one awarded entry from the points ledger, as seen on the wire: id, activity, points, subject, and when it happened.
- Depends on:
PointsActivityType,PointsSubjectKeys(for the meaning ofSubjectKey), and thePointsEntryIdentifierTypealias (int,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:10). - Concept, the append-only ledger row and its snapshot value.
[Rubric §8, Data Architecture]assesses whether history is preserved rather than recomputed.Pointsis what the rules said at award time, not what the current configuration would pay (PointsEntryDTO.cs:4-5, and the same statement on the writer,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:12-17). Retuning the economy mid-conference therefore changes the next award and never rewrites an attendee's history, which is what makes a total explainable. - Concept, the DTO that deliberately omits the owner.
[Rubric §30, Compliance, Privacy & Data Governance]and[Rubric §11, Security]. There is noUserIdon this record, and that absence is load-bearing: the same type serves the caller's own ledger (where the owner is the caller by construction) and the organizer's recent-activity tail (where the owner must not be disclosed).GetPointsOverviewHandlerprojects a privateOverviewRowthat does carry the user id, uses it only for the distinct participant count, and drops it before anything leaves the handler (GetPointsOverviewHandler.cs:92-108,:73-80,:85). - Walkthrough (
PointsEntryDTO.cs:7-23):Id(line 10);ActivityType(line 13);Points(line 16), the snapshot;SubjectKey(line 19), defaulted tostring.Empty, holding theevent:/session:/sponsor:key built byPointsSubjectKeys;OccurredOnUtc(line 22), a UTCDateTime. - Why it's built this way:
OccurredOnUtcis separate from the row's auditCreatedOnbecause an award can be written by a handler reacting to an event that happened earlier; the ledger sorts on the earn instant, with the id as the tie-break so awards stamped in the same instant keep a stable order across pages (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:79-88). - Where it's used:
MyPointsDTO.EntriesviaGetMyPointsHandler(GetMyPointsHandler.cs:55-66) andPointsOverviewDTO.RecentEntriesviaGetPointsOverviewHandler(GetPointsOverviewHandler.cs:67-81).
PointsSubjectKeys
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:11· Level 1 · class (static)
- What it is: the one place that formats the subject key scoping an award to the thing it was earned on. Three factories (
event:,session:,sponsor:) plus the column-length constant they must fit. - Depends on:
NotificationScopeKeyfromMMCA.Common.Shared.Notifications(PointsSubjectKeys.cs:2), and the module identifier aliasesEventIdentifierType,SessionIdentifierType,SponsorIdentifierType(solution-wideglobal using, see primer §2). Externals:System.Globalizationandstring.Create(PointsSubjectKeys.cs:1,:32). - Concept introduced, the idempotency key built in one place.
[Rubric §8, Data Architecture]assesses whether the schema's guarantees have a single owner in code, and[Rubric §1, SOLID]assesses single responsibility. An award is idempotent because of the unique index on(UserId, ActivityType, SubjectKey)(PointsEntryConfiguration.cs:45-48). That index only works if every producer of a subject key formats it identically:session:42andSession: 42are different rows and therefore two awards for one attendance. This class exists so no call site ever hand-formats the string (PointsSubjectKeys.cs:7-9): one shape, one culture, one place to change it.[Rubric §15, Best Practices & Code Quality]. - Concept, the format is shared with the notification scope key rather than re-derived. The event- and session-scoped keys are not formatted here at all:
ForEventandForSessiondelegate toNotificationScopeKey.ForEvent/NotificationScopeKey.ForSessionin MMCA.Common (PointsSubjectKeys.cs:20,:26). That is deliberate, because the two subsystems already agreed on the same string. The framework type owns the format, theevent/sessionprefixes, and the regex that guards it,^(event|session):[0-9]+$, which the notification hub enforces before a client may join a live group (MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:20-44,:32). Delegating means a points key and a live-channel key for the same session are byte-identical and cannot drift apart.[Rubric §15, Best Practices & Code Quality]and[Rubric §6, CQRS & Event-Driven Design]: the shared format lives in the framework, and the ADC-specific extension of it (the sponsor scope, which the notification pattern does not know about) stays in the module. - Walkthrough
MaxLength = 64(PointsSubjectKeys.cs:14): the ceiling the column accepts. Both halves of the system read this same constant, the EF configuration throughHasMaxLength(PointsSubjectKeys.MaxLength)(PointsEntryConfiguration.cs:38-40) and the domain throughPointsEntryInvariants.EnsureSubjectKeyIsValid, which range-checks 1 toMaxLengththroughCommonInvariants.EnsureStringLengthIsWithin(PointsEntryInvariants.cs:38-46), so a truncating schema and a permissive validator cannot drift apart.ForEvent(eventId)(PointsSubjectKeys.cs:19-20) andForSession(sessionId)(:25-26): thin forwards toNotificationScopeKey, so theevent:{id}andsession:{id}shapes have exactly one definition in the codebase.ForSponsor(sponsorId)(PointsSubjectKeys.cs:31-32): the one key formatted locally,string.Create(CultureInfo.InvariantCulture, $"sponsor:{sponsorId}"). There is no sponsor scope in the notification pattern, so this scope is Engagement's own.string.Createwith an explicitCultureInfo.InvariantCultureis the deliberate choice over plain interpolation: an id formatted under a culture with non-ASCII digits or different separators would produce a key that never matches the one written yesterday. The framework methods it forwards to make the same choice for the same reason (NotificationScopeKey.cs:16-18).
- Why it's built this way: the subject key is the half of the idempotency rule that varies per activity, so it has to be data rather than a column. Encoding the scope as a prefix (
event:,session:,sponsor:) keeps one column serving three scopes without a discriminator column, and keeps the key human-readable in a support query. - Where it's used:
AttendeeCheckedInPointsHandlerpicks the scope per check-in (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:73,:81,:92);SessionFeedbackSubmittedPointsHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:42),EventFeedbackSubmittedPointsHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:40), andSessionQuestionSubmittedPointsHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:85) each build theirs the same way. The value ends up onPointsEntryDTOandUserEngagementPointsEntryExportDTO.
DisabledBookmarkCountService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.UserSessionBookmarks·MMCA.ADC.Engagement.Shared/UserSessionBookmarks/DisabledBookmarkCountService.cs:7· Level 1 · class (internal sealed)
- What it is: the null-object implementation of
IBookmarkCountServicethat a host registers when the Engagement module is switched off. Every count answers zero. - Depends on:
IBookmarkCountServiceonly. - Concept introduced, the disabled-module stub.
[Rubric §2, Design Patterns](Null Object) and[Rubric §7, Microservices Readiness]both apply. The module system lets a host boot a subset of modules; a module that is off must still satisfy the interfaces other modules resolve, or the container throws at startup and the whole host fails on a feature nobody asked for. SoIModuleexposesRegisterDisabledStubs, and Engagement's implementation registers this class plus the data-subject export stub (MMCA.ADC.Engagement.API/EngagementModule.cs:30-34).[Rubric §29, Resilience & Business Continuity]: the degradation is graceful and legible, a session simply shows zero saves instead of erroring. - Walkthrough:
GetBookmarkCountForSessionAsyncreturnsTask.FromResult(0)with no query allocated at all (DisabledBookmarkCountService.cs:10-11).GetBookmarkCountsForSessionsAsynchonours the batch contract the interface documents rather than returning an empty map: it projects the requested ids into a dictionary of zeros,(sessionIds ?? []).Distinct().ToDictionary(id => id, _ => 0)(DisabledBookmarkCountService.cs:21-22). Two guards are packed into that one line and the<remarks>above it explains the second (:14-17):?? []means a caller passing null still gets a usable empty result instead of aNullReferenceException, andDistinct()mirrors the realBookmarkCountServiceso a duplicate id collapses instead of throwing out ofToDictionary. Note there is noasynchere at all: both methods are synchronous returns wrapped in a completed task. - Why it's built this way: keeping the stub in
.Sharedbeside the interface means any host can register it without referencing Engagement's Application layer, which is precisely the situation in the Conference service, where Engagement's real implementation is not even loaded. The comment inMMCA.ADC.Conference.Service/Program.cs:33-38records the intended flow, and the pipeline comment at:326-332states the ordering rule: the module registration runs first, thenAddEngagementBookmarkCountClient()Replaces whatever is there with the gRPC adapter. - Where it's used:
EngagementModule.RegisterDisabledStubs(MMCA.ADC.Engagement.API/EngagementModule.cs:32), called byModuleLoaderfor a disabled module; it is the fallback the Conference service starts from before the gRPC client registration overwrites it.
UserSessionBookmarkDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.UserSessionBookmarks·MMCA.ADC.Engagement.Shared/UserSessionBookmarks/UserSessionBookmarkDTO.cs:8· Level 1 · record
- What it is: the read contract for one personal-schedule entry: which user saved which session, when, and under what bookmark id.
- Depends on:
IBaseDTO<TIdentifierType>fromMMCA.Common.Shared.DTOs(UserSessionBookmarkDTO.cs:1,:8), and theUserSessionBookmarkIdentifierType,UserIdentifierType,SessionIdentifierTypealiases. - Concept, the DTO as the module's outward-facing projection.
[Rubric §3, Clean Architecture]assesses whether the domain entity leaks past the application boundary: it does not,UserSessionBookmarknever crosses the wire andUserSessionBookmarkDTOMapperdoes the projection.[Rubric §9, API & Contract Design]: implementingIBaseDTO<T>is what lets this record flow through the framework's generic read plumbing, for example theIEntityQueryService<UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkIdentifierType>the controller injects (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:39), with no bookmark-specific query code. - Walkthrough:
Idsatisfies theIBaseDTO<T>contract (UserSessionBookmarkDTO.cs:11);UserIdandSessionIdare the two scalar cross-module references (:14,:17), scalar rather than navigation properties because the referenced rows live in other services' databases;CreatedOn(:20) is the audit timestamp. The first three arerequired,CreatedOnis not, which is the readable signal that it is server-stamped rather than caller-supplied. There is noIsDeletedhere: soft-deleted bookmarks are filtered out by the global query filter long before projection. - Why it's built this way: a flat, four-member record keeps the payload cheap for a list endpoint that a phone pages through on a conference floor, and keeps the shape identical whether it is produced in-process or deserialized from HTTP in the UI.
- Where it's used: returned by
CreateBookmarkHandlerandGetUserBookmarksHandlerinside aResult<T>; declared on both controller actions (BookmarksController.cs:34,:35,:51); deserialized client-side byBookmarkServiceand bySessionBookmarkUIService(MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:71).
MyPointsDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/MyPointsDTO.cs:7· Level 2 · record (sealed)
- What it is: the caller's own points view: the running total, their leaderboard status and published name, and a page of the entries behind the total.
- Depends on:
PointsEntryDTO. - Concept, the always-caller-scoped read model.
[Rubric §11, Security]assesses whether a read can be widened by a hostile caller. This DTO has no user id on it, and neither does the route that returns it:GetMyPointsHandlerresolves the attendee throughICurrentUserService.RequireUserId("Points.Forbidden")and returns that failure verbatim when the token carries no user id (GetMyPointsHandler.cs:41-45). The paging arguments select how much of the caller's own ledger comes back, never whose ledger it is (PointsController.cs:44-48), and they are clamped throughPagingMath.Clampagainst aMaxPageSizeof 100 rather than trusted (GetMyPointsHandler.cs:31-32,:48-51). - Concept, total summed on read rather than materialized.
[Rubric §12, Performance & Scalability]assesses whether a read cost is proportionate to the workload.Totalis the sum of every entry the caller owns, computed per request (GetMyPointsHandler.cs:90-94), not a counter maintained on a user row. The handler documents the trade explicitly (GetMyPointsHandler.cs:16-23): at conference scale (about 100 attendees, a few dozen entries each) the summed read is trivial and it removes the class of bug where a materialized total drifts from its ledger. The named scale lever, if the population ever grows, is a materialized total updated by the awarder, and it would change only that handler andPointsAwarder. - Walkthrough (
MyPointsDTO.cs:7-20):Total(line 10), summed over every entry and not just the returned page;IsOnLeaderboard(line 13);LeaderboardDisplayName(line 16), nullable, null when the caller is not on the board;Entries(line 19), anIReadOnlyList<PointsEntryDTO>defaulted to[]and returned newest first. - Why it's built this way: carrying the leaderboard status on the same document the points page already fetches means the UI renders the join/leave toggle without a second round trip; the opt-in read relies on the soft-delete query filter, so an attendee who left the board correctly reports as not on it (
GetMyPointsHandler.cs:68-77).IsOnLeaderboardis derived fromdisplayName is not nullrather than carried as its own column, so the flag and the name can never disagree (GetMyPointsHandler.cs:95-96). - Where it's used: returned by
PointsController.GetMyPointsAsyncfromGET /api/points/me(PointsController.cs:49-65); fetched client-side byIPointsUIService/PointsService(PointsService.cs:22-40) for theMyPointspage.
PointsOverviewDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsOverviewDTO.cs:12· Level 2 · record (sealed)
- What it is: the organizer rollup of the points game: how many attendees are playing, what has been paid out, the per-activity breakdown, and a tail of recent activity.
- Depends on:
PointsActivityTotalDTO,PointsEntryDTO. - Concept, the cross-attendee read that carries no attendee.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a privileged view exposes more personal data than its purpose needs. This is the only points surface that reads across every attendee, and it is built so there is nothing to leak:RecentEntriescarries entry ids, activity, points, and timestamps only (PointsOverviewDTO.cs:6-10), so an organizer sees THAT points were earned and for what, never by whom. Who earned what stays with the attendee and with the leaderboard names those attendees chose to publish.[Rubric §11, Security]: the endpoint is additionally gated by[HasPermission(EngagementPermissions.PointsViewOverview)]on top of the controller-level[Authorize](PointsController.cs:35,:120-121), the permission model of ADR-020. - Walkthrough (
PointsOverviewDTO.cs:12-25):ParticipantCount(line 15), distinct attendees with at least one entry;TotalPointsAwarded(line 18);PerActivity(line 21), thePointsActivityTotalDTObreakdown, defaulted to[];RecentEntries(line 24), newest first, defaulted to[]. - Why it's built this way:
GetPointsOverviewHandlerreads the ledger once and folds every figure in memory rather than issuing a query per number (GetPointsOverviewHandler.cs:17-22,:41-50): the Application layer has no EF Core reference, so a SQLGROUP BYis not available to it, and at conference scale one projected pass is cheaper than the round trips a per-figure query set would cost. The requested tail length is clamped withMath.Clamp(query.RecentCount, 1, MaxRecentCount)against a ceiling of 100 (GetPointsOverviewHandler.cs:28-29,:38).[Rubric §12, Performance & Scalability]: the trade is documented along with the materialized-rollup lever that would replace it. - Where it's used: returned by
PointsController.GetPointsOverviewAsyncfromGET /api/points/overview(PointsController.cs:114-136); rendered byOrganizerPointsOverview(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/OrganizerPointsOverview.razor.cs:34).
ISessionBookmarkUIService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.UserSessionBookmarks·MMCA.ADC.Engagement.Shared/UserSessionBookmarks/ISessionBookmarkUIService.cs:15· Level 3 · interface
- What it is: the narrow UI-side contract that lets the Conference session pages render a bookmark star and toggle it inline, without navigating to an Engagement page and without referencing the Engagement UI assembly.
- Depends on:
UserSessionBookmarkDTO,ResultandErrorTypefromMMCA.Common.Shared.Abstractions(ISessionBookmarkUIService.cs:1), and the identifier aliases. - Concept introduced, the cross-module UI service contract.
[Rubric §18, UI Architecture]assesses how one module's page composes another module's capability. The pattern here is optional resolution: Conference's pages hold the dependency as a nullable property and ask the container for it rather than injecting it,BookmarkService = ServiceProvider.GetService<ISessionBookmarkUIService>()(MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionList.razor.cs:89, and the same line atPublicSessionDetail.razor.cs:58), so a host that never calledAddEngagementUI()simply renders no stars instead of failing to construct the page.PublicSessionListViewthen takes it as a nullable[Parameter](PublicSessionListView.razor.cs:60).[Rubric §7, Microservices Readiness]: this is the presentation-layer twin of whatIBookmarkCountServicedoes for the application layer. - Concept,
Resultall the way to the page. Every member reports its outcome as aResultcarrying the API's own errors with theirErrorTypeintact, and the interface doc comment states the rule (ISessionBookmarkUIService.cs:9-13): the calling page branches instead of catching, and only the caller's ownOperationCanceledExceptionstill propagates.[Rubric §9, API & Contract Design]and[Rubric §24, Forms, Validation & UX Safety]: a "not there" answer and a "the remove failed" answer are different facts, and a boolean cannot tell them apart. That is spelled out for the delete member (:34-38): a missing bookmark answers 404, which arrives as anErrorType.NotFoundfailure rather than a barefalse. - Walkthrough: three members, each shaped by a real UI need.
GetBookmarkedSessionIdsAsync(userId, cancellationToken)(ISessionBookmarkUIService.cs:20-21) returnsResult<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>>, a map rather than a set. A list page needs both facts at once: which sessions are starred, and the bookmark id it must send to un-star each one, so the dictionary saves a second round trip per row.CreateBookmarkAsync(userId, sessionId, cancellationToken)(:26-28) returns the created DTO inside aResult<T>, which is what lets the caller revert an optimistic star and show the server's own refusal without exception handling.DeleteBookmarkAsync(bookmarkId, sessionId, cancellationToken)(:40-43) takes the session id even though the API key alone identifies the row. The doc comment says why (:31-33): the session-reminder layer (ADR-042 Wave 2) needs to know which session's scheduled local notification to cancel, and the bookmark id cannot answer that after the row is gone.
- Why it's built this way: the interface lives in
.Shared, the assembly Conference already references, while its implementation lives in Engagement's UI project. That split is what makes the star a genuinely optional feature of the Conference page rather than a hard dependency. All threeCancellationTokenparameters are defaulted here, unlike onIBookmarkCountService, because the callers are Blazor event handlers rather than a generated gRPC client. - Where it's used: implemented by
SessionBookmarkUIService(MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:26-29), registered scoped inDependencyInjection(MMCA.ADC.Engagement.UI/DependencyInjection.cs:34), consumed by Conference'sPublicSessionList,PublicSessionDetailandPublicSessionListView. Its nullable-injection convention is cited as precedent byISessionLiveUIService(MMCA.ADC.Engagement.Shared/SessionQuestions/ISessionLiveUIService.cs:4-5).
PointsSettings
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:12· Level 9 · class (sealed)
- What it is: the configuration object for the points economy: what each earn rule pays and how long the public leaderboard is. Bound from the
Pointsconfiguration section. - Depends on:
PointsActivityType. Externals: the options binder (Microsoft.Extensions.Options), through the host'sAddOptions<PointsSettings>().Bind(...). - Concept introduced, configuration as an operational control, not just a default.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses how configuration is bound and consumed, and[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether an operator can degrade a feature without a deploy. A value of 0, or a missing configuration entry (which binds to 0), is a deliberate per-rule kill switch:PointsAwarderreadsGetPointsFor(activity)and returns success without writing anything when the value is not positive (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:43-51), so one earn rule can be turned off mid-conference while the rest keep earning, and the ledger is not polluted with zero-value entries (PointsSettings.cs:7-10). - Concept, deliberately not
ValidateOnStart. The Engagement host binds these options without eager validation, and the inline comment states why (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:123-125): a missingPointssection binds every award to the defaults below, and an explicit 0 is the documented kill switch, so there is no value an organizer could set that should stop this host from booting mid-conference. The same comment also explains why the binding lives in the host rather than inAddEngagementModule: the module registration takes noIConfiguration(Program.cs:119-122). Contrast the pattern withCheckInSettings, bound the same way immediately after for the same reason (Program.cs:129-134).[Rubric §13, Observability & Operability]: the failure mode chosen here is "boot and keep serving", not "refuse to start". - Walkthrough
SectionName = "Points"(PointsSettings.cs:15): the constant the host binds against, so the section name is not duplicated as a literal at the call site (Program.cs:126-127).- Six
intaward properties with defaults:EventCheckIn = 25(line 18),SessionCheckIn = 10(line 21),SessionFeedback = 15(line 24),EventFeedback = 15(line 27),QuestionAsked = 5(line 30),SponsorVisit = 20(line 37). The deployedappsettings.jsonsets exactly these same values (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:26-34), so the checked-in defaults are the live economy. SponsorVisitcarries the longest doc comment of the six (PointsSettings.cs:32-36) and it is the interesting one: the deep link an attendee scans is shareable, so the once-per-sponsor cap enforced by the subject key plus a bounded value is what makes passing the URL around worthless.LeaderboardSize = 10(line 40): how many rows the public board shows.GetLeaderboardHandlerreads it throughMath.Max(settings.Value.LeaderboardSize, 0)beforeTake(...)(GetLeaderboardHandler.cs:63,:70), so a negative configured value yields an empty board rather than an exception.GetPointsFor(activity)(PointsSettings.cs:48-58): aswitchexpression overPointsActivityTypewith a_ => 0arm (line 57), so an undefined activity is treated exactly like a configured 0 and awards nothing.
- Why it's built this way:
init-only properties keep the bound options immutable after startup, and puttingGetPointsForon the settings object rather than in the awarder means the "what is this worth" lookup has one home; the awarder is left holding only the idempotency and persistence concerns. Keeping the type in*.Shared(not Application) is what lets the host bind it without referencing the Application layer. - Where it's used: bound by the Engagement service host (
Program.cs:126-127); injected asIOptions<PointsSettings>intoPointsAwarder(PointsAwarder.cs:31,:46) andGetLeaderboardHandler(GetLeaderboardHandler.cs:30,:63). - Caveats / not-in-source: whether a live production deployment overrides any of these values through environment configuration is not determinable from source; the repository shows only the checked-in
appsettings.jsonvalues, which match the class defaults.
EngagementRoutePaths
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:8· Level 0 · class (static)
- What it is: the single source of truth for every Engagement URL: the four live-layer routes, the two feedback routes, the three badge check-in routes, the two points routes, and the two deep-link-only self-service QR routes.
- Depends on: nothing first-party. It uses the
SessionIdentifierType,EventIdentifierType,SponsorIdentifierTypeandRoomIdentifierTypealiases, plusSystem.Globalization(EngagementRoutePaths.cs:1). - Concept, centralized route paths.
[Rubric §25, Navigation & Information Architecture]assesses whether URL strings are declared once or scattered acrossNavigateTocall sites and@pagedirectives. Every route here is either aconst(parameterless) or a static method (id-carrying), so renaming a route touches one file and the compiler finds the call sites.[Rubric §27, Internationalization]shows up in an easy-to-miss detail: each interpolated route is built withstring.Create(CultureInfo.InvariantCulture, $"...")(EngagementRoutePaths.cs:12-14,:17-18,:31-32) rather than plain interpolation, so a culture with non-ASCII digits or a different number format can never produce a URL the router will not match. - Walkthrough
- Live layer (
EngagementRoutePaths.cs:10-14):const string HappeningNow = "/happening-now", plusSessionDetails,SessionLiveandSessionPresent, which build/conference/sessions/{id},.../liveand.../present. - Feedback (
EngagementRoutePaths.cs:16-18):EventFeedback(eventId)andSessionFeedback(sessionId), whose outputs match the@pagetemplates on the two feedback pages. - Badge check-in (
EngagementRoutePaths.cs:20-23):MyBadge = "/my-badge",CheckInScan = "/check-in",OrganizerAttendance = "/organizer/attendance", the three routes carried by the pages later in this part. - Points (
EngagementRoutePaths.cs:25-27):MyPoints = "/points"andOrganizerPointsOverview = "/organizer/points". - Self-service QR (
EngagementRoutePaths.cs:31-32):SponsorVisit(sponsorId)andRoomCheckIn(roomId)build/engage/sponsors/{id}and/engage/rooms/{id}. The comment above them (EngagementRoutePaths.cs:29-30) records the design decision that matters to a reader: these are deep-link-only landings reached by scanning a printed code, so neither contributes a nav item.
- Live layer (
- Why it's built this way: methods are used wherever an id is embedded so the interpolation (and its invariant culture) lives in one place; plain
consts are used for the parameterless routes because they must be usable in attribute and collection-initializer positions, which is exactly howEngagementUIModuleconsumes them. - Where it's used: five of the constants seed the nav items in
EngagementUIModule(MMCA.ADC.Engagement.UI/EngagementUIModule.cs:23-28); the feedback and live builders are called from pages that link an attendee onward; the QR builders are what a printed sponsor or room code encodes.
ScanOutcomeKind
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:299· Level 0 · enum (private, nested inCheckInScan)
- What it is: the four things one check-in attempt can mean to the organizer holding the phone, quoting the type's own summary (
CheckInScan.razor.cs:298). - Depends on: nothing.
- Concept, the closed outcome vocabulary behind a UI feed.
[Rubric §21, Accessibility]is the reason this is an enum and not a pre-formatted string: the page fans each member out through three separateswitchexpressions, one for the sentence (OutcomeText,CheckInScan.razor.cs:243-250), one for the alert severity (SeverityFor,:249-256) and one for the icon (IconFor,:258-265), so colour never carries the status alone. The markup pairs the severity with the icon on every row and says so in a comment (CheckInScan.razor:99-102), which is what WCAG 2.1 AA asks for.[Rubric §27, i18n]: because the enum stays a symbol until render time, the wording lives entirely in the page's resource file. - Walkthrough:
CheckedIn,AlreadyCheckedIn,NotABadge,Failed(CheckInScan.razor.cs:301-304).AlreadyCheckedInis not an error state: a repeat scan is a normal thing to happen at a door, so it maps toSeverity.Warningwith a replay icon (:252,:261) rather than to a failure.NotABadgecovers a scanner reading a poster or sponsor QR and maps toSeverity.Info(:253). OnlyFailedmaps toSeverity.Error(:254). - Why it's built this way: four named cases keep the render-time mapping exhaustive and reviewable in one screen. All three
switchexpressions still carry a_ =>default arm (:246,:255,:264) so adding a member cannot throw at runtime, only produce the generic failure presentation until the arms are extended. - Where it's used: carried by
ScanOutcomeand consumed only insideCheckInScan; it isprivate, so it never leaves the page.
SessionAttendanceRow
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/OrganizerAttendance.razor.cs:128· Level 0 · record (private sealed, nested inOrganizerAttendance)
- What it is: one rendered attendance row, a session title paired with its check-in count. It is the view model the organizer attendance table iterates, distinct from the server DTO it is built from.
- Depends on: nothing first-party (a
stringand anint). - Concept, the page-local view model.
[Rubric §18, UI Architecture]assesses whether a page renders raw server contracts or a shape it owns. The stats endpoint returnsSessionAttendanceDTO, which carries aSessionIdand aCount(MMCA.ADC.Engagement.Shared/CheckIns/Attendance/SessionAttendanceDTO.cs:9-12) and no title, because the Engagement service does not own the session catalog. Rather than resolve a title inside the markup (which would put I/O in the render path), the page projects each DTO into this record once, already labelled and already sorted. - Walkthrough: a positional
recordwithTitleandCount(OrganizerAttendance.razor.cs:128), constructed inside the projection inBuildSessionRowsAsync(OrganizerAttendance.razor.cs:93-100) and rendered as the table body rows (OrganizerAttendance.razor:58-63), whereTitlebecomes the row header cell andCountis formatted withToString("N0", CultureInfo.CurrentCulture). - Why it's built this way: it is
private sealedbecause nothing outside the page has a use for it, and arecordbecause it is pure data, compared and never mutated after projection. - Where it's used: only by
OrganizerAttendance, which holds them in the_rowslist (OrganizerAttendance.razor.cs:33).
CheckInState
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns.Rooms·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Rooms/RoomCheckIn.razor.cs:97· Level 0 · enum (protected, nested)
- What it is: the five outcomes one scan of a printed room QR can have, as seen by the attendee. It is nested inside
RoomCheckInand is the single field its markup switches on. - Depends on: nothing (a bare
enum). Its values are produced fromCheckInErrorCodesby the page's mapping switch. - Concept introduced, the page-owned outcome enum.
[Rubric §19, State Management]assesses whether a component's visible states are enumerated or reconstructed ad hoc from a scatter of booleans. This page has exactly one state field, so "loading", "recorded", "nothing scheduled here", "event not published" and "unavailable" are mutually exclusive by construction, and the markup is a flat chain of comparisons (RoomCheckIn.razor:12,:16,:41,:47) with no combination to reason about.[Rubric §9, API & Contract Design]is the other half of the story: two of the refusals arrive as the same HTTP 404, so the state cannot be picked from a status code; the server's stable error code carried back onSelfCheckInOutcome<TResult>is what makes the distinction possible. - Walkthrough: five members in the order the attendee meets them.
Loading(RoomCheckIn.razor.cs:100) is the initial value ofState(RoomCheckIn.razor.cs:34) and covers the in-flight post.Recorded(:103) covers both a fresh check-in and a re-confirmed earlier one; the distinction isRoomCheckInResultDTO.AlreadyCheckedIn, not a separate state.NoCurrentSession(:106) means nothing is scheduled in that room right now, and, per the member comment, an unknown room reads the same way.NotPublished(:109) means the owning event is not published yet.Unavailable(:112) is the catch-all for a disabled feature or an unreachable service. - Why it's built this way: collapsing "unknown room" into
NoCurrentSessionand the feature gate intoUnavailableis a deliberate disclosure choice: a printed code that produces a distinguishable answer for a room that does not exist would confirm which room ids do exist. See ADR-072 for the surrounding scan-surface decision. - Where it's used: only inside
RoomCheckIn(RoomCheckIn.razor.cs:34,:60,:70-75,:83) and its markup.protectedrather thanprivateso the bUnit component tests can assert the rendered outcome (seeRoomCheckInTests).
VisitState
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns.Sponsors·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Sponsors/SponsorVisit.razor.cs:97· Level 0 · enum (protected, nested)
- What it is: the four outcomes one scan of a printed sponsor-booth QR can have. The sibling of
CheckInState, nested insideSponsorVisit, with one fewer member because a sponsor booth has no "what is running here right now" question to answer. - Depends on: nothing (a bare
enum); its values are chosen fromCheckInErrorCodes. - Concept: the page-owned outcome enum is taught on
CheckInState; the same reasoning applies unchanged here. - Walkthrough:
Loading(SponsorVisit.razor.cs:100), the initial value ofState(:35);Recorded(:103), which again covers both a first visit and a repeat, split in the markup bySponsorVisitResultDTO.AlreadyVisited(SponsorVisit.razor:19);NotPublished(:106); andUnavailable(:109). The mapping is a ternary rather than a switch because only one error code is distinguished (SponsorVisit.razor.cs:73-75):CheckInErrorCodes.EventNotPublishedselectsNotPublished, and everything else, including a null code, falls toUnavailable. - Why it's built this way: the member comment on
Unavailable(SponsorVisit.razor.cs:108) folds "feature off", "unknown sponsor" and "service unreachable" into one member, and the markup comment (SponsorVisit.razor:56-57) states the reason: the three are the same sentence to an attendee holding a phone, and naming them apart would only confirm which sponsor codes exist. That makes the collapse a[Rubric §26, Front-End Security]decision (no existence disclosure through error differentiation) as much as a copy decision. - Where it's used: only inside
SponsorVisit(SponsorVisit.razor.cs:35,:63,:73-75,:83) and its markup;protectedsoSponsorVisitTestscan drive it.
ScanOutcome
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:296· Level 1 · record (private sealed, nested inCheckInScan)
- What it is: one line in the organizer's on-screen scan feed: what happened to a badge, and to whom, in the order the organizer saw it (
CheckInScan.razor.cs:295). - Depends on:
ScanOutcomeKind. - Concept, the immutable UI event record.
[Rubric §19, State Management]assesses where transient UI state lives and how it changes. The feed is a plainList<ScanOutcome>field on the page (CheckInScan.razor.cs:58) whose entries are never edited after creation: a new scan is inserted at index 0 and the list is trimmed from the tail (AddOutcome,CheckInScan.razor.cs:234-241, bounded by theconst int MaxOutcomes = 10at:21). Immutable entries plus a bounded list mean the feed cannot grow without limit during a long scanning session and cannot be retroactively rewritten by a later scan. - Walkthrough: a positional
recordwithAttendeeNameandKind(CheckInScan.razor.cs:296). It is constructed in four places, and the differences are the interesting part:new ScanOutcome(string.Empty, ScanOutcomeKind.NotABadge)when the scanned text is not a badge payload (:131), a namedFailedwhen a manual write comes back a failure (:189), aFailedcarrying whatever name the caller had when the response itself is a failure (:216), and the success path inRecordResultAsync, which resolves a name and picksAlreadyCheckedInorCheckedInfrom the response (:226-228). Where no name is available the page substitutes the localizedOutcome.UnknownAttendeestring (:227) rather than rendering an empty row. - Why it's built this way: keeping the name on the record (instead of re-resolving it at render time) means the feed is pure data by the time the markup runs, so re-rendering never triggers another lookup.
- Where it's used: only inside
CheckInScan; rendered as oneMudAlertper entry (CheckInScan.razor:97-105).
MyBadge
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/MyBadge.razor.cs:15· Level 5 · class (partial)
- What it is: the code-behind for the attendee's own badge page (
@page "/my-badge",MyBadge.razor:1). It fetches the caller's opaque badge credential, encodes it as a QR code, and renders the attendee's name beside it as plain text. - Depends on:
ICheckInUIService(MyBadge.razor.cs:17),BadgePayload(:52), andMyBadgeDTOas the value inside the fetch result. Externals: Blazor's[CascadingParameter] Task<AuthenticationState>(:19-20), MudBlazor'sBreadcrumbItem/Icons, the sharedQrCodeImage,PageLoadingStateandPageErrorStatecomponents fromMMCA.Common.UI.Components, and an injectedIStringLocalizer<MyBadge>namedLdeclared in the markup (MyBadge.razor:5). - Concept introduced, the opaque-credential badge.
[Rubric §11, Security]and[Rubric §30, Compliance, Privacy & Data Governance]both apply, and the class summary states the rule (MyBadge.razor.cs:10-14): the QR carries only the credential, nothing about the person. Anyone photographing a badge across the room learns a GUID, not a name or an email, and the server remains the sole authority on which attendee that credential belongs to (MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:6-11). The human-readable name is rendered beside the code as ordinary text (MyBadge.razor:39) so an organizer can confirm the badge belongs to the person holding it; it is never inside the code.[Rubric §21, Accessibility]: the QR image carries anAltTextfrom the resource file, and itsPixelsPerModule="14"plusMediumerror correction are documented in the markup comment (MyBadge.razor:31-32) as making the code readable at arm's length and tolerant of glare or a fingerprint. - Concept, distinguishing "no badge" from "the fetch failed".
[Rubric §24, Forms, Validation & UX Safety]: the page reads the result twice, once for the happy path and once for the shape of the failure.badgeResult.TryGetValue(out var badge)unwraps a success (:50);else if (!badgeResult.IsNotFound())sets the error message only when the failure was something other than a 404 (:54-59), and the inline comment records that a not-found is simply an attendee who has no badge yet. The markup then has three distinct terminal states, not two: the error panel (MyBadge.razor:16-19), the empty-state alert when_payloadis still null (:20-25), and the badge card (:26-45). - Walkthrough (teaching order)
- State: a
CancellationTokenSourcefield (MyBadge.razor.cs:22), the breadcrumb list (:24), aprotected bool IsLoadingthat startstrue(:27), and three privates for the error message, the encoded payload and the display name (:29-31). OnInitializedAsync(:33) builds the two-item breadcrumb trail (:35-39), then, inside atry, awaits the cascadedAuthenticationStateforstate.User.Identity?.Name(:43-47) and callsCheckInService.GetMyBadgeAsync(_cts.Token)(:49). Only on a successful unwrap does it compute_payload = BadgePayload.Format(badge.Credential)(:52).- Failure handling is the house pattern:
OperationCanceledExceptionis swallowed as expected during disposal (:61-64), andIsLoadingis cleared infinally(:65-68) so the page can never be stuck on the spinner. Because the service returns aResult, an ordinary server refusal never reaches acatchat all. Dispose(bool)/Dispose()(:71-93) are the standard disposable pattern with a_disposedguard, cancelling and disposing the token source so an in-flight fetch stops when the attendee navigates away.
- State: a
- Why it's built this way: encoding the payload on the client from a credential the server issued keeps the QR format in exactly one place,
BadgePayload.Format(BadgePayload,MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:20), which is the same class the scanner side parses with (:30). Display and check-in cannot drift apart because they share the type and itsmmca-adc:badge:prefix (:15). - Where it's used: routed at
/my-badgebehind a plain[Authorize]attribute (MyBadge.razor:2), any signed-in attendee, and surfaced in the nav byEngagementUIModule(EngagementUIModule.cs:24). Its QR is whatCheckInScanreads at a session door. - Caveats / not-in-source: the resource strings and the
QrCodeImagerendering behaviour live outside this file; this section covers the code-behind and the small amount of markup cited.
RoomCheckIn
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns.Rooms·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Rooms/RoomCheckIn.razor.cs:20· Level 5 · class (partial)
- What it is: the code-behind for the room QR landing page (
@page "/engage/rooms/{RoomId:int}",RoomCheckIn.razor:1). An attendee scans the code printed beside a room door, signs in if needed, lands here, and the page records the check-in itself. The scan never names a session: the server decides which session that room is hosting at this instant. - Depends on:
ICheckInUIService(the post),SelfCheckInOutcome<TResult>(the return shape),RoomCheckInResultDTO(the payload),CheckInErrorCodes(the refusal vocabulary),IToastService(the snackbar), and its own nestedCheckInState. Externals: BlazorComponentBase(RendererInfo,[Parameter]), MudBlazorBreadcrumbItem/MudAlert/Icons,System.Globalization, and an injectedIStringLocalizer<RoomCheckIn>namedLsupplied by the markup (RoomCheckIn.razor:5). - Concept introduced, the write-on-arrival landing page.
[Rubric §18, UI Architecture]assesses component/behavior separation and render-mode discipline. This page inverts the usual "render, then let the user press something" shape: the arrival is the interaction, so the round trip runs fromOnInitializedAsync. That makes the Blazor render-mode question load-bearing, because a prerendered pass and the interactive pass both runOnInitializedAsync, and the whole point of the page is a write. The two guards atRoomCheckIn.razor.cs:47-50(!RendererInfo.IsInteractiveand a_postedlatch) are what keep one arrival to one post.[Rubric §19, State Management]covers the single-field state model taught onCheckInState.[Rubric §11, Security]applies through@attribute [Authorize](RoomCheckIn.razor:2): the page identifies the attendee from the authenticated session, so a scanned code carries no identity of its own. - Walkthrough (teaching order)
[Parameter] RoomIdentifierType RoomId(RoomCheckIn.razor.cs:23) is bound from the{RoomId:int}route constraint, so an unparseable code never reaches the component. The injectedICheckInUIServiceandIToastServicefollow (:25-26), then aCancellationTokenSource(:28), the breadcrumb list (:30) and the_postedlatch (:31).State(RoomCheckIn.razor.cs:34) starts atCheckInState.Loadingand isprotectedwith a private setter;Result(:37) holds theRoomCheckInResultDTOand is documented as present only in theRecordedstate.OnInitializedAsync(:39) builds the two-item breadcrumb trail first (:41-45) so even the non-interactive prerender has a complete chrome, then returns early if the render is not interactive or the post already ran (:47-50). Only after that does it set_posted = true(:52) and awaitCheckInService.RecordRoomCheckInAsync(RoomId, _cts.Token)(:56).- The success branch (
:57-68) assignsResult, moves toCheckInState.Recorded, and raises a success toast only whenResult?.AlreadyCheckedIn == false(:62-65): a rescan is a normal thing to do, so it renders as information (RoomCheckIn.razor:22-24) rather than as a second celebration. - The refusal branch (
:70-75) is aswitchexpression overoutcome.ErrorCode:CheckInErrorCodes.NoCurrentSessionbecomesNoCurrentSession,CheckInErrorCodes.EventNotPublishedbecomesNotPublished, and every other code (includingnull) falls through the discard arm toUnavailable. This is the client-side consumer of the error-code contract thatSelfCheckInOutcome<TResult>exists to carry. catch (OperationCanceledException)(:77-80) is silent (expected on disposal); any other exception lands onUnavailable(:81-84), so a transport fault and a disabled feature look identical to the attendee.FormatCheckedInOn(:93-94) renders the recorded instant withToLocalTime()and the"g"format underCultureInfo.CurrentCulture. The XML comment (:87-92) gives the reason: the attendee is comparing the timestamp against the clock on the wall beside them.[Rubric §27, Internationalization]covers both halves of that, the culture-aware format and the fact that every visible string is anL["..."]resource lookup (ADR-027).Dispose(bool)/Dispose()(:117-137) implement the standard disposable pattern with a_disposedlatch, cancelling and disposing_ctsso an in-flight post stops when the attendee navigates away.
- Why it's built this way: the class comment (
RoomCheckIn.razor.cs:10-19) states the two constraints. The write runs on the interactive render only, and only once per page instance: a prerender must not spend a round trip, and a browser back button must not read as a second arrival. The server is idempotent per session regardless (a repeat returnsAlreadyCheckedIn), so the latch is about not making a pointless call rather than about correctness. Resolving the session server-side from the room, instead of encoding it in the QR, means the printed code never expires when the schedule moves (ADR-072). The page complements organizer badge scanning at the door rather than replacing it: one session yields one check-in by either path, which is exactly what the "already checked in" branch says (RoomCheckIn.razor:20-21). - Where it's used: the target of the room QR rendered on the Conference room detail page (
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:58, aQrCodeButtonoverConferenceRoutePaths.RoomCheckInLink,ConferenceRoutePaths.cs:61) per ADR-071. The post lands onCheckInsController's room-visits endpoint (CheckInsController.cs:163, feature-gated onEngagementFeatures.RoomCheckInatCheckInsController.cs:165) and is handled byRecordRoomCheckInHandler.[Rubric §28, Front-End Testing]: covered byRoomCheckInTests(bUnit). - Caveats / not-in-source: the outcome copy, the success card and the points hint live in
RoomCheckIn.razor(:30-35) and the resource files, not in this code-behind. Whether a given deployment hasEngagementFeatures.RoomCheckInon is configuration, not source.
SponsorVisit
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns.Sponsors·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Sponsors/SponsorVisit.razor.cs:21· Level 5 · class (partial)
- What it is: the code-behind for the sponsor-booth QR landing page (
@page "/engage/sponsors/{SponsorId:int}",SponsorVisit.razor:1). The attendee scans the booth's printed code with the phone camera, signs in if needed, and lands here; the page records the visit itself, so the whole interaction is one scan with nothing to press. - Depends on:
ICheckInUIService,SelfCheckInOutcome<TResult>,SponsorVisitResultDTO,CheckInErrorCodes,IToastService, its nestedVisitState, and (from the markup)ConferenceRoutePathsfor the follow-on "all sponsors" link. Externals as onRoomCheckIn: BlazorRendererInfo/[Parameter], MudBlazor components,System.Globalization, and an injectedIStringLocalizer<SponsorVisit>(SponsorVisit.razor:6). - Concept: the write-on-arrival landing page is taught on
RoomCheckIn; this page is the same shape with a simpler refusal map. The one difference worth naming is what happens after success:[Rubric §25, Navigation & Information Architecture]assesses whether a dead-end page gives the reader somewhere to go, and this one ends with a filled "all sponsors" button back into the public sponsor list (SponsorVisit.razor:42-48), which the room page has no equivalent of because a room scan is followed by sitting down. - Walkthrough of what differs from
RoomCheckIn[Parameter] SponsorIdentifierType SponsorId(SponsorVisit.razor.cs:24) instead of a room id;Statedefaults toVisitState.Loading(:35) andVisitholds aSponsorVisitResultDTO(:38).OnInitializedAsync(:40) carries an inline comment naming the pattern it follows (:48-49): the visit is a write, so it stays off the prerender pass, and the interactive instance is the one that owns the round trip (theADCHomepattern). The same!RendererInfo.IsInteractive || _postedguard follows (:50-53), thenCheckInService.RecordSponsorVisitAsync(SponsorId, _cts.Token)(:59).- Success (
:60-71) setsVisit, moves toVisitState.Recorded, and toasts only on a first visit (Visit?.AlreadyVisited == false,:65-68). - The refusal map (
:73-75) is a ternary, not a switch: onlyCheckInErrorCodes.EventNotPublishedis distinguished; everything else isUnavailable. There is no room-style "nothing scheduled here" case, because a booth is not on a schedule. FormatVisitedOn(:93-94) is the local-time formatter, identical in mechanism to the room page's, with the same reason recorded in its XML comment (:87-92).Dispose(bool)/Dispose()(:114-134) are the same disposable pattern over_cts.
- Why it's built this way: the class comment (
SponsorVisit.razor.cs:10-20) is explicit that the server is idempotent per sponsor regardless of the guard, so the interactive-only latch is about not making a pointless round trip rather than about correctness. Recording the visit without a button press is the point of the surface: a sponsor booth interaction that costs an attendee two taps is an interaction most attendees will not complete, which is the engagement argument ADR-072 records for attendee-self-recorded scan surfaces. - Where it's used: the target of the sponsor QR rendered on the Conference sponsor detail page (
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:70, aQrCodeButtonoverConferenceRoutePaths.SponsorVisitLink,ConferenceRoutePaths.cs:60). The post lands onCheckInsController's sponsor-visits endpoint (CheckInsController.cs:132, feature-gated onEngagementFeatures.SponsorVisitsatCheckInsController.cs:134, seeEngagementFeaturesand ADR-031) and is handled byRecordSponsorVisitHandler. Component-tested bySponsorVisitTests. - Caveats / not-in-source: the confirmation card, the points hint and the "already visited" copy live in
SponsorVisit.razor(:29-40) and its resources. How many points a visit earns is decided server-side by the points earn rules, not here.
AttendeeSearchPanel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/AttendeeSearchPanel.razor.cs:16· Level 7 · class (partial,DataGridListPageBase<AttendeeSummary>)
- What it is: the manual check-in fallback, a paged, sortable, mobile-aware search over the Identity users endpoint with a per-row check-in action. It is a component, not a page: it has no
@pagedirective and is embedded byCheckInScan. - Depends on:
DataGridListPageBase<TDto>as its base (AttendeeSearchPanel.razor.cs:16),IAttendeeLookupServiceplusAttendeeSummaryandAttendeeSearchField(MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:15,:39),MobileInfiniteScrollList<TItem>andListPageActions, andResultfor the mobile fetch signature (:3,:86). Externals: MudBlazorMudDataGrid/GridState/GridData, and an injectedIStringLocalizer<AttendeeSearchPanel>(AttendeeSearchPanel.razor:4). - Concept introduced, extracting a list surface out of a page.
[Rubric §15, Best Practices & Code Quality]and[Rubric §18, UI Architecture]: the class summary records the reason for the split (AttendeeSearchPanel.razor.cs:10-15), namely that the page owns the scan loop and its own cancellation while the grid keeps the shared base's behaviour (server paging, mobile card layout, state restore) unchanged. Inheriting the framework base rather than hand-rolling a grid is what buys the mobile path:[Rubric §22, Responsive & Cross-Browser]shows inReloadActiveLayoutAsync(:59-60), which delegates toListPageActions.ReloadActiveLayoutAsync(IsMobile, _infiniteList, _dataGrid)so the same filter change reloads either the desktop data grid or the mobile infinite-scroll list, whichever one is live. - Walkthrough
- Contract with the base:
Titlefrom the localizer (:19) andGridRef => _dataGrid(:32), which is how the base drivesReloadServerData();OnMobileDataRequestedAsync(:55) routes the base's mobile hook back through the same reload, andRetryLoadAsync(:57) is what the empty-state retry button calls. - Parameters from the host page:
CanCheckIn(:24) gates the per-row action, andOnCheckIn(:27) is theEventCallback<AttendeeSummary>raised byCheckInAsync(:130). The component performs no write of its own; it reports which row was actioned and lets the page decide. - Filter persistence:
SaveFilters/RestoreFilters(:39-52) round-trip the search term and the chosen field through the base's state store, withEnum.TryParsefalling back toLastNameif the stored value is unreadable (:49-51). - Search handling:
OnSearchChangedAsyncandOnSearchFieldChangedAsync(:62-72) each set a field and reload the active layout. The markup wires the text field withImmediate="true" DebounceInterval="300"(AttendeeSearchPanel.razor:11), so typing does not fire a request per keystroke, a[Rubric §23, Front-End Performance]detail. - Data loading:
LoadServerData(:74-84) hands the base a lambda that unpacks the grid's filter dictionary into the lookup service's three optional arguments plus paging and sorting;FetchMobilePageAsync(:86-99) does the same for the mobile list and returnsTask<Result<(IReadOnlyList<AttendeeSummary> Items, int TotalItems)>>, pinning the sort to"LastName" / "asc"since the card layout has no sort UI. The markup binds it throughFetchPageResult(AttendeeSearchPanel.razor:29), theResult-aware overload, which is what lets a failed page renderListNoRecordsContentwithLoadFailedand a retry rather than an empty list (:85). ApplySearchTerm(:101-121) is the load-bearing piece and its doc comment explains it (:101-104, echoed in the markup at:14): the Identity users endpoint ANDs its filters, so one search box broadcast across email, first name and last name would match nobody. The single term is therefore applied to exactly the field the organizer picked, as a"contains"filter (:112-120).FilterValue(:123-128) then reads a filter back out, treating whitespace as absent.
- Contract with the base:
- Why it's built this way: an organizer at a door needs the fallback to be fast and forgiving, and the shared base already solved paging, mobile layout and state restore for every other list in the app. Raising an
EventCallbackinstead of calling the check-in service directly keeps the write path (and its scope, target and error reporting) in one place, the page.[Rubric §21, Accessibility]: both action buttons carry a per-rowaria-labelbuilt from the attendee's display name (AttendeeSearchPanel.razor:42,:78), so a screen-reader user hears which person a "Check in" button belongs to. - Where it's used: rendered unconditionally near the bottom of the scan page,
<AttendeeSearchPanel CanCheckIn="CanCheckIn" OnCheckIn="ManualCheckInAsync" />(CheckInScan.razor:113). The comment above it (CheckInScan.razor:108-109) states the intent: on web and Windows heads there is no camera, so this search is the check-in surface.
CheckInScan
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:21· Level 10 · class (partial)
- What it is: the organizer check-in surface (
@page "/check-in",[Authorize(Roles = "Organizer")],CheckInScan.razor:1-2). On a head with a camera it loops the shared barcode scanner and posts each badge payload; everywhere else the manual search below the scan area is the whole page. Both paths write through the same endpoints and report the attendee by name. - Depends on:
ICheckInUIService,IAttendeeLookupService,ISessionLookupService,ILiveEventUIService,IBarcodeScannerServiceandIToastService(CheckInScan.razor.cs:26-31); the shared contractsBadgePayload,CheckInScope,CheckInAttendeeRequest,ManualCheckInRequest,CheckInResultDTO;LiveEventContextandSessionInfo;ResultandErrorType(:4); and its own nestedScanOutcome/ScanOutcomeKindplus theAttendeeSearchPanelcomponent. - Concept introduced, the capability-gated scan loop.
[Rubric §18, UI Architecture]and the device-capability abstraction of ADR-042 meet here.ScannerAvailable => Scanner.IsSupported(:38, with the ADR named in the comment at:37) is the whole capability switch: the markup renders the scan card only when it is true (CheckInScan.razor:68), and every head that cannot open a camera falls through to the manual panel with no conditional compilation and no per-platform page.[Rubric §11, Security]: scanned text is untrusted input, soBadgePayload.TryExtractCredentialscreens it before any request goes out (:129), and a poster or sponsor QR is reported as a skipped scan rather than an error (:127-128).[Rubric §24, Forms, Validation & UX Safety]:CanCheckIn(:41-42) refuses to start a scan or a manual write until a target exists, and the markup renders an inline "pick a target" alert when it is false (CheckInScan.razor:59-64). - Walkthrough (teaching order)
- Constants and state:
MaxOutcomes = 10bounds the on-screen feed (:21); theCancellationTokenSource(:30),IsLoading(:35),_loadError,_liveEvent,_sessionsand_selectedSessionId(:44-47), the scope field, the_isScanningflag and the_outcomeslist (:52-55). - Default scope:
_scope = CheckInScope.Sessionwith a comment that is real operational knowledge (:49-51, repeated for the reader in the markup atCheckInScan.razor:33-35), namely that the conference runs door and arrival check-in through TicketLeap, so session check-in is the working path andEventscope is kept for future info-desk use. OnInitializedAsync(:57) resolves the current live event (:67) and returns early when there is none (:68-71), then loads the session catalog once through aResult-returning lookup (:73-78) and filters it to that event, ordering by start time with?? DateTime.MaxValueso unscheduled sessions sort last, then by title underStringComparer.CurrentCulture(:80-83). The same swallow-cancel, always-clear-IsLoadingshape as the other pages (:85-92).- Target selection:
OnScopeChangedAsyncstops any running scan when the scope changes (:95-100), which prevents a scan started against one target from continuing under another;OnSessionChangedjust records the selection (:102). StartScanningAsync(:109-147) is the loop. It refuses to start twice or without a target (:111-114), then repeatedly awaitsScanner.ScanAsync(_cts.Token)while_isScanningholds and cancellation has not been requested (:119-121). Anullpayload means cancelled or permission denied, and the documented behaviour (:104-108) is that the loop ends quietly, never with an error dialog (:122-125). A non-badge payload adds aNotABadgeoutcome and continues (:129-133). Otherwise it submits and callsStateHasChanged()so the feed paints between scans (:135-136).SubmitScanAsync(:151-165) builds aCheckInAttendeeRequestcarrying the credential, the scope, the event id and, only in session scope, the selected session (:153-159). It does not catch anything: the write returns aResultthatRecordResultAsyncturns into aFailedrow while the loop keeps scanning, and a genuine cancellation propagates to the loop's own handler (:161-164).ManualCheckInAsync(:168-206) is the fallback the panel raises. It toasts a warning when no target is selected (:170-174), builds aManualCheckInRequestby user id (:178-184), and on a failed result adds a namedFailedoutcome (:189) plus a toast whose wording depends on why it failed: a refusal the API stated is shown verbatim throughresult.LocalizedErrorMessage(L), while anErrorType.Unexpectedfault keeps the page's own wording instead of raw diagnostic text (:191-196).[Rubric §11, Security]and[Rubric §13, Observability & Operability]: that branch is the boundary between what an organizer may read and what belongs in the logs.RecordResultAsync(:212-229) turns one response into an outcome. A failedResultbecomes aFailedrow (:214-218). The scan path has no name to start from because a badge carries only a credential, so it resolves one viaAttendeeLookup.GetDisplayNameAsync(checkIn.UserId, ...)(:220-224); the manual path already has one and skips the lookup. Finally the response'sAlreadyCheckedInflag selects the outcome kind (:228), which is how a repeat scan reads as a recognisable warning instead of an error.- Presentation helpers
OutcomeText/SeverityFor/IconFor(:240-265) and the boundedAddOutcome(:231-238), covered underScanOutcomeKind. Dispose(bool)(:269-284) clears_isScanningbefore cancelling and disposing the token source (:278-280), so the loop's condition fails on the next iteration as well as its await being cancelled.
- Constants and state:
- Why it's built this way: one page serving both a camera head and a keyboard head is a deliberate choice, and it is only affordable because the scan capability is behind an injected interface. Treating a duplicate as a first-class outcome rather than an error is the other design decision that matters at a real door: the organizer needs to see who the badge belongs to either way, which is why
CheckInResultDTOcarriesAlreadyCheckedIninstead of returning a conflict (MMCA.ADC.Engagement.Shared/CheckIns/CheckInResultDTO.cs:3-7,:14). - Where it's used: routed at
/check-in, Organizer-only, and surfaced in the nav byEngagementUIModulewith the same role requirement (EngagementUIModule.cs:25). It writes throughCheckInServiceto theCheckInsControllerendpoints. - Caveats / not-in-source: what the camera actually does on each platform is behind
IBarcodeScannerServiceand is covered in the device-capability chapter, not here.
OrganizerAttendance
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.CheckIns·MMCA.ADC.Engagement.UI/Pages/CheckIns/OrganizerAttendance.razor.cs:17· Level 10 · class (partial)
- What it is: the organizer attendance rollup for the current event (
@page "/organizer/attendance",[Authorize(Roles = "Organizer")],OrganizerAttendance.razor:1-2): one headline arrival figure plus one row per session that has at least one check-in. - Depends on:
ICheckInUIService,ILiveEventUIService,ISessionLookupService(OrganizerAttendance.razor.cs:19-21),AttendanceStatsDTOandSessionAttendanceDTO, and its own nestedSessionAttendanceRow. Externals: MudBlazorMudSimpleTable/BreadcrumbItem,System.Globalizationin the markup (OrganizerAttendance.razor:3), and an injectedIStringLocalizer<OrganizerAttendance>(:6). - Concept, client-side enrichment across a service boundary.
[Rubric §7, Microservices Readiness]and[Rubric §12, Performance & Scalability]both apply. The class summary states the situation (OrganizerAttendance.razor.cs:11-16): the stats endpoint reports session identifiers only, because Engagement owns check-ins and Conference owns the session catalog, so titles have to be joined somewhere. This page does it in the client, andBuildSessionRowsAsyncdocuments the choice (:71-74): the whole catalog is fetched once rather than one request per row, because a busy conference day reports dozens of sessions at a time. That is the N+1 avoidance rule applied to a UI join. - Walkthrough
- State: the token source (
:21), breadcrumbs (:23),IsLoading(:26), the error string, event name, the raw_statsand the projected_rows(:28-31). OnInitializedAsync(:33) resolves the live event and returns early if there is none (:43-47), stores its name (:49), fetches the rollup withGetAttendanceStatsAsync(liveEvent.EventId, _cts.Token)(:51), sets the localized load error and returns when theResultis a failure (:52-56), then builds the rows (:58-59). Same swallow-cancel and clear-IsLoadingdiscipline as its siblings (:61-68). Note the three distinct render states the markup then keys off: error (OrganizerAttendance.razor:17-20), no live event with_statsstill null (:21-26), and the rollup itself (:27-67).BuildSessionRowsAsync(:75) short-circuits to an empty list when the rollup has no session rows, avoiding a pointless catalog fetch (:77-81). A failed catalog fetch clears the rows and raises the same load error rather than silently rendering untitled rows (:83-89). Otherwise it projects each DTO into aSessionAttendanceRow, substituting a localizedSessions.UnknownTitlewhen the catalog does not know the session (:93-95), which keeps a deleted or cross-event session from rendering a blank row. Sorting isOrderByDescending(Count)thenThenBy(Title, StringComparer.CurrentCulture)(:97-98), a culture-aware tie-break, so the busiest sessions lead.Dispose(:103-123) is the same disposable pattern as the other check-in pages.
- State: the token source (
- Why it's built this way: sorting and labelling once, on load, keeps the markup a pure loop over
_rows(OrganizerAttendance.razor:58-63) with no I/O in the render path.[Rubric §21, Accessibility]: that markup is a realMudSimpleTablewith a<caption>,<th scope="col">headers and a<th scope="row">per session (OrganizerAttendance.razor:49-63), not a styleddivgrid, so a screen reader announces the counts with their session names.[Rubric §27, i18n]: both the headline figure and each row count are formattedToString("N0", CultureInfo.CurrentCulture)(:34,:62). - Where it's used: routed at
/organizer/attendanceand surfaced as an Organizer-only nav item byEngagementUIModule(EngagementUIModule.cs:26). Its numbers come from the same check-in rows thatCheckInScanwrites.
EngagementUIModule
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17· Level 11 · class (sealed)
- What it is: the Engagement module's UI descriptor. It implements
IUIModuleto contribute six navigation items, one invisible layout component, and its own assembly for Blazor component discovery. - Depends on:
IUIModule,NavItemandNavSectionfromMMCA.Common.UI.Common(EngagementUIModule.cs:4-5),RoleNamesfromMMCA.Common.Shared.Auth(:3),EngagementRoutePaths,LiveEventListener(:2), MudBlazorIcons, andSystem.Reflection. - Concept, the pluggable UI module descriptor.
[Rubric §18, UI Architecture]assesses how a modular monolith composes its front end without the host hard-coding each module's menu: every module ships anIUIModule, and the shared shell merges their nav items, layout components and assemblies.[Rubric §25, Navigation & IA]: the menu is declarative data here, not imperative wiring.[Rubric §11, Security]: three of the six items carryRequiredRole: RoleNames.Organizer(:25,:26,:28) so the sharedNavMenuhides them from attendees; this is menu trimming, and the real enforcement remains the[Authorize(Roles = "Organizer")]attribute on each page.[Rubric §27, i18n]: per the ADR-027 comment (:19-20), eachTitleis a resource key, resolved byNavMenuagainst the co-locatedEngagementUIModule.resxpair at render time through thetypeof(EngagementUIModule)title-resource pointer passed to every item, so the labels localize instead of being hard-coded English. - Walkthrough
NavItems(:21-29) is a collection expression of six entries, each pairing a resource key, a centralized route and an icon:Nav.HappeningNowtoHappeningNow(:23),Nav.MyBadgetoMyBadge(:24),Nav.CheckIntoCheckInScan(:25, Organizer),Nav.AttendancetoOrganizerAttendance(:26, Organizer),Nav.MyPointstoMyPoints(:27), andNav.PointsOverviewtoOrganizerPointsOverview(:28, Organizer).Nav.MyPointsis the only one that setsSection: NavSection.User, which places it in the user section beside the profile entry rather than in the main menu.LayoutComponentTypes(:31):[typeof(LiveEventListener)], an invisible component the shell renders on every page; the class summary says it joins the event live channel while the event is live (:14-15).Assembly(:33):typeof(EngagementUIModule).Assembly, so Blazor's router and component discovery can find this module's routable pages.
- Why it's built this way: exposing nav, layout components and the assembly as read-only properties lets the host treat every module identically, and keeping the descriptor beside the routes it points at means adding a page is a two-line change in one project. Note what is deliberately absent: the two self-service QR routes (
EngagementRoutePaths.cs:29-32) contribute no nav item because they are reachable only by scanning a printed code. - Where it's used: registered as a singleton
IUIModulebyDependencyInjection'sAddEngagementUI(MMCA.ADC.Engagement.UI/DependencyInjection.cs:76); consumed by the shared shell'sNavMenuand router.
DependencyInjection
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC.Engagement.UI/DependencyInjection.cs:22· Level 12 · class (static)
- What it is: the Engagement UI composition root. One static class exposing an
AddEngagementUI()extension onIServiceCollectionthat registers every Engagement UI service (bookmarks, reminders, feedback, check-in, points, lookups, the live layer, notification scoping) plus theEngagementUIModuledescriptor. - Depends on:
IServiceCollectionandMicrosoft.Extensions.DependencyInjection.Extensions(forTryAddSingleton,:2), the Engagement UI service interfaces and implementations,ISessionBookmarkUIServiceandISessionLiveUIServicefrom.Shared(:3-4), andIUIModuleplusINotificationScopeProviderfrom the Common UI framework (:6-7). - Concept,
extension(IServiceCollection)registration.[Rubric §5, Vertical Slice]and[Rubric §1, SOLID]assess whether a slice owns its own wiring: this is the single place the Engagement UI declares its services, so a host callsAddEngagementUI()and stays ignorant of the internals. The C# previewextension(IServiceCollection services)block (DependencyInjection.cs:24) is the DI-registration idiom used across the codebase (see the primer onextension(T)members), which is what letsAddEngagementUIread as an instance method on the collection. - Walkthrough:
AddEngagementUI()(:23) registers in labelled groups, all scoped (per circuit) unless noted.- Bookmarks (
:26-27):IBookmarkUIServicetoBookmarkService,ISessionBookmarkUIServicetoSessionBookmarkUIService. SessionReminderCoordinator(:31), registered as a concrete type. The comment (ADR-042 Wave 2,:29-30) records that it keeps local notifications in sync with bookmarks and is a pure no-op on hosts whose local-notification service is unsupported.- Feedback (
:34-36):IQuestionLookupServicetoQuestionLookupService,IEventFeedbackUIServicetoEventFeedbackService,ISessionFeedbackUIServicetoSessionFeedbackService. - Badge check-in (
:38-40):ICheckInUIServicetoCheckInService, the one service behind the attendee badge, both organizer write paths and the attendance rollup. - Points (
:42-44):IPointsUIServicetoPointsService. - Cross-module lookups (
:46-48):ISessionLookupServicetoSessionLookupServiceandIAttendeeLookupServicetoAttendeeLookupService, the two contracts the check-in surfaces use to name sessions and people that other services own. - Live layer (
:50-53):ILivePollUIService,ILiveEventUIService,INowNextService; Wave 2 addsISessionQuestionUIServiceandISessionLiveUIService(:55-58). - Notification scoping (
:60-66):services.TryAddSingleton(TimeProvider.System)then a plainAddScoped<INotificationScopeProvider, CurrentEventNotificationScopeProvider>(). Both lines carry load-bearing comments (:60-64): the plainAddScopeddeliberately overrides the null provider thatAddNotificationUIregisters withTryAddScoped, so the two calls stay order-independent; andTimeProvideris registered here because it drivesCurrentEventNotificationScopeProvider's cache expiry and no UI head registers it (the heads never callAddInfrastructure, which is where the framework's ownTryAddSingletonlives). - Module descriptor (
:68-69):AddSingleton<IUIModule, EngagementUIModule>(), a singleton because the descriptor is immutable metadata, while everything else is scoped since it holds per-circuit state. - Returns
services(:71) for fluent chaining.
- Bookmarks (
- Why it's built this way: one extension method per module UI keeps registration cohesive and lets a host compose modules by convention. The
TryAddversusAddchoices are the part worth reading closely:TryAddwhere this module is a fallback provider (TimeProvider), plainAddwhere it must win over a framework default (the notification scope provider).[Rubric §29, Resilience, Reliability & Business Continuity]: getting that pair wrong is exactly how the notification bell would silently stop scoping to the current event. - Where it's used: called by the Blazor UI host's startup when the Engagement module is enabled, alongside the other modules'
AddXUI()calls.
FeedbackAnswerModel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/FeedbackAnswerModel.cs:23· Level 0 · class (public sealed)
- What it is: the form model for one answer on the two attendee feedback pages. It is the two-way binding target behind every question field and, more importantly, the single declaration site for the free-text length rule that both pages enforce.
- Depends on: nothing first-party. Externals:
System.ComponentModel.DataAnnotations([MaxLength]). It is consumed throughModelValidation.ForPropertyandDataAnnotationsModelValidator, both fromMMCA.Common.UI. - Concept introduced, the annotated form model as the one rule declaration.
[Rubric §24, Forms, Validation & UX Safety]assesses whether input rules are declared once or restated per field. The naive Blazor shape writes aMaxLengthon the markup, aCounterbeside it, and a hand-rolled length check in the submit path: three copies of the same number that drift independently. Here the[MaxLength]attribute atFeedbackAnswerModel.cs:33is the rule,AnswerValueMaxLength(:30) is the number, and the markup reads the constant for both the input cap and the character counter (EventFeedback.razor:68-69,SessionFeedback.razor:79-80).[Rubric §19, State Management]covers the other half of its job: each page holds aDictionary<QuestionIdentifierType, FeedbackAnswerModel>of in-progress answers (EventFeedback.razor.cs:54,SessionFeedback.razor.cs:54) rather than editing the DTO it will eventually post, so an unsaved edit never round-trips through a server contract.[Rubric §27, Internationalization]: theErrorMessageis not a sentence but a resource key,"Field.MaxLengthError", resolved by each page's own localizing validator (ADR-027), so the shared model produces a per-page translation without the model knowing anything about locales. - Walkthrough
public const int AnswerValueMaxLength = 4000(FeedbackAnswerModel.cs:30) is the client-side mirror of the server invariant. The XML comment names its source, and it checks out:EventInvariants.AnswerValueMaxLengthis4000(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:59) andSessionInvariants.AnswerValueMaxLengthis the same (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:37). The same number also sizes the column (EventQuestionAnswerConfiguration.cs:26,SessionQuestionAnswerConfiguration.cs:26), so a value the form accepts is a value the table can hold.TextValue(:34) is thestring?bound by the text and email question types, and carries the[MaxLength(AnswerValueMaxLength, ErrorMessage = "Field.MaxLengthError")]annotation (:33).RatingValue(:37) is a plainintwith no annotation, because a rating is picked from a five-star control rather than typed. Zero is the unanswered sentinel:GetAnswerValuetreatsRatingValue > 0as the only answered state (EventFeedback.razor.cs:290-292,SessionFeedback.razor.cs:321-323), so an untouched rating serializes tonulland is skipped on submit.- Both members are settable auto-properties, deliberately. MudBlazor binds with
@bind-SelectedValueand@bind-Value(EventFeedback.razor:50,:55,:65), which needs a writable property, so arecordwithinit-only members could not serve as the target. The mutability is a consequence of the binding contract, not a lapse from the workspace'srequired/initdefault.
- Why it's built this way: the type doc (
FeedbackAnswerModel.cs:5-22) records the one design tension worth understanding. Both pages render a dynamic question list, so whether a given answer is required is per-question data (QuestionDTO.IsRequired,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:47), not a property of the model. A[Required]attribute here would apply to every question at once and would be wrong for most of them, so required stays on the field's ownRequired="@question.IsRequired"parameter (EventFeedback.razor:61,:73) and only the length rule, which genuinely is per-value, is declared on the model. Lifting the model to a public file-level type shared by both pages, rather than a private nested holder per page, is what makes one declaration serve both forms. - Where it's used: seeded one instance per question in each page's
OnInitializedAsync(EventFeedback.razor.cs:114,SessionFeedback.razor.cs:119), populated byPreFillExistingAnswers, read byGetAnswerValueon submit, and reset in place byDeleteAnswerAsync. A separate prototype instance is constructed in each page'sOnInitializedpurely to build the validation delegate (EventFeedback.razor.cs:69-72,SessionFeedback.razor.cs:69-72):ModelValidation.ForPropertyvalidates the value handed to it rather than the instance, so one prototype serves the whole dynamic list. - Caveats / not-in-source: nothing here enforces the length. The attribute is inert until a validator runs it; the enforcement path is the pages'
_validateAnswerTextdelegate on the client and the Conference invariants on the server.
CheckInErrorCodes
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/CheckInErrorCodes.cs:8· Level 0 · static class
- What it is: the two stable error codes the self-service QR landing pages branch on, held as constants beside the service that reads them instead of being retyped inside each page.
- Depends on: nothing (two
const stringfields). Consumed together withSelfCheckInOutcome<TResult>, which carries the code back from the transport layer. - Concept introduced, the error code as a client-facing contract.
[Rubric §9, API & Contract Design]assesses whether failures are expressed in a form a client can branch on. HTTP status codes are not enough here and the type doc says why by implication (CheckInErrorCodes.cs:3-7): two of the three refusals arrive as the same 404, so a page choosing its state from the status alone cannot tell "nothing is scheduled in this room" from "this feature is off". A stable machine-readable code in the ProblemDetails body is the only distinguishing signal, and it must never be a human-readable message, because that message is localized and would break the branch in any non-English locale.[Rubric §9, API & Contract Design]covers the placement: one declaration site for the vocabulary means a page never spells a code out inline, so a typo is a compile error rather than a silently unreachable branch. - Walkthrough (
CheckInErrorCodes.cs:8-18):EventNotPublished = "CheckIns.EventNotPublished"(:11) means the owning event is not published yet, so nothing is being recorded for it.NoCurrentSession = "CheckIns.NoCurrentSession"(:17) means nothing is scheduled in the scanned room at this instant, and the member comment (:13-16) records the security decision folded into it: an unknown room answers the same way by design, so the state never confirms which room identifiers exist. - Why it's built this way: the deliberate absence is as informative as the presence. There is no constant for "feature disabled" or "unknown target", because those produce a bare 404 with no code, which both pages treat as their generic unavailable state (
RoomCheckIn.razor.cs:70-74,SponsorVisit.razor.cs:73-74). Refusing to name them is what keeps a printed code from confirming which sponsors or rooms exist (ADR-072). - Where it's used: read by
RoomCheckIn(:72-73) andSponsorVisit(:73) when mapping an outcome onto their page state; produced on the server as literal strings byCheckInProcessor(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:30) andRecordRoomCheckInHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:101), and documented on the endpoint itself (CheckInsController.cs:150). - Caveats / not-in-source: the two sides of this contract are separate string literals in separate projects, not one shared constant, because the UI project deliberately does not reference the Application layer. Nothing but a test keeps them equal.
NowNextSessionInfo
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:27· Level 0 · sealed record
- What it is: one row of the now-and-next snapshot, a session's identifier, title, room name, and its wall-clock start and end in the event's own time zone.
- Depends on: the
SessionIdentifierTypealias (solution-wideglobal using, see primer §2). Externals: BCLDateTime, plusSystem.Text.Jsonname-based binding at the call site. - Concept, the locally mirrored wire shape.
[Rubric §7, Microservices Readiness]assesses whether a module can be lifted out without dragging a neighbor's assembly along, and[Rubric §9, API & Contract Design]how a payload contract is expressed to its consumers. The bytes on the wire are produced by Conference's NowNextSessionDTO; Engagement redeclares only the fields it renders instead of referencingMMCA.ADC.Conference.Sharedfor one payload, and JSON binds them by property name. The type doc on the enclosing snapshot records that this is a deliberate mirror and that the Android home-screen widget makes the same call with a mirror of its own (INowNextService.cs:6-9; the widget's private copy is atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/NowNextWidgetProvider.cs:130-134). The same tolerant-reader trade-off taught on AttendeeRow applies: nothing at compile time keeps the mirror aligned with the DTO, only the round-trip tests do. - Walkthrough (
INowNextService.cs:27-32): five positional members,SessionId(line 28, the deep-link target),Title(29),RoomName(30, nullable),StartsAtLocal(31), andEndsAtLocal(32). Two details are load-bearing.RoomNameis null whenever the session carries no room id, or carries one the event's room map does not contain, a decision the server makes in a single expression (GetNowNextHandler.cs:78). And the two times are plainDateTimewall clock in the event's zone, not UTC and notDateTimeOffset: the server passes the stored wall-clock values straight through (GetNowNextHandler.cs:79-80) while keeping the UTC instants it computed for its own filtering on its side of the row (:81-82), so no client ever converts a zone. - Why it's built this way: glanceable surfaces (a page card, a home-screen widget) should print exactly what the printed schedule says, so the wall-clock value travels as data. The UTC instants stay on the server's side of the contract for callers that do their own math; this mirror deliberately drops them, along with
EventId. The slice arrives with ADR-042 Wave 8 (GetNowNextHandler.cs:13). - Where it's used: only inside NowNextSnapshot's
NowandNextlists; the HappeningNow page holds them in_sessionsNow/_sessionsNext(HappeningNow.razor.cs:49-50) and renders one card per row. - Caveats / not-in-source: the mirror is checked by NowNextServiceTests round-tripping a JSON body, not by the compiler, so a renamed server property would deserialize to a default value rather than break the build.
SelfCheckInOutcome<TResult>
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/SelfCheckInOutcome.cs:20· Level 0 · sealed record (generic)
- What it is: the return shape of one self-service check-in post, a sponsor booth visit or a room check-in. It carries either the recorded payload or the server's stable error code, so the calling page can pick its state without catching an exception.
- Depends on: nothing first-party; it is closed over
SponsorVisitResultDTOandRoomCheckInResultDTOat the two call sites, and itsErrorCodevalues come fromCheckInErrorCodes. Externals: BCL only, with awhere TResult : classconstraint (SelfCheckInOutcome.cs:21) so the nullableValuemeans exactly "absent". - Concept introduced, the result envelope for a refusal that is not a fault.
[Rubric §1, SOLID]and[Rubric §9, API & Contract Design]both apply: the type doc (SelfCheckInOutcome.cs:3-13) explains that the landing pages must tell three server answers apart (no session running in this room, event not published, and the flat "unavailable" a disabled feature or unknown target produces), that all three arrive as ordinary non-success responses, and that two of them share the same 404 status. A result carrying only a message would force the page to parse localized prose; an exception would force atry/catcharound a normal outcome. Carrying the code as data is what makes the page's mapping aswitchexpression over constants. Note this is deliberately not the framework's Result, which the other four members of the same interface do return: this is a UI-layer transport envelope for one narrow HTTP pattern, so it stays small and local rather than reusing an abstraction whose error vocabulary it does not need. - Walkthrough (
SelfCheckInOutcome.cs:20-25): two positional members,Value(the recorded outcome, null when the server refused) andErrorCode(null when the response carried none: a bare feature-gate 404 or an unparseable body, per the param doc at:16-19). One computed member,IsSuccess => Value is not null(:24), so success is derived from the payload rather than tracked as a third field that could disagree with it. Construction is centralized inCheckInService.PostSelfCheckInAsync(CheckInService.cs:123-133): the success branch deserializesTResultand pairs it with a null code (:122-125), the failure branch passes null with whateverReadErrorCodeAsyncextracted from the RFC 9457 problem body (:128,:145). - Why it's built this way: the four other members on the same service run through
HttpResultExecutor.ExecuteAsyncand answer with a framework Result (CheckInService.cs:27-28,:71,:94), because an organizer facing a broken scan wants a typed failure to surface. The two attendee-facing self-service writes must not, because every refusal is a sentence the landing page renders calmly to someone holding a phone in a hallway. The method doc records exactly that split (CheckInService.cs:106-113). Keeping the generic on the payload lets one private helper serve both endpoints with no duplication (ADR-072). - Where it's used: returned by
ICheckInUIService.RecordSponsorVisitAsyncand.RecordRoomCheckInAsync(ICheckInUIService.cs:54,:60), implemented atCheckInService.cs:57and:62, consumed bySponsorVisit(:59-63,:73) andRoomCheckIn(:56-60,:70-74).
SessionReminder
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderPlanner.cs:15· Level 0 · sealed record
- What it is: an immutable value describing one planned on-device reminder for a bookmarked session, a stable notification id, when it should fire, and where a tap takes the user.
- Depends on: the
SessionIdentifierTypealias (solution-wideglobal using, see primer §2). Externals: BCLDateTimeOffset. No first-party types beyond the alias. - Concept, the immutable planning DTO.
[Rubric §19, State Management]assesses whether client-side state is modeled as explicit, predictable values rather than mutable ad-hoc fields. Asealed recordgives value equality andinit-only positional members for free (see primer §4), so a planned reminder is a snapshot that cannot drift afterSessionReminderPlannercomputes it. The type carries no behavior: it is pure data handed from the pure planner to the stateful coordinator. - Walkthrough (
SessionReminderPlanner.cs:15-21): six positional members,NotificationId(int, line 14),SessionId(15),SessionTitle(16),StartsAtUtc(17),DeliverAt(18), andDeepLinkRoute(19). The doc comment (:3-12) is load-bearing intent:NotificationIdis derived deterministically from the session id so rescheduling replaces an existing OS notification instead of stacking a duplicate,DeliverAtis the fire instant (start minus lead, clamped to now), andDeepLinkRouteis the app-relative route published on tap. - Why it's built this way: keeping the planned reminder as a value lets the planner stay a pure function and the coordinator diff plans by id (ADR-042 Wave 2, the local-notification dispatcher).
- Where it's used: produced by
SessionReminderPlanner.Plan(SessionReminderPlanner.cs:81-87); consumed bySessionReminderCoordinator, which callsPlanatSessionReminderCoordinator.cs:159and turns each returned value into aLocalNotificationRequest(:180).
NowNextSnapshot
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:15· Level 1 · sealed record
- What it is: the whole "happening now / up next" payload for one event as the Engagement UI consumes it, the event's display name, whether the event is live at the query instant, and two lists of NowNextSessionInfo rows.
- Depends on: NowNextSessionInfo. Externals: BCL
IReadOnlyList<T>. - Concept, the two-bucket time projection, computed once on the server.
[Rubric §12, Performance & Scalability]assesses whether work sits where it is cheapest, and[Rubric §18, UI Architecture]whether a page renders a prepared view model rather than deriving one. Both buckets are decided by GetNowNextHandler against a single instant taken from an injectedTimeProvider(GetNowNextHandler.cs:29), never from the browser clock.Nowis every eligible session whose window is half-open around that instant,StartsAtUtc <= utcNow && utcNow < EndsAtUtc(GetNowNextHandler.cs:53), so a session that ends exactly now is already out and the one starting exactly now is already in.Nextis not "the next session": it is the batch that shares the earliest future start (:58-66), which is what makes parallel tracks appear together instead of one arbitrary room winning. The empty cases are ordinary, not error states: between sessionsNowis empty, after the last sessionNextis empty (nextStartis null and the list is[],:60-62), and before the first sessionNowis empty whileNextcarries the opening batch. - Walkthrough (
INowNextService.cs:15-19): four positional members,EventName(line 16),IsLive(17),Now(18), andNext(19).IsLiveis independent of the two lists: the server compares the same instant against the event's live window from CurrentEventSelector.GetLiveWindowUtc(StartDate, EndDate, TimeZone)(GetNowNextHandler.cs:68-69), so a live event during a coffee break reportsIsLive: truewith an emptyNow. Ordering is fixed server-side too:Nowsorts by start then by room name ordinal-ignore-case (:54-55),Nextby room name alone (:65) since every row in it shares one start. - Why it's built this way: no client can compute either bucket correctly on its own, because eligibility depends on data a public catalog read does not expose (see INowNextService), so the snapshot is shipped precomputed and every surface (page and widget) shows the same answer. The one snapshot type also keeps
IsLivenext to the lists, which is what lets a caller distinguish "the conference is not running" from "nothing is on right now". - Where it's used: carried by the Result that INowNextService
.GetAsyncreturns; the HappeningNow page projects it withresult.Value?.Now ?? []andresult.Value?.Next ?? [](HappeningNow.razor.cs:157-159), so a failed read renders the same empty state as an empty list. Conference's own wire type is NowNextDTO (GetNowNextHandler.cs:71), and the Android widget deserializes a third copy of the shape (NowNextWidgetProvider.cs:127,:132). - Caveats / not-in-source: nothing on the record marks how fresh it is. Staleness is bounded by two caches only, the query result cache of 30 seconds (GetNowNextQuery implements IQueryCacheable,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:38) and the endpoint's 60-secondNowNextCacheoutput-cache policy (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232, applied atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:173and:187). The page fetches the snapshot from its own load path (HappeningNow.razor.cs:157) and has no timer of its own for it, so a long-open tab keeps its first now-and-next lists until something refreshes it.
SessionReminderPlanner
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderPlanner.cs:31· Level 1 · static class
- What it is: a pure, side-effect-free planner that turns a set of bookmarked sessions plus an event time zone and a lead time into a list of
SessionRemindervalues. All the date math (wall-clock to UTC, DST edge cases, lead-window clamping) lives here. - Depends on:
SessionReminder,SessionInfo(the input session shape),EngagementRoutePaths(the tap route), and theSessionIdentifierTypealias. Externals: BCLTimeZoneInfo,DateTimeOffset. - Concept, the deterministic pure function.
[Rubric §14, Testability]assesses whether logic can be exercised without infrastructure. Every input the planner needs is a parameter, includingnow(SessionReminderPlanner.cs:53), the current instant, which the caller supplies rather than the planner readingDateTimeOffset.UtcNowitself. That single choice makes DST behavior, lead-window clamping and skip rules unit-testable with fixed clocks and no OS notification stack.[Rubric §27, Internationalization]also applies: session times are wall-clock local to the event's IANA time zone, never UTC in the DTOs (SessionInfo.StartsAtis a bareDateTime?,ISessionLookupService.cs:12), so correct conversion is a first-class concern here rather than an afterthought. - Walkthrough:
NotificationIdSeed(:34) is a private constant offset that keeps reminder ids clear of any other local-notification ids the app might use. The comment above it (:31-33) states the assumption the whole replace-by-id contract rests on:SessionIdentifierTypehash codes are stable for value types.GetNotificationId(sessionId)(:37-38) shifts the seed 16 bits and XORs the session id's hashunchecked, producing that stable id.Plan(bookmarkedSessions, timeZoneId, leadTime, now)(:47-89) guards its arguments (:53-54), resolves theTimeZoneInfo(:56), then per session skips those with no start time (:61-64) or already started (:67-70), computesdeliverAt = startsAtUtc - leadTimeand, when that instant has already passed, fires an immediate reminder 30 seconds out rather than dropping it (:72-77), and finally emits aSessionReminderwhose tap route comes fromEngagementRoutePaths.SessionDetails(:79-85,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:12).ToUtc(localWallClock, timeZone)(:91-104) is the DST-correct converter: it marks the wall timeUnspecified(:93), shifts a spring-forward invalid time forward one hour (:96-99), and letsGetUtcOffsetresolve ambiguous fall-back times to the standard offset (:101-103).
- Why it's built this way: separating planning (pure, here) from scheduling (I/O, in the coordinator) means the tricky calendar logic is provable in isolation and the coordinator stays a thin best-effort orchestrator (ADR-042 Wave 2, named in the type doc at
:22). - Where it's used: called by
SessionReminderCoordinator.ReplanAsync(SessionReminderCoordinator.cs:159);GetNotificationIdis also called there to cancel reminders for sessions that left the tracked set (:99), to cancel everything when reminders are switched off (:118), and to sweep the ids that dropped out of the plan (:170). Covered by SessionReminderPlannerTests.
ICheckInUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/ICheckInUIService.cs:20· Level 3 · interface
- What it is: the single UI contract for every QR badge check-in surface: the attendee's own badge, the organizer scan and manual fallback writes, the two attendee self-service scans, and the organizer attendance rollup.
- Depends on:
MyBadgeDTO,CheckInAttendeeRequest,ManualCheckInRequest,CheckInResultDTO,SelfCheckInOutcome<TResult>,SponsorVisitResultDTO,RoomCheckInResultDTO,AttendanceStatsDTO, Result, and theSponsorIdentifierType/RoomIdentifierType/EventIdentifierTypealiases. The UI service abstraction itself is taught onIBookmarkUIService. - Concept introduced, two error contracts in one interface.
[Rubric §9, API & Contract Design]assesses whether a contract's failure semantics are legible from its signatures, and this interface is the clearest example in the module because it deliberately carries two. Four methods return a Result, the ordinary shape for this module: a refusal is a typed failure the page renders. Two methods returnSelfCheckInOutcome<TResult>instead, because those two are the endpoints an attendee reaches by scanning a printed code, and every refusal there has to be told apart by a stable error CODE and rendered as a calm sentence. The type doc states exactly that split (ICheckInUIService.cs:13-18). The class doc onCheckInService(CheckInService.cs:16-19) states the other axis: every call is authenticated, the badge read is caller-scoped ("my badge"), and the three organizer calls are role-gated server-side, so[Rubric §11, Security]lives on the server, not in this contract. - Walkthrough (
ICheckInUIService.cs:20-76):GetMyBadgeAsync(ct)(:23) fetches the signed-in attendee's own badge credential; an attendee with no badge answersErrorType.NotFound, which the badge page renders as its empty state (:18-21).CheckInByCredentialAsync(request, ct)(:30-32) checks an attendee in from a scanned or typed badge payload; organizer only.ManualCheckInAsync(request, ct)(:40-42) is the fallback used when a badge cannot be scanned; organizer only. Both post through the sharedPostCheckInAsync(CheckInService.cs:98), whose doc (:80-84) records that a repeat check-in is reported throughAlreadyCheckedInrather than as a failure, so there is no conflict branch to interpret.RecordSponsorVisitAsync(sponsorId, ct)(:50-52) records the caller's visit to one sponsor booth; the doc states a repeat visit is a success carryingAlreadyVisited, not a failure (:44-47).RecordRoomCheckInAsync(roomId, ct)(:60-62) checks the caller into whatever session the scanned room is hosting right now. The doc sentence at:54-57is the whole design of that surface: the session is never sent, the server resolves it from the room and the configured grace window, which is why a printed room code never expires when the schedule moves.GetAttendanceStatsAsync(eventId, ct)(:69-71) fetches the attendance rollup for one event; organizer only (CheckInService.cs:75-82).
- Why it's built this way: one interface rather than one per surface keeps the whole check-in vocabulary, and the single
checkinsresource it maps to (CheckInService.cs:24), in one reviewable place; the six methods correspond one-for-one to the six endpoints onCheckInsController(ADR-071 for the scan surfaces, ADR-072 for the check-in and points model). - Where it's used: implemented by
CheckInService(CheckInService.cs:22), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:47), and injected into five pages:CheckInScan(CheckInScan.razor.cs:26),MyBadge(MyBadge.razor.cs:17),OrganizerAttendance(OrganizerAttendance.razor.cs:19),RoomCheckIn(RoomCheckIn.razor.cs:25) andSponsorVisit(SponsorVisit.razor.cs:26). Server-side the whole controller sits behind[FeatureGate(EngagementFeatures.CheckIn)](CheckInsController.cs:37) with the two self-service writes gated again individually (:130,:161), seeEngagementFeaturesand ADR-031.
INowNextService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:40· Level 3 · interface
- What it is: the UI-side contract for reading one event's now-and-next snapshot, a single method returning a Result over a NowNextSnapshot.
- Depends on: NowNextSnapshot, Result, and the
EventIdentifierTypealias. Externals: BCLTask,CancellationToken. - Concept, a port over a server-owned computation.
[Rubric §18, UI Architecture]assesses whether components depend on typed contracts rather than transport, and[Rubric §7, Microservices Readiness]whether cross-module reads travel through an explicit boundary. The interface doc (INowNextService.cs:34-39) is the contract worth memorizing, because it enumerates what the server owns and the client therefore must not re-derive: session eligibility (scheduled, non-service, not declined or cancelled), the event-local wall-clock conversion, and the "next = the batch sharing the earliest future start" rule. Concretely, eligibility isCalendarExportMapper.IsExportable(GetNowNextHandler.cs:48). None of that is visible in a public catalog listing, which is precisely why the page cannot compute now-and-next itself; the implementation's doc records that this endpoint replaces the old whole-catalog fetch on Happening Now (NowNextService.cs:8-12). - Walkthrough (
INowNextService.cs:49-51): one method,GetAsync(eventId, cancellationToken = default), returningTask<Result<NowNextSnapshot>>. The failure contract is the doc at:42-46: an event that is not found or not published answers anErrorType.NotFoundfailure, which the page renders as "nothing scheduled". The server produces it asResult.FailurecarryingError.NotFoundwhen the event is missing orIsPublishedis false (GetNowNextHandler.cs:32-36), the API surfaces it as a 404, andProblemDetailsResultReaderturns it back into a typed failure in the client (NowNextService.cs:29-31). Answering with a failure rather than throwing is what lets a caller degrade to an empty schedule with notry/catchof its own. - Why it's built this way: the one-method port is the DI swap point (
services.AddScoped<INowNextService, NowNextService>(),MMCA.ADC.Engagement.UI/DependencyInjection.cs:60) and the test extension point, so the page can be exercised against a fake snapshot with no HTTP stack; it also keeps Engagement free of any compile-time reference to Conference for this read (ADR-007 and ADR-008 put the module in its own process, so this crosses a real service boundary over public HTTP; the implementation uses the plain"APIClient"factory client with no token,NowNextService.cs:23, because the endpoint is anonymous). - Where it's used: injected into the HappeningNow page (
HappeningNow.razor.cs:28) and called from its load path (:156). Implemented by NowNextService (NowNextService.cs:14); covered by NowNextServiceTests. - Caveats / not-in-source: the contract exposes only the per-event form. The endpoint also serves an id-less form where the server auto-selects the current-or-next published event, but no first-party C# client for it exists in this UI; the Android widget calls
Events/now-nextwith a bareHttpClientinstead (NowNextWidgetProvider.cs:119-120). Note also a wording gap: the doc comment says "not declined or cancelled" while the code's actual gate is the calendar-export allow-list above.
CheckInService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/CheckInService.cs:20· Level 4 · sealed class
- What it is: the single HTTP client for every badge and scan surface in the module, implementing
ICheckInUIServiceagainst thecheckinsresource (CheckInService.cs:24). It carries six calls with three different audiences: the attendee reading their own badge, the organizer scanning or manually checking someone in, and the attendee self-recording a sponsor booth or room visit. - Depends on:
ICheckInUIService,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,MyBadgeDTO,CheckInAttendeeRequest,ManualCheckInRequest,CheckInResultDTO,SponsorVisitRequest,SponsorVisitResultDTO,RoomCheckInRequest,RoomCheckInResultDTO,AttendanceStatsDTO,SelfCheckInOutcome<TResult>. Externals:IHttpClientFactory,System.Text.Json(JsonDocument),System.Net.Http.Json,System.Globalization. - Concept introduced, two failure dialects in one client. The authenticated-HTTP shape is taught at
BookmarkService; what is new here is that this class deliberately reports failure two different ways depending on who is looking at the screen.[Rubric §9, API & Contract Design]assesses whether a client contract preserves the distinctions the server actually makes. The four organizer and badge calls answer with aResult, carrying the server's own error code and type. The two attendee self-service calls do not: they return aSelfCheckInOutcome<TResult>carrying only a raw error-code string, because the QR landing pages have to tell "no session is running in this room" from "the owning event is not published" and both arrive as HTTP 404 (CheckInService.cs:115-122, and the outcome record's own doc atSelfCheckInOutcome.cs:5-12). A status code alone cannot pick the state, so the code is read out of the problem body and handed to the page.[Rubric §26, Front-End Security]is the reason the pages then collapse several of those codes into one message: seeCheckInStateandVisitState. - Walkthrough (public calls first, then the two private posters)
GetMyBadgeAsync(:23-38) GETscheckins/my-badge. The comment (:33-35) records the contract precisely: a 404 arrives as aNotFoundfailure rather than the oldnull, so "this attendee has no badge" stays a distinguishable state while the badge page renders exactly the empty surface it rendered fornull. The server mints a badge on first use, so this read is a create-or-get on the other side (CheckInsController.cs:50).CheckInByCredentialAsync(:41-44) andManualCheckInAsync(:47-50) are one-line delegations toPostCheckInAsync, differing only in the relative URL: the rootcheckinsandcheckins/manual. Both server endpoints are permission-gated onEngagementPermissions.CheckInManage(CheckInsController.cs:79,:99), so the client does no role check of its own.RecordSponsorVisitAsync(:53-59) andRecordRoomCheckInAsync(:62-68) build their one-field requests inline (SponsorVisitRequest,RoomCheckInRequest) and delegate toPostSelfCheckInAsync. Neither call passes an attendee identity: the server takes it from the token, so a scanned code can only ever check in the person holding the phone. Each also has its own feature gate on the server (CheckInsController.cs:134,:161).GetAttendanceStatsAsync(:71-87) GETscheckins/stats?eventId=with invariant formatting (:79) and returns theAttendanceStatsDTOrollup; the endpoint is permission-gated the same way as the writes (CheckInsController.cs:183).PostCheckInAsync<TRequest>(:94-109) is theResult-returning poster shared by the two organizer writes. Its doc comment (:89-93) records why there is no conflict branch: a repeat check-in comes back withAlreadyCheckedInset rather than as a 409, so the caller has one success shape to interpret.PostSelfCheckInAsync<TRequest, TResult>(:119-138) is the outcome-returning poster, and notably the one method in the class that does not go throughHttpResultExecutoror the reader. On success it reads the payload withReadFromJsonAsyncand wraps it (:131-135); on any non-success it returnsnew SelfCheckInOutcome<TResult>(null, await ReadErrorCodeAsync(...))(:137), so a refusal is data, not an exception.ReadErrorCodeAsync(:145-180) walks the problem body'serrorsarray and returns the first element'scodestring (:156-170). Everything else yieldsnull: an empty body (:152-153), a body with noerrorsarray, a non-string code, or aJsonExceptionfrom an HTML error page or a bare challenge (:174-179, with the comment naming that case).nullis a meaningful value here, it is what the pages render as the generic "unavailable" state.
- Why it's built this way: the class doc (
:12-14) sets the rule that every call is authenticated, including the badge read, because the badge is caller-scoped rather than public (CheckInsController.cs:38). The split between the two private posters is the client-side consequence of a server design decision recorded in ADR-072: organizer scanning and attendee self-service produce the same domain writes but face very different humans, so the refusal vocabulary is stable (CheckInErrorCodes) and the client passes it through untranslated. - Where it's used: registered scoped as
ICheckInUIService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:47) and injected by five surfaces:MyBadge(MyBadge.razor.cs:17, called at:49),CheckInScan(CheckInScan.razor.cs:26, called at:163,:186),OrganizerAttendance(OrganizerAttendance.razor.cs:19, called at:51),RoomCheckIn(RoomCheckIn.razor.cs:25, called at:56) andSponsorVisit(SponsorVisit.razor.cs:26, called at:59). The whole controller sits behind theEngagementFeatures.CheckIngate (CheckInsController.cs:37), so a deployment with the feature off answers 404 to every one of these calls. - Caveats / not-in-source:
CheckInServiceTestscovers only the self-service path, two facts onRecordSponsorVisitAsyncasserting that the response is disposed on both the success (:30) and the failure (:58) branch, the latter also checking the error code is read. The other four calls are exercised indirectly through the bUnit page tests (MyBadgeTests,CheckInScanTests,OrganizerAttendanceTests,RoomCheckInTests,SponsorVisitTests), which mock the interface rather than the transport. Which error codes a given server build emits is the API's contract, not this file's.
NowNextService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/NowNextService.cs:14· Level 4 · sealed class
- What it is: the HTTP implementation of
INowNextService, one GET against the Conference API's anonymousEvents/{id}/now-nextendpoint, read into aNowNextSnapshotand handed back untouched. - Depends on:
INowNextService,NowNextSnapshot,HttpResultExecutor,ProblemDetailsResultReader. Externals:IHttpClientFactory,HttpClient,CultureInfo. - Concept introduced, the thin lookup service (the contrast with the authenticated services above).
[Rubric §18, UI Architecture]and[Rubric §12, Performance & Scalability]. UnlikeBookmarkServicethis class does not derive fromAuthenticatedServiceBase: it takes the named"APIClient"from the factory directly (NowNextService.cs:23), the same shape as the module's other read-only lookups (SessionLookupService). That named client is registered once in the framework (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:85) with the gateway base address plus the auth and culture delegating handlers, so a bearer token still rides along when the user has one, but the endpoint is[AllowAnonymous](MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:172) and an anonymous visitor gets the same payload. The consequence to notice: no token fetch and noRetryPolicywrap this call (:25-27callsGetAsyncdirectly), because there is nothing user-specific to authorize and the answer is cheap to re-request. - Walkthrough (
NowNextService.cs:17-33), which is mostly guards- The whole body runs inside
HttpResultExecutor.ExecuteAsync(:20), so a refused connection or a broken socket becomes aHttp.TransportFailurefailure rather than an exception the page has to catch. - Both disposables are scoped with
using, the client (:23) and the response (:25), so a per-call fetch leaks neither. - The relative URI is built with
string.Create(CultureInfo.InvariantCulture, $"Events/{eventId}/now-next")(:26). Invariant formatting is not decoration: the id is interpolated into a path, and a culture-sensitive numeric format would produce a path the API cannot route. - The response goes through
ProblemDetailsResultReader.ReadAsync<NowNextSnapshot>(:31), so 404 becomes aNotFoundfailure, with the comment naming the case it covers (an unpublished or deleted event) and the consequence: the page shows nothing scheduled, exactly as it did for the oldnull(:29-30). A 5xx becomes a failure carrying the server's status code instead, so the page can tell "no such event" from "the service is broken" by reading the error rather than by catching an exception. - The
CancellationTokenis threaded through the GET (:27), the read (:31) and the executor (:32), so page disposal cancels an in-flight fetch and the cancellation propagates rather than being reported as an error. - There is no client-side filtering, sorting or time math anywhere in the method: the lists arrive ordered and filtered and are passed straight to the page.
- The whole body runs inside
- Why it's built this way: the class doc (
:8-13) states the rationale directly, the endpoint already excludes service, declined and cancelled sessions and does the event-local time math, so nothing is recomputed here. A whole-catalog fetch plus client-side time comparison could not tell an ineligible session apart, since eligibility is not part of a public catalog listing. The call is also cheap to repeat: the server output-caches it under theNowNextCachepolicy (EventsController.cs:173) and the payload is identical for every role, so no per-user cache key is needed. - Where it's used: registered scoped as
INowNextServicein the module's UI wiring (MMCA.ADC.Engagement.UI/DependencyInjection.cs:60); consumed only by theHappeningNowpage today (HappeningNow.razor.cs:28, called at:156).[Rubric §14, Testability]: covered by three facts inNowNextServiceTests, the exact request path (:20), the unchanged projection of the server snapshot (:33), and the 404-to-NotFound-failure degradation (:50). - Caveats / not-in-source: no timeout, retry or circuit breaker is configured on this path in the framework registration of the named client. A transient failure is therefore one transport failure and one localized error message, with a page reload as the only retry. Note also that the Conference API exposes a second, id-less form of the same snapshot for the home-screen widget (
EventsController.cs:186); this service does not call it.
SessionReminderCoordinator
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderCoordinator.cs:20· Level 4 · sealed partial class
- What it is: the piece that keeps on-device session reminders in step with the user's bookmarks (ADR-042 Wave 2). It owns a tracked session set and two settings in device preferences, and on every change it replans the whole schedule and cancel-then-reschedules the difference (class doc,
:8-15). - Depends on:
ILocalNotificationService,IDevicePreferences,ISessionLookupService,ILiveEventUIService,SessionReminderPlanner,LocalNotificationRequest,LiveEventContext,SessionInfo,SessionReminder. Externals:IStringLocalizer<T>,ILogger<T>with a source-generated[LoggerMessage]. - Concept introduced, best-effort derived state behind a capability gate. Two ideas meet here. First,
[Rubric §19, State Management]: a scheduled local notification is derived state that only stays correct if it tracks the bookmark set, and the bookmark set changes from any page and any device. This class is the one place that derivation lives. Second,[Rubric §29, Resilience & Business Continuity]: reminders are a convenience and must never fail a bookmark operation, so every entry point is best-effort.MutateTrackedAsync(:107-110) andApplySettingsAsync(:69-72) both wrap their work incatch (Exception ex) when (ex is not OperationCanceledException)and log a warning throughLogReminderSyncFailed(:190-191), rather than propagating. Note the exception filter: a genuine cancellation is not swallowed. Third, and this is what makes the coupling cheap, every entry point begins withif (!notifications.IsSupported) return;(:57-60,:88-91), so on a web head the whole class is a no-op that touches no storage and issues no calls. The[LoggerMessage]source generator is the framework's structured-logging default,[Rubric §13, Observability & Operability]: a failure here is invisible to the user by design, so a log entry is the only signal that reminders may be stale. - Walkthrough (constants, then the four entry points, then the two private engines)
- The preference keys and defaults are public consts:
EnabledKey(:26),LeadMinutesKey(:29) andDefaultLeadMinutes = 15(:34), with the tracked-set key kept private (:31).IsSupported(:37) forwards the capability flag so a settings page can hide the section entirely. NotifyBookmarkAddedAsync(:40-41) andNotifyBookmarkRemovedAsync(:44-45) are one-liners overMutateTrackedAsync, appending withDistinct()and filtering with the default equality comparer for the identifier alias respectively.ResyncAsync(:51-52) replaces the tracked set outright with an authoritative bookmark map. Its doc (:47-50) names the reason it exists: a page that loads the user's bookmarks has the truth in hand, so this is the moment to heal drift caused by changes made on another device.ApplySettingsAsync(:55-73) persists both settings then replans, which is also how disabling works:ReplanAsynccancels everything whenenabledis false.GetSettingsAsync(:76-81) reads the pair back for the device-settings UI, defaulting to enabled and 15 minutes.MutateTrackedAsync(:83-111) is the shared write path: read the tracked array, apply the caller's mutation, persist it (:94-96), cancel reminders for sessions that left the set (:99-103), then replan the rest. Cancelling before replanning is deliberate and the comment says so (:98).ReplanAsync(:113-188) is the heart of the class and reads as a sequence of early returns, each protecting the existing schedule: reminders switched off cancels everything tracked and returns (:115-125); a denied OS permission returns without scheduling, and the comment records that the prompt appears at most once and a denial leaves reminders silently off until the user grants them in system settings (:127-132); no live event returns (:134-138); a catalog read the API refuses returns, with the comment giving the reason, replanning against an empty catalog would cancel reminders that are still valid (:142-148). Note the shape of that last guard:sessionLookup.GetAllAsyncreturns aResultand the code branches onTryGetValue(:145), so "the API said no" and "there are no sessions" are different outcomes.- Past the guards it narrows the tracked ids to sessions in the current event (
:150-154), callsSessionReminderPlanner.Planwith the event's IANA zone, the lead time andDateTimeOffset.UtcNow(:156-160), then does the stale sweep (:169-176): the tracked ids minus the planned ids, cancelled. The comment there is the best single paragraph in the file (:161-168), it names the four ways a session can stay tracked yet drop out of the plan (deleted, moved to another event, times cleared per BR-16, start moved into the past), notes that nothing upstream cancels those, and explains why the sweep sits after a plan was computed rather than beside the early returns: cancelling on a transient null live event would wipe reminders that are still valid. - Finally it schedules each planned reminder as a
LocalNotificationRequestwith a localized title and body (:178-187). The body takes the session title and the lead minutes as format arguments (:183), so the wording is resource-driven rather than concatenated.[Rubric §27, i18n].
- The preference keys and defaults are public consts:
- Why it's built this way: ADR-042 puts device capabilities behind interfaces the web head can answer "unsupported" to, which is what lets this class be registered unconditionally (
MMCA.ADC.Engagement.UI/DependencyInjection.cs:38, whose comment repeats the no-op guarantee) and injected into a service every head uses. Notification ids are derived deterministically from the session id (SessionReminderPlanner.GetNotificationId,SessionReminderPlanner.cs:39-40), which is what makes rescheduling replace instead of duplicate, and is why a replan-everything strategy is affordable. - Where it's used: injected as a concrete class by
SessionBookmarkUIService(SessionBookmarkUIService.cs:29), which drives all three notify or resync members, and by the host's device-settings page (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/DeviceSettings.razor:4), which reads the settings (:67), writes them on change (:89,:95) and seeds its lead-time control fromDefaultLeadMinutes(:61).[Rubric §14, Testability]:SessionReminderCoordinatorTestscovers twelve facts, one per branch above, including the two stale-sweep cases (:129,:155), that no live event cancels nothing (:176), that an unsupported host touches nothing (:223), that lookup failures never throw out of the coordinator (:237), and that a failed catalog read leaves the existing schedule alone (:249). - Caveats / not-in-source:
IsSupported(:37) has no consumer in the repo today, including the device-settings page its doc points at; the page relies on the internal no-ops instead. The class is also registered scoped while the state it manages is device-global: correctness rests on preferences being the single source of truth and on every mutation replanning from scratch, not on any one instance living long. Whether a scheduled notification actually fires is the platform's business, outside this file.
SessionFeedback
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/SessionFeedback.razor.cs:18· Level 6 · class (partial)
- What it is: the code-behind for the attendee session-feedback page (
@page "/feedback/session/{SessionId}",SessionFeedback.razor:1). It checks three business preconditions before it will render the question form at all, then loads the dynamic question set, pre-fills any answers the attendee already submitted, and posts the whole form back in one atomic call. - Depends on:
IQuestionLookupService,ISessionFeedbackUIService,IEntityService<TEntityDTO, TIdentifierType>(bound asIEntityService<SessionDTO, SessionIdentifierType>,SessionFeedback.razor.cs:22),IToastService,SessionDTO,QuestionDTO,SessionQuestionAnswerDTO,FeedbackAnswerModel,ModelValidationandDataAnnotationsModelValidator, plus theParse<T>extension onDomainHelperand theIsNotFound()result extension fromMMCA.Common.UI.Common. Externals: BlazorNavigationManager, MudBlazorMudForm/BreadcrumbItem,System.Globalization, and from the markup an injectedIStringLocalizer<SessionFeedback>L(SessionFeedback.razor:6), the sharedUnsavedChangesGuardcomponent (SessionFeedback.razor:10-13) andErrorSummary(SessionFeedback.razor:106-107). - Concept introduced, the precondition-gated dynamic form.
[Rubric §18, UI Architecture]assesses component/logic separation: the markup lives inSessionFeedback.razorand the behavior in thispartial class, so the page's flow is reviewable apart from its layout.[Rubric §24, Forms, Validation & UX Safety]is the load-bearing category here and it shows up three times. First,CheckPreconditions(SessionFeedback.razor.cs:141) refuses before rendering inputs, so when feedback is not permitted the attendee gets one sentence explaining why rather than a form that fails on submit. Second, validation is declarative and single-sourced:_validateAnswerText(:63) is one delegate built fromFeedbackAnswerModel's annotations and handed to every free-text field, so the 4000-character rule is never written twice. Third,UnsavedChangesGuard IsDirty="_isDirty"wraps the page, so navigating away with unsaved edits prompts first. The dynamic part is the interesting one: the questions are data, not markup, so the render is aswitchoverquestion.QuestionType(SessionFeedback.razor:58-85) that picks aMudRating, an emailMudTextField, or a three-line multilineMudTextField, and each field'sRequired/RequiredErrorcomes from the DTO rather than from the page.[Rubric §19, State Management]covers the_answersworking map;[Rubric §27, i18n]covers the fact that every visible string, error messages included, is anL["..."]lookup. - Walkthrough (teaching order)
- Injected services (
SessionFeedback.razor.cs:20-24) and the[Parameter] string SessionId(:26) arrive from the route. Note the parameter is astring, not an id alias: the route template carries no:intconstraint, so parsing is the page's job. IsLoading/IsSubmitting(:32-33),HasExistingFeedback(:35) andSubmitButtonText(:37-48) drive the chrome: the button reads "Submitting" while a post is in flight, then "Update" or "Submit" depending on whether prior answers exist. The private state fields (:50-57) hold the load error, the precondition message, the session title, the loaded questions, the_answersmap, the_existingAnswerIdsmap, theMudFormreference and the_isDirtyflag.MarkDirty(:65) is the one-line handler every input's@bind-...:aftercalls.OnInitialized(:68-72) builds_validateAnswerTextonce, by handing a throwawayFeedbackAnswerModel, themodel => model.TextValueselector and aDataAnnotationsModelValidator(L)toModelValidation.ForProperty. The strongly-typedForPropertyoverload (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:69) is the right one here because these fields have noForexpression and the value being validated has not been written to a model yet; the localizer resolves theErrorMessageresource key (ADR-027).OnInitializedAsync(:74) builds the three-item breadcrumb trail, then loads the session throughSessionService.GetByIdAsync(sessionId, false, _cts.Token)(:88). The failure branch (:89-97) splits the two reasons apart withsessionResult.IsNotFound(): a genuine 404 keeps the page's own "session not found" wording, anything else falls to the generic load failure.CheckPreconditions(session)(:101) runs next, and a returned message stops the load before any questions are fetched (:102-106). Only then does it load the"Session"questions ordered bySort(:109-116), seed oneFeedbackAnswerModelper question (:119), and pre-fill from the server (:122-129). Every await passes_cts.Token;OperationCanceledExceptionis swallowed as expected on disposal (:131-134), andIsLoadingis cleared infinally(:135-138).CheckPreconditions(:141) encodes three rules, each returning a localized message. BR-91 rejects service sessions (session.IsServiceSession,:144-147). BR-49 rejects sessions whoseStatusis set and is not"Accepted", compared case-insensitively (:150-154). BR-16 rejects unscheduled sessions, whereStartsAtorEndsAtis null (:158-161), because session feedback is a time-gated action. Returningnullmeans feedback is allowed.PreFillExistingAnswers(:166) is defense in depth. Because feedback questions are shared across sessions, an answer belonging to a different session would still match byQuestionId, so the loop skips any answer whoseSessionIddoes not equal the current one (:172-175) before mapping a rating (culture-invariantint.TryParse,:184-187) or a text value onto the model. It also records the server-assigned answer id in_existingAnswerIds(:182), which is what enables the per-question clear button.SubmitFeedbackAsync(:196) validates the form and toasts a warning if it is invalid (:198-206), then projects the answered questions into apendinglist of(QuestionId, AnswerValue)tuples, dropping blanks (:216-220). An empty list short-circuits: with nothing answered there is no form to send, so the submit stays a local no-op rather than a request the batch endpoint would refuse (:222-232). Otherwise it makes one call,FeedbackService.SubmitAnswersAsync(sessionId, pending, _cts.Token)(:226), which the interface documents as applied atomically server-side (IFeedbackUIService.cs:57-64). A failure therefore wrote nothing: the page toasts an error and returns with_isDirtystill set, so the same Submit button retries. On success it clears_isDirty, toasts the count, then callsStateHasChanged()beforeNavigateBack()(:237-238); the inline comment (:236) explains why: the flush letsUnsavedChangesGuard'sIsDirtyparameter update beforeNavigateTofires theLocationChanginghandler, otherwise the guard would prompt over changes that were just saved.DeleteAnswerAsync(:250) clears one previously-saved answer: it looks the answer id up in_existingAnswerIds, callsFeedbackService.DeleteAnswerAsync, removes the entry, resets the model's two members (:266-271), and clears_isDirtybecause the delete already committed server-side (:273-275).ParseSessionId(:284-285) converts the route string withSessionId.Parse<SessionIdentifierType>(), theDomainHelperextension, rather than a hand-rolled parse.NavigateBack(:287-288) returns to the Conference session detail route.GetAnswerValue(:314) is the static bridge from aFeedbackAnswerModelback to a wire string, treatingRatingValue > 0as the only answered rating (:321-323).Dispose(bool)/Dispose()(:292-312) implement the standard disposable pattern, cancelling and disposing_ctsso in-flight loads stop when the attendee leaves.
- Injected services (
- Why it's built this way: pushing the three business rules into one
CheckPreconditionsmethod keeps the render path binary (either a precondition message or the form) and makes each BR individually reviewable and individually testable. The single batch submit is the more consequential choice, and it is the one place this page diverges fromEventFeedback: because the server applies a whole session form atomically, there is no partially-saved state to report to the attendee, so the failure path is a plain retry rather than a "3 of 5 saved" message. The cancellation-token discipline on every await and theStateHasChanged-before-navigate ordering are correctness, not style. - Where it's used: rendered at
/feedback/session/{SessionId}, reached from the public session detail page'sSubmitFeedback(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionDetail.razor.cs:337). The same route is also expressed asEngagementRoutePaths.SessionFeedback(sessionId)(EngagementRoutePaths.cs:18). Its dependencies are registered by the Engagement UIDependencyInjection. Component-tested bySessionFeedbackTests. - Caveats / not-in-source: the markup, the resource strings and
UnsavedChangesGuard's own behavior live outside this file. The BR numbers in the comments are the page's own claim about which business rule it implements; the authoritative statements live in the ADC specifications guide, not in this file.
EventFeedback
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:19· Level 8 · class (partial)
- What it is: the code-behind for the attendee event-feedback page (
@page "/feedback/event/{EventId}",EventFeedback.razor:1). It loads the event name and its dynamic question set, pre-fills any answers the attendee already submitted, and persists each answer as a POST-as-upsert (BR-107, per the class doc comment atEventFeedback.razor.cs:15-18). - Depends on:
IQuestionLookupService,IEventFeedbackUIService,IEventLookupService,IToastService,QuestionDTO,EventQuestionAnswerDTO,FeedbackAnswerModel,ModelValidation,DataAnnotationsModelValidatorandDomainHelper(theParse<T>extension). Externals: BlazorNavigationManager, MudBlazorMudForm/BreadcrumbItem,System.Globalization, and from the markup an injectedIStringLocalizer<EventFeedback>L(EventFeedback.razor:6) plus the sharedUnsavedChangesGuard(EventFeedback.razor:10-13) andErrorSummary(EventFeedback.razor:95-96) components. - Concept: the dynamic feedback form (the
_answersmap, the single validation delegate, theUnsavedChangesGuardpairing and theStateHasChanged-before-navigate ordering) is taught onSessionFeedbackand is identical here. What distinguishes this page is the submit strategy, and it is a genuine behavioral difference rather than a copy:IEventFeedbackUIServiceexposes only a per-questionSubmitAnswerAsync(IFeedbackUIService.cs:26), with no batch counterpart, so the page posts a loop and must be able to report a partial save.[Rubric §24, Forms, Validation & UX Safety]assesses exactly that: a form that can half-succeed owes the user an honest count and a safe retry. - Walkthrough of what differs from
SessionFeedback- There is no precondition gate and no
_preconditionMessage: an event either loads or it does not.OnInitializedAsync(EventFeedback.razor.cs:74) resolves the event name fromEventLookup.GetAllAsync(:86), setting a localized_loadErrorwhen the lookup itself fails (:88) or when the id is absent from it (:99). Everything after that (questions ordered bySortat:111, oneFeedbackAnswerModelper question at:114, the pre-fill at:117-124) matches the session page step for step. PreFillExistingAnswers(:136) applies the same cross-entity guard, skipping answers whoseEventIddoes not match the current event (:142-145).SubmitFeedbackAsync(:166) validates the form (:168-176), projects the answered questions intopendingdropping blanks (:186-189), then walks that list one upsert at a time (:192-202). The loop is deliberately failure aware: it counts successes insubmitted, and on the first failure toastsSnackbar.PartialSubmitwith bothsubmittedandpending.Count(:197) and returns while leaving_isDirtyset. That is safe precisely because the endpoint is an upsert: pressing Submit again re-sends the already-saved answers as no-ops and retries the rest. Compare the session page, which gets an all-or-nothing guarantee from the server and therefore has nothing partial to report.ParseEventId(:253-254) usesEventId.Parse<EventIdentifierType>();NavigateBack(:256-257) returns to the Conference event detail route.DeleteAnswerAsync(:220),GetAnswerValue(:283) and the disposable pattern (:261-281) are the session page's, unchanged in mechanism.
- There is no precondition gate and no
- Why it's built this way: the page owns orchestration only, all I/O goes through injected UI service interfaces, which keeps it testable under bUnit and swappable per host. The per-question loop is not a preference: it is the shape the event feedback contract offers, and the partial-submit message is the page being truthful about what that contract can guarantee. Sharing the rest of the form shape with
SessionFeedbackby convention rather than through a base class keeps each page self-contained and independently editable, which is what let the session page adopt a batch endpoint without touching this one. - Where it's used: rendered at
/feedback/event/{EventId}, reached from the public event detail page'sSubmitFeedback(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor.cs:177). The same route is also expressed asEngagementRoutePaths.EventFeedback(eventId)(EngagementRoutePaths.cs:17). Its dependencies are registered by the Engagement UIDependencyInjection. Component-tested byEventFeedbackTests. - Caveats / not-in-source: the markup, the resource strings and
UnsavedChangesGuard's own behavior live outside this file; this section covers the code-behind plus the bindings its members exist to serve.
AttendeeRow
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/AttendeeLookupService.cs:184· Level 0 · record (private sealed, nested)
- What it is: the wire shape of one row returned by the Identity
/Usersendpoint, declared privately insideAttendeeLookupServicepurely soSystem.Text.Jsonhas something to bind to. It never leaves the service: every row is projected into the publicAttendeeSummarybefore it reaches a caller. - Depends on: the
UserIdentifierTypealias (solution-wideglobal using, see primer §2). Externals:System.Text.Jsonbinding at the call site, wrapped inPagedCollectionResult<T>. - Concept introduced, the private deserialization shape.
[Rubric §9, API & Contract Design]assesses how a consumer expresses the contract it reads. Two types for one payload looks redundant until you read what the private one is allowed to be: the doc comment (AttendeeLookupService.cs:180-183) records that only the fields the check-in surfaces render are read and that the device and audit fields the endpoint may carry are ignored, which is exactly the tolerant-reader posture.[Rubric §7, Microservices Readiness]is the other half: Engagement redeclares five fields instead of referencing the Identity assembly for its list DTO (IAttendeeLookupService.cs:6-9states that constraint for the public twin), so Identity can move, be versioned, or add columns without a compile-time edit here. - Walkthrough (
AttendeeLookupService.cs:184-189): five positional members,UserId,Email,FirstName,LastName,Role, structurally identical toAttendeeSummaryminus its computedDisplayName. It is bound asPagedCollectionResult<AttendeeRow>(AttendeeLookupService.cs:83-84) so one read picks up both the page items and the pagination metadata, and converted one row at a time by the privateToSummary(:177-178). - Why it's built this way:
private sealedkeeps the wire shape from becoming an accidental public contract. The projection point is the single place where a server rename would have to be absorbed, and the computed display fallback lives on the public record rather than on the row, so the transport type stays inert data. - Where it's used: only inside
AttendeeLookupService, bySearchAsync(:50,:83-88) and, through it, by the roster snapshotLoadRosterAsyncbuilds (:143-175). - Caveats / not-in-source: nothing at compile time keeps this shape aligned with what Identity actually serializes. A renamed server property binds to the member default rather than failing the build; only AttendeeLookupServiceTests round-tripping a JSON body catches it.
AttendeeSearchField
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:41· Level 0 · enum
- What it is: which one of last name, first name or email the organizer's single search box applies to when looking an attendee up at the door.
- Depends on: nothing (a bare
enum). - Concept introduced, the enum that exists because of a server semantic.
[Rubric §9, API & Contract Design]assesses whether a client contract makes the server's actual behavior usable. The type doc states the reason outright (IAttendeeLookupService.cs:36-40): the Identity users endpoint ANDs its filters, so broadcasting one typed term across all three fields would match nobody. A UI that offered one box and no field picker would therefore be a UI that silently never finds anyone.[Rubric §24, Forms, Validation & UX Safety]covers the resulting shape: one box plus one explicit selector, so the organizer always knows which field the term is being matched against. - Walkthrough (
IAttendeeLookupService.cs:41-51): three members with explicit values,LastName = 0(:44),FirstName = 1(:47),Email = 2(:50). The explicit ordinals matter because the value is round-tripped as a string through the grid's filter state and parsed back withEnum.TryParse(AttendeeSearchPanel.razor.cs:49-51), which falls back toLastNamewhen the stored value does not parse.LastNameis also the field's initial value (AttendeeSearchPanel.razor.cs:36), which is the field an organizer reads off a badge first. - Why it's built this way: an enum rather than a raw string keeps the three-way mapping exhaustive and reviewable in one
switch(AttendeeSearchPanel.razor.cs:112-118), which projects the selected member onto the matchingAttendeeSummaryproperty name and writes a single("contains", term)filter (:120). - Where it's used: only by
AttendeeSearchPanel, as the bound value of aMudSelect(AttendeeSearchPanel.razor:15-22) and as the discriminator in its filter builder; the resulting filter is whatIAttendeeLookupService.SearchAsyncreceives as itsemail/firstName/lastNamearguments (IAttendeeLookupService.cs:71-79).
SessionInfo
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/ISessionLookupService.cs:9· Level 0 · record
- What it is: the lightweight projection of a Conference session carrying just what Engagement pages need to label a bookmark, a check-in row or a live row: id, title, optional start and end times, and the owning event id. It sits at the top of
ISessionLookupService.csbecause it is the shape that lookup contract returns. - Depends on: the
SessionIdentifierTypeandEventIdentifierTypealiases (Conference identifier aliases, solution-wideglobal using, see primer §2). No first-party service dependencies. - Concept introduced, the cross-module display projection. Engagement owns no session table (database-per-service, ADR-006), so it cannot join to a session title: it fetches the fields it needs over HTTP and holds them in this small record, a deliberately thinner shape than Conference's own
SessionDTO.[Rubric §9, API & Contract Design]assesses how a consumer models another service's data at its boundary; declaring five fields here means a change to unrelatedSessionDTOmembers never ripples into Engagement.[Rubric §7, Microservices Readiness]is the same decision seen from the extraction side, and it is the identical posture the neighbouringAttendeeSummarytakes against Identity: the consumer redeclares the view it renders rather than depending on the producer's assembly. - Walkthrough (
ISessionLookupService.cs:9-14): five positional members,(SessionIdentifierType Id, string Title, DateTime? StartsAt, DateTime? EndsAt, EventIdentifierType EventId).StartsAtandEndsAtare nullable because a session imported from the schedule source may not yet have times assigned, so every consumer has to tolerate an unscheduled session. It is a plainpublic record, notsealed. - Why it's built this way: a compact record lets
SessionLookupServicebuild a whole-catalog dictionary cheaply, and value equality plusinit-only members mean a page can hold, compare and sort the rows client-side without a second fetch per row. - Where it's used: returned by
ISessionLookupService(a catalog keyed by id, or one row), constructed bySessionLookupServicefromSessionDTOpayloads (SessionLookupService.cs:38-39,:64-65); consumed byCheckInScanas its session list (CheckInScan.razor.cs:49), bySessionReminderPlanneras the bookmarked-session input it schedules against (SessionReminderPlanner.cs:50), and by the live-session pages (Pages/SessionLive/SessionLive.razor.cs,Pages/SessionLive/PresenterView.razor.cs). - Caveats / not-in-source: nothing here constrains
Titleto non-empty or the times to a sane order; both come straight off the Conference payload, and the nullable times are the only shape signal that an unscheduled session exists.
ISessionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/ISessionLookupService.cs:19· Level 3 · interface
- What it is: the contract for fetching Conference session data for display enrichment inside Engagement pages, returning
SessionInfoprojections. Two members: a whole-catalog read and a single-session read, bothResult-typed. - Depends on:
SessionInfo,Result(MMCA.Common.Shared.Abstractions, imported atISessionLookupService.cs:1), theSessionIdentifierTypealias. Externals: BCLIReadOnlyDictionaryandTask. - Concept introduced, two reads with an explicit efficiency contract, and absence expressed as a typed failure. The doc on
GetAllAsync(ISessionLookupService.cs:21-26) is unusually prescriptive: use it "only when a page genuinely needs the whole set (e.g. the bookmark reminder planner, which schedules against every bookmarked session)", while single-session pages "must useGetByIdAsyncinstead of transferring the catalog to label one row". That is the interface teaching its own performance discipline.[Rubric §12, Performance & Scalability]assesses whether whole-collection transfers are avoided on paths that render one item; the split-method contract encodes the fast path in the type rather than in a review comment. The second idea is theResultposture:GetByIdAsync's doc states that a missing session answers anErrorType.NotFoundfailure rather than the oldnull(:31-32), so callers branch on anErrorTypeinstead of on a null check that could not distinguish "no such session" from "the fetch broke". - Walkthrough
GetAllAsync(CancellationToken)(:27-28) returnsResult<IReadOnlyDictionary<SessionIdentifierType, SessionInfo>>, the catalog keyed by id, so a page can label many rows with O(1) lookups.GetByIdAsync(SessionIdentifierType, CancellationToken)(:34-36) returnsResult<SessionInfo>and fails withNotFoundrather than answering an empty value.
- Why it's built this way: keeping both shapes on one interface lets a page pick the cost that matches its need while the implementation shares one HTTP client and one mapping; typing both on
Resultmeans a Conference outage is a renderable state, not an unhandled exception on a bookmark list. Note the contrast with the siblingIAttendeeLookupService, which dropsResulton its naming member on purpose: here both reads feed content a page actually renders, so both failures matter. - Where it's used: implemented by
SessionLookupService, registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:54). Injected byCheckInScan(CheckInScan.razor.cs:28),OrganizerAttendance, the live-session pages, andSessionReminderCoordinator, which is the whole-catalog case theGetAllAsyncdoc names. - Caveats / not-in-source: the "must use
GetByIdAsync" rule is a doc comment, not a compiler or test constraint; nothing fails the build if a single-session page callsGetAllAsync.
QuestionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/QuestionLookupService.cs:13· Level 4 · sealed class
- What it is: the HTTP implementation of
IQuestionLookupService. It fetches the user-authored feedback questions for one entity type ("Event" or "Session") from the Conference-ownedquestionsresource. - Depends on:
IQuestionLookupService,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,QuestionDTO,PagedCollectionResult<T>. Externals:IHttpClientFactory. - Concept: the same authenticated-HTTP shape as
BookmarkService, used here to reach a Conference-owned read endpoint from the Engagement UI.[Rubric §7, Microservices Readiness]assesses whether a module reaches across a boundary through a contract that survives extraction: this one already does, because the call is plain HTTP through the Gateway rather than a project reference into Conference. The Engagement UI references onlyQuestionDTOfromMMCA.ADC.Conference.Shared(QuestionLookupService.cs:1), which is the wire contract, not the implementation. - Walkthrough (
:17-36):GetQuestionsAsync(questionEntity, ct)builds one filtered, paged URL (:25) that constrainsQuestionEntityto the URL-escaped argument andQuestionSourcetoUser, withpageSize=100. TheUri.EscapeDataStringon the caller-supplied value is the one input-handling detail worth naming: the argument is astring, not an enum, so escaping it is what keeps a stray character from reshaping the query. The retry is the token-aware overload (:27-29), the envelope is read as aPagedCollectionResult<T>(:31-32) andMapped to a bareIReadOnlyList(:34), so a failure travels on untouched while a success arrives already unwrapped. - Why it's built this way: filtering
QuestionSourcetoUserserver-side rather than fetching everything and filtering in the page keeps system-authored questions off the wire entirely, and pushingQuestionEntityinto the query lets the same service serve both feedback pages with no branching. - Where it's used: registered scoped as
IQuestionLookupService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:41); injected by both feedback pages,EventFeedback(EventFeedback.razor.cs:21) andSessionFeedback(SessionFeedback.razor.cs:20). Covered byQuestionLookupServiceTests. - Caveats / not-in-source:
questionEntityis an unconstrainedstringin both the interface (IFeedbackUIService.cs:15) and this implementation, so the valid values ("Event", "Session") are a convention the callers hold, not a type the compiler checks. ThepageSize=100is a hard ceiling in this file.
SessionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/SessionLookupService.cs:13· Level 4 · sealed class
- What it is: the HTTP implementation of
ISessionLookupService. It fetches sessions from the Conference-ownedsessionsresource and builds either a session-keyed lookup ofSessionInfoprojections or one row. - Depends on:
ISessionLookupService,SessionInfo,SessionDTO(Conference shared),PagedCollectionResult<T>,HttpResultExecutor,ProblemDetailsResultReader. Externals:IHttpClientFactory(SessionLookupService.cs:13),System.Globalization. Note what is absent: unlike its two neighbours in this folder, it does not derive fromAuthenticatedServiceBaseand takes no token storage, because it reads the plain"APIClient"(:22,:54) against a public, output-cached listing. - Concept introduced, the single-page catalog transfer with a documented cap.
GetAllAsyncfetchessessions?includeFKs=false&includeChildren=false(:27) with no pagination loop at all, and the inline comment (:24-25) is what makes that safe to read: the base/sessionsendpoint has nopageSizeparameter, it always serves a single page capped atMaxPageSize(500), which comfortably covers a conference's session catalog.[Rubric §12, Performance & Scalability]assesses transfer sizing; here theincludeFKs=false&includeChildren=falsequery trims the payload on the wire and the five-fieldSessionInfoprojection trims it on the heap, so the whole-catalog read the interface sanctions stays cheap.[Rubric §9, API & Contract Design]: the class consumes Conference through the wire contract only, referencingSessionDTOand nothing else from the other module. - Walkthrough
GetAllAsync(:17-45) runs insideHttpResultExecutor.ExecuteAsync(:19), reads the response as aPagedCollectionResult<SessionDTO>throughProblemDetailsResultReader.ReadAsync(:30-31), thenread.Map(...)(:33) fills aDictionary<SessionIdentifierType, SessionInfo>by loopingpage.Items(:36-40) and widens it toIReadOnlyDictionary(:42). BecauseMapruns on success only, no null guard on the page is needed: a failed read short-circuits carrying the API's own error.GetByIdAsync(:48-67) GETssessions/{sessionId}with an invariant-culture interpolated URI (:57), reads oneSessionDTO(:62) and maps it into aSessionInfo(:64-65). The comment there (:60-61) records the contract from the page's side: a missing session answers 404, which arrives as aNotFoundfailure rather than the oldnull, and the pages render exactly the same "session unavailable" state for it.
- Why it's built this way: building the dictionary once lets a page label many bookmark or attendance rows with O(1) lookups, while the per-id method serves single-session pages without paying the catalog transfer: exactly the fast and slow split
ISessionLookupServiceprescribes. Neither read retries, unlike the token-bearing services in this folder, because both are cheap idempotent GETs against a cached public endpoint. - Where it's used: registered scoped as
ISessionLookupService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:54). It is also the model the siblingAttendeeLookupServicenames in its class doc (AttendeeLookupService.cs:12-15) for the search-through, lookup-from-snapshot split. Covered bySessionLookupServiceTests(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Services/Lookups/SessionLookupServiceTests.cs). - Caveats / not-in-source: the 500-row cap is a server-side fact recorded in a comment here, not a value this file reads or asserts. If a conference ever exceeded it,
GetAllAsyncwould silently return a partial catalog, and nothing in this class detects or reports the truncation.
AttendeeSummary
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:15· Level 5 · record
- What it is: the subset of an attendee's account the check-in surfaces need, five positional members plus one computed display name. Enough to recognise a person standing at a door, and nothing more (
IAttendeeLookupService.cs:15-34). - Depends on: the
UserIdentifierTypealias and four strings. Nothing first-party; notably not Identity'sUserListDTO. - Concept introduced, the consumer-owned view contract.
[Rubric §7, Microservices Readiness]assesses whether a module's cross-boundary dependencies are on contracts it can keep working when the other side becomes a separate service. The doc comment (:5-8) states the decision outright: this record is declared here rather than reusing the Identity list DTO, so the Engagement UI depends on the Identity HTTP contract only, not on the Identity assembly.[Rubric §30, Compliance, Privacy & Data Governance]is the second reason to declare it narrowly: the Identity users endpoint can return more about a person than a door scanner needs, and a hand-declared five-field record is a data-minimisation decision expressed in the type system. The same instinct appears one level down in the privateAttendeeRow(AttendeeLookupService.cs:184-189), whose comment says only the fields the check-in surfaces render are read. - Walkthrough: the positional members are
UserId,Email,FirstName,LastNameandRole(:16-20).DisplayName(:26-33) is the only behavior: it trims"{FirstName} {LastName}"and falls back toEmailwhen the result is blank, with the comment giving the reason, a row must never render blank. That single fallback is why a caller can bind a grid column straight toDisplayNamewithout a null guard. The sibling enum in the same file,AttendeeSearchField(:41-51), names which of the three fields a single search term applies to, and its own doc explains why that is necessary: the Identity users endpoint ANDs its filters, so broadcasting one term across all three would match nothing (:36-40). - Why it's built this way: a
record(not aclass) gives value equality for free, which matters because the search panel re-fetches pages and compares rows. It stays mutation-free through positionalinitmembers, in line with the workspace'srequired/initdefault. - Where it's used: the grid item type of
AttendeeSearchPanel(AttendeeSearchPanel.razor.cs:16, with the filter columns named throughnameof(AttendeeSummary.Email)and friends at:80-82,:95-97,:114-117), the payload of itsOnCheckIncallback (:27,:130), and the argument ofCheckInScan's manual check-in path (CheckInScan.razor.cs:171). Produced only byAttendeeLookupService.ToSummary(AttendeeLookupService.cs:177-178). - Caveats / not-in-source:
Roleis a plain string mirroring whatever the Identity endpoint sends; no enum or constant in this file constrains it.
IAttendeeLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:57· Level 6 · interface
- What it is: the cross-module lookup contract over the Identity users endpoint, used by the organizer check-in fallback to find an attendee by name or email and to put a name on the person a scan just resolved (
:53-56). Two members, deliberately asymmetric. - Depends on:
AttendeeSummary(the row shape),Result, theUserIdentifierTypealias. - Concept introduced, a contract shaped by what the server can actually answer.
[Rubric §9, API & Contract Design]assesses whether an abstraction reflects the capability underneath it rather than an idealised one. The obvious contract here would beGetByIdAsync, and it is precisely the one the Identity endpoint cannot serve: it filters by name and email but takes no identifier (see the implementation's class doc,AttendeeLookupService.cs:12-15). So the interface exposes what the transport supports, a filtered pagedSearchAsync, plus a narrowerGetDisplayNameAsyncwhose implementation is free to solve the identifier problem however it can.[Rubric §1, SOLID]: the two members are the two questions the check-in surfaces ask, and the second returns astring?rather than a full record because a scan only needs a name to render. - Walkthrough
SearchAsync(:71-79) takes three optional filter fragments (email,firstName,lastName), paging (pageNumber = 1,pageSize = 10), optionalsortColumn/sortDirection, and a token. It returnsResult<(IReadOnlyList<AttendeeSummary> Items, int TotalItems)>: the tuple exists because a data grid needs the total to draw a pager, not just the page, and theResultexists because a non-organizer caller gets a refusal the panel must render rather than an empty grid. The doc comment (:59-61) names the matching semantics: each filter is a "contains" match, and the server ANDs them. That is the fact the sibling enumAttendeeSearchFieldexists to work around.GetDisplayNameAsync(:88-90) breaks the pattern on purpose: it returns a barestring?, not aResult. The doc says why (:81-85):nullcovers both "the account is unknown" and "the roster snapshot behind this lookup could not be read", because naming is a convenience beside a completed check-in and must never fail the caller. AResulthere would push a decision onto a page that has nothing useful to do with it.
- Where it's used: implemented by
AttendeeLookupService, registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:55). Injected byAttendeeSearchPanel(AttendeeSearchPanel.razor.cs:21) for the search half and byCheckInScan(CheckInScan.razor.cs:27, used at:223) for the name-resolution half. - Caveats / not-in-source: the interface says nothing about caching or freshness; the roster snapshot and its recheck window are entirely an implementation decision, described at
AttendeeLookupService.
AttendeeLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Lookups·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/AttendeeLookupService.cs:17· Level 7 · sealed class
What it is: the HTTP implementation of
IAttendeeLookupServiceover the Identity/Usersendpoint. Search reads straight through to the API; the by-identifier lookup is answered from an in-memory roster snapshot this class builds and refreshes itself, because the endpoint cannot be queried by identifier (class doc,:10-16).Depends on:
IAttendeeLookupService,AttendeeSummary,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,PagedCollectionResult<T>. Externals:IHttpClientFactory,TimeProvider,System.Globalization.Concept introduced, the scoped roster snapshot with single-flight and a negative-cache window. This is the most stateful class in the group, and each piece of that state answers a specific failure of the naive version.
[Rubric §12, Performance & Scalability]assesses whether repeated work is avoided on a hot path, and the hot path here is real: an organizer scanning badges at a door produces a stream of identifier lookups, many of them for codes the roster does not know. The naive implementation refetched the entire roster on every unrecognised code, which the comment at:113-115records as the defect this design fixes. Three mechanisms combine:- The snapshot (
_rosterNames,:42), an identifier-to-name map built once per scanning session. - Single flight (
_rosterLoad,:45, documented at:44as "published so concurrent misses share it instead of stampeding"), so several misses arriving together await one load. - A negative-cache window (
RosterRecheckInterval, 60 seconds,:38), so a miss against a fresh snapshot is reported as a genuine unknown instead of triggering a refetch, while a miss against a stale one refetches and lets an attendee who registered mid-conference resolve on the next scan (:33-37).
[Rubric §14, Testability]shows in the constructor:TimeProvider? timeProvider = null(:20) defaulting toTimeProvider.System(:40) is what makes the 60-second window testable without a real 60-second wait, andAttendeeLookupServiceTestsuses exactly that with a hand-rolled advanceable clock (:50,:107).[Rubric §19, State Management]covers the lifetime question: the service is registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:55), so the snapshot lives and dies with the Blazor circuit rather than being a process-wide cache of attendee names.- The snapshot (
Walkthrough (constants, then the two public members, then the loader)
- The constants encode the sizing decisions:
RosterPageSize = 500(:25, matching the server's page cap noted as BR-11) andMaxRosterPages = 5(:31), whose comment (:27-30) gives both reasons for the bound, five pages cover 2500 accounts (far above the real attendee count) and a paging bug therefore cannot loop forever. SearchAsync(:50-92) assembles its query into aDictionary<string, string?>with an ordinal comparer (:62-71), then drops blank values and URL-escapes the rest in one LINQ pass (:73-75). That is the notable difference from the hand-built query strings elsewhere in the module: filters here are genuinely optional, so "omit when empty" has to be a rule rather than a set ofifstatements. The response is read as aPagedCollectionResult<AttendeeRow>(:83-84) andMapped, on success only, into the page ofAttendeeSummaryrows plusPaginationMetadata.TotalItemCount(:86-90).GetDisplayNameAsync(:95-132) is the interesting one, and it reads as four ordered guards. A snapshot hit returns immediately (:99-102). Otherwise, if a load is already in flight, the caller awaits that task and reads its result rather than starting a second one (:104-108). Otherwise, if the snapshot exists and is younger thanRosterRecheckIntervalmeasured against the injected clock, the miss is answerednull(:110-116), with the comment naming the behavior this replaced. Only past that does it start a load, publish it to_rosterLoad(:118-119), await it, and stamp_rosterLoadedAtTicks(:122-123). Thefinally(:125-129) clears_rosterLoadeven on failure, with the comment giving the reason: a failed load must leave the next miss free to retry rather than be told "recently loaded".LoadRosterAsync(:143-175) pages the roster withSearchAsyncitself, no filters, 500 rows per page, sorted byEmailascending (:149-154). The sort is not cosmetic: it is what makes page boundaries stable across the several requests that make up one snapshot, so a row cannot be skipped by shifting between calls. A page the API refuses simplybreaks the loop (:156-159), and the doc above says why that is acceptable (:138-141): the roster is a naming convenience behindGetDisplayNameAsync, whose contract is already "null when the account cannot be named", so a partial snapshot degrades to that answer instead of failing a check-in. The loop also stops early when a page comes back empty or the accumulated count reaches the server's reported total (:168-171).ToSummary(:177-178) and the privateAttendeeRowwire shape (:184-189) close the file, the latter deliberately narrower than what the endpoint returns (:180-183).
- The constants encode the sizing decisions:
Why it's built this way: the class doc (
:12-15) frames it as mirroringSessionLookupService, search straight through, by-identifier from a snapshot, with a miss refreshing the snapshot once. The design is driven entirely by a server capability gap rather than by a performance target, and the constants are sized for one conference (about 2500 accounts, at a real observed attendance far below that), not for a general-purpose directory.Where it's used:
AttendeeSearchPaneldrivesSearchAsyncfrom both its data-grid loader (AttendeeSearchPanel.razor.cs:79) and its mobile infinite-scroll loader (:94);CheckInScancallsGetDisplayNameAsyncafter a scan resolves to an identifier (CheckInScan.razor.cs:226).[Rubric §14, Testability]: four facts inAttendeeLookupServiceTestscover exactly the four behaviors above, one roster fetch for repeated unknown ids (:19), a known id answered from the snapshot (:35), a refetch past the recheck interval that resolves a new attendee (:50), and concurrent misses sharing one load (:72).Caveats / not-in-source: three things are worth knowing before relying on this class.
- The
/Usersendpoint is permission-gated onIdentityPermissions.UsersRead(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:138-139, BR-51), so a non-organizer caller gets aForbiddenfailure out ofSearchAsync, not an empty list, andnullout ofGetDisplayNameAsync. MaxRosterPages * RosterPageSizeis a hard 2500-account ceiling on the snapshot. Past it, ids on later pages would never resolve by name, and nothing logs or surfaces that.- The single-flight guarantee rests on
_rosterLoadbeing assigned (:119) before any yield point, with no lock taken and the field neithervolatilenor interlocked. On the single-threaded Blazor circuit context that holds, and the concurrency test exercises it, but the class is not written to be shared across threads and its scoped registration is what keeps it from being.
- The
LiveEventContext
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventContext.cs:13· Level 0 · sealed record
- What it is: the UI's read-only view of the currently published event's live window: the event id and name, its IANA time zone, and the UTC start/end of the window during which the live layer (polls and session Q&A) is active. It is a
sealed recordwith two small behaviors attached, not a bare DTO. - Depends on:
EventIdentifierType(the Conference module's identifier alias), BCLDateTimeandTimeZoneInfo. No NuGet or first-party service dependencies; this is a pure value. - Concept introduced, client-side re-derivation of a server-enforced window. The doc comment (
LiveEventContext.cs:4) is explicit that the window uses "the same math the backend enforces": StartDate 00:00 local through EndDate + 1 day 00:00 local, converted to UTC via the event's zone. The UI does not invent its own liveness rule, it mirrors the authoritative one so the ambient LiveEventListener and the HappeningNow page light up at exactly the moment the API would accept a vote.[Rubric §19, State Management]assesses how derived UI state is kept consistent with its source of truth; the record centralizes the "am I live" decision in one value both surfaces call, rather than scattering time-zone arithmetic across components.[Rubric §12, Performance & Scalability]applies mildly:IsLiveAtis a pure comparison, so an ambient listener can re-evaluate it without a round trip. - Walkthrough: the primary constructor (
LiveEventContext.cs:13-18) capturesEventId,Name,TimeZoneId,LiveWindowStartUtc, andLiveWindowEndUtc.IsLiveAt(DateTime utcNow)(:22-23) returns true whenutcNowis inside the half-open window: note the inclusive lower bound and the exclusive< LiveWindowEndUtcupper bound matching the parameter doc that calls the end "exclusive" (:12).ToEventLocal(DateTime utcNow)(:30-31) converts a UTC instant into the event's local time viaTimeZoneInfo.ConvertTimeFromUtcoverTimeZoneInfo.FindSystemTimeZoneById(TimeZoneId), with no fallback: the doc comment states the id always resolves because EventInvariants.EnsureTimeZoneIsValidguards every write path (:25-28). - Why it's built this way: making the record own both the window and the "is it live / what is local time" helpers keeps the liveness contract in a single testable value. The conversion trusts the domain invariant instead of defensively catching
TimeZoneNotFoundException, so an invalid zone is prevented where it is written rather than papered over where it is read. - Where it's used: produced by LiveEventService from Conference event data; consumed by the HappeningNow page and the ambient LiveEventListener to decide whether to show live surfaces.
CreateBookmarkRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.Create·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkRequestValidator.cs:9· Level 1 · class (sealed)
- What it is: the shape gate in front of CreateBookmarkHandler. Two rules, both saying the same thing about a different identifier: a bookmark needs a real user and a real session.
- Depends on: CreateBookmarkRequest, the
UserIdentifierType/SessionIdentifierTypealiases; externals: FluentValidation (AbstractValidator<T>,RuleFor,NotEqual,WithMessage,WithErrorCode). - Concept introduced, the request validator and where it sits in the command pipeline.
[Rubric §9, API & Contract Design](assesses whether a contract states its own preconditions and reports violations in a machine-readable form) and[Rubric §5, Vertical Slice](assesses whether everything one use case needs lives in the use case's own folder). The wiring is worth learning once because every validator in this unit rides it. Validators are never resolved by name:ScanModuleApplicationServices<ClassReference>()in the module's composition root (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88) scans the assembly and registers eachAbstractValidator<T>asIValidator<T>. At request time ValidatingCommandDecorator<TCommand, TResult> receivesIEnumerable<IValidator<TCommand>>and runs ALL of them, not just the first registration (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:19-22,32-37), so a module-authored validator and a framework one both get their say and the caller sees every broken rule in one response. Either way the validation runs before the handler sees the command, and a handler is free to assume the structural rules already held. The other thing to notice isWithErrorCode: the string"UserSessionBookmark.UserId.Required"(CreateBookmarkRequestValidator.cs:16) is a stable code a client can branch on, which a localized message never is. - Walkthrough: a block-bodied constructor with two rules (
CreateBookmarkRequestValidator.cs:11-22).UserIdmust not equaldefault(UserIdentifierType)(CreateBookmarkRequestValidator.cs:13-16). The alias isint(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2), so this rejects an unset zero. Writing it asNotEqual(default(...))rather thanGreaterThan(0)keeps the rule correct if the alias ever changes width or becomes a struct id.SessionIdgets the identical treatment againstdefault(SessionIdentifierType)(CreateBookmarkRequestValidator.cs:18-21), also anintalias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15).
- Why it's built this way: the validator deliberately checks only what it can decide without touching a database or another module. Ownership (may this caller bookmark for this user) is enforced in BookmarksController against the caller's user-identifier claim (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:65-71, ADR-033), and session eligibility (BR-49, BR-91) is a cross-module call inside the handler. Three checks, three layers, none duplicated. - Where it's used: applied automatically to every
CreateBookmarkRequestcommand entering the pipeline; the request itself arrives fromBookmarksController.CreateAsync(BookmarksController.cs:55-78).
ILiveEventUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ILiveEventUIService.cs:7· Level 1 · interface
- What it is: the UI-facing contract for resolving the current published event and its live window. One method,
GetCurrentEventAsync, returning a nullable LiveEventContext. - Depends on: LiveEventContext and BCL
Task/CancellationToken. - Concept introduced, nullable-as-absence at a UI boundary. The doc comment (
ILiveEventUIService.cs:10-11) says the method returnsnull"when no published event exists (or the API is unavailable)". Rather than throwing when there is nothing live, the contract makes "no live event" a first-class, expected return that the ambient listener handles by staying dormant. Note the contrast with its sibling contracts: ILivePollUIService and ISessionQuestionUIService answer with Result, which distinguishes "nothing there" from "the call failed"; this interface deliberately collapses both intonullbecause the caller's behavior is identical either way.[Rubric §1, SOLID]applies through Dependency Inversion: components depend on this abstraction, not on the HTTP-bound implementation, so tests can substitute a fake.[Rubric §18, UI Architecture]assesses how the UI layer separates data-resolution contracts from rendering; this interface is that boundary for the live layer. - Walkthrough:
Task<LiveEventContext?> GetCurrentEventAsync(CancellationToken cancellationToken = default)(ILiveEventUIService.cs:14). The type-level doc names both consumers explicitly, the Happening Now page and the ambientLiveEventListener(:4-5). - Why it's built this way: a one-method interface is the minimum surface the HappeningNow page and the ambient listener need, keeping the contract easy to fake and hard to misuse.
- Where it's used: implemented by LiveEventService; consumed by HappeningNow, LiveEventListener, and CurrentEventNotificationScopeProvider.
RoomCheckInRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RoomCheckInRequestValidator.cs:9· Level 1 · class (sealed)
- What it is: the one-rule validator for the room-QR self check-in request. The room id must be a positive number, and that is the entire structural contract of the request.
- Depends on: RoomCheckInRequest, the
RoomIdentifierTypealias; externals: FluentValidation. - Concept reinforced: the pipeline wiring taught under CreateBookmarkRequestValidator.
[Rubric §11, Security]is the interesting tag here, in the negative: the request is this thin on purpose. The attendee names a room and nothing else, and the server resolves which session that room is hosting, so there is no field a caller could tamper with to check in somewhere they are not standing (see RecordRoomCheckInHandler). - Walkthrough: an expression-bodied constructor holding a single
RuleFor(RoomCheckInRequestValidator.cs:11-15):RoomIdGreaterThan(0), message "Room ID is required.", error code"CheckIn.RoomId.Required".GreaterThan(0)works becauseRoomIdentifierTypeisint(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:12). - Where it's used: runs ahead of RecordRoomCheckInHandler, whose command arrives from CheckInsController (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:163,173).
SessionLiveUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionLiveUIService.cs:10· Level 1 · sealed class
- What it is: the Engagement-side implementation of the ISessionLiveUIService extension point: it maps a session id to the route of that session's Live page, so the Conference session-detail page can light up a Live button only when the Engagement module is enabled.
- Depends on: ISessionLiveUIService (the contract, declared in
MMCA.ADC.Engagement.Shared.SessionQuestions, imported atSessionLiveUIService.cs:1), EngagementRoutePaths (the route builder), and theSessionIdentifierTypealias. - Concept introduced, cross-module UI extension point resolved by module presence. Conference must not hard-reference an Engagement route, that would couple the two modules. Instead Conference depends on the abstract
ISessionLiveUIService, and Engagement registers this implementation when its module loads. When Engagement is absent, no implementation is registered and the Live button stays off.[Rubric §7, Microservices Readiness]assesses whether modules collaborate through boundaries that survive extraction into separate services; this is the UI-layer version of that discipline, a capability advertised only when its owner is running.[Rubric §1, SOLID]applies through Dependency Inversion: Conference depends on the interface, not on the concrete route builder. - Walkthrough:
GetSessionLivePath(SessionIdentifierType sessionId)(SessionLiveUIService.cs:13) delegates straight toEngagementRoutePaths.SessionLive(sessionId)(:14). The class is asealedexpression-bodied one-liner carrying/// <inheritdoc />; all it does is put an Engagement route behind a Conference-visible contract. - Why it's built this way: routing knowledge for the Live page belongs to Engagement, so Engagement owns the string; Conference only needs the abstraction to conditionally render a link.
- Where it's used: registered in the Engagement UI module's DI; consumed by the Conference session-detail page to render its Live button, which lands on SessionLive.
SponsorVisitRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordSponsorVisit·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/SponsorVisitRequestValidator.cs:9· Level 1 · class (sealed)
- What it is: the sponsor-booth twin of RoomCheckInRequestValidator. One rule, one identifier.
- Depends on: SponsorVisitRequest, the
SponsorIdentifierTypealias; externals: FluentValidation. - Concept reinforced: same pipeline placement as the two validators above. The shared shape of these two files is not an accident: both flows are attendee-self-recorded scans where the only client input is the id printed on the QR, so both validators have exactly one thing to check.
- Walkthrough: expression-bodied constructor, one
RuleFor(SponsorVisitRequestValidator.cs:11-15):SponsorIdGreaterThan(0), message "Sponsor ID is required.", error code"CheckIn.SponsorId.Required". The error code prefix isCheckIn.rather thanSponsor.because a booth visit is stored as a CheckIn row withScope = Sponsor, and the code namespace follows the aggregate, not the endpoint. - Where it's used: runs ahead of RecordSponsorVisitHandler, reached from CheckInsController (
CheckInsController.cs:132,142).
LiveBroadcastPatch
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveBroadcastPatch.cs:14· Level 2 · internal static class
- What it is: two small helpers that apply a live broadcast payload directly onto a page's in-memory list, patching a poll's tallies or a question's upvote count in place, so the page does not have to refetch from the API to show a fresh count.
- Depends on:
LivePollResultsDTOandSessionQuestionDTO(the two list element types it rewrites) andSessionQuestionUpvoteChangedPayload(the upvote broadcast body). Externals:System.Text.JsonwithJsonSerializerOptions.Web. - Concept introduced, patch-on-broadcast instead of reload-on-broadcast.
[Rubric §12, Performance & Scalability]and[Rubric §23, Front-End Performance]assess whether a real-time surface scales with the number of connected clients. The naive pattern is to treat a broadcast as a "something changed" ping and refetch; the comment at HappeningNow.razor.cs:135-137records what that costs in practice, V votes times C viewers becoming V*C authenticated refetches under burst voting. The broadcasts here already carry the fresh counts (BR-229/BR-238,LiveBroadcastPatch.cs:9-10), so the payload is the update, not a notification of one.[Rubric §19, State Management]is the discipline that makes it safe: these two helpers are the only events treated this way. Every structural event (a poll opened or closed, a question posted or answered) still reloads, so the optimization is confined to counters where a lost update self-corrects on the next broadcast. - Concept, the boolean fallback contract. Both methods return
falsefor anything they cannot apply (:26,:53), and the caller reloads. That is what keeps the fast path from becoming a correctness risk: an unknown poll id, an unknown question id or an unparsable body all degrade to the behavior the page would have had without the patch at all. - Walkthrough:
TryApplyPollResults(polls, payloadJson, preserveMyVote)(:27-48) deserializes the payload as aLivePollResultsDTO(:31), finds the matching entry byPollId(:35), and replaces it in place (:39-41). ThepreserveMyVoteflag is the subtle parameter and its doc (:19-25) is the reason it exists: the broadcast strips per-user data, so an attendee circuit passestrueand carries the current entry'sMyVoteOptionIdacross the patch, or the marker rendering the viewer's own vote would vanish; the projector view passesfalsebecause it shows no personal vote and a stale marker must not survive into its list.TryApplyUpvoteCount(questions, payloadJson)(:54-73) is the simpler twin: deserializeSessionQuestionUpvoteChangedPayload(:58), find byId(:62), and rewrite only theUpvoteCountwith awithexpression (:66), leaving every other field of the question untouched.- Both wrap the work in
try/catch (JsonException)(:44-47,:69-72), catching the parse failure specifically rather than swallowing everything.
- Why it's built this way:
internal staticwith no injected state is the honest shape. There is nothing to configure and nothing to mock: the input is a list and a string, the output is a bool, and the three pages that use it call it identically. Usingwithexpressions on the record DTOs rather than mutating them keeps the list elements immutable values, which is what lets a page compare or re-render without worrying that a background patch aliased an object it already handed to a component. - Where it's used: by the three conference-day pages, each guarding the call with the channel's event name so an unrelated broadcast never reaches it. SessionLive routes both events through one
switchand reloads onnull(SessionLive.razor.cs:188-195, patching at:190and:192withpreserveMyVote: true); PresenterView handles them as two early-return branches withpreserveMyVote: false(PresenterView.razor.cs:121,:127); HappeningNow patches only poll results and falls back toReloadPollsAsync(HappeningNow.razor.cs:140,:145). - Caveats / not-in-source: the class is
internal, so nothing outsideMMCA.ADC.Engagement.UIcan call it, and no dedicated test class for it exists in the repository. Its behavior is exercised only through the pages that call it.
ILivePollUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ILivePollUIService.cs:15· Level 3 · interface
- What it is: the full UI contract for the live-poll layer: read open polls (event-wide or per session) with tallies and the caller's own vote, cast or change a vote, and drive the organizer lifecycle (create, open, close, delete), plus a session-scoped manage list.
- Depends on: LivePollResultsDTO, LivePollDTO, CreateLivePollRequest (all
MMCA.ADC.Engagement.Shared.LivePolls, imported atILivePollUIService.cs:1), Result (MMCA.Common.Shared.Abstractions,:2), and theLivePollIdentifierType/LivePollOptionIdentifierType/EventIdentifierType/SessionIdentifierTypealiases. - Concept introduced, a Result-typed UI contract: a server refusal is data, not an exception. The type doc states it outright (
ILivePollUIService.cs:11-12): "Every member answers with aResult: a server refusal is data the page renders, never an exception. Only the caller's own cancellation still propagates." Every one of the ten members returnsTask<Result>orTask<Result<T>>, so a 403 from a poll the caller may not manage, or a 412 from a stale open, arrives as a value a Blazor page can bind to an alert.[Rubric §24, Forms/Validation/UX Safety]assesses whether failure states are renderable rather than fatal; typing the contract onResultremoves the try/catch from every consuming page. A second idea also appears here, two result shapes for two audiences: read-and-vote methods return LivePollResultsDTO (tallies plus the caller's own vote) while the manage views return the richer LivePollDTO.[Rubric §9, API & Contract Design]covers that fit-the-shape-to-the-consumer split.[Rubric §11, Security]applies through the explicit note that manage operations requireengagement:live:manageand that "the API enforces this regardless of what the UI renders" (:8-9): the UI contract never pretends to be the security boundary. - Walkthrough: attendee and reader path:
GetOpenPollsAsync(ILivePollUIService.cs:18),GetOpenSessionPollsAsync(:21),GetResultsAsync(:24),CastVoteAsync(:27, which returns the fresh tallies so a vote needs no follow-up read). Manage path:GetEventPollsAsync(:30, ALL polls of an event) andGetSessionManagePollsAsync(:36), whose doc records that the session list is "open to the session's assigned speakers as well as organizers (BR-236), unlike the event-wide manage list" (:33-34). Lifecycle:CreateAsync(:39, creates as Draft),OpenAsync(:42) andCloseAsync(:45), each taking abyte[] rowVersionalongside the id, andDeleteAsync(:48, "must not be Open"). TherowVersionparameter on the two transitions is the optimistic-concurrency token of ADR-035: the caller must state which version of the poll it saw. Every method takes a trailingCancellationToken. - Why it's built this way: grouping the whole poll lifecycle behind one interface lets the various poll surfaces (HappeningNow, SessionLive, SessionLiveModerationPanel, PresenterView) inject a single dependency and lets tests fake it wholesale, while the
Resultreturn type keeps every one of those pages free of HTTP error handling. - Where it's used: implemented by LivePollUIService; consumed by the live-poll Blazor pages and panels.
ISessionQuestionUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ISessionQuestionUIService.cs:15· Level 3 · interface
- What it is: the UI contract for the session Q&A layer: attendees read approved questions and submit their own, moderators work a queue (approve / dismiss / mark-answered), and anyone can upvote or remove an upvote.
- Depends on: SessionQuestionDTO and SubmitQuestionRequest (
MMCA.ADC.Engagement.Shared.SessionQuestions, imported atISessionQuestionUIService.cs:1), Result, and theSessionIdentifierType/SessionQuestionIdentifierTypealiases. - Concept introduced, two views of the same queue.
GetQuestionsAsync(ISessionQuestionUIService.cs:18) returns the attendee view (every approved question plus the caller's own pending/dismissed ones), whileGetModerationQueueAsync(:21) returns all statuses with Pending first. The same two disciplines as its poll sibling appear verbatim: every member answers with a Result and only caller cancellation propagates (:11-12), and moderation needs organizer/admin or an assigned-speaker claim with "the API enforces this regardless of what the UI renders (BR-236)" (:8-9).[Rubric §24, Forms/Validation/UX Safety]assesses how submission and moderation flows are shaped; the split read methods keep an attendee from seeing another attendee's un-approved question while giving moderators the full picture.[Rubric §11, Security]applies through the server-authoritative moderation note. - Walkthrough: reads:
GetQuestionsAsync(:18),GetModerationQueueAsync(:21). Submit:SubmitAsync(:24), whose doc notes the question "starts at the event's moderation default (BR-233)". Moderation:ApproveAsync(:27),DismissAsync(:30),MarkAnsweredAsync(:33), each taking(SessionQuestionIdentifierType id, byte[] rowVersion, CancellationToken)so the write is conditional on the version the moderator saw (ADR-035) and two moderators racing cannot silently overwrite each other. Upvoting:UpvoteAsync(:36) andRemoveUpvoteAsync(:39), each returningResult<int>, the fresh upvote count, so the button can re-render from the response. - Why it's built this way: one interface spans attendee, moderator, and voter roles so a session Live page injects a single service; the API remains the enforcer, so the contract can expose the moderation methods without granting rights. Returning the count from the upvote verbs saves a follow-up read on the hottest interaction in the room.
- Where it's used: implemented by SessionQuestionUIService; consumed by SessionLive, SessionLiveModerationPanel, SessionLiveQuestionPanel, and PresenterView.
LiveChannelSubscription
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveChannelSubscription.cs:15· Level 3 · sealed class
- What it is: a join-once / leave-once handle for one live notification channel, held as a field by the conference-day pages that listen for real-time events. It owns the two pieces of state and the join and leave sequences those pages used to repeat inline, so a page states only which channel it wants and what to do with an event (class doc,
:5-9). - Depends on:
NotificationHubService(:1, passed in per join rather than injected). Externals:IAsyncDisposable,IDisposable,GC.SuppressFinalize. - Concept introduced, the component-owned resource handle.
[Rubric §19, State Management]assesses where transient client state lives and who is responsible for tearing it down. The remarks block (:11-14) is the whole design in three sentences: this is not a DI service. A pagenews one up as a field, so the handle's lifetime is the component's, nothing has to be registered or scoped, and disposal is a no-op for a handle that never joined. Compare that with the alternative of a scoped service tracking channel membership: it would outlive the component and would have to be told when the component went away. Here the component's ownDisposeAsyncis the only signal needed.[Rubric §15, Best Practices & Code Quality]: two pages held the identical field pair and repeated the same three-step join and two-step leave; folding that into one type means a change to the join protocol happens once. - Walkthrough
- The three fields (
:17-19) are the hub service, the handler subscription token, and the channel key. All three are nullable and all three are set together. IsJoined(:25) reports simply whether_channelKeyis non-null. Its doc (:21-24) names the exact job it does: the pages call it as the already-joined guard insideOnAfterRenderAsync, which is deliberately notfirstRender-gated (BR-229 / BR-238). SeeHappeningNowatHappeningNow.razor.cs:114andSessionLiveatSessionLive.razor.cs:138for the guard in place.JoinAsync(:35-51) null-checks the hub service (:40), returns early when a key is already recorded so a second call is a no-op (:42-45), then assigns all three fields and subscribes the handler throughOnChannelEvent(:47-49) before awaitingJoinChannelAsync(:50). The ordering is the load-bearing part and the doc says why (:28-30): the key is recorded before the join is awaited, soIsJoinedalready answers true for any render that interleaves with the in-flight join. Without that, a render landing mid-join would seefalseand start a second join.DisposeAsync(:54-63) disposes the handler subscription with the null-conditional (:56), leaves the channel only when both the service and the key are present (:57-60), and callsGC.SuppressFinalize(:62). A handle that never joined therefore disposes cleanly with no hub traffic.
- The three fields (
- Why it's built this way: real-time listening on a Blazor page is a resource with a strict pairing (subscribe and join on the way in, unsubscribe and leave on the way out) that the render loop can interleave with. Making the handle a plain field with an idempotent join and an idempotent dispose puts both halves in one place a reader can check at a glance.
- Where it's used:
HappeningNowholds one for the event channel (HappeningNow.razor.cs:52, joined at:124-127viaLivePollChannel.ForEvent, disposed at:276) andSessionLiveholds one for the session channel (SessionLive.razor.cs:57, joined at:143viaLivePollChannel.ForSession, disposed at:329). Both guard the join withRendererInfo.IsInteractiveas well, which keeps the prerender pass and the bUnit suite off the hub (HappeningNow.razor.cs:110-114). - Caveats / not-in-source: the class is not thread-safe.
JoinAsyncreads and writes_channelKeyaround an await with no lock, which holds on the single-threaded Blazor circuit context the pages run on and is not written to be shared beyond it. There is also noLeaveAsync: leaving is disposal, so a page that wanted to switch channels would need a new handle.
LivePollUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:15· Level 4 · sealed class
- What it is: the HTTP implementation of ILivePollUIService, calling the Gateway's
/LivePollsroutes with an authenticated client, a retry pipeline, and Problem-Details-to-Resulttranslation. - Depends on: AuthenticatedServiceBase (base class supplying
CreateAuthenticatedClientAsync,RetryPolicy, andNewIdempotencyKey), HttpResultExecutor and ProblemDetailsResultReader, ConcurrencyETag and IdempotencyHeaders (MMCA.Common.Shared.Http), ILivePollUIService and the poll DTO/request records, plusIHttpClientFactoryand ITokenStorageService (LivePollUIService.cs:15-17). - Concept introduced, the two-halves translation of an HTTP call into a
Result. Every method here has the same skeleton: the body is wrapped inHttpResultExecutor.ExecuteAsync(..., cancellationToken)and ends in aProblemDetailsResultReader.ReadAsynccall. Those two do complementary jobs: the reader converts a response (an RFC 9457 Problem Details body from the API back into the original Error list, preserving ErrorType), and the executor converts the absence of a response (a refused connection, DNS failure, dropped socket, orHttpClienttimeout) into a transport failure, while rethrowing anOperationCanceledExceptionthat the caller's own token caused. Together they are what makes theResult-typed contract in ILivePollUIService honest. Between them sitsRetryPolicy.ExecuteAsyncfrom the base.[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether resilience and error translation are applied uniformly; every method here gets all three for free by construction.[Rubric §26, Front-End Security]applies because each call acquires a bearer-token-bearing client viaCreateAuthenticatedClientAsync. - Walkthrough:
Endpoint = "livepolls"(LivePollUIService.cs:19). Reads:GetOpenPollsAsync(:22) GETslivepolls/open?eventId=built withstring.Create(CultureInfo.InvariantCulture, ...)(:30), andread.Map(...)widensList<LivePollResultsDTO>toIReadOnlyList<...>(:36);GetOpenSessionPollsAsync(:41) is the same call keyed bysessionId(:49);GetResultsAsync(:60) GETslivepolls/{pollId}/results(:68);GetEventPollsAsync(:106) GETslivepolls?eventId=(:114) andGetSessionManagePollsAsync(:125) GETslivepolls/manage?sessionId=(:133), both returning LivePollDTO lists. Non-idempotent writes carry an idempotency key:CastVoteAsync(:78) addsIdempotencyHeaders.IdempotencyKeywithNewIdempotencyKey()(:93) then POSTs a CastVoteRequest (:95) tolivepolls/{pollId}/votes(:98), andCreateAsync(:144) does the same before POSTing the CreateLivePollRequest (:156,:159-160). The long comments at:87-92and:152-155explain the placement precisely: the key is minted ONCE outside the retry pipeline, because generating it inside the retried delegate would give every attempt a fresh key and leave the server nothing to deduplicate on, turning a retried vote into a second write. Lifecycle:OpenAsync(:168) andCloseAsync(:172) both delegate toPostLifecycleAsync(pollId, action, rowVersion, ...)(:190), which formats the row version withConcurrencyETag.Format(:197) and, per attempt, builds a freshHttpRequestMessagecarryingConcurrencyETag.IfMatchHeaderName(:205-206) because a sent request message cannot be re-sent by the retry pipeline (:199-201); a stale-view open or close answers 412 Precondition Failed.DeleteAsync(:176) DELETEslivepolls/{pollId}(:183) with no precondition. - Why it's built this way: folding the repeated client / retry / read ceremony into shared helpers keeps each method down to its URL and payload, so the class reads as a faithful map of the interface onto REST routes. The idempotency-key and If-Match placements are the two spots where that ceremony genuinely matters, and both carry an in-code explanation so a later edit does not quietly move them inside the retry delegate (ADR-035, ADR-021).
- Where it's used: registered as the
ILivePollUIServicein the Engagement UI module; injected into HappeningNow, SessionLivePollPanel, SessionLiveModerationPanel, and PresenterView.
SessionQuestionUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionQuestionUIService.cs:15· Level 4 · sealed class
- What it is: the HTTP implementation of ISessionQuestionUIService, calling the Gateway's
/SessionQuestionsroutes with the same executor + retry + reader shape as LivePollUIService. - Depends on: AuthenticatedServiceBase, HttpResultExecutor, ProblemDetailsResultReader, ConcurrencyETag, IdempotencyHeaders, ISessionQuestionUIService, SessionQuestionDTO/SubmitQuestionRequest,
IHttpClientFactory, and ITokenStorageService (SessionQuestionUIService.cs:15-17). - Concept reinforced: this is the structural sibling of LivePollUIService and reuses the same triad (executor wrapper,
RetryPolicy.ExecuteAsync,ProblemDetailsResultReader.ReadAsync) introduced there, so the concept is not re-taught. What differs is the count-returning upvote pair and the moderation helper.[Rubric §29, Resilience, Reliability & Business Continuity]and[Rubric §26, Front-End Security]apply for the same inherited-resilience and token-flow reasons as its poll sibling. - Walkthrough:
Endpoint = "sessionquestions"(SessionQuestionUIService.cs:19). Reads:GetQuestionsAsync(:22) GETssessionquestions?sessionId=(:30) andGetModerationQueueAsync(:41) GETssessionquestions/moderation?sessionId=(:49), each widening the deserializedList<SessionQuestionDTO>viaread.Map(:36,:55).SubmitAsync(:60) mints an idempotency key outside the retry pipeline (:73) before POSTing the SubmitQuestionRequest (:76-77); the comment (:68-72) names the concrete failure it prevents, an attendee whose submit times out over conference wifi posting the same question twice. Moderation:ApproveAsync(:85),DismissAsync(:89), andMarkAnsweredAsync(:93) each delegate toPostModerationAsync(id, action, rowVersion, ...)(:126) with the action strings"approve","dismiss", and"answered"; that helper formatsConcurrencyETag.Format(rowVersion)(:133) and builds a freshHttpRequestMessageper attempt carryingIf-Match(:141-142), so "two moderators racing surface as 412 Precondition Failed" (:136). Upvotes:UpvoteAsync(:97) POSTssessionquestions/{id}/upvoteswith a null body (:104) andRemoveUpvoteAsync(:112) DELETEs the same route (:119); both deserialize the fresh count withProblemDetailsResultReader.ReadAsync<int>(:107,:122) and neither carries a key or a precondition, because setting or clearing one caller's upvote is naturally idempotent. - Why it's built this way: one private helper (
PostModerationAsync) collapses the repeated conditional-POST ceremony for the three moderation verbs, leaving each public method to state only its action string. The deliberate asymmetry (keys on submit, If-Match on moderation, neither on upvote) matches each operation's actual retry hazard rather than applying one blanket policy. - Where it's used: registered as
ISessionQuestionUIService; consumed by SessionLive, SessionLiveModerationPanel, SessionLiveQuestionPanel, and PresenterView.
CreateBookmarkHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.Create·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:16· Level 9 · class (sealed partial)
- What it is: the create-or-reactivate use case for a session bookmark. It enforces BR-21 (no duplicate), BR-49 and BR-91 (the session must be bookmarkable), and BR-135 (re-bookmarking revives the soft-deleted row instead of inserting a new one).
- Depends on: IUnitOfWork, ISessionBookmarkValidationService (Conference-owned), IBookmarkManagementDomainService, UserSessionBookmarkDTOMapper, IUniqueConstraintViolationDetector, UserSessionBookmark, Result / Error, ICommandHandler<in TCommand, TResult>; externals:
ILogger<T>with[LoggerMessage]source generation. - Concept introduced, the reactivating create and the pre-check-plus-index pair.
[Rubric §8, Data Architecture](assesses whether soft-delete semantics are honored on the write path, not just filtered on reads),[Rubric §6, CQRS & Event-Driven], and[Rubric §7, Microservices Readiness]. Two ideas live here. First, under a soft-delete-everywhere policy (ADR-005) a "create" is not always an insert: the row may already exist withIsDeleted = true, and inserting a second one would both violate the unique index and orphan the audit trail. Second, a uniqueness pre-check in application code is never the guarantee, only the pleasant path: two concurrent requests can both pass it, so the database index has to be the real rule and the handler has to translate its violation back into the same domain error. Both patterns recur in PointsAwarder, which is the best evidence they are the module's convention rather than one-off defensiveness. - Walkthrough: six constructor dependencies (
CreateBookmarkHandler.cs:16-22), then oneHandleAsync(:26-89).- Cross-module eligibility first (
:30-33):ValidateSessionForBookmarkAsyncis a Conference contract, satisfied in-process in the monolith and by a gRPC client when the modules run as separate services (ADR-007). This is the reciprocal half of the bidirectional pair: Conference calls Engagement's BookmarkCountService in the other direction, which is exactly why the AppHost deliberately omits a reciprocalWaitFor. - BR-21 duplicate check (
:37-48) viaExistsAsync, returningError.Conflictwith code"UserSessionBookmark.Duplicate". - BR-135 revival lookup (
:50-55): one call toFindIncludingDeletedAsyncwithasTracking: true, which returns the matching rows already partitioned into(Active, SoftDeleted)and lets the handler discard the active half with_. The framework calls this "the resurrection read" and documents both halves of the contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:196-215): dropping the query filter means "include soft-deleted rows" and nothing more (the tenant filter stays in force), andasTracking: trueis required because the row is about to be mutated rather than inserted, since an untracked source would leave the reactivation silently unsaved. - The decision itself is delegated to the domain service (
:57-60):CreateOrReactivate(deletedBookmark, ...)returns either a fresh aggregate or the revived one, and the handler only decides persistence:AddAsyncruns solely when there was no deleted row to revive (:62-66). - The save is wrapped in a filtered catch (
:68-84).uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception)recognizes the unique index on(UserId, SessionId)firing, logs at Debug, and returns the SAME conflict error the pre-check produces (:79-83), so a lost insert race reads to the caller as an ordinary 409 instead of a 500. Note the detector is INJECTED rather than a static helper, which is what keeps the provider-specific error classification swappable per database engine. - Success logs at Information and returns the mapped DTO (
:86-88). Both log lines are[LoggerMessage]partials (:91-95), so the message templates are compiled rather than formatted per call.
- Cross-module eligibility first (
- Why it's built this way: reactivation preserves the audit fields and any scalar references pointing at the bookmark, which is the whole point of soft delete; and pushing the create-or-reactivate choice into a domain service keeps the invariant testable without a database.
- Where it's used: resolved as
ICommandHandler<CreateBookmarkRequest, Result<UserSessionBookmarkDTO>>by BookmarksController (BookmarksController.cs:35,73), which performs the ownership check first and returns 201 with a/bookmarks/{id}location on success (BookmarksController.cs:75-77). Registered by the convention scan (DependencyInjection.cs:88) and therefore wrapped by the decorator pipeline, so validation, logging, and the transaction are already applied around it.
LiveEventService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventService.cs:14· Level 9 · sealed class
- What it is: the implementation of ILiveEventUIService: it fetches the currently-live-or-next published event from the Conference API and computes its live window into a LiveEventContext, degrading to
nullwhen nothing is live or the API is down. - Depends on: ILiveEventUIService, CurrentEventSelector (the shared selection plus window math), EventDTO and PagedCollectionResult<T>, LiveEventContext, and
IHttpClientFactory(LiveEventService.cs:14). It uses the plain"APIClient"(:21), not the authenticated base: the published-events read is public. - Concept introduced, fail-soft resolution: absence and failure both become
null. This is the one live-layer service that has not moved to theResultshape, and the choice is deliberate rather than an omission: the type doc states that "API failures degrade tonullso the live layer simply stays dormant" (LiveEventService.cs:11-12), and the only consumer behavior for both cases is "render nothing".[Rubric §29, Resilience & Business Continuity]assesses graceful degradation under a dependency outage; here an unavailable Conference API turns the live layer off instead of erroring a page.[Rubric §12, Performance & Scalability]applies because the same public, output-cached events read backs this call. The trade-off worth naming: a page cannot tell an outage from "the conference has not started", so no diagnostic message is possible at this boundary. - Walkthrough:
GetCurrentEventAsync(:17) opens atry(:19), creates the"APIClient"(:21), and fetchesevents?includeFKs=false&includeChildren=falseasPagedCollectionResult<EventDTO>viaGetFromJsonAsync(:23-25). It handsCurrentEventSelector.SelectCurrentOrNextthe published subset with a null-safewrapper?.Items?.Where(e => e.IsPublished) ?? [](:28), accessor lambdas for start date, end date, and time zone (:29-31), andDateTime.UtcNow(:32); a null selection returnsnull(:33-36). On a hit it computes(startUtc, endUtc)fromCurrentEventSelector.GetLiveWindowUtc(StartDate, EndDate, TimeZone)(:38-39) and constructs the LiveEventContext from the event id, name, time zone, and window (:41-46). A thrownHttpRequestExceptionis caught (:48) and also returnsnull, with a comment recording that the live layer stays dormant while the API is unavailable (:50-51). - Why it's built this way: delegating both "which event" and "what window" to CurrentEventSelector means the UI and the API share one implementation of the conference-day math, which is what lets LiveEventContext claim to mirror the backend window exactly. The two
nullpaths keep the ambient listener silent whenever there is nothing to show. - Caveats / not-in-source: the
catchcoversHttpRequestExceptiononly (:48). A malformed JSON body would surface aJsonExceptioninstead, and how that propagates is not handled here; nothing in this file states the intended behavior for it. - Where it's used: registered as
ILiveEventUIService; consumed by HappeningNow, the ambient LiveEventListener, CheckInScan, and OrganizerAttendance.
RecordRoomCheckInHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:28· Level 12 · class (sealed partial)
- What it is: the attendee-facing self check-in from a room's printed QR. The request names a room; the SERVER decides which session that room is hosting right now and records a session check-in for the caller.
- Depends on: IUnitOfWork, IEventLiveValidationService and its RoomSessionInfo result, ICurrentUserService, CheckInSettings via
IOptions<T>, CheckInProcessor, CheckIn, RoomCheckInResultDTO, ICommandHandler<in TCommand, TResult>; externals:TimeProvider,ILogger<T>. - Concept introduced, server-side resolution as the anti-abuse property.
[Rubric §11, Security](assesses whether authority for a decision sits with the party that can be trusted with it) and[Rubric §12, Performance & Scalability]in passing. The class doc states the property (RecordRoomCheckInHandler.cs:16-27): because only the room travels in the request, a shared room link records nothing outside the session's window, and nobody can name a session they are not standing in. Worth contrasting with the organizer paths, where the client does send a session id: there the caller is a trusted organizer, here the caller is the beneficiary of the write. The second teachable point is the answer-shape rule at:53-63: an unknown room and a room with nothing scheduled deliberately produce the SAME attendee-facing error, because distinguishing them would leak which room ids exist. Transport-level failures still propagate unchanged, so a Conference outage is not misreported as an empty schedule. - Walkthrough: six constructor dependencies (
:26-32), oneHandleAsync(:35-96).- Caller identity from the token, never the request (
:39-45):RequireUserId("CheckIns.Forbidden"), the same guard the organizer handlers use. - Cross-module resolution (
:50-52):GetCurrentRoomSessionInfoAsync(command.RoomId, settings.Value.RoomCheckInGraceMinutes, ...). The grace window travels in the call rather than living in Conference because it is this module's policy (:47-49); its default is 15 minutes (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInSettings.cs:21), and the setting's own doc (CheckInSettings.cs:16-20) explains that a zero-grace window would reject the entire pre-session queue. - Failure triage (
:60-62): aNotFoundin the returned errors collapses to the sharedNoCurrentSession()error (:98-102); anything else is passed through verbatim. - Published gate (
:65-67): an unpublished event fails even when a session resolves, and the failure is the processor's sharedEventNotPublished(...)with this handler named as the source (CheckInProcessor.cs:29-33), so the error still points at the entry point the caller used. - The write goes through
CheckInProcessor.RecordAsync(:71-81) withCheckInScope.Session, the event id Conference reported,sponsorId: null, andcheckedInByUserId: userId. That last argument is the only field that distinguishes a self check-in from a door scan (:22-23), which is what keeps the two operationally separable while everything downstream (points award, attendance rollup, export posture) treats them identically. Idempotency lives insideRecordAsync: it looks for an existing row on the same scope-specific predicate the organizer paths use (CheckInProcessor.cs:201-220) and, when it finds one, returnsAlreadyRecorded: truewith the ORIGINALCheckedInOnrather than writing again (CheckInProcessor.cs:71-75). - The response (
:89-95) carries the session title Conference returned plusAlreadyCheckedInandCheckedInOnfrom the outcome; the Information log fires only on a genuinely new row (:86-87).timeProvider.GetUtcNow()supplies the timestamp inside the processor (CheckInProcessor.cs:83), so the flow is testable without wall-clock dependence ([Rubric §14, Testability]).
- Caller identity from the token, never the request (
- Why it's built this way: writing an ordinary session check-in rather than a new row type means AttendeeCheckedInPointsHandler, the rollups, and the privacy export need no change at all to cover the self-service surface.
- Where it's used: CheckInsController resolves it as
ICommandHandler<RoomCheckInRequest, Result<RoomCheckInResultDTO>>(CheckInsController.cs:45,173).
RecordSponsorVisitHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordSponsorVisit·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:36· Level 12 · class (sealed partial)
- What it is: records the caller's visit to one sponsor booth, scanned from the sponsor's printed deep-link QR. The row is a CheckIn with
Scope = SponsorwhoseCheckedInByUserIdequals itsUserId, which is what marks it self-recorded. - Depends on: IUnitOfWork, IEventLiveValidationService and its SponsorLiveInfo result, ICurrentUserService, CheckInProcessor, SponsorVisitResultDTO, ICommandHandler<in TCommand, TResult>; externals:
TimeProvider,ILogger<T>. - Concept introduced, where to draw the reuse line, and a written-down accepted trade-off.
[Rubric §15, Best Practices & Code Quality]and[Rubric §11, Security]. The doc comment (RecordSponsorVisitHandler.cs:19-34) is the most instructive part of the file and rewards reading in full. It argues first that this flow is deliberately not a third branch insideCheckInProcessor.ExecuteAsync: that entry point resolves its own target through Conference, and this flow asks a different question (the sponsor lookup) and answers in a different shape (the sponsor name), so folding it in would make the organizer path carry conditionals for a question it never asks. The reuse line is drawn one level lower instead: the caller check, the not-published failure, and the idempotent record all still run through CheckInProcessor, just throughRecordAsyncandEventNotPublishedrather thanExecuteAsync. Splitting a helper by which half is genuinely shared, rather than sharing the whole entry point, is the transferable lesson. Then the doc states the trade-off honestly (:25-32): the deep-link URL is shareable, exactly like the badge QR, and the live window is deliberately not enforced. What makes sharing worthless is the combination of gates: the owning event must be published, the filtered unique index caps the award at one per sponsor per attendee, the configured value is low with0turning it off entirely (PointsSettings), and the feature flag answers 404 when the surface is retired. Rotating tokens and date windows were considered and rejected as inconsistent with the badge QR. Documenting a rejected alternative next to the accepted risk is what makes this an architecture decision rather than an oversight. - Walkthrough: five constructor dependencies (
:34-39), oneHandleAsync(:42-94).ArgumentNullException.ThrowIfNull(command)(:46) and the caller from the token (:48-52), sameCheckIns.Forbiddenguard as the room flow.- Sponsor lookup (
:56-60):GetSponsorLiveInfoAsyncproves the sponsor exists and returns its owning event plus display name, so a QR printed for a sponsor that was later pulled records nothing (:54-55). Unlike the room flow, failures pass through unchanged, since a nonexistent sponsor id is not the same kind of enumeration risk as a room schedule. - Published gate (
:63-64), the same sharedEventNotPublishederror as the other check-in paths, sourced to this handler. - The write through
CheckInProcessor.RecordAsync(:70-79) withCheckInScope.Sponsor,sessionId: null, the sponsor id, andcheckedInByUserId: userId. Idempotency again lives in the processor: a(UserId, SponsorId, Scope == Sponsor)predicate (CheckInProcessor.cs:222-223) means a second scan of the same booth reports the ORIGINAL visit instead of writing a second row. The comment names the filtered unique index on(UserId, SponsorId) WHERE [Scope] = 2as the race backstop (:66-68). - The response carries the sponsor name (
:87-93) so the attendee's confirmation screen needs no second cross-module call, and the Information log fires only on a genuinely new visit (:84-85).
- Why it's built this way: returning the same DTO shape on both the fresh and repeat paths means the client renders one screen, and the
AlreadyVisitedflag is presentation, not an error the user has to interpret. - Where it's used: CheckInsController (
CheckInsController.cs:44,142).
CheckInAttendeeRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.CheckInAttendee·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeRequestValidator.cs:9· Level 2 · class (sealed)
- What it is: the validator for the organizer scanning path. Four rules covering the badge credential, the scope, the event, and the conditionally required session.
- Depends on: CheckInAttendeeRequest, CheckInScope, the
EventIdentifierTypealias; externals: FluentValidation (NotEmpty,IsInEnum,NotEqual,NotNull,When). - Concept introduced, the conditional rule.
[Rubric §9, API & Contract Design](assesses whether a request that means different things in different modes states which fields each mode requires). The request is one payload serving two scopes, so a flat "all fields required" rule set would be wrong for an event-scope check-in and a flat "nothing required" set would let a session check-in arrive with no session. FluentValidation's.When(...)states the mode-specific half declaratively (CheckInAttendeeRequestValidator.cs:33) instead of pushing anifinto the handler.IsInEnum()is the second thing worth naming: a C# enum backed byintwill happily deserialize an out-of-range number, so an enum field is not self-validating and the rule at:18-21is the check that makes it so. - Walkthrough: four rules in the constructor (
CheckInAttendeeRequestValidator.cs:11-34).CredentialNotEmpty()with message "Badge credential is required." and code"CheckIn.Credential.Required"(:13-16). Presence only: whether the credential decodes and whether it names a real badge are decided in the handler, and both failures are answered identically there for anti-oracle reasons.ScopeIsInEnum()with code"CheckIn.Scope.Invalid"(:18-21).EventIdnotdefault(EventIdentifierType)with code"CheckIn.EventId.Required"(:23-26).SessionIdNotNull()with code"CheckIn.SessionId.Required", guarded by.When(x => x.Scope == CheckInScope.Session)(:29-33). The comment above it (:28) states the asymmetry outright: the event is required for both scopes, only the session is scope-conditional.
- Why it's built this way: the event stays mandatory even for a session scan although the shared core will later overwrite it with the event Conference reports for that session (CheckInProcessor,
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:124-128,174-177). Requiring it keeps the request self-describing for logs and clients; letting the server value win keeps a stale organizer screen from filing attendance under the wrong event. - Where it's used: runs ahead of CheckInAttendeeHandler (
CheckInsController.cs:77,88).
LeaderboardOptInChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/DomainEvents/LeaderboardOptInChanged.cs:15· Level 2 · record (sealed)
- What it is: the in-process domain event the LeaderboardOptIn aggregate raises when an attendee joins the public leaderboard, rejoins it, or leaves it.
- Depends on: BaseDomainEvent, DomainEntityState, the
LeaderboardOptInIdentifierType/UserIdentifierTypealiases. - Concept introduced, the one-event-per-aggregate shape (BR-60).
[Rubric §4, DDD](assesses whether state changes are named domain facts rather than inferred from rows) and[Rubric §6, CQRS & Event-Driven]. Instead ofLeaderboardOptInCreatedplusLeaderboardOptInDeleted, the module raises ONE record carrying aDomainEntityStatediscriminator (LeaderboardOptInChanged.cs:16). The doc comment states the convention (:8-9) and the module holds to it for all three of its aggregates in this unit. The payload discipline is the second lesson: the comment says outright that the event carries no display name because "the event is a signal, not a publication channel" (LeaderboardOptInChanged.cs:9-10). A subscriber running in the same unit of work can load anything else it needs, and a fat payload would go stale between raise and dispatch. - Walkthrough: a three-member positional record (
LeaderboardOptInChanged.cs:15-19):State,LeaderboardOptInId,UserId. Raised from three call sites on the aggregate, and each one tells you something:CreateraisesAdded(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:75), reactivation of a previously soft-deleted opt-in raisesAddedagain rather than a distinct "reactivated" fact (LeaderboardOptIn.cs:98), and the soft delete raisesDeleted(LeaderboardOptIn.cs:114). A subscriber therefore never has to know whether a row is new or revived, which is exactly the information a leaderboard projection does not care about. - Why it's built this way: opting out of the leaderboard is a privacy action (ADR-005 distinguishes soft delete from erasure), so it must be a first-class fact and not a silently flipped flag.
- Where it's used: raised by the aggregate and asserted by the domain tests (
MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Points/LeaderboardOptInTests.cs:76,105,139). - Caveats / not-in-source: no handler subscribes to this event anywhere in current ADC source. It is a published extension point that the framework's DomainEventDispatcher will deliver the moment one is written, not shipped behavior. Contrast UserSessionBookmarkChanged below, which does have a subscriber.
ManualCheckInRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.ManualCheckIn·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInRequestValidator.cs:9· Level 2 · class (sealed)
- What it is: the validator for the organizer's manual fallback. Structurally the same four rules as CheckInAttendeeRequestValidator, with one substitution.
- Depends on: ManualCheckInRequest, CheckInScope, the
UserIdentifierType/EventIdentifierTypealiases; externals: FluentValidation. - Concept reinforced: the conditional rule and the enum check taught in the previous section apply unchanged, including the identical
.When(x => x.Scope == CheckInScope.Session)guard and the same comment about the event staying required for both scopes (ManualCheckInRequestValidator.cs:28-33). - Walkthrough: the one line that differs is the first rule (
ManualCheckInRequestValidator.cs:13-16):UserIdnotdefault(UserIdentifierType)with code"CheckIn.UserId.Required", where the scan path validates a badgeCredentialinstead. That single substitution is the whole difference between the two paths at this layer, and it is the security-relevant one: on this endpoint the attendee is named by the caller, so the endpoint itself is organizer-only rather than the badge row being the authority.Scope(:18-21),EventId(:23-26) and the conditionalSessionId(:29-33) are line-for-line the scan validator's rules, messages and error codes included. - Why it's built this way: identical error codes across the two paths mean a client shows the same message whichever way an organizer records the check-in, which matters because the two surfaces sit side by side on the same organizer screen.
- Where it's used: runs ahead of ManualCheckInHandler (
CheckInsController.cs:101,112).
PointsEntryChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/DomainEvents/PointsEntryChanged.cs:17· Level 2 · record (sealed)
- What it is: the domain event raised when one award lands in the points ledger. It carries the whole award: who earned it, for what activity, and how many points.
- Depends on: BaseDomainEvent, DomainEntityState, PointsActivityType, the
PointsEntryIdentifierType/UserIdentifierTypealiases. - Concept reinforced, the same BR-60 shape as LeaderboardOptInChanged, with an honest caveat in the doc.
[Rubric §8, Data Architecture](assesses whether the storage model matches the domain's mutability) and[Rubric §4, DDD]. The comment (PointsEntryChanged.cs:8-10) says the ledger is append-only, so in practiceStateis alwaysAdded, and that the discriminator is carried anyway to keep one event shape across the module. That is a deliberate small redundancy in favor of consistency, and the file says so rather than leaving a reader to wonder why a never-deleted entity carries a delete state. - Walkthrough: five positional members (
PointsEntryChanged.cs:17-23):State,PointsEntryId,UserId,ActivityType,Points. Unlike the other two events in this unit, this one carries the value, because the awarded points are a SNAPSHOT of what the rule was worth at earn time (see PointsAwarder) and cannot be re-derived from configuration afterwards. Raised exactly once inside thePointsEntry.Createfactory, after the four invariants pass (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:96-101). - Why it's built this way: the value is the payload a future subscriber (a live points ticker, an audit trail) would otherwise have to guess at, and guessing it from today's PointsSettings would give the wrong answer for any award earned before a mid-conference retune. Note the entry's own
Idis stilldefaultwhen the event is captured (PointsEntry.cs:91-97, the identity is generated by the INSERT), so the useful correlation keys are the user and the activity, not the entry id. - Where it's used: raised by PointsEntry and asserted by the domain tests (
MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Points/PointsEntryTests.cs:55,65). - Caveats / not-in-source: current source contains no subscriber to this event.
SelfHttpWarmupTask
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/SelfHttpWarmupTask.cs:23· Level 2 · class (sealed, internal)
- What it is: the Engagement service's startup warm-up. Once Kestrel is listening it replays the hot bookmarks read against this host's own endpoint so routing, authentication, and the middleware pipeline are JIT-compiled before a real attendee request arrives.
- Depends on: SelfHttpWarmupTaskBase from
MMCA.Common.Aspire.Warmup; externals: ASP.NET Core (IServer,IHostApplicationLifetime,IHostEnvironment),IConfiguration,ILogger<T>. - Concept reinforced, the cold-start warm-up task (ADR-025).
[Rubric §12, Performance & Scalability](assesses whether first-request latency after a restart or scale-out is managed rather than paid by a user) and[Rubric §13, Observability & Operability](assesses that readiness reflects an instance's real ability to serve). The class doc states the contract (SelfHttpWarmupTask.cs:6-17): the base class discovers the bound cleartext address, waits for the host lifetime, and issues the configured GETs; the runner registered byAddWarmupReadiness()insideAddServiceDefaults()holds/health/readynot-ready until the task has had its chance, so the orchestrator does not route traffic into a cold instance. Failures are logged and fall back to lazy warm-up on the first real request rather than blocking startup. - Walkthrough: a primary-constructor class forwarding all five dependencies to the base (
SelfHttpWarmupTask.cs:23-29) and overriding three members.Paths(SelfHttpWarmupTask.cs:33-36), one entry:bookmarks?pageNumber=1&pageSize=10. The comment above it (:31-32) ties the string to the attendee bookmark list under the module-wide[Authorize]policy (BookmarksController,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33).Name => "SelfHttpWarmup"(:39) andWarmupPaths => Paths(:42), the base's two abstractions.RequireSuccessStatusCode => false(:49), the interesting override. The controller requires an authenticated caller, so the unauthenticated self-request is refused by design. The XML comment (:44-48) makes the argument: the refusal still traverses Kestrel, routing, authentication, and the whole middleware pipeline, which is exactly where the cold-start JIT cost lives, so treating a 401 as a failure would log a spurious warning on every startup.
- Why it's built this way: the class doc (
SelfHttpWarmupTask.cs:9) draws the contrast with Conference's warm-up, which also populates an output cache. This service caches nothing (its base output-cache policy isNoCache,MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:152), so the task buys pipeline JIT only, and it says so rather than pretending to prime a cache it cannot reach. The Identity service ships a same-named task with the same posture over its own hot read (SelfHttpWarmupTask). - Where it's used: registered with
services.AddWarmupTask<SelfHttpWarmupTask>()in the Engagement service host (Program.cs:158), where the surrounding comment (Program.cs:154-157) repeats the rationale for an operator reading the host file. - Caveats / not-in-source: the base type decides how the bound address is discovered and whether the run is skipped under the Testing environment; this file supplies only the paths and the status-code policy.
UserSessionBookmarkChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:21· Level 2 · record (sealed)
- What it is: the domain event the UserSessionBookmark aggregate raises when a bookmark is created, reactivated, or soft-deleted. It is the one event in this unit with a live subscriber.
- Depends on: BaseDomainEvent, DomainEntityState, the
UserSessionBookmarkIdentifierType/UserIdentifierType/SessionIdentifierTypealiases. - Concept introduced, the identity hole in a create-time domain event.
[Rubric §4, DDD]and[Rubric §8, Data Architecture](assesses whether the code accounts for what the database, not the object graph, actually decides). The BR-60 single-event shape is the same one LeaderboardOptInChanged teaches, but this file adds a lesson those two do not. TheBookmarkIdparameter doc (UserSessionBookmarkChanged.cs:12-18) says the field is ZERO whenever a brand-new bookmark raisesAdded: identity is generated by the INSERT, the positional record captures the value before that INSERT runs, and nothing re-stamps it afterwards. A reactivated bookmark does carry its real id, because that row already exists. The consequence is stated in the same doc and is the part to remember: handlers correlate onUserIdandSessionId, which are both set before the event is raised, and never onBookmarkId. That is a general trap with value-captured events over database-generated keys, not a quirk of this aggregate. - Walkthrough: four positional members (
UserSessionBookmarkChanged.cs:21-26):State,BookmarkId,UserId,SessionId. Three raise sites, and the shape earns its keep at each:CreateraisesAddedwith the still-zero id and a comment saying so (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:55-57);ReactivateraisesAddedagain, and only whenUndelete()succeeded (UserSessionBookmark.cs:69-73), which is BR-135 in one line, so a subscriber counting bookmarks sees "one more bookmark exists" for both a fresh star and a revived one;DeleteraisesDeleted, and only when the soft delete actually flipped (UserSessionBookmark.cs:84-88), so a second delete of an already-deleted bookmark raises nothing and subscribers cannot observe a duplicate fact. - Why it's built this way: carrying
SessionIdas well asUserIdmeans a subscriber can act on the session dimension without loading the aggregate, which is exactly what the one subscriber does. - Where it's used: UserSessionBookmarkCacheEvictionHandler subscribes as
IDomainEventHandler<UserSessionBookmarkChanged>(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:43-46) and broadcasts an output-cache eviction to Conference so its cached session reads stop serving a stale bookmark count. Its doc explains why the DOMAIN event is the right hook (:23-30): the create path has a bespoke handler, but the delete path runs on the framework's genericDeleteEntityCommandand has no ADC handler at all, whereas the aggregate raises this one event on all three paths that move a count. Also raised in the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/UserSessionBookmarks/UserSessionBookmarkTests.cs:45,58,77,105). - Caveats / not-in-source: the per-session bookmark count Conference consumes is served by a live query in BookmarkCountService, not by projecting this event; the subscriber only invalidates Conference's cache of that number. Do not read the event as the backing mechanism for the count itself.
CurrentEventNotificationScopeProvider
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Notifications·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Notifications/CurrentEventNotificationScopeProvider.cs:30· Level 2 · sealed partial class
- What it is: ADC's implementation of the framework's notification scope hook. It answers "which conference event is in focus right now" as the opaque string
event:{EventId}, so a notification sent for an event that has since ended stops surfacing once the next event becomes the current one. - Depends on:
INotificationScopeProvider(the framework contract it implements),NotificationScopeKey(the canonical key formatter),ILiveEventUIServiceandLiveEventContext(the resolution source). Externals:TimeProvider,System.Threading.Lock,ILogger<T>with source-generated[LoggerMessage]methods. - Concept introduced, the app-supplied scope over a framework-owned mechanism.
[Rubric §3, Clean Architecture]and[Rubric §12, Performance & Scalability]assess whether a cross-cutting concern is expressed as a framework extension point the application fills in rather than as application logic baked into the framework. MMCA.Common knows notifications carry an optional scope key (ADR-024) and nothing more; the meaning of that string is ADC's business, and the type doc says so directly (CurrentEventNotificationScopeProvider.cs:11-12: to the framework it is just an opaque string). - Concept introduced, failing closed on a view filter.
[Rubric §11, Security]. The framework contract is blunt about the direction of failure: implementations must never throw, and in an application whose notifications are all scoped they must fail closed, returning the last known key rather than null, because null on that contract means "unscoped" and silently widens the view to every notification (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-14). ADC is such an application, so this provider never answers null. The remarks block (:18-24) spells out the consequence: an instance that has never resolved an event answers with theUnresolvedScopeKeysentinel,event:0(:38), a well-formed key (it satisfiesNotificationScopeKey.Pattern, so the hub's channel-key guard accepts it) that no row carries, so the reader sees an empty inbox instead of everyone's. - Walkthrough:
- The primary constructor (
:29-32) takes the live-event service, aTimeProviderand a logger, and the class declaresINotificationScopeProvider.UnresolvedScopeKeyis composed fromNotificationScopeKey.EventPrefix(:38, andMMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:23) so the sentinel cannot drift from the prefix the pattern atNotificationScopeKey.cs:32accepts.CacheDurationis five minutes (:40). - The cache is two fields plus a
Lock(:45-47). The comment above them (:42-44) is the concurrency reasoning worth reading: the lock guards the two fields only, the refresh runs outside it (aLockcannot be held across anawait), so two callers racing an expired cache both fetch. That is accepted deliberately, because the events read is idempotent and both callers converge on the same key, which beats serializing every poll behind a gate. GetCurrentScopeKeyAsync(ct)(:50) takes the lock, returns the cached key while it is still fresh (:52-58), then resolves outside the lock:liveEventService.GetCurrentEventAsync(ct)(:63) and, when an event comes back, builds the key throughNotificationScopeKey.ForEvent(:66), which formats the identifier underCultureInfo.InvariantCulture(NotificationScopeKey.cs:37-38) so a culture with non-ASCII digits cannot produce a key the pattern rejects.- The two unresolved paths log and fall through: no current published event logs at Information (
:70,:134-135), a thrown lookup logs at Warning (:73-76,:131-132), and both leavescopeKeynull. - The fail-closed answer (
:78-83) returnsLastResolvedOrSentinel()(:123-129), the last key this instance resolved orevent:0. Nothing is cached on that path and the expiry is left alone (:120-121), so the very next call retries the lookup rather than serving a stale answer for a whole window. - The write-back (
:85-89) caches only a successful resolution, stamping_cacheExpiresOnfive minutes out from the injected clock. GetCurrentScopeDisplayNameAsync(ct)(:104-116) is the optional half of the contract (a default interface method,INotificationScopeProvider.cs:39): it returns the current event's name for a send surface to caption its target (:108-109), and fails closed the way that member defines it, by returning null so a missing caption hides information rather than stating the wrong audience (:113-114, logged at Debug via:137-138).
- The primary constructor (
- Why it's built this way: the caching is not premature. Both notification HTTP services ask for the scope on every call (
NotificationInboxService.cs:180,PushNotificationService.cs:34) andNotificationBellpolls the unread count on aPeriodicTimerwhose default interval is 30 seconds (NotificationBell.razor.cs:92,MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:22), so an uncached provider would put an events fetch behind every poll.[Rubric §12, Performance & Scalability]and[Rubric §31, Cost/FinOps]both land on that arithmetic.[Rubric §13, Observability & Operability]: the three[LoggerMessage]methods are source-generated and each message states what the operator loses, not just that something failed. The registration is the other deliberate detail:services.AddScoped<INotificationScopeProvider, CurrentEventNotificationScopeProvider>()(MMCA.ADC.Engagement.UI/DependencyInjection.cs:73) uses plainAddScopedso it overrides theNullNotificationScopeProviderthatAddNotificationUIregisters withTryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24), which keeps the two calls order-independent (DependencyInjection.cs:61-62); the line above it (:65) has toTryAddSingleton(TimeProvider.System)because no UI head registers aTimeProvider(the heads never callAddInfrastructure,:63-64). - Where it's used: resolved by the framework's notification services listed above, and by the send page for its targeting caption (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:77); never called directly by an ADC page. Covered by CurrentEventNotificationScopeProviderTests. - Caveats / not-in-source: the cache is per instance and the registration is scoped, so on Blazor Server the window is per circuit, not per user or per process. How long a stale key can survive an event rollover is therefore bounded by the five-minute window, not by any invalidation signal.
IPointsAwarder
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/IPointsAwarder.cs:19· Level 3 · interface
- What it is: the single write path into the points ledger. One method: award the configured points for one activity, or do nothing when the rule is off or the attendee already earned that exact award.
- Depends on: PointsActivityType, PointsSubjectKeys (named in the parameter docs), Result, the
UserIdentifierTypealias. - Concept introduced, the deliberately vocabulary-free contract.
[Rubric §3, Clean Architecture](assesses whether abstractions are stated in terms the layer owns rather than in terms of an outer concern) and[Rubric §15, Best Practices & Code Quality]. The doc comment argues the design explicitly (IPointsAwarder.cs:10-17): neither this interface nor the PointsEntry entity names an event, a session, a check-in, or any other ADC concept. An award is a user, an activity, an opaque subject key, and a timestamp. That is what would make lifting the ledger (the entity, this awarder, and the read queries) into MMCA.Common a MOVE rather than a rewrite, and it is the same "extract reusable infrastructure, keep the vocabulary local" instinct the workspace applies everywhere. The ADC-specific part stays in the thin award adapters that translate a module event into an (activity, subjectKey) pair, for example AttendeeCheckedInPointsHandler.[Rubric §1, SOLID]also applies at the interface-segregation level: one method, one reason to call it. - Walkthrough:
Task<Result> AwardAsync(UserIdentifierType userId, PointsActivityType activity, string subjectKey, DateTime occurredOnUtc, CancellationToken cancellationToken = default)(IPointsAwarder.cs:35-40). The return contract is the part to read carefully (IPointsAwarder.cs:30-34): the result succeeds when the award EXISTS after the call, whether this call created it, an earlier call did, or the rule is configured to award nothing. It fails only when the entry itself is invalid, which the doc calls a caller bug rather than a retryable condition. Callers therefore never need to distinguish "awarded" from "already awarded", which is what lets a broker redelivery run straight through. - Why it's built this way: stating idempotency, the per-rule kill switch, and the value snapshot once in the contract (
IPointsAwarder.cs:6-9) means each new earn rule is a small adapter rather than a re-derivation of the same three concerns. - Where it's used: registered by hand as scoped (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:84, with the comment at:76-82explaining that a plain service is invisible to the handler convention scan below it) and implemented by PointsAwarder. Consumers resolve it from their own scope: AttendeeCheckedInPointsHandler (.../Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:48), EventFeedbackSubmittedPointsHandler (.../EventFeedbackSubmittedPointsHandler.cs:38), SessionFeedbackSubmittedPointsHandler (.../SessionFeedbackSubmittedPointsHandler.cs:40), and SessionQuestionSubmittedPointsHandler (.../Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:78).
IBookmarkUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Bookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/IBookmarkUIService.cs:11· Level 3 · interface
- What it is: the UI-side contract for the
BookmarksAPI resource as the Engagement UI consumes it, create, list-by-user, and delete. - Depends on:
CreateBookmarkRequest,UserSessionBookmarkDTO, Result, and theUserIdentifierType/EventIdentifierType/UserSessionBookmarkIdentifierTypealiases. Externals: BCLTask. - Concept introduced, the UI service abstraction.
[Rubric §18, UI Architecture]assesses whether components talk to typed service interfaces rather than rawHttpClients. Components depend on this interface, not onBookmarkService, so a page can be tested against a fake while the transport concerns (auth headers, retry policy, error translation) stay behind the contract inAuthenticatedServiceBase. Every interface in this unit follows the same shape. - Concept introduced, Result all the way to the page.
[Rubric §9, API & Contract Design]. The type doc states the rule for the whole family (IBookmarkUIService.cs:6-10): every member answers with a Result, so a server refusal is data the page renders, never an exception it has to catch. That is what lets a component writeif (!result.TryGetValue(out var value))and branch, the pattern taught in group 01 and used verbatim by every consumer page in this module. - Walkthrough (
IBookmarkUIService.cs:11-33):CreateAsync(request, ct)returnsResult<UserSessionBookmarkDTO>(:14-16);GetUserBookmarksAsync(userId, eventId?, pageNumber, pageSize, ct)returns aResultover a tuple of the page items plus the total count (:19-24), witheventIdoptional and paging defaulted topageNumber = 1/pageSize = 10;DeleteAsync(id, ct)returns a bareResult(:30-32), and its doc records the deliberate change of shape (:26-29): a bookmark that is not there is anErrorType.NotFoundfailure rather than the oldfalse, so a caller can tell "already gone" from "the call failed" without inventing its own convention. - Why it's built this way: the identifier-keyed shape (
UserSessionBookmarkIdentifierTypeon delete,userIdon read) mirrors the REST resource one-for-one, which keeps the service a thin translation of the endpoint rather than a second place where bookmark rules could accumulate. - Where it's used: implemented by
BookmarkService(BookmarkService.cs:17) and registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:33). - Caveats / not-in-source: no page or component in the repository injects this interface today. The session-level surfaces (the star toggle on the public session list and detail pages) go through the sibling
ISessionBookmarkUIServiceinstead, which is the oneSessionBookmarkUIServiceimplements. This contract stays registered and implemented, so it is available, but its consumers are not in source.
AttendeeCheckedIn
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:24· Level 3 · record (sealed)
- What it is: the cross-module announcement that an attendee was checked in. One record carries every check-in shape: an organizer badge scan, the manual organizer fallback, a self-recorded sponsor booth visit, and a room check-in.
- Depends on:
BaseIntegrationEvent(the base it derives from,AttendeeCheckedIn.cs:32) and the module identifier aliases (UserIdentifierType,EventIdentifierType,SessionIdentifierType,SponsorIdentifierType). It namesCheckInScopeNamesonly in its documentation, never as a type in its signature. It carries an explicit wire name through[EventName("Engagement.AttendeeCheckedIn.v1")](:23), so the contract on the broker does not move when the CLR type is renamed or relocated. Externals:DateTimeOffset. - Concept introduced, the wire contract written for consumers you cannot redeploy.
[Rubric §9, API & Contract Design]assesses whether contracts are versionable rather than merely correct today, and this record makes three deliberate choices for that. First,Scopeis astring(:26), not theCheckInScopeenum: the doc comment (:11-14) states the reason, which is that adding a scope later stays an additive change for a consumer that has not been rebuilt, where an unknown enum member would deserialize into a value the consumer's own enum cannot name. Second,SponsorIdis optional and last (:31), with the doc comment (:22) recording that placement so a consumer keeps deserializing payloads written before sponsor visits existed. Third, thev1suffix in the event name (:23) makes the version part of the routed contract rather than an implicit property of the assembly.[Rubric §6, CQRS & Event-Driven]covers the delivery half: this is an integration event, not a domain event, so it does not merely dispatch in process. It is captured with the row that produced it and published by the outbox processor (ADR-003).[Rubric §7, Microservices Readiness]applies because the payload is all scalars: nothing on it can only be resolved inside the Engagement process. - Walkthrough: seven positional members.
UserId(:25) is the attendee.Scope(:26) is one of theCheckInScopeNamesstring constants.EventId(:27) is set for every scope, which is what lets a consumer bucket any check-in by conference without a lookup.SessionId(:28) is nullable and set only for a Session scope.CheckedInByUserId(:29) records who performed the check-in, an organizer for a scan and the attendee themselves for a self-recorded visit (:20), which is what keeps self-recorded rows distinguishable downstream.CheckedInOn(:30) is the recorded instant.SponsorId(:31) is the trailing optional member, defaulted tonull. - Why it's built this way: the doc comment (
:6-10) ties the event to the aggregate factory: it is added insideCheckIn.Create(CheckIn.cs:112-119) rather than in a handler, so the outbox captures it in the same transaction as the row. That gives the property the points economy depends on: a persisted check-in has published exactly one event, and a duplicate scan, which short-circuits before the factory, publishes none. Note the raise call isAddDomainEvent(CheckIn.cs:112): the framework's dual-dispatch treats anIIntegrationEventadded to an aggregate as outbox-bound, so an aggregate raises both kinds through one API. See ADR-072 for the surrounding badge-and-points decision. - Where it's used: raised by
CheckIn(CheckIn.cs:112), consumed byAttendeeCheckedInPointsHandler. The Engagement service subscribes its own receive endpoint to this type (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:286), which the surrounding comment (:268-277) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it, except under the InProcess test transport, where it is delivered in process so the integration tier exercises the same handler without a broker.
LiveChannelPublishProcessor
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30· Level 3 · class (sealed)
- What it is: the single background reader that drains
LiveChannelPublishQueueand forwards each queued broadcast toILiveChannelPublisher. It is the piece that keeps live-layer broadcasts (poll opened, results changed, question approved) off the command request path. - Depends on:
LiveChannelPublishQueue(the concrete queue, injected as itself for itsReader,LiveChannelPublishProcessor.cs:31),ILiveChannelPublisherresolved per item (:51), andBestEffort(:45). Externals:BackgroundServicefromMicrosoft.Extensions.Hosting,IServiceScopeFactory, andILogger. - Concept introduced, the single-reader hosted drain.
[Rubric §12, Performance & Scalability]assesses what work sits on the request path: a command handler here never awaits a broadcast, it enqueues, and this worker pays the network cost afterwards.[Rubric §29, Resilience & Business Continuity]is the reason the loop looks the way it does: the publish is best effort (BR-229, ADR-039), so no failure is allowed to escape the drain, and a down or hung Notification peer costs at most the adapter's own deadline per item and can never crash the host or fail a command (:15-20).[Rubric §13, Observability & Operability]covers the diagnostics: the swallow is delegated toBestEffort, so a peer that has quietly stopped accepting broadcasts is countable on thebesteffort.dispatch.failedmeter rather than being a Warning nobody alerts on (:21-28, and the meter itself atMMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:107-110). The queue counts its own backpressure drops separately (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:61-70).[Rubric §29, Resilience, Reliability & Business Continuity]covers the lifetime mismatch this class exists to bridge: aBackgroundServiceis a singleton while the gRPC publisher adapter is registered scoped, so the worker opens one scope per item (:50-51). - Walkthrough
- The primary constructor (
:30-33) takes the queue, anIServiceScopeFactoryand a logger. The class is plainsealed, notpartial: the logging it would otherwise generate itself lives insideBestEffort. PublishOperationPrefix(:36) is the constant"live-channel-publish:". It is completed per item with the work item's event name (:46) to form the best-effort operation name. The comment above the class (:21-28) explains the cardinality reasoning: the event name is a small fixed set of channel constants and is therefore safe as a metric tag, while the channel key is per session and is deliberately left out because fanning the tag out tells an operator nothing they can act on.ExecuteAsync(:39) is oneawait foreachoverqueue.Reader.ReadAllAsync(stoppingToken)(:41). There is exactly one of these loops in the process, and the queue is created withSingleReader = true(LiveChannelPublishQueue.cs:37), which is what makes delivery FIFO and therefore per-session order preserving: successivepoll.results-changedtallies cannot arrive out of order (LiveChannelPublishProcessor.cs:11-14).- Per item the body is handed to
BestEffort.ExecuteAsync(:45-58) with the operation name, the logger, the publish lambda and the stopping token. Inside the lambda an async DI scope is created (:50), the publisher is resolved from it (:51), andPublishAsyncis awaited with the work item's channel key, event name and pre-serialized payload plus the token the helper passes in (:52-56). - Cancellation is separated from failure, and the split is shared rather than local:
BestEffortrethrows the caller's own cancellation instead of recording it as a failure (BestEffort.cs:59-64), and this loop catches that rethrow whenstoppingToken.IsCancellationRequestedand returns quietly (:60-65).
- The primary constructor (
- Why it's built this way: the enqueue side cannot block and cannot fail, so backpressure has to be resolved somewhere. It is resolved in the queue, not here: the channel is bounded at 1024 items with
BoundedChannelFullMode.DropOldest(LiveChannelPublishQueue.cs:18,:36), which chooses the freshest broadcast over the oldest when the drain falls behind, because live channel events are ephemeral. ADropOldestwrite always succeeds, so the queue registers anitemDroppedcallback (LiveChannelPublishQueue.cs:40,:61-65) as the only way a discard becomes observable. This worker's job is only to be the one reader that gives that channel its ordering guarantee, and to make its swallowed failures countable rather than merely logged. - Where it's used: registered as a hosted service by the module's infrastructure registration (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21), so it starts with any host that boots the Engagement module. Its producers are the live-layer handlers that holdILiveChannelPublishQueue(for exampleMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:23and.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:29). In the deployed topology the publisher is the gRPC adapter targeting Notification's dedicated Http2 endpoint (ADR-012). Covered byLiveChannelPublishProcessorTests. - Caveats / not-in-source: whether a given deployment actually reaches a Notification peer is configuration and runtime, not source. Nothing in this file retries a failed publish; a dropped or failed broadcast is gone, which is the stated contract rather than an omission.
BookmarkService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Bookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/BookmarkService.cs:15· Level 4 · sealed class
- What it is: the HTTP implementation of
IBookmarkUIService, talking to thebookmarksresource through the Gateway with authenticated, retried requests. The endpoint prefix is the constEndpoint = "bookmarks"(BookmarkService.cs:19). The class doc (:11-13) records why it does not derive from the generic CRUD base: the bookmark endpoints do not fit that shape. - Depends on:
IBookmarkUIService(the contract it satisfies),AuthenticatedServiceBase(base class),ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,CreateBookmarkRequest,UserSessionBookmarkDTO,PagedCollectionResult<T>(BookmarkService.cs:1-7). Externals:IHttpClientFactory,System.Net.Http.Json,System.Globalization. - Concept introduced, the authenticated HTTP UI service. This is the shape every write-capable service in this module follows, so it is worth reading once in full. Four pieces do the work, three inherited and one static:
CreateAuthenticatedClientAsync()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:51) takes the named"APIClient"from the factory (:53), reads the access token from the circuit-scopedITokenStorageServiceand sets theBearerheader (:57-62). AnInvalidOperationExceptionis swallowed (:64-67), which is the SSR prerender pass where JS interop is not available yet: the call proceeds unauthenticated rather than throwing.[Rubric §26, Front-End Security]assesses how a client handles credentials; the token is never persisted by this class, it is fetched per call from the storage abstraction (ADR-051).RetryPolicy(AuthenticatedServiceBase.cs:25, built at:131-134) is a static Polly policy: 3 retries with exponential backoff (2s, 4s, 8s perDefaultBackoffat:114-116) plus up to one second of jitter so a room full of clients does not re-converge on the same instant. Retryable means 5xx except 501 and 505, plus 408 and 429 (:100-109). ItsonRetrydisposes each retried response (:134), because Polly hands back only the final outcome and every intermediate 5xx would otherwise leak its content buffer.[Rubric §29, Resilience & Business Continuity]assesses whether transient failures are absorbed close to where they happen; this is the client half of that.ProblemDetailsResultReader(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58) converts the response into aResult. It understands four payload shapes (:22-49): the MMCA error array (lossless,ErrorTypepreserved), the ASP.NET validation dictionary, plain RFC 9457 problem details, and a non-JSON or empty body, which it synthesizes into one error codedHttp.{status}(:65).[Rubric §9, API & Contract Design]assesses whether the error contract survives the transport, and this is where it is decoded.HttpResultExecutor.ExecuteAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:52,:87) wraps the whole delegate and converts the absence of a response into a failure: a refused connection, a DNS failure, a dropped socket, aJsonException(:121-122), each becomingHttp.TransportFailure(:34). A client timeout becomesHttp.Timeout(:37), while the caller's own cancellation is deliberately rethrown (:65-68) so a disposed component's abort is not reported as an error to render. The two halves together are what make a service method honestly typed as returning aResult(:11-16).
- Walkthrough (members in call order)
CreateAsync(:22-36) POSTs theCreateBookmarkRequestas JSON tobookmarksand reads the createdUserSessionBookmarkDTOback through the reader (:34). There is notry/catchand noEnsureSuccessStatusCode: every server answer is aResult, every transport fault is caught by the executor.GetUserBookmarksAsync(:39-74) builds its query string by hand into aList<string>withstring.Create(CultureInfo.InvariantCulture, ...)per parameter (:48-53) and appendseventIdonly when the optional filter is supplied (:55-56). Invariant formatting is not decoration here: these identifiers are interpolated into a URL, and a culture-sensitive numeric format would produce a query the API cannot bind. The response is read as aPagedCollectionResult<T>and thenMapped, on the success branch only, into a(Items, TotalItems)tuple usingPaginationMetadata.TotalItemCount(:68-72).Mapis the point worth noticing: the projection runs only when the read succeeded, so no null check is needed and a failure passes through carrying its original errors.DeleteAsync(:77-94) DELETEs by bookmark id and returns the valuelessResult(:92). The comment above it (:89-91) records the behavior change deliberately: a bookmark that is not there answers 404, which now arrives as aNotFoundfailure rather than the oldfalse, so "nothing to remove" stays distinguishable from "the remove failed".
- Why it's built this way: the class doc (
:11-13) states it directly, the bookmark endpoints (create with an owner in the body, list by user, delete by bookmark id) do not fit the genericEntityServiceBaseCRUD shape, so this service spells out the three calls theBookmarksControllercontract actually offers (BookmarksController.cs:50,:83,:122). The whole controller sits behind theEngagementFeatures.SessionBookmarksgate (BookmarksController.cs:32, seeEngagementFeaturesand ADR-031). - Where it's used: registered scoped as
IBookmarkUIServicein the module's UI wiring (MMCA.ADC.Engagement.UI/DependencyInjection.cs:33).[Rubric §14, Testability]: covered directly byBookmarkServiceTests. - Caveats / not-in-source: no page injects
IBookmarkUIServicetoday. The only references in the repo are the interface, this implementation and the DI registration; the inline bookmark toggles the UI actually renders go throughSessionBookmarkUIServiceinstead. Note also thatCreateAsyncsends noIdempotency-Keyheader, so a retried create relies on the server's duplicate guard rather than on idempotency replay (contrastSessionFeedbackService.SubmitAnswersAsync). Finally, all three calls use the retry overload without aCancellationToken(:30,:62,:86): the token still reaches the HTTP call itself, but Polly's own backoff waits are not cancellable on this path, unlike every other service in this unit.
SessionBookmarkUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Bookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:26· Level 5 · sealed class
- What it is: a cross-module HTTP service implementing
ISessionBookmarkUIService, used by the Conference UI to read bookmark state and toggle bookmarks inline. Beyond the three HTTP calls, every successful read or mutation also feeds the session-reminder layer. - Depends on:
ISessionBookmarkUIService,AuthenticatedServiceBase,ITokenStorageService,SessionReminderCoordinator(injected as a concrete class,:28),HttpResultExecutor,ProblemDetailsResultReader,CreateBookmarkRequest,UserSessionBookmarkDTO. Externals:IHttpClientFactory,System.Net.Http.Json,System.Globalization. - Concept introduced, the side effect on the write path.
[Rubric §19, State Management]assesses where derived client state is kept in step with the server. Local session reminders are exactly that kind of derived state: they are only correct if they track the bookmark set, and the bookmark set can change from any page or any device. Putting the reminder calls inside this one service, rather than in each component that toggles a bookmark, is what makes that hold regardless of entry point. The ordering is the load-bearing part: HTTP first, reminder only after a confirmed success. The class doc adds the guarantee that makes this safe to read (:18-23): nothing here throws for a server answer, the response is read throughProblemDetailsResultReaderwith the API's ownErrorTypeintact and transport faults become failures throughHttpResultExecutor, so the reminder layer is driven only by outcomes the server actually confirmed.[Rubric §29, Resilience]: reminder failures are swallowed inside the coordinator, so they never surface to the bookmark caller. - Walkthrough
GetBookmarkedSessionIdsAsync(:33-61) GETsbookmarks/session-ids?userId=and reads aDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>(:46-48), which is why a page can render a bookmark toggle and know the id needed to delete it from one call. It then branches onTryGetValue(:52-55): a failure is re-wrapped with its original errors and returns early, so the reminder call is unreachable on that path. On success it callsreminderCoordinator.ResyncAsync([.. bookmarks.Keys], cancellationToken)(:58) with the comment naming the intent: this response is the authoritative set, so it is the right moment to heal drift caused by another device. The dictionary is finally re-wrapped as the interface's read-only type (:60).CreateBookmarkAsync(:64-90) builds aCreateBookmarkRequestfrom the two identifiers (:68), POSTs it, and callsNotifyBookmarkAddedAsync(sessionId, ...)only whenresult.IsSuccess(:84-87).DeleteBookmarkAsync(:93-118) DELETEs by bookmark id and callsNotifyBookmarkRemovedAsync(sessionId, ...)only on a confirmed delete (:112-115). A 404 is aNotFoundfailure and therefore does not notify: nothing was removed.- The coordinator members these three calls use are
ResyncAsync(SessionReminderCoordinator.cs:54),NotifyBookmarkAddedAsync(:40) andNotifyBookmarkRemovedAsync(:44).
- Why it's built this way: the class doc (
:12-17) records both halves, the reminder wiring lives here so reminders track bookmarks no matter which page toggled them (ADR-042 Wave 2), and reminder failures never reach the bookmark caller. The coordinator is registered scoped alongside this service (MMCA.ADC.Engagement.UI/DependencyInjection.cs:38) and its comment there states it is a no-op on hosts whose local-notification capability is unsupported, which is why a web-only host pays nothing for this coupling. - Where it's used: registered scoped as
ISessionBookmarkUIService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:34); resolved optionally by the Conference public pages, which callServiceProvider.GetService<ISessionBookmarkUIService>()and null-check the result (PublicSessionListatPublicSessionList.razor.cs:41,:86;PublicSessionDetailatPublicSessionDetail.razor.cs:40,:54), and passed down as a nullable parameter toPublicSessionListView(:60). That optional resolution is the extraction story in practice: Conference renders correctly in a host where the Engagement UI module is not registered at all. Covered bySessionBookmarkUIServiceTests. - Caveats / not-in-source: the dependency is on the concrete
SessionReminderCoordinator, not an interface, so a test of this service has to construct a real coordinator; the test suite does exactly that, assembling an inert one from mocks (SessionBookmarkUIServiceTests.cs:129-136). Whether a reminder is actually scheduled is decided inside the coordinator andSessionReminderPlanner, not here.
PointsAwarder
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:29· Level 10 · class (sealed partial)
- What it is: the only implementation of IPointsAwarder. It writes one award idempotently, or writes nothing when the rule is switched off.
- Depends on: IUnitOfWork, PointsSettings via
IOptions<T>, PointsEntry, IUniqueConstraintViolationDetector, Result; externals:IOptions<T>,ILogger<T>with[LoggerMessage]. - Concept introduced, the configuration kill switch and the value snapshot.
[Rubric §17, DevOps & Deployment](assesses whether operational levers are configuration rather than code),[Rubric §8, Data Architecture], and[Rubric §11, Security]in its anti-abuse sense. Three ideas are stated once here so no earn rule has to restate them. (1) The storedPointsvalue is a SNAPSHOT of the configured award at the moment it was earned (PointsAwarder.cs:12-17), so retuning the economy mid-conference changes what the NEXT award is worth and never rewrites history: a total is always explainable by what the rules said when it was earned. (2) The unique index on(UserId, ActivityType, SubjectKey)is the source of truth for both idempotency and anti-farming (:18-23), which is what makes a replayed broker message and a farmed scan both land exactly once. (3) A configured0is a per-rule kill switch that takes one earn rule out of service without a deploy. - Walkthrough: four constructor dependencies (
PointsAwarder.cs:29-33), one method (:36-86).- Kill switch (
:43-51):settings.Value.GetPointsFor(activity); when the value is<= 0it logs at Debug and returns success without writing.GetPointsFormaps each of the six activities to its configured property and returns0for anything undefined (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:48-58), so a missing configuration entry and a deliberate zero behave identically. Writing nothing (rather than a zero-value row) keeps the ledger free of entries that would otherwise have to be filtered out of every read. - Idempotency pre-check (
:55-64):ExistsAsyncon(UserId, ActivityType, SubjectKey). The comment (:55-56) enumerates what lands here: a repeat scan, a second answer to the same feedback form, and a broker redelivery. All three leave as a clean success. - Create (
:66-68):PointsEntry.Createvalidates its own invariants and returns a Result (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:76-104), a failure of which is the one case that propagates as a failure, because it means the caller passed something invalid. - Save with a race backstop (
:70-82): the sameIsUniqueConstraintViolationfilter as CreateBookmarkHandler, but the verdict is the opposite one. Here a lost race is a SUCCESS (:77-81), because "the award the caller wanted now exists" is exactly the outcome requested; a bookmark create, by contrast, is a user-visible action whose duplicate the user should be told about. - Four
[LoggerMessage]partials (:88-98): rule disabled, already awarded, and lost race all log at Debug, while an actual award logs at Information, so the conference-day log volume tracks real awards rather than no-ops.
- Kill switch (
- Why it's built this way: the class doc is explicit that the pre-check "is not the rule" (
:21-22). Reading the index as the authority and the pre-check as an optimization is what keeps the concurrent case correct rather than merely unlikely. - Where it's used: registered as
IPointsAwarder(DependencyInjection.cs:84) and called by the four award adapters listed under IPointsAwarder. Read back by PointsService and by the data-subject export, where the ledger and the check-in history are exported as separate collections (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:51-53,78).
CheckInAttendeeHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.CheckInAttendee·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:21· Level 12 · class (sealed partial)
- What it is: the organizer scanning path. A scanned (or typed) badge credential comes in, the server resolves it to an attendee, and the shared check-in core records the result.
- Depends on: IUnitOfWork, IEventLiveValidationService, ICurrentUserService plus its CurrentUserServiceExtensions guard, BadgePayload, AttendeeBadge, CheckInProcessor, CheckInResultDTO, IReadRepository<TEntity, TIdentifierType>, ICommandHandler<in TCommand, TResult>; externals:
TimeProvider,ILogger<T>. - Concept introduced, the anti-oracle error and the credential as the sole authority.
[Rubric §11, Security](assesses whether error responses and logs avoid handing an attacker information) and[Rubric §1, SOLID]. Three security decisions are visible in this small file, each with a comment saying why. First, the attendee is NEVER taken from the request: the credential is the only input and the badge row is the sole authority on whose badge it is, while the organizer comes from the caller's token (CheckInAttendeeHandler.cs:15-20). Second, a malformed payload and a well-formed but unknown credential return the SAME error (:41-46,:53-56, via the sharedBadgeNotFound()helper at:77-81), because a scanner able to distinguish them would be an oracle for guessing valid credentials. Third, the scanned credential is deliberately absent from the log line (:83-84): it is a bearer value, and a log carrying it would be a badge anyone with log access could replay. - Walkthrough: five constructor dependencies (
:19-24), oneHandleAsync(:27-75).ArgumentNullException.ThrowIfNull(command)(:31), then the caller guard:currentUserService.RequireUserId("CheckIns.Forbidden")(:33-39). That is the framework'sextension(ICurrentUserService)member (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35-46), which collapses the read-then-null-check-then-fail block into one call returningResult<UserIdentifierType>and defaults the classification toErrorType.Forbidden. All four check-in handlers in this unit open the same way with the same code string.BadgePayload.TryExtractCredential(command.Credential, out var credential)(:43), aTryshape so a malformed scan is an ordinary branch rather than an exception.- Badge lookup through
GetReadRepository(:48-52), notGetRepository: nothing about the badge is mutated here, and using the read repository states that.asTracking: falsereinforces it. - Delegation to CheckInProcessor
.ExecuteAsyncwithbadge.UserId(:58-67). Everything from target validation through idempotency to the write lives there, which is why this handler is short. - Logging only on success (
:69-72), carrying the repeat flag so an operator can see at a glance whether a scan wrote a row or reported an existing one.
- Why it's built this way: the two organizer entry points differ ONLY in how the attendee is identified, so the identification is all that lives in each handler and the shared rules live once in the processor (
CheckInProcessor.cs:10-21), which is a static helper precisely so neither handler gains a dependency it does not own. - Where it's used: CheckInsController resolves it as
ICommandHandler<CheckInAttendeeRequest, Result<CheckInResultDTO>>(CheckInsController.cs:41,88). The check-in it writes is what later drives AttendeeCheckedInPointsHandler through the AttendeeCheckedIn integration event.
ManualCheckInHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.UseCases.ManualCheckIn·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInHandler.cs:18· Level 12 · class (sealed partial)
- What it is: the organizer's manual fallback for when a badge will not scan. No credential is involved: the attendee is named in the request, and everything after that is the same core the scan path runs.
- Depends on: IUnitOfWork, IEventLiveValidationService, ICurrentUserService, CheckInProcessor, CheckInResultDTO, ICommandHandler<in TCommand, TResult>; externals:
TimeProvider,ILogger<T>. - Concept reinforced, the shared core with a per-entry-point difference.
[Rubric §15, Best Practices & Code Quality]and[Rubric §11, Security]. Compared with CheckInAttendeeHandler, the credential extraction and badge lookup are simply absent (ManualCheckInHandler.cs:40-49versusCheckInAttendeeHandler.cs:45-69);command.UserIdgoes straight into the processor. The doc comment carries the compensating control (ManualCheckInHandler.cs:13-16): an organizer-only endpoint backs it, which is what makes naming another user acceptable here and unacceptable on the attendee-facing flows in this unit. The payoff is stated in the same comment: a manual check-in and a scanned one produce identical rows and events, so nothing downstream needs to know which door the check-in came through. - Walkthrough: five constructor dependencies (
:17-22), oneHandleAsync(:25-56).ArgumentNullException.ThrowIfNull(command)(:29), the sameRequireUserId("CheckIns.Forbidden")caller guard as the scan path (:31-37), the delegation toCheckInProcessor.ExecuteAsync(:39-48), and a success-only Information log whose template differs from the scan path only by the word "manually" (:58-64), which is what lets an operator tell the two apart in the logs even though the rows are identical. - Why it's built this way: keeping the fallback on exactly the same core avoids the classic drift where the rarely used path slowly stops enforcing a rule the main path gained.
- Where it's used: CheckInsController (
CheckInsController.cs:42,112).
IEventFeedbackUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:23· Level 3 · interface
- What it is: the UI contract for submitting and reading a user's own answers to event feedback questions.
- Depends on:
EventQuestionAnswerDTO, Result, and theEventIdentifierType/QuestionIdentifierType/EventQuestionAnswerIdentifierTypealiases. Externals: BCLTask. - Concept introduced, POST-as-upsert (BR-107).
[Rubric §9, API & Contract Design]assesses how idempotent write semantics are expressed to clients.SubmitAnswerAsync(IFeedbackUIService.cs:26-30) is one method for both the first answer and every edit: the server upserts on(eventId, questionId)for the caller, so the UI never branches on create-versus-update and never has to know whether an answer already exists. That is what letsEventFeedbackretry a partially failed submit by pressing the same button, since re-posting an already-saved answer is a no-op. The type doc names the rule outright (:19-22). This is the UI service abstraction ofIBookmarkUIServicespecialized to feedback, with the same "every member answers with a Result" contract. - Walkthrough (
IFeedbackUIService.cs:23-42):SubmitAnswerAsync(eventId, questionId, answerValue, ct)returnsResult<EventQuestionAnswerDTO>, the persisted answer (:26-30);GetMyAnswersAsync(eventId, ct)returns the caller's answers for one event (:33-35), which is what the page pre-fills from (EventFeedback.razor.cs:117-118);DeleteAnswerAsync(eventId, answerId, ct)removes one by its server-assigned id (:38-41). Note the answer value crosses as a plainstring: rating and text questions share one transport shape and the question type decides how it is read back. - Why it's built this way: it is kept as its own type rather than folded into one generic contract with its session twin
ISessionFeedbackUIService, because the answer DTOs and the identifier aliases differ and a shared generic would buy nothing at three members apiece. - Where it's used: implemented by
EventFeedbackService(EventFeedbackService.cs:16), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:42), consumed byEventFeedback(EventFeedback.razor.cs:22, submitting at:194).
IPointsUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/IPointsUIService.cs:14· Level 3 · interface
- What it is: the UI contract for the points game surfaces: the attendee's own ledger, the leaderboard and its opt-in switch, and the organizer rollup.
- Depends on:
MyPointsDTO,LeaderboardEntryDTO,PointsOverviewDTO, Result. Externals: BCLTask. The UI service abstraction itself is taught onIBookmarkUIService. - Concept introduced, the server-owned identity on a write.
[Rubric §11, Security]assesses whether a client can influence data it should not own.SetLeaderboardParticipationAsync(participate, ct)(:41) takes a single boolean and nothing else, and the doc says why (:35-38): the published name is taken from the caller's token server-side, never from this call. A client that could pass its own display name could publish someone else's, so the parameter simply does not exist. The same discipline shapesGetMyPointsAsync(:23-26), which carries no user identifier at all: "mine" is whoever the bearer token says it is. - Concept, opt-in as the default privacy posture.
[Rubric §30, Compliance, Privacy & Data Governance]assesses how personal data reaches a public surface. Points accrue for every attendee, but the leaderboard shows only those who opted in, under the name they published at opt-in (:28-31), and the opt-in is an explicit write the attendee makes.[Rubric §9, API & Contract Design]covers the rest of the shape: like every service in this module, every member answers with a Result, so a refusal is data the page renders and only the caller's own cancellation still propagates (:9-12). - Walkthrough (
IPointsUIService.cs:14-51):GetMyPointsAsync(pageNumber = 1, pageSize = 20, ct)(:23-26) returns the running total, the opt-in state and one page of awarded entries, newest first, in a singleMyPointsDTO. The three concerns travel together because the "my points" page renders them as one screen.GetLeaderboardAsync(ct)(:33) returns the whole public leaderboard as one list, with no paging parameters: it is a short, capped list by nature.SetLeaderboardParticipationAsync(participate, ct)(:41) joins or leaves, returning a bareResultbecause there is nothing to read back.GetPointsOverviewAsync(recentCount = 20, ct)(:48-50) is the organizer rollup, with the recent-entry count as the only knob; organizer only (:43-45).
- Why it's built this way: one interface for four related reads and one write keeps the whole points vocabulary and its single
pointsresource (PointsService.cs:20) in one place, in the same shape asICheckInUIServicedoes for check-in (ADR-072 defines the check-in and points model together). - Where it's used: implemented by
PointsService(PointsService.cs:16-18), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:51), and injected into two pages:MyPoints(MyPoints.razor.cs:21, reading at:75and:85and writing the opt-in at:117) andOrganizerPointsOverview(OrganizerPointsOverview.razor.cs:24, reading at:46).
IQuestionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:11· Level 3 · interface
- What it is: a small read contract that loads the feedback questions to render, filtered by whether they apply to an Event or a Session.
- Depends on:
QuestionDTO, a Conference-owned contract (IFeedbackUIService.cs:2), and Result. Externals: BCLTask. - Concept, the cross-module UI read.
[Rubric §18, UI Architecture]and[Rubric §7, Microservices Readiness]. Feedback lives in Engagement but the questions are Conference-owned, so this contract lets the Engagement UI pull them through the same authenticated HTTP layer without a compile-time reference to Conference internals: only the sharedQuestionDTOcrosses the boundary. This is a different trade-off from the mirrored shape on NowNextSessionInfo, and the difference is worth noticing: a.SharedDTO is a published contract project both sides may reference, while a mirror is what you write when you do not want the reference at all. - Walkthrough (
IFeedbackUIService.cs:11-17): one method,GetQuestionsAsync(questionEntity, ct), wherequestionEntityis the string discriminator ("Event"or"Session"), returningResult<IReadOnlyList<QuestionDTO>>. The discriminator is a bare string rather than an enum, so a typo at a call site is a runtime empty list, not a compile error; both call sites pass a literal (EventFeedback.razor.cs:104). - Why it's built this way: keeping the question read on its own interface rather than duplicating it on both feedback services means one implementation serves both pages, and the discriminator stays the only difference between the two calls.
- Where it's used: implemented by
QuestionLookupService(QuestionLookupService.cs:15), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:41), consumed by bothEventFeedback(EventFeedback.razor.cs:21,:104-105) andSessionFeedback(SessionFeedback.razor.cs:20) to build their question lists.
ISessionFeedbackUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:48· Level 3 · interface
- What it is: the UI-side contract for session feedback, four members covering the whole attendee-facing lifecycle of an answer: submit one, submit a whole form at once, read back what the caller already answered, and remove an answer (
IFeedbackUIService.cs:48-76). It is the third of three interfaces declared in one file, besideIQuestionLookupService(:11) andIEventFeedbackUIService(:23). - Depends on:
ResultandResult<T>fromMMCA.Common.Shared.Abstractions(:4),SessionQuestionAnswerDTOandQuestionDTOfrom the Conference contracts (:1-3), plus theSessionIdentifierType,QuestionIdentifierTypeandSessionQuestionAnswerIdentifierTypealiases. No BCL dependency beyondTaskandCancellationToken. - Concept introduced, the Result-typed client contract. Every member here answers with a
Resultrather than a nullable DTO or a bareTask, and the class doc states it outright (:46).[Rubric §9, API & Contract Design]assesses whether a client abstraction preserves the distinctions the server makes: a nullable return collapses "no such answer", "you are not allowed" and "the network is down" into onenull, while aResultkeeps the server's ownErrorTypeand error code intact all the way to the page.[Rubric §24, Forms/Validation/UX Safety]is the front-end consequence: a feedback form can render the server's validation message for the field that failed instead of a generic banner. The pattern itself is taught in the primer and ADR-013; what is new at this layer is that the transport now speaks it too, throughProblemDetailsResultReaderandHttpResultExecutor. - Walkthrough
SubmitAnswerAsync(:51-55) takes the session, the question and the answer string, and returns the persistedSessionQuestionAnswerDTO. It is a POST-as-upsert (BR-107,:46): the caller never has to know whether it is creating a first answer or replacing an existing one.SubmitAnswersAsync(:61-64) is the batch form, takingIReadOnlyList<(QuestionIdentifierType QuestionId, string AnswerValue)>and returning every saved answer. Its doc comment carries the contract that matters most (:57-60): the server applies the form atomically, so the result is all-or-nothing. That is the difference between a form that half-saves on a flaky connection and one that does not.GetMyAnswersAsync(:67-69) reads the caller's own answers for a session. No user identifier appears in the signature: the server scopes the read to the token holder.DeleteAnswerAsync(:72-75) removes one answer by identifier, with the owningsessionIdalongside it, and returns the valuelessResult.
- Why it's built this way: keeping the contract in the UI layer over Shared DTOs only is what lets
SessionFeedbackbe bUnit-tested against a mock with no HTTP in sight.[Rubric §1, SOLID]: the batch member exists as its own method rather than as an overload with an options flag, because it maps to a genuinely different endpoint with a different guarantee. - Where it's used: implemented by
SessionFeedbackServiceand registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:43); injected bySessionFeedback(SessionFeedback.razor.cs:21), which drives all four members (:122,:226,:259). - Caveats / not-in-source: the interface says nothing about idempotency. That the batch submit carries an
Idempotency-Keyis an implementation decision, described atSessionFeedbackService.
EventFeedbackSubmittedPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:27· Level 4 · class (sealed partial)
- What it is: the award adapter that turns Conference's
EventFeedbackSubmittedinto an event-scoped points award. It is one of the handlers in this folder and the simplest of them. - Depends on:
ScopedIntegrationEventHandlerBase<TIntegrationEvent>closed overEventFeedbackSubmitted(:30), which is the framework base that implementsIIntegrationEventHandler<in TIntegrationEvent>;IPointsAwarder(resolved from the supplied scope,:38),PointsSubjectKeys(:40) andPointsActivityType(:44). Externals:IServiceScopeFactoryand a source-generated[LoggerMessage](:53-54), which is why the class ispartial. - Concept introduced, the thin award adapter.
[Rubric §3, Clean Architecture]assesses where knowledge sits. The rule the module protects is stated onIPointsAwarder(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/IPointsAwarder.cs:10-17): neither the awarder nor the ledger entity names an event, a session or a check-in, so an award is a user, an activity, an opaque subject key and a timestamp. All the conference vocabulary lives in adapters like this one, which is what would make lifting the ledger into MMCA.Common a move rather than a rewrite.[Rubric §1, SOLID]shows up twice: mapping lives here while idempotency and the per-rule kill switch live once in the awarder, and the two blocks every integration handler would otherwise repeat (the DI scope preamble and the log-and-rethrow envelope) live once in the base (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:45-63).[Rubric §6, CQRS & Event-Driven]covers the delivery posture: the base propagates exceptions rather than swallowing them, so delivery stays at-least-once and redelivery is normal (ScopedIntegrationEventHandlerBase.cs:26-33), which is why this handler is written to be safely repeatable rather than to guard against a second call. - Walkthrough: the class overrides
HandleScopedAsync(:33-36) instead ofHandleAsync. The base has already null-guarded the event and opened the per-delivery async scope, and hands in that scope'sIServiceProvider(ScopedIntegrationEventHandlerBase.cs:47-55), so the body is only its own resolutions plus its own logic: resolve the scopedIPointsAwarder(:38), build the subject key withPointsSubjectKeys.ForEvent(integrationEvent.EventId)(:40), which delegates toNotificationScopeKey.ForEventand therefore produces the invariant-cultureevent:{id}string (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:19-20,MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:37-38), then awardPointsActivityType.EventFeedbackat the event's ownSubmittedOnUtc(:42-47). A rejected award (the awarder returning a failure rather than throwing) is logged at Warning and not rethrown (:49-50), because the awarder only fails when the entry itself is invalid, which is a caller bug rather than a retryable condition (IPointsAwarder.cs:30-34). An exception takes the other path: the base logs it once and lets it propagate so the delivery is retried. - Why it's built this way: the class comment (
:13-18) states the multiplicity fact that makes this handler safe to keep this simple. Event feedback writes one answer row per question (BR-107), so one submitted form arrives here as several events. Nothing counts them: they all resolve to the same event subject key, and the awarder's uniqueness rule collapses them into a single award. That same property is what makes a broker redelivery a no-op, so no dedupe logic is written twice. - Where it's used: registered by the convention scan (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), which reaches MMCA.Common's singleton registration for everyIIntegrationEventHandler<>implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198); the singleton lifetime is exactly why the base opens a scope per delivery (:19-23). The Engagement service subscribes the matching consumer (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:288). Covered byEventFeedbackSubmittedPointsHandlerTests.
SessionFeedbackSubmittedPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:29· Level 4 · class (sealed partial)
- What it is: the award adapter for session feedback, mapping Conference's
SessionFeedbackSubmittedonto a session-scoped award. The structural twin ofEventFeedbackSubmittedPointsHandler. - Depends on: the same set as its sibling, with
SessionFeedbackSubmittedas the base's closed type (:32) andIPointsAwarderresolved from the scope the base supplies (:40). - Concept: the thin award adapter is taught on
EventFeedbackSubmittedPointsHandler; the same reasoning applies unchanged.[Rubric §15, Best Practices & Code Quality]is worth naming here: the two files are near-identical and are deliberately kept separate rather than folded into one generic handler, because each is bound to a different event type at the DI boundary and, onceScopedIntegrationEventHandlerBase<TIntegrationEvent>absorbed the scope and error plumbing, what is left to share is five lines of mapping. - Walkthrough of what differs: only two lines. The subject key is built with
PointsSubjectKeys.ForSession(integrationEvent.SessionId)(:42), producingsession:{id}(PointsSubjectKeys.cs:25-26,NotificationScopeKey.cs:43-44), and the activity isPointsActivityType.SessionFeedback(:46). Everything else, theHandleScopedAsyncoverride (:35-38), the awarder resolution (:40), theSubmittedOnUtctimestamp (:48) and the log-and-continue path for a rejected award (:51-52), matches the event handler line for line. - Why it's built this way: the class comment (
:14-20) is explicit that Conference raises this event once per newly created answer, so one submission normally arrives here as several events, and nothing here counts them: they all resolve to the same session subject key and the awarder's uniqueness rule collapses them into a single award. - Where it's used: registered by the convention scan as a singleton (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88,MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198); the consumer is wired atMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:287. Covered bySessionFeedbackSubmittedPointsHandlerTests.
SessionQuestionSubmittedPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.DomainEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51· Level 4 · class (sealed partial)
- What it is: the award adapter for session Q and A. It awards
PointsActivityType.QuestionAskedthe first time an attendee asks a question in a session. It is the one award adapter in the module that rides a domain event rather than an integration event, which makes it the best place in this chapter to see the two delivery models side by side. - Depends on:
IDomainEventHandler<in TDomainEvent>implemented overSessionQuestionChanged(:53),IPointsAwarderresolved per event (:78),PointsActivityType(:84),PointsSubjectKeys(:85) andDomainEntityState(:60). Externals:IServiceScopeFactory,ILogger, and four source-generated[LoggerMessage]methods (:100-110), which is why the class ispartial. - Concept introduced, choosing at-most-once on purpose.
[Rubric §6, CQRS & Event-Driven]assesses whether a delivery guarantee is a decision or an accident. Its siblings inPoints/IntegrationEventHandlersall consume outbox-published integration events, which are at-least-once and survive a crash. This one subscribes to an in-process domain event, and the class comment (:30-39) argues the trade-off explicitly: dispatch happens after the question's transaction commits, so a crash in the window between the commit and this handler loses one small award and nothing else, no question is lost and no total is corrupted. The alternative (a second outbox contract, a broker round trip and inbox dedup for a handful of points) buys durability the feature does not need.[Rubric §29, Resilience & Business Continuity]is the same paragraph read from the operations side, and it names the exit: promoting the path later is a one-file change on each side, the aggregate raises an integration event instead and this class becomes anIIntegrationEventHandler. ContrastUserDeletedPointsHandler, which sits on the integration path and deliberately lets an exception propagate, because a missed erasure is not a missed nicety. - Concept introduced, taking everything off the event rather than reading it back.
[Rubric §15, Best Practices & Code Quality]assesses whether a class works with the data it is actually given. The comment at:21-29records the trap this file is written around:SessionQuestionChangedis captured by value while the aggregate is still new, so on the Added path itsQuestionIdis zero (the identity is generated by the INSERT, which has not run when the event is raised, and the event is never re-stamped). The event contract states the same rule at its own source (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:16-22) and carriesUserIdandSessionIdprecisely because they are set before the raise (:24-28). This handler therefore reads no row at all: the two fields it needs come off the event, so there is no read-back to get wrong. - Concept introduced, the subject key as the anti-farming rule. The subject key is the session, never the question (
:85), so an attendee who asks five questions in one session earns once.[Rubric §8, Data Architecture]is why that holds under concurrency: the limit is enforced by the ledger's unique index insidePointsAwarderrather than by counting here (:17-19), so two simultaneous submissions cannot both slip past a read-then-write check. - Walkthrough
- The primary constructor (
:51-53) takes anIServiceScopeFactoryand a logger. Domain event handlers are registered as singletons by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:186-191), which is why this class opens its own scope instead of injecting scoped services (:45-47). HandleAsync(:56) null-guards the event (:58), then returns unless the state isDomainEntityState.Added(:60-64).SessionQuestionChangedis raised for moderation and deletion too (SessionQuestion.cs:134,:158,:190,:234), so this filter is what keeps a moderator approving a question from paying the asker a second time. The skip is logged at Debug (:62,:100-101).- The second guard rejects a defaulted
UserId(:66-73). The comment (:68-70) is worth reading as a lesson in log-level choice: the path is unreachable through the aggregate, because the Create invariants reject a default user, so reaching it can only mean a new raise site forgot to pass the asker. It is therefore a Warning (:103-104), not a silent return. - Inside the
try(:75) one async DI scope is opened (:77) and the scopedIPointsAwarderresolved (:78).AwardAsyncis called with the event'sUserId,PointsActivityType.QuestionAsked,PointsSubjectKeys.ForSession(domainEvent.SessionId)anddomainEvent.DateOccurred(:82-87). The comment (:80-81) explains the timestamp choice:DateOccurredis stamped when the aggregate raised the event (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28), which is when the attendee actually asked, so no clock has to be injected here. - A rejected award (the awarder returning a failure) logs at Warning (
:89-90,:106-107). - The
catch(:93) swallows everything exceptOperationCanceledExceptionbehind an inline CA1031 suppression whose justification is written into the pragma itself (:92-94): the award is best effort and must never fail the question that was already committed.[Rubric §13, Observability & Operability]covers the discipline around it: the swallow is never silent,LogAwardFailedrecords the exception with the user and session (:96,:109-110), and the class comment states the rule that every declining path says so at a level matching how surprising it is (:40-44).
- The primary constructor (
- Why it's built this way: both guards exist so the game can never damage the feature it decorates. The state filter keeps the ledger honest, the broad catch keeps a points outage from becoming a Q and A outage, and taking the asker off the event removes the only lookup that could quietly award nobody. The swallow is also why this class implements
IDomainEventHandler<in TDomainEvent>directly rather than deriving from the framework'sSafeDomainEventHandler<TDomainEvent>: it wants the exception to stop here, not to be logged and rethrown. The points design as a whole is ADR-072. - Where it's used: discovered and registered as a singleton by
ScanModuleApplicationServices<ClassReference>()(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), and invoked by the framework's domain event dispatcher after the Q and A write path'sSaveChangesAsync. Its raise site isSessionQuestion.Create(SessionQuestion.cs:109). Covered bySessionQuestionSubmittedPointsHandlerTests. - Caveats / not-in-source: how many points
QuestionAskedis worth, and whether the rule is switched off at all, is configuration read insidePointsAwarder, not here.
UserSessionBookmarkCacheEvictionHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.DomainEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:43· Level 4 · class (sealed)
- What it is: the handler that broadcasts an output-cache eviction to the Conference service every time a bookmark is created, reactivated or removed, so Conference's cached session reads stop serving a stale bookmark count.
- Depends on:
IDomainEventHandler<in TDomainEvent>implemented overUserSessionBookmarkChanged(:46),IEventBusresolved per event (:74),OutputCacheEvictionRequested(:77) andBestEffort(:68). Externals:IServiceScopeFactory,ILogger. - Concept introduced, evicting a cache you do not own.
[Rubric §12, Performance & Scalability]assesses whether a cross-cutting concern is solved once at the right layer. ASP.NET Core's output cache is per host:IOutputCacheStoreis a local store, so a write in the owning service leaves a stale cached response sitting in front of every other process until its TTL expires (MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:10-13). Bookmark counts are the sharp case: they are owned by Engagement but served by Conference. The class comment (:13-21) names the symptom that motivated the class, a speaker watching their dashboard seeing a star land up to a minute later, and the previous answer, a short TTL, which is a floor rather than a fix. The fix is to make eviction an event like any other: this handler publishesOutputCacheEvictionRequestedcarrying one tag, and the Conference host's own eviction handler drops that tag on arrival (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250, and the reasoning at:233-242).[Rubric §7, Microservices Readiness]is why it has to be an event at all: under database-per-service (ADR-006) and process-per-service, Engagement has no handle on the other host's cache store, so the broker is the only reachable path.[Rubric §29, Resilience & Business Continuity]covers the failure posture: the publish runs throughBestEffort(:68), so a failure becomes one Warning plus one metric and the stale entry expires on Conference's own TTL exactly as it did before this existed (:31-36). The TTL stays deliberately, as the backstop for a dropped message (Program.cs:242-244). - Concept introduced, subscribing to the aggregate rather than to the use case.
[Rubric §6, CQRS & Event-Driven]assesses where a reaction is hooked. The obvious hook is the create command handler, but the delete path runs on the framework's genericDeleteEntityCommand<TEntity, TIdentifierType>and has no ADC handler at all to add a line to.UserSessionBookmarkChangedis raised by the aggregate itself on every path that moves a count (UserSessionBookmark.cs:57on create,:73on reactivate,:88on delete), so subscribing to the domain event covers all three with one class and leaves the delete flow untouched (:22-30). It also inherits the dispatch guarantee: domain-event dispatch is deferred until after the transaction commits and dropped on rollback, so no eviction is ever broadcast for a bookmark that did not persist. - Walkthrough
- The primary constructor (
:43-46) takes anIServiceScopeFactoryand a logger; the class is a singleton by the framework convention for domain event handlers (:37-39), which is why it opens its own scope rather than injecting the scoped bus. SessionsCacheTag(:53) is the literal"conference:sessions". Its doc comment (:48-52) is the load-bearing part: the tag string IS the contract between the two hosts, and it is spelled exactly as Conference registers it on its cache policies (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:223,:231,:243). Nothing in the type system checks that agreement.OperationName(:56) is the low-cardinality"bookmark-cache-evict-broadcast"that becomes theoperationtag on the best-effort metric.HandleAsync(:59) null-guards (:63) and then hands everything toBestEffort.ExecuteAsync(:68-80). Inside the lambda it opens one async scope (:73), resolvesIEventBus(:74) and publishesnew OutputCacheEvictionRequested { Tags = [SessionsCacheTag] }(:76-78). Publishing through the event bus means the message is persisted to the outbox with the same machinery as any other integration event (ADR-003).- The handler deliberately does not filter on
domainEvent.State, and the comment says why (:65-67): every state the aggregate raises (Added on create and reactivate, Deleted on removal) moves the count Conference has cached, and evicting once more than strictly needed only costs one un-cached read.
- The primary constructor (
- Why it's built this way: the alternative shapes each fail on something concrete. Hooking the command handlers misses the generic delete. Calling
EvictByTagAsynclocally does nothing, because the cache entries are in another process. Making the publish mandatory would let a broker hiccup fail a bookmark the attendee already saved. Subscribing to the aggregate's own event and wrapping the publish inBestEffortis the combination that covers every write path, crosses the process boundary, and cannot hurt the write it reacts to. The caching strategy this fits into is ADR-026, and the authenticated-read caching it evicts is ADR-040. - Where it's used: registered as a singleton by the convention scan (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, reachingMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:186-191). Its counterpart on the receiving side isOutputCacheEvictionHandler, registered on the Conference host. Covered byUserSessionBookmarkCacheEvictionHandlerTests. - Caveats / not-in-source: the Conference host comment records that both halves are needed, the handler and the broker consumer, and that registering only one is a silent no-op (
Program.cs:247-249); nothing in this file can detect that the other half is missing. Whether the broadcast actually reaches Conference is broker configuration and runtime, not source.
EventFeedbackService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/EventFeedbackService.cs:14· Level 4 · sealed class
- What it is: the HTTP implementation of
IEventFeedbackUIService, backed by theeventquestionanswersresource (EventFeedbackService.cs:18). Its submit call is a POST-as-upsert: the same POST creates a first answer or replaces an existing one (BR-107, per the class doc at:11-12). - Depends on:
IEventFeedbackUIService,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,EventQuestionAnswerDTO,PagedCollectionResult<T>. Externals:IHttpClientFactory,System.Net.Http.Json,System.Globalization. - Concept: the authenticated-retried-HTTP shape is taught at
BookmarkService. What this pair of feedback services adds is the upsert contract:[Rubric §9, API & Contract Design]assesses whether a client has to know which of create or update applies, and here it does not, which is what lets the feedback page loop over answered questions and post each one the same way regardless of whether the attendee is answering or revising. - Walkthrough
SubmitAnswerAsync(:21-40) posts an anonymous object{ EventId, QuestionId, AnswerValue }(:31) rather than a named request record, and reads back the persistedEventQuestionAnswerDTO(:38).GetMyAnswersAsync(:43-62) builds a filtered paged URL against the generic paged endpoint:filters[EventId].operator=equals&filters[EventId].value={eventId}&pageSize=100&includeChildren=false(:51). Note what it does not do: there is no user filter in the query, because the endpoint scopes to the caller server-side. The envelope is read as aPagedCollectionResult<T>andMapped to a bareIReadOnlyListwith a collection expression (:60), so the page never sees the paging wrapper.DeleteAnswerAsync(:65-81) DELETEs by answer id with the owningeventIdas a query parameter (:74) and returns the valuelessResult.
- Why it's built this way: all three calls use the retry overload that takes the
CancellationToken(:33-36,:53-55,:75-77), so Polly's backoff waits are cancellable and a page that navigates away does not keep a doomed retry alive. - Where it's used: registered scoped as
IEventFeedbackUIService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:42); injected byEventFeedback(EventFeedback.razor.cs:22), which is the only consumer. Covered byEventFeedbackServiceTests. - Caveats / not-in-source: the
pageSize=100on the read is a hard ceiling in this file, so an event with more than 100 answers by one attendee would be truncated silently. No page currently approaches that, and nothing here guards it. Unlike its session-scoped twin this service has no batch submit: the server'sEventQuestionAnswerscontroller offers nobatchroute (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:143is the only POST), so the event feedback page posts answers one at a time.
PointsService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:16· Level 4 · sealed class
- What it is: the HTTP implementation of
IPointsUIServiceagainst thepointsresource (PointsService.cs:20). Four calls, four endpoints, no client-side computation of any kind. - Depends on:
IPointsUIService,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,MyPointsDTO,LeaderboardEntryDTO,SetLeaderboardParticipationRequest,PointsOverviewDTO. Externals:IHttpClientFactory,System.Net.Http.Json,System.Globalization. - Concept: the authenticated-HTTP shape from
BookmarkService, applied to a surface where authentication is doing four different jobs at once. The class doc (:11-14) enumerates them, and they are worth reading as a[Rubric §11, Security]exercise: the ledger read and the leaderboard opt-in are caller-scoped ("my points"), the leaderboard read is caller-agnostic but still requires a signed-in attendee (PointsController.cs:35), and the overview is role-gated on the server (PointsController.cs:121). The client enforces none of that and does not try to: it attaches the token and lets the server decide, which is why a 403 arrives as aForbiddenfailure the page can render rather than as a page that guessed wrong about permissions. - Walkthrough (in interface order)
GetMyPointsAsync(:23-40) GETspoints/me?pageNumber=&pageSize=with invariant formatting (:32) and returnsMyPointsDTO. No user id is sent; the server reads the caller from the token (PointsController.cs:49).GetLeaderboardAsync(:43-56) GETspoints/leaderboard, reads aList<LeaderboardEntryDTO>(:53) andMaps it to the interface'sIReadOnlyList(:54). The variance cast is the only transformation: the ordering and the ranks are the server's.SetLeaderboardParticipationAsync(:59-78) PUTs aSetLeaderboardParticipationRequestcarrying only the boolean (:67) topoints/me/leaderboard-participation. The inline comment (:65-66) explains the shape of the round trip: the endpoint answers 204 with no body (PointsController.cs:99), so the method returns the valuelessResultand the caller re-reads its own points to pick up the new opt-in state and display name.GetPointsOverviewAsync(:81-97) GETspoints/overview?recentCount=(:89) for the organizer rollup.- All four share the same three-step shape:
HttpResultExecutor.ExecuteAsyncon the outside, the retry policy with the cancellation-token overload in the middle,ProblemDetailsResultReader.ReadAsyncon the response.
- Why it's built this way: every number the game shows (totals, per-activity breakdowns, ranks) is computed server-side and arrives as a DTO, so there is no client-side scoring logic to keep in step with the earn rules.
[Rubric §30, Compliance, Privacy & Data Governance]: the leaderboard is opt-in, so publishing a name is an explicit act with its own endpoint rather than a side effect of earning points. See ADR-072 for the surrounding decision. - Where it's used: registered scoped as
IPointsUIService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:51); consumed byMyPoints(MyPoints.razor.cs:21, called at:75,:85,:117) andOrganizerPointsOverview(OrganizerPointsOverview.razor.cs:24, called at:46). The whole controller sits behind theEngagementFeatures.Pointsgate (PointsController.cs:34, seeEngagementFeaturesand ADR-031). - Caveats / not-in-source: there is no
PointsServiceTestsfile in the UI test project; coverage comes through the bUnit page tests (MyPointsTests,OrganizerPointsOverviewTests), which mockIPointsUIService. How points are earned and what each activity is worth is server-side rule, nothing in this file.
SessionFeedbackService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/SessionFeedbackService.cs:14· Level 4 · sealed class
- What it is: the HTTP implementation of
ISessionFeedbackUIService, backed by thesessionquestionanswersresource (:18). The session-scoped counterpart ofEventFeedbackService, same POST-as-upsert contract (BR-107, class doc:11-12), plus one call its event twin does not have. - Depends on:
ISessionFeedbackUIService,AuthenticatedServiceBase,ITokenStorageService,HttpResultExecutor,ProblemDetailsResultReader,IdempotencyHeaders,SessionQuestionAnswerDTO,PagedCollectionResult<T>. Externals:IHttpClientFactory,System.Net.Http.Json,System.Globalization. - Concept introduced, the client half of request idempotency. The auth, retry and Result-reading story is taught at
BookmarkServiceand the upsert contract atEventFeedbackService; neither is re-taught. What is new isSubmitAnswersAsync, and specifically where its idempotency key is minted.[Rubric §29, Resilience & Business Continuity]assesses whether a retry can be taken safely, and the inline comment at:54-57is the reason it can be: the key is generated once, outside the retried delegate. A key generated inside would differ per attempt, the server could not recognize the retry, and a timed-out submit would re-apply the whole form. Because theHttpClientis created per logical operation and serves every attempt, setting the key as a default request header (:58) makes it ride along unchanged through all three retries. The generator itself is inherited:NewIdempotencyKey()is a compact GUID (AuthenticatedServiceBase.cs:43), and its remarks (:36-41) say the same thing from the framework side. The server end is ADR-017, applied to this route by the[Idempotent]attribute (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:192-193). - Walkthrough
SubmitAnswerAsync(:21-40) POSTs the anonymous{ SessionId, QuestionId, AnswerValue }object (:31) to the root resource and reads back the persistedSessionQuestionAnswerDTO(:38). Same shape as its event twin.SubmitAnswersAsync(:43-76) is the batch form. It null-checks the answers list (:50), mints and attaches the idempotency key (:58), projects the tuples into an anonymous{ SessionId, Answers = [{ QuestionId, AnswerValue }] }body (:60-64), POSTs tosessionquestionanswers/batch(:68), andMaps the returnedList<T>to the interface'sIReadOnlyList(:74). The server applies the form atomically, so oneResultcovers the whole submit.GetMyAnswersAsync(:79-98) reads the filtered paged list withfilters[SessionId]...&pageSize=100&includeChildren=false(:87) and unwraps the envelope (:96).DeleteAnswerAsync(:101-117) DELETEs by answer id withsessionIdas a query parameter (:110).- All four calls use the retry overload that takes the
CancellationToken(:33,:66,:89,:111).
- Why it's built this way: a session feedback form is several answers a person filled in at once, and half-applying it on a flaky conference wifi connection would be worse than failing outright. The batch endpoint plus the replayed idempotency key is what turns "submit the form" into a single atomic, safely retryable operation.
- Where it's used: registered scoped as
ISessionFeedbackUIService(MMCA.ADC.Engagement.UI/DependencyInjection.cs:43); injected bySessionFeedback(SessionFeedback.razor.cs:21), which reads existing answers (:122), submits the whole pending form through the batch call (:226) and deletes single answers (:259).[Rubric §14, Testability]:SessionFeedbackServiceTestscovers eleven facts, including that the batch call sends the whole form in one request (:61) and that it carries anIdempotency-Keyheader (:91), that a domain-exception response becomes a failure carrying the server's own message (:43,:109,:191), that an empty envelope reads as an empty list (:162), and that an already-cancelled token aborts before a request is sent (:209). - Caveats / not-in-source:
SubmitAnswerAsync(the single-answer form) sends no idempotency key, even though the server route carries[Idempotent](SessionQuestionAnswersController.cs:165-166). A retried single-answer submit is therefore deduped by the upsert semantics rather than by replay. ThepageSize=100on the read is the same hard ceiling as on the event side.
CheckInsController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:39· Level 5 · class (sealed)
- What it is: the REST surface for QR badge check-in. Six endpoints covering the attendee's own badge, the organizer scan and its manual fallback, the two attendee self-recorded scan surfaces (sponsor booth, room), and the organizer attendance rollup.
- Depends on:
ApiControllerBase(forHandleFailure), six injected handlers overICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>(CheckInsController.cs:40-45), the request and result contractsCheckInAttendeeRequest,ManualCheckInRequest,SponsorVisitRequest,RoomCheckInRequest,CheckInResultDTO,SponsorVisitResultDTO,RoomCheckInResultDTO,MyBadgeDTO,AttendanceStatsDTO, the use-case typesGetOrCreateMyBadgeCommandandGetAttendanceStatsQuery, plusIdempotentAttribute,EngagementFeatures,EngagementPermissionsandResult. Externals: ASP.NET Core MVC,Asp.Versioning, andMicrosoft.FeatureManagement.Mvc's[FeatureGate]. - Concept introduced, the authorization ladder on one controller.
[Rubric §11, Security]assesses whether each endpoint carries the weakest authorization that is still correct. This controller has three rungs, and reading them top to bottom is the fastest way to understand the whole check-in feature. The class-level[Authorize](:34) is the floor: authenticated, no policy, no role. Three endpoints add[HasPermission(EngagementPermissions.CheckInManage)](:75,:99,:179), which is the organizer rung. The remaining three deliberately stay at the floor, and the doc comments say why:my-badge(:43-45) and the two self-recorded scans (:117-119,:148-149) take the attendee from the token and never from the request, so there is no ownership argument a caller could tamper with and therefore no ownership check to get wrong.[Rubric §9, API & Contract Design]covers the other decision worth studying: a repeat scan answers 200 with anAlreadyCheckedInflag rather than 409 (:62-67,:151-152), because at a door a second scan is a normal event, not a client error, and the organizer needs to see whose badge it is either way.[Rubric §12, Performance & Scalability]covers feature gating: the whole controller is behindEngagementFeatures.CheckIn(:33) and the two self-service endpoints add their own gates (:130,:161), so a disabled surface answers 404 rather than 403 (ADR-031). - Concept introduced, idempotency layered onto an already-repeatable endpoint. Every one of the four POSTs carries
[Idempotent](:74,:98,:129,:160), the framework's replay cache (ADR-017,IdempotentAttribute). The doc comments are careful about why that is safe rather than merely convenient, and they are worth reading as a checklist: the endpoint's response must not drift within the retry window. For the scan and manual paths the argument is that a repeat already answers the same 200 (:68-72,:94-96), so a replayed response says exactly what a re-executed one would; for sponsor visits it isAlreadyVisited(:124-127); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (:154-158), so nothing in the response depends on a value that could change mid-retry.[Rubric §29, Resilience & Business Continuity]is what this buys: a conference-day network that is dropping responses stops costing a second database round trip per retry. - Walkthrough (endpoints in file order)
GetMyBadgeAsync(:49),GET my-badge: dispatches a parameterlessGetOrCreateMyBadgeCommand(:53), which mints a badge on first use. It is a command rather than a query precisely because it can write.CheckInAsync(:80),POST: the organizer scan path, dispatchingCheckInAttendeeRequesttoCheckInAttendeeHandler. Declares 400, 403 and 404 alongside the 200 (:76-79).ManualCheckInAsync(:104),POST manual: the fallback for a dead phone or a head with no camera (:91-93), same permission and same outcome shape.RecordSponsorVisitAsync(:134),POST sponsor-visits: attendee self-service behindEngagementFeatures.SponsorVisits(:130). The response carries the sponsor name so the landing page needs one round trip (:121-122).RecordRoomCheckInAsync(:165),POST room-visits: attendee self-service behindEngagementFeatures.RoomCheckIn(:161). The session is never client supplied; the server resolves it from the room plus the configured grace window and answers 404CheckIns.NoCurrentSessionwhen nothing is running there (:149-151).GetAttendanceStatsAsync(:182),GET stats?eventId=: the organizer rollup, with the event id[FromQuery, Required](:183) and no paging arguments.- Every action is the same three lines: dispatch the handler, then
HandleFailure(result.Errors)orOk(result.Value). No branching, no mapping, no business rule lives in this file.
- Why it's built this way: the class comment (
:24-28) records the scoping fact that explains the whole surface: the conference runs door and arrival check-in through TicketLeap, so the Event scope here is not a door process. Session check-in is the working path, and Event scope stays available for a future info-desk or points-activation use. Keeping every endpoint a pure dispatch keeps the decisions (idempotency of the business rule, session resolution, badge verification) in handlers that are unit testable without HTTP (ADR-072). - Where it's used: mounted at
/checkinsby the route attribute (:31) and fronted by the YARP Gateway. Its clients are the organizer scanning UI and the two attendee landing pages,RoomCheckInandSponsorVisit, throughICheckInUIService. Covered byCheckInsControllerTests. - Caveats / not-in-source: which features are switched on in a given environment is configuration. The permission-to-role mapping behind
EngagementPermissions.CheckInManageis resolved by the auth layer, not here, and the idempotency key header and retention window are the framework filter's, not this controller's.
PointsController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36· Level 5 · class (sealed)
- What it is: the REST surface for the points game: the caller's own ledger, the public leaderboard, joining or leaving that leaderboard, and the organizer rollup.
- Depends on:
ApiControllerBase, four handlers overICommandHandler<in TCommand, TResult>/IQueryHandler<in TQuery, TResult>(PointsController.cs:37-40), the queriesGetMyPointsQuery,GetLeaderboardQueryandGetPointsOverviewQuery, the requestSetLeaderboardParticipationRequest, the contractsMyPointsDTO,LeaderboardEntryDTOandPointsOverviewDTO, plusEngagementFeatures,EngagementPermissionsandResult. Externals: ASP.NET Core MVC,Asp.Versioning,[FeatureGate], and[Range]fromSystem.ComponentModel.DataAnnotations. - Concept introduced, the surface with no ownership argument.
[Rubric §11, Security]assesses how ownership is enforced per endpoint. Contrast this controller withBookmarksController, which has to bind a body field and a query argument to the caller's claim in two different ways. Here the class comment (:24-29) states the design instead: nothing on this surface takes a user id. The three attendee endpoints resolve the caller from the token inside their handlers, so there is no argument a caller could change, and the one endpoint that reads across every attendee returns no attendee identity at all.[Rubric §30, Compliance, Privacy & Data Governance]is the other half: the leaderboard serves only the display-name snapshot an attendee published at opt-in (:69-74), so rendering the board makes no call into Identity and exposes nothing an attendee did not choose to publish, and the organizer overview carries activity, points and timestamps only (:116-119).[Rubric §9, API & Contract Design]covers the paging arguments:pageNumber/pageSize(:54-55) andrecentCount(:126) are[Range]-validated and select how much comes back, never whose data it is (:44-48). - Walkthrough (endpoints in file order)
GetMyPointsAsync(:53),GET me: dispatchesGetMyPointsQuerywith the paging pair defaulting to page 1 of 20 (:54-55,:59), returning the running total, the caller's leaderboard status and a page of ledger entries.GetLeaderboardAsync(:78),GET leaderboard: dispatches a parameterlessGetLeaderboardQuery(:82). The board length is fixed by configuration (Points:LeaderboardSize), not by the caller (:72-73).SetLeaderboardParticipationAsync(:103),PUT me/leaderboard-participation: the one write. The request carries only a boolean because the published display name is taken from the caller's token server-side and never from the body (:93-97), and it answers 204 (:100,:111).GetPointsOverviewAsync(:125),GET overview: the only endpoint with a permission,[HasPermission(EngagementPermissions.PointsViewOverview)](:121).- The whole controller is behind
[FeatureGate(EngagementFeatures.Points)](:34) with a bare[Authorize](:35) as the floor.
- Why it's built this way: designing the routes so no endpoint accepts an identity is a cheaper guarantee than checking one. There is no filter to forget and no claim comparison to invert, which is the failure mode the inline checks on
BookmarksControllerexist to prevent. See ADR-072 for the points design and ADR-033 for the ownership axis this surface sidesteps. - Where it's used: mounted at
/points(:32) behind the Gateway; consumed byMyPointsandOrganizerPointsOverviewthroughIPointsUIService. Covered byPointsControllerTests. - Caveats / not-in-source:
Points:LeaderboardSizeand the per-activity point values are configuration, read by the handlers rather than by this controller. Note also that the one write here carries no[Idempotent], unlike the four POSTs onCheckInsController: setting participation is a PUT of an absolute value, so a replay lands on the same state without help.
UserDeletedPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:37· Level 8 · class (sealed partial)
- What it is: the erasure handler for the leaderboard. When Identity deletes an account, this takes the entry off the public board and overwrites the display name it published there. It is the one consumer in this folder that awards nothing.
- Depends on:
ScopedIntegrationEventHandlerBase<TIntegrationEvent>closed over Identity'sUserDeleted(:40),IUnitOfWork(:50), theIRepository<TEntity, TIdentifierType>it obtains forLeaderboardOptIn(:51) and that aggregate's ownDelete()/EraseDisplayName()members (:69,:81). Externals:IServiceScopeFactory, source-generated[LoggerMessage](:96-103). - Concept introduced, cross-database erasure by event.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a deletion promise actually reaches every copy of the data. Under database-per-service (ADR-006) Identity cannot delete a row in the Engagement database, so an erasure has to travel as an event and be carried out by the owning service. The class comment (:13-21) names the exact scope: the leaderboard is the one place Engagement holds a name rather than a scalar id,LeaderboardOptIn.DisplayNameis a snapshot the attendee published at opt-in, and without this handler that name would keep sitting in the table after the account was erased. It also names what is deliberately left alone: the points ledger, because aPointsEntrycarries a scalar user id and an opaque subject key and names nobody, the same posture already applied to session bookmarks. That is the ADR-005 distinction between soft-delete for lifecycle and erasure for personal data, applied per column rather than per table.[Rubric §29, Resilience & Business Continuity]covers the retry posture stated at:26-27: exceptions are deliberately not caught, so a failure is logged once by the base and then propagates, and the outbox and the broker retry it rather than acking it away with a log line (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:26-33). The award adapters share that exception posture, because they share the base; what differs is what they do with a rejected award, which they log and move past, whereas this handler has no such branch: a missed point is a nuisance, a missed erasure is a broken promise. - Walkthrough
HandleScopedAsync(:43-46) is the override; the base has null-guarded the event and opened the per-delivery scope. The body takes the user id (:48), then reaches the scopedIUnitOfWork(:50) and a tracked repository forLeaderboardOptIn(:51) out of the supplied provider.- The read (
:56-61) is the subtle line. It passesasTracking: true(mutations follow) andignoreQueryFilters: true, and the comment (:53-55) explains that the flag is load-bearing here for the opposite reason it is used on the join path: an attendee who left the board earlier still has a soft-deleted row carrying the published name, and a filtered read would walk straight past the very row that has to be erased. - The loop (
:65-84) does two independent things per row. If the row is not already deleted it callsDelete()and, on failure, logs and moves to the next row rather than aborting the batch (:67-77). If the display name is not already erased it callsEraseDisplayName()(:79-83). Either action setschanged. - When nothing changed the handler logs and returns without a save (
:86-90); otherwise it saves once for the whole batch and logs the row count (:92-93).
- Why it's built this way: the two-flag loop is what makes the handler idempotent in both directions (
:22-25): an account that never joined the board has no row and writes nothing, and an account already off the board with an erased name reaches the same end state and writes nothing again. At-least-once delivery makes redelivery normal, so that property is a requirement rather than a nicety. - Where it's used: registered as a singleton by the convention scan (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88,MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198), with the consumer wired atMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:289; the surrounding comment (:264-266) records that this is the one consumer here that earns nothing. Covered byUserDeletedPointsHandlerTests. - Caveats / not-in-source: the privacy commitment the comment cites (PRIVACY.md section 5) lives in the ADC repo's private docs, not in this file. What
EraseDisplayName()writes in place of the name is defined onLeaderboardOptIn.
AttendeeCheckedInPointsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:31· Level 10 · class (sealed partial)
- What it is: the award adapter for check-ins. It turns an
AttendeeCheckedIninto the (activity, subject key) pair the ledger understands and hands it toIPointsAwarder. It is the richest of the award adapters because the incoming scope is a string that has to be mapped. - Depends on:
ScopedIntegrationEventHandlerBase<TIntegrationEvent>closed overAttendeeCheckedIn(:34),IPointsAwarder(:48),CheckInScopeNames(:70,:77,:88),PointsActivityTypeandPointsSubjectKeys(:72-73,:80-81,:91-92). Externals:IServiceScopeFactory,StringComparison.Ordinal, source-generated[LoggerMessage](:101-105). - Concept introduced, treating an unknown contract value as data, not as a fault.
[Rubric §6, CQRS & Event-Driven]assesses how a consumer behaves when the producer is ahead of it. The class comment (:16-22) states the rule: the scope arrives as a wire string rather than an enum, so a value this build has never heard of is normal contract evolution, and the handler logs a warning and awards nothing instead of throwing and dead-lettering a message that no retry could ever fix.[Rubric §29, Resilience & Business Continuity]is the practical consequence: an unmappable payload cannot wedge the queue.[Rubric §3, Clean Architecture]is the same boundary its siblings keep, stated in this file's own words (:12-15): all the ADC vocabulary lives here and the awarder below it knows nothing about events or sessions. - Walkthrough
HandleScopedAsync(:37-40) is the override. The base has already null-guarded the event and opened the delivery scope, so the first thing the body does is callTryMapAward(:42), before resolving anything: an unmappable event costs no service resolution and no database work, only the warning at:44.- On a successful map it resolves the scoped
IPointsAwarderfrom the supplied provider (:48) and awards with the event'sCheckedInOn.UtcDateTime(:50-55). A rejected award is logged at Warning with the activity, the user and the subject key (:57-58), not rethrown. TryMapAward(:65) is three ordinal string comparisons in scope order.Eventmaps toPointsActivityType.EventCheckInscoped byPointsSubjectKeys.ForEvent(:70-75).Sessionmaps toSessionCheckInonly when the payload actually carries a session id, using a property pattern (:77-83).Sponsormaps toSponsorVisitunder the same condition on the sponsor id (:88-94), and the comment above it (:85-87) explains why a Sponsor scope with no sponsor id falls through to the same log-and-skip as an unknown scope: an award scoped to nothing would collide with every other sponsor-less payload on the ledger's uniqueness rule.- The fall-through sets
activity = default, an empty subject key, and returnsfalse(:96-98), which is the single exit the caller's guard reads.
- Why it's built this way: this handler is where the module's most interesting delivery property lives. The event is published by the same service that consumes it, so the check-in write and the award are two separate transactions joined by the broker (
MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:269-275). That keeps the scan endpoint's latency independent of the points write and lets the award retry on its own. The same comment records the fallback if a deployment ever has trouble with the round trip (:275-277): the award could move to an in-module domain event handler on the sameCheckIncreation, which is a one-file change because the module already awards session-question points that way, throughSessionQuestionSubmittedPointsHandler. - Where it's used: registered as a singleton by the convention scan (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88,MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198), which is why the base opens a scope per delivery (:23-27); the consumer is wired atMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:286. Covered byAttendeeCheckedInPointsHandlerTests. - Caveats / not-in-source: how many points each activity is worth, and whether a rule is switched off, is decided inside
PointsAwarderfrom configuration, not here.
BookmarksController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:34· Level 11 · class (sealed)
- What it is: the REST surface for session bookmarks, the attendee's personal schedule (UC-11): create one, list a user's bookmarks paginated, fetch the bookmarked session ids as a lookup, and delete one. All endpoints require authentication (BR-42,
:25-26). - Depends on:
ApiControllerBase, a create handler overCreateBookmarkRequest(:34), query handlers forGetUserBookmarksQuery(:35) andGetBookmarkedSessionIdsQuery(:36), a delete handler overDeleteEntityCommand<TEntity, TIdentifierType>(:37),IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>for the ownership probe (:38),ICurrentUserService(:39), plusUserSessionBookmark,UserSessionBookmarkDTO,PagedCollectionResult<T>,Error,RoleNames,OwnerOrAdminFilter,IdempotentAttributeandEngagementFeatures. Externals: ASP.NET Core MVC,Asp.Versioning,[FeatureGate],CultureInfofor theCreatedlocation. - Concept introduced, three ways to enforce one ownership rule.
[Rubric §11, Security]assesses row-level authorization, and this controller is the reference example in the module because the same rule ("a non-organizer may only touch their own bookmarks") is enforced three different ways, each matched to where the owner identity actually travels (ADR-033).- On the two GETs the owner arrives as a
userIdquery argument, so the shared[ServiceFilter(typeof(OwnerOrAdminFilter))](:84,:105) compares it to the caller'suser_idclaim, with Organizer bypassing. The ADC vocabulary for that filter is configured in the Engagement module registration, not here (:80-82). - On POST the owner arrives in the request body, which no query-argument filter can see, so the check is inline (
:58-70) and the comment records the consequence of omitting it (:58-62): any authenticated user could create bookmarks owned by another user by supplying a foreignUserId. - On DELETE the owner is not in the request at all, it is a property of the stored row, so the check is a database probe (
:139-141). The DELETE also teaches a second decision: it returns 404 rather than 403 for a bookmark the caller does not own (:129-130,:144-147), so the response cannot be used to learn that a given bookmark id exists. Contrast the whole picture withPointsController, which avoids all three mechanisms by never accepting a user id.
- On the two GETs the owner arrives as a
- Walkthrough (endpoints in file order)
- The class carries
[FeatureGate(EngagementFeatures.SessionBookmarks)](:31) and a bare[Authorize](:32), and is mounted at/bookmarks(:29). CreateAsync(:54): the one endpoint with[Idempotent](:50). The doc comment states the safety argument (:44-48): the request names the (user, session) pair explicitly and a duplicate already answers 409, so replaying a retried star is what the attendee meant rather than a second row or a spurious conflict (ADR-017). The action then runs the inline body-vs-claim check for non-organizers, failing withError.Forbidden("Bookmarks.Forbidden", ...)when the claim is missing or does not match (:63-70), dispatches, and answers 201 with a relative location built culture-invariantly (:76).GetUserBookmarksAsync(:86):userIdis[FromQuery, Required],eventIdis an optional filter, and paging defaults to page 1 of 10 (:87-90); returns aPagedCollectionResult<T>.GetBookmarkedSessionIdsAsync(:107): returns anIReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>(:106-107), which is the shape a session list needs to render bookmark toggles without one call per row.DeleteAsync(:125): for a non-organizer it resolves the caller id, forbids when absent (:133-137), then asks the query service whether a row with this id belongs to this user (:139-141) and, if not, returns aNotFounderror tagged with the controller and entity names (:144-147). Only then does it dispatch the genericDeleteEntityCommand<TEntity, TIdentifierType>(:150-152), which soft-deletes, and answer 204 (:156).
- The class carries
- Why it's built this way: the mixed enforcement is deliberate rather than inconsistent. A shared action filter can only read what is on the request line, so the body and the stored-row cases cannot use it, and pushing the DELETE check into the handler would either duplicate it across every future mutation or force the generic delete command to learn about ownership. Keeping the probe inline is the same per-mutation pattern Store's
OrdersControlleruses. - Where it's used: mounted at
/bookmarksand routed to the Engagement service by the Gateway; consumed by the Conference session-list and personal-schedule surfaces. Note that the writes here are also what triggerUserSessionBookmarkCacheEvictionHandler: the aggregate raisesUserSessionBookmarkChangedon every path this controller reaches, so a star or an un-star broadcasts an output-cache eviction to Conference. Covered byBookmarksControllerTests. - Caveats / not-in-source: the
OwnerOrAdminFilterconfiguration (theuser_idclaim name, theuserIdargument name, and the Organizer bypass role) is set during module registration and is not visible in this file. The business-rule numbers in the comments (UC-11, BR-42) are the controller's own claim; the authoritative statements live in the ADC specifications guide.
MyPoints
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/MyPoints.razor.cs:16· Level 5 · class (partial)
- What it is: the code-behind for the attendee's own points page (
@page "/points",MyPoints.razor:1,@attribute [Authorize],MyPoints.razor:2). It shows the running total, the ledger entries behind it, the leaderboard opt-in switch, and the public board. - Depends on:
IPointsUIService(the only injected service,MyPoints.razor.cs:21),Result(the load method's own return type,:73),PointsEntryDTO(:35),LeaderboardEntryDTO(:36),MyPointsDTO(read at:75-83) andPointsActivityType(:158). Externals: BlazorComponentBase, MudBlazorBreadcrumbItem/Icons/MudSwitch,System.Globalization, and an injectedIStringLocalizer<MyPoints>namedLfrom the markup (MyPoints.razor:6). - Concept introduced, making a stateful child control follow the server.
[Rubric §19, State Management]assesses who owns a visible piece of state. AMudSwitchkeeps its own checked state, and Blazor re-parameterizes a child only when a parameter actually changes, so after a rejected write the control would keep showing what the attendee clicked while the page holds what the server accepted. The page solves it with a render key:_switchGeneration(:42) is bound as@keyon the switch (MyPoints.razor:72) and incremented whenever the server's value disagrees with the requested one (:145-148), which re-mounts the control and forces it to drop its internal state.[Rubric §24, Forms, Validation & UX Safety]covers the second decision in the same method: a failed write and a failed read-back are reported differently (:111-114), because conflating them made a refresh blip force the switch back to the pre-write value and show the opposite of the server state.[Rubric §27, Internationalization]covers both theL["..."]resource lookups and the culture-aware timestamp rendering (ADR-027). - Concept introduced, a page that reads failures instead of catching them.
[Rubric §1, SOLID]and[Rubric §15, Best Practices & Code Quality]both show in one detail:LoadAsyncreturnsResult(:73) rather than throwing, and it composes the two service calls by propagating their errors (:76-79,:86-89). The only exception the page catches isOperationCanceledExceptionon disposal (:59-62,:131-134), so the error state is driven by a value the UI service returned, not by an exception type the page has to recognize. - Walkthrough (teaching order)
EntryPageSize(:19) fixes the activity feed at 20 entries per read; the injectedIPointsUIService(:21), aCancellationTokenSource(:23) and the breadcrumb list (:25) follow.IsLoadingisprotectedwith a private setter (:28) so component tests can observe the first load.- The private state (
:30-36) is a load error, a save error, an in-flight save latch, the total, the opt-in flag, the entries and the board._switchGeneration(:42) is the render key described above. OnInitializedAsync(:44) builds the two-item breadcrumb trail (:46-50), then awaitsLoadAsyncand sets the localized load error when it comes back a failure (:54-57), swallowsOperationCanceledExceptionas expected on disposal (:59-62), and clearsIsLoadinginfinally(:63-66).LoadAsync(:73) makes two calls: the caller's ledger (:75) and the public board (:85). Each is unwrapped withTryGetValueand short-circuits intoResult.Failurecarrying the service's own errors (:76-79,:86-89). Both are re-read after an opt-in change, because joining adds a row to the board and leaving removes one (:69-72).OnParticipationChangedAsync(:101) returns early when a save is already in flight (:103-106), latches_isSavingand clears the previous save error (:108-109), then writes throughSetLeaderboardParticipationAsync(:117). A failed write sets the save-failed message (:118-121); a successful write followed by a failed refresh sets the refresh-failed message instead and adopts the requested value locally (:122-129) so the key guard below does not re-mount the switch back onto a stale reading.ActivityLabel(:158-168) maps eachPointsActivityTypemember to a resource key by an explicit switch rather than by enum name, so a renamed member breaks the build instead of silently rendering a missing key (:151-155).FormatOccurredOn(:177-180) stampsDateTimeKind.Utcbefore converting to local time. The comment (:170-176) gives the reason: a serializer that drops the UTC marker leaves the valueUnspecified, and the local conversion would then shift an already-local reading a second time.Dispose(bool)/Dispose()(:184-204) are the standard disposable pattern with a_disposedlatch, cancelling and disposing_ctsso an in-flight load stops when the attendee navigates away.
- Why it's built this way: the page makes one promise, that the switch shows the server's state and not the click's, and both the key bump and the split error messages exist to keep it. Everything else is orchestration only: all I/O goes through
IPointsUIService, which keeps the page testable under bUnit and swappable per host. - Where it's used: routed at
/points, the value ofEngagementRoutePaths.MyPoints(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:26). Component-tested byMyPointsTests. - Caveats / not-in-source: the tables, the empty states and every visible string live in
MyPoints.razorand the resource files. Whether the page is reachable at all depends on theEngagement.Pointsfeature gate onPointsController, which is configuration.
OrganizerPointsOverview
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/OrganizerPointsOverview.razor.cs:19· Level 5 · class (partial)
- What it is: the code-behind for the organizer rollup of the points game (
@page "/organizer/points",OrganizerPointsOverview.razor:1,@attribute [Authorize(Roles = "Organizer")],OrganizerPointsOverview.razor:2): how many attendees are playing, what the economy has paid out, which mechanic the room actually used, and the newest awards. - Depends on:
IPointsUIService(OrganizerPointsOverview.razor.cs:24),PointsOverviewDTO(:34) andPointsActivityType(:76). Externals as onMyPoints: MudBlazorBreadcrumbItem/Icons,System.Globalization, and an injectedIStringLocalizer<OrganizerPointsOverview>(OrganizerPointsOverview.razor:6). - Concept introduced, distinguishing "no data yet" from "the read failed". There is no write on this surface, so none of the switch-state machinery
MyPointsneeds appears here, but the load does one thing its sibling does not.[Rubric §24, Forms, Validation & UX Safety]assesses whether the UI tells the truth about what happened: the load unwraps theResultwithTryGetValueand, when it is a failure, checksIsNotFound()before deciding (:47-57). A not-found rollup leaves_overviewnull so the page keeps rendering its empty state, and only a real failure becomes the error state; the comment (:53-55) records that this preserves the behavior the older null-returning service produced.[Rubric §30, Compliance, Privacy & Data Governance]is the other point worth teaching: the class comment (:13-17) states that the recent entries carry NO attendee identity by design, because the overview endpoint reports activity, points and timestamps only. An organizer can watch the game move without seeing who earned what, and the only published identities anywhere in this feature are the leaderboard names attendees opted in to.[Rubric §11, Security]covers the role gate, which is declared on the markup rather than in this file. - Walkthrough:
RecentEntryCount(:22) fixes the tail at 20 entries.IsLoading(:31) isprotectedwith a private setter for the component tests;_loadErrorand_overview(:33-34) are the only other state.OnInitializedAsync(:36) builds the breadcrumbs (:38-42), awaitsGetPointsOverviewAsync(RecentEntryCount, _cts.Token)(:46), applies the found / not-found / failed split described above (:47-57), swallowsOperationCanceledException(:59-62) and clearsIsLoadinginfinally(:63-66).ActivityLabel(:76-86) andFormatOccurredOn(:95-98) are theMyPointshelpers repeated verbatim, including the explicitDateTimeKind.Utcstamp and its recorded reason (:88-94).Dispose(bool)/Dispose()(:102-122) are the same disposable pattern over_cts. - Why it's built this way: the two helper methods are duplicated across the two points pages rather than shared, the same trade-off the feedback pages make with their nested answer holder: each page stays self-contained and independently editable, and the duplicated code is two small pure functions with no state.
- Where it's used: routed at
/organizer/points, the value ofEngagementRoutePaths.OrganizerPointsOverview(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:27). Its data comes fromPointsController's overview endpoint (PointsController.cs:125) viaGetPointsOverviewHandler. Component-tested byOrganizerPointsOverviewTests. - Caveats / not-in-source: the stat cards, the per-activity table and the recent-entry table live in
OrganizerPointsOverview.razor, not in this code-behind.
CheckInInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckInInvariants.cs:10· Level 6 · class (static)
- What it is: the five invariant rules for
CheckIn. Four are simple id and enum checks; the fifth encodes the rule that makes one aggregate able to carry three different check-in shapes. - Depends on:
CommonInvariants(CheckInInvariants.cs:2),ResultandError(Error.Invariantat:88,:93,:104), plusCheckInScope(:45) and theUserIdentifierType/EventIdentifierType/SessionIdentifierType/SponsorIdentifierTypealiases (solution-wideglobal using, see primer §2). Externals:System.Enum. - Concept introduced, the discriminated-shape invariant.
[Rubric §4, Domain-Driven Design]assesses whether the model can express an illegal state. This aggregate is a polymorphic row: a Session check-in must name a session and must not name a sponsor, a Sponsor visit is the mirror image, and an Event check-in names neither.EnsureTargetMatchesScopestates that rule once, in the domain, so no handler and no controller can persist a row that belongs to two shapes at once (:33-37).[Rubric §8, Data Architecture]is the reason it matters beyond tidiness: the storage layer builds three filtered unique indexes on the same table, one per scope (CheckInConfiguration,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:48-62), and a row with two targets set would be visible to two of them. The invariant and the index filters are two statements of one rule, which the doc comment names explicitly (:34-36). - Walkthrough (teaching order)
EnsureUserIdIsValid(:16-17),EnsureEventIdIsValid(:23-24) andEnsureCheckedInByUserIdIsValid(:30-31) are threeCommonInvariants.EnsureIdIsNotDefaultdelegations with stable codes ("CheckIn.UserId.Invalid","CheckIn.EventId.Invalid","CheckIn.CheckedInByUserId.Invalid"). The event id is required for every scope (:19), which is what lets the attendance rollup bucket any row by conference.EnsureTargetMatchesScope(:44-71) runs the session check first and returns it when it fails (:50-61), otherwise runs the sponsor check (:62-70), so the caller gets the first specific failure rather than a merged pair.EnsureTargetPresence(:75-95) is the private rule stated once.isOwningScopedecides the direction: the owning scope requires the id and treatsnullordefaultas missing (:87-89), and every other scope forbids it outright (:92-94). The comment above it (:73-74) records why one method serves both targets: both areintaliases, so the parameter is typedint?.EnsureScopeIsDefined(:101-104) rejects an undefined enum value withEnum.IsDefined, which matters because a scope can arrive from a deserialized request rather than from C# code.
- Why it's built this way: pushing the shape rule into the domain rather than into each use case means the three write paths (organizer scan, sponsor visit, room check-in) cannot drift apart, and the separate error codes make a failure legible at the API boundary without a message-parse.
- Where it's used: composed with
Result.CombineinsideCheckIn.Create(CheckIn.cs:98-103); that factory is the only caller. - Caveats / not-in-source:
EnsureTargetPresencetreats a supplied-but-defaultid as missing only for the owning scope (:87); a non-owning scope rejects any non-null value,defaultincluded (:92).
LeaderboardOptIn
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:19· Level 6 · class (sealed)
- What it is: the aggregate root recording that one attendee agreed to publish their score on the points leaderboard. Opting out soft-deletes the row, so "the board" is exactly the set of active opt-ins and nothing else (
LeaderboardOptIn.cs:9-11). It carries two pieces of state: the attendee's id and the display name they chose to publish. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(bound toLeaderboardOptInIdentifierType),LeaderboardOptInInvariants, theLeaderboardOptInChangeddomain event,DomainEntityState,IdValueGeneratedAttribute, andResult. Externals: onlySystem.StringComparison. - Concept introduced, the published-name snapshot.
[Rubric §7, Microservices Readiness]assesses whether a read can be served without reaching across a service boundary.DisplayNameis not a lookup key into Identity: it is the name the attendee explicitly published at opt-in, stored here verbatim (LeaderboardOptIn.cs:12-16). That single choice is what letsGetLeaderboardHandlerproject the whole board out of the Engagement database (GetLeaderboardHandler.cs:39-43) with no gRPC call into Identity, and it also bounds the exposure: the board can only ever leak the name the attendee chose to put on it.[Rubric §30, Compliance, Privacy and Data Governance]is the other half, taught below onEraseDisplayName. - Concept, opt-in as a row rather than a flag.
[Rubric §4, Domain-Driven Design]assesses whether the model states the rule rather than encoding it in a boolean somewhere. Participation is a first-class aggregate with its own lifecycle (join, leave, rejoin), which means leaving is auditable (CreatedOn/ByandLastModifiedOn/Bycome from the auditable base) and rejoining is a state transition on the same row rather than a new record. - Walkthrough
[IdValueGenerated](LeaderboardOptIn.cs:18): the id is database-generated, so the factory writesId = defaultand SQL Server'sIDENTITYfills it (seeIdValueGeneratedAttribute).DisplayNameMaxLength = 100(:22) andErasedDisplayName = "Deleted Account"(:29): two constants that the rest of the system reads rather than restates. The EF configuration binds the column length to the first (LeaderboardOptInConfiguration,LeaderboardOptInConfiguration.cs:28-30) andLeaderboardOptInInvariantsvalidates against it (LeaderboardOptInInvariants.cs:23). The second matches the placeholder the Identity aggregate anonymizes to, so an erased account reads the same wherever a row survives (:24-28).UserId(:32) andDisplayName(:35):private setscalars.UserIdis a cross-database reference, never a navigation (ADR-006).IsDisplayNameErased(:42): a computedboolcomparingDisplayNametoErasedDisplayNamewithStringComparison.Ordinal. It exists so a redelivered erasure can recognise that there is nothing left to write (:37-41).- Two constructors, an EF-only parameterless one (
:45) and a private assigning one (:47-51). Construction always runs through the factory. Create(userId, displayName)(:60-78):Result.Combineover both invariants (:64-66), errors re-wrapped asResult.Failure<LeaderboardOptIn>on failure (:67-68), the entity built withId = default(:70-73), thenLeaderboardOptInChanged(DomainEntityState.Added, ...)raised (:75).Reactivate(displayName)(:87-102): validates the name first (:89-91), calls the inheritedUndelete()(:93), and only on success overwritesDisplayNameand raises the sameAddedevent (:97-98). Rejoining therefore republishes the attendee's current name, not the one they had the first time (:80-83, BR-135).Delete()(:109-117): overrides the base soft-delete, callsbase.Delete()(:111) and raisesLeaderboardOptInChanged(DomainEntityState.Deleted, ...)on success (:114). This is "left the board", not "erased".EraseDisplayName()(:130): a one-line, irreversible overwrite ofDisplayNamewithErasedDisplayName.[Rubric §30, Compliance, Privacy and Data Governance]assesses whether erasure is modelled distinctly from deletion. The remarks (:119-129) state why the two are deliberately separate methods: taking an entry off the board and erasing the name it carried are different promises, and only the second is irreversible. The row itself survives (anonymize in place, ADR-005), so the scalarUserIdreference and the audit trail stay intact, and the operation is idempotent because a redelivered erasure rewrites the same placeholder.
- Why it's built this way: the unique index on
UserIdis filtered on the soft-delete flag (LeaderboardOptInConfiguration,LeaderboardOptInConfiguration.cs:33-35), so an attendee can hold at most one active opt-in while their history survives (ADR-095). That index is precisely what makesReactivatenecessary rather than optional: without it a rejoin would insert a second row and collide, which is the reasoning recorded at the call site (SetLeaderboardParticipationHandler.cs:67-69). - Where it's used: reactivated and created by
SetLeaderboardParticipationHandler(SetLeaderboardParticipationHandler.cs:87and:92respectively); read byGetLeaderboardHandler(GetLeaderboardHandler.cs:39-43, ordering tie-break onDisplayNameat:71); soft-deleted and erased byUserDeletedPointsHandler(UserDeletedPointsHandler.cs:63-82); persisted perLeaderboardOptInConfiguration. Unit-tested byLeaderboardOptInTests. - Caveats / not-in-source: the erasure comment cites
PRIVACY.mdsection 5 (LeaderboardOptIn.cs:121); that document lives in the ADC repo's own docs, not in this file, so the citation cannot be verified from the source under this type.
LeaderboardOptInInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptInInvariants.cs:9· Level 6 · class (static)
- What it is: the two-rule invariant helper for
LeaderboardOptIn: the opt-in must name a real attendee, and the published name must be present and fit the column. - Depends on:
CommonInvariants(LeaderboardOptInInvariants.cs:1),ResultandError, theUserIdentifierTypealias (solution-wideglobal using, see primer §2), and theLeaderboardOptIn.DisplayNameMaxLengthconstant. - Concept introduced, the static invariant class.
[Rubric §4, Domain-Driven Design]assesses whether business rules live in the model rather than leaking into handlers or the database schema alone. Every aggregate in this module pairs with a static class whose methods each return aResult, so a factory can compose them withResult.Combineand report every violation at once instead of failing on the first.[Rubric §1, SOLID]: one method, one rule, one reason to change. The idiom is the same one the framework value objects use in Group 02; the three siblings in this unit (CheckInInvariants,PointsEntryInvariantsandUserSessionBookmarkInvariants) are the same shape with different rules. - Walkthrough: two expression-bodied methods, both taking a
sourcestring that the caller passes as its own method name so a failure carries its origin without a stack trace.EnsureUserIdIsValid(userId, source)(:15-16): delegates toCommonInvariants.EnsureIdIsNotDefaultwith code"LeaderboardOptIn.UserId.Invalid"and message"User ID must be provided.". Adefaultid (zero or empty, whichever the alias resolves to) fails.EnsureDisplayNameIsValid(displayName, source)(:22-29): fails when the name is null, empty, whitespace, or longer thanLeaderboardOptIn.DisplayNameMaxLength, returningError.Invariantwith code"LeaderboardOptIn.DisplayName.Invalid"and an interpolated message that reads the constant rather than hardcoding 100 (:26).
- Why it's built this way: the length rule is stated once as a constant on the aggregate and consumed in three places (this check, the EF column at
LeaderboardOptInConfiguration.cs:28-30, and the error message), so widening the column cannot silently disagree with the domain rule. Validating the name in the domain rather than only at the API boundary matters becauseLeaderboardOptIn.Reactivatealso writes it (LeaderboardOptIn.cs:89), and that path never passes through a request validator. - Where it's used: called by
LeaderboardOptIn.Create(LeaderboardOptIn.cs:64-66) and byReactivate(LeaderboardOptIn.cs:89).
PointsEntryInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntryInvariants.cs:10· Level 6 · class (static)
- What it is: the four-rule invariant helper for
PointsEntry. It is the widest invariant class on the points side of the module, because a ledger row has four things that can be wrong: the attendee, the activity, the amount, and the subject the award is scoped to. - Depends on:
CommonInvariants,ResultandError,PointsActivityTypeandPointsSubjectKeysfromMMCA.ADC.Engagement.Shared(PointsEntryInvariants.cs:1), and theUserIdentifierTypealias. Externals:System.Enum. - Concept: the static invariant class is taught on
LeaderboardOptInInvariants. What is worth teaching here is the boundary between a rule and a kill switch.[Rubric §4, Domain-Driven Design]: a zero-point award is rejected outright here (:26-29), because turning an earn rule off is the awarder's job, not the ledger's. A rule configured to 0 short-circuits insidePointsAwarderbefore any entity is built (PointsAwarder.cs:45-50), so a zero reaching this factory can only be a caller bug, which is exactly what the doc comment states (:19-22). - Walkthrough: four methods, same shape as the siblings.
EnsureUserIdIsValid(userId, source)(:16-17):CommonInvariants.EnsureIdIsNotDefault, code"PointsEntry.UserId.Invalid".EnsurePointsArePositive(points, source)(:26-29):points > 0orError.Invariant("PointsEntry.Points.Invalid", ...).EnsureSubjectKeyIsValid(subjectKey, source)(:38-45): rejects null, empty, whitespace, or longer thanPointsSubjectKeys.MaxLength(64,PointsSubjectKeys.cs:13), with code"PointsEntry.SubjectKey.Invalid". The comment (:31-33) explains why this is more than cosmetic: the key is part of the unique index, so a truncated or blank key would break idempotency rather than merely look wrong.EnsureActivityTypeIsDefined(activityType, source)(:51-54):Enum.IsDefined(activityType), code"PointsEntry.ActivityType.Invalid".[Rubric §15, Best Practices and Code Quality]:PointsActivityTypedeliberately starts at 1 and reserves 0 for "no activity" (PointsActivityType.cs:11-15), so this one check turns a defaulted field or an unset payload into a validation failure instead of a silently mis-attributed award.
- Why it's built this way: three of the four rules exist to protect the ledger's unique index on
(UserId, ActivityType, SubjectKey)(PointsEntryConfiguration,PointsEntryConfiguration.cs:46-48). Idempotency and anti-farming both rest on that index, and an index cannot defend itself against a blank or truncated component, so the domain does. - Where it's used: called by
PointsEntry.Create(PointsEntry.cs:83-87), combined throughResult.Combine. Unit-tested byPointsEntryInvariantsTests.
UserSessionBookmarkInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmarkInvariants.cs:9· Level 6 · class (static)
- What it is: the static invariant helper for
UserSessionBookmark. Two rules, both guarding that the aggregate's cross-module foreign keys are actually set before a bookmark can be constructed. - Depends on:
CommonInvariants(UserSessionBookmarkInvariants.cs:1),Result, and theUserIdentifierType/SessionIdentifierTypealiases (solution-wideglobal using, see primer §2). - Concept: the static invariant class is taught on
LeaderboardOptInInvariants. This is the minimal instance of it: no lengths, no enums, just the two identifiers.[Rubric §1, SOLID]: each method has exactly one reason to change. - Walkthrough: two one-line methods.
EnsureUserIdIsValid(userId, source)(:11-12): forwards toCommonInvariants.EnsureIdIsNotDefaultwith code"UserSessionBookmark.UserId.Invalid"and message"User ID must be provided.".EnsureSessionIdIsValid(sessionId, source)(:14-15): the same shape forSessionId, code"UserSessionBookmark.SessionId.Invalid".- Both take the
sourcestring the caller fills with its own method name, so a failure carries the originating call site.
- Why it's built this way: enforcing "a bookmark must reference a real user and a real session" in the domain, not only through a database
NOT NULL, means an invalid bookmark can never be materialized. BecauseSessionIdandUserIdpoint at rows in other services' databases (database-per-service, ADR-006), there is no cross-database foreign key to lean on, so the not-default check is the domain's own front line. - Where it's used: called by
UserSessionBookmark.Create(UserSessionBookmark.cs:44-46), combined throughResult.Combine. - Caveats / not-in-source: unlike its siblings in this unit, this class carries no XML doc comments on its members (
UserSessionBookmarkInvariants.cs:11-15); the intent has to be read off the error codes.
AttendeeBadgeInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Badges·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadgeInvariants.cs:9· Level 6 · class (static)
- What it is: the one invariant rule for
AttendeeBadge: a badge must be bound to a real user. - Depends on:
CommonInvariants(AttendeeBadgeInvariants.cs:16) andResult. - Concept: the static invariant class beside its aggregate is taught in group 02.
[Rubric §4, DDD]assesses whether invariants are stated where the model can enforce them: keeping them in a static class that the factory composes means each rule is individually named, individually testable, and reusable by any future mutator on the same aggregate. - Walkthrough: one method.
EnsureUserIdIsValid(userId, source)(:15-16) delegates toCommonInvariants.EnsureIdIsNotDefaultwith the stable error code"AttendeeBadge.UserId.Invalid", the message, the calling member name for attribution, andnameof(userId)as the target. Thesourceparameter is passed by its caller asnameof(Create)(AttendeeBadge.cs:42), which is what puts the failing member into the error rather than a stack trace. - Why it's built this way: a badge carries almost no state (an owner and an opaque credential,
AttendeeBadge.cs:21-24), and the credential is generated internally, so there is exactly one thing a caller can get wrong. The class is still written out rather than inlined into the factory so the badge follows the same shape as every other aggregate in the module, including its much larger siblingCheckInInvariants. - Where it's used:
AttendeeBadge.Create(AttendeeBadge.cs:42) is the only caller in the module.
PointsEntry
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:31· Level 7 · class (sealed)
- What it is: the aggregate root recording one points award, and the whole of the points ledger. An attendee's total is never a stored number: it is the sum of their entries.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>(bound toPointsEntryIdentifierType),IAuditedEntity,PointsEntryInvariants, thePointsEntryChangeddomain event,PointsActivityTypeandPointsSubjectKeys,DomainEntityState,IdValueGeneratedAttribute, andResult. - Concept introduced, the append-only ledger.
[Rubric §8, Data Architecture]assesses whether the schema states the intended semantics rather than relying on convention. This type has a factory and no mutators at all: every property has aprivate setand nothing inside the class ever writes one after construction (PointsEntry.cs:33-46). A total is therefore always derivable and never a number somebody edited (:11-13).[Rubric §4, Domain-Driven Design]: immutability is the model's statement that an award is a historical fact, not a mutable balance. - Concept introduced, the value snapshot.
Pointsstores the configured award as it stood at award time (:39-40), resolved by the caller before it reaches the factory (:72).[Rubric §15, Best Practices & Code Quality]and[Rubric §8, Data Architecture]: retuning the economy mid-conference changes what the next award is worth and never rewrites history (:14-17). The alternative (storing only the activity and multiplying by today's configured value at read time) would silently restate every past award every time an operator changed a setting. - Concept introduced, idempotency and anti-farming in one index.
[Rubric §12, Performance and Scalability]and[Rubric §11, Security]both apply. The unique index on(UserId, ActivityType, SubjectKey)(PointsEntryConfiguration,PointsEntryConfiguration.cs:46-48) is the real rule behind both properties (:18-22): a replayed award collides with the row it already wrote, and N questions asked in one session collapse onto one subject key (session:{id},PointsSubjectKeys.cs:24-25) so they award exactly once. Neither guarantee is implemented by counting in application code.PointsAwarderadds a pre-check for the ordinary duplicate (PointsAwarder.cs:56-63) and reads the index violation from a concurrent race as already-awarded rather than as a failure (PointsAwarder.cs:74-81, viaDuplicateKeyDetection). - Concept, the audit marker on a ledger.
[Rubric §13, Observability and Operability]assesses whether operationally load-bearing writes leave a trail. The class implementsIAuditedEntity(:31) for a stated reason (:23-28): the ledger decides a prize-bearing leaderboard, so the append-only rule is worth being able to prove rather than merely assert. With the trail (ADR-075), an insert that was never followed by an update is visible in the data, and the one write that does move a total (a soft-delete or erasure of an entry) is recorded with it. - Walkthrough
[IdValueGenerated](:30): database-generated id, so the factory writesId = default(:93).- Five
private setproperties:UserId(:34, a cross-database scalar),ActivityType(:37),Points(:40),SubjectKey(:43, defaulted tostring.Emptyso the EF constructor never leaves it null),OccurredOnUtc(:46, supplied by the caller'sTimeProviderrather than read from a clock here). - Two constructors: EF-only parameterless (
:49) and the private assigning one (:51-63). Create(userId, activityType, points, subjectKey, occurredOnUtc)(:76-104):Result.Combineover all four invariants (:83-87), failure re-wrapped asResult.Failure<PointsEntry>(:88-89), the entity built withId = default(:91-94), thenPointsEntryChanged(DomainEntityState.Added, id, userId, activityType, points)raised (:96-101). There is noUpdate, noAdjust, and noDeleteoverride: correcting an award means writing the compensating history, not editing the row.
- Why it's built this way: the contract is deliberately conference-agnostic. Neither this entity nor
IPointsAwardernames an event, a session, or a check-in: an award is a user, an activity, an opaque subject key, and a timestamp (IPointsAwarder.cs:10-17).[Rubric §7, Microservices Readiness]: that is what would make lifting the ledger into MMCA.Common a move rather than a rewrite, leaving only the thin award adapters behind in ADC. The scan surfaces that feed it are described in ADR-072. - Where it's used: written only by
PointsAwarder(PointsAwarder.cs:65, the single write path into the ledger); read byGetMyPointsHandler(GetMyPointsHandler.cs:54),GetPointsOverviewHandler(GetPointsOverviewHandler.cs:40) andGetLeaderboardHandler(GetLeaderboardHandler.cs:54), all projecting toPointsEntryDTOrather than returning the aggregate; exported byUserEngagementExportService; persisted perPointsEntryConfiguration, which adds aUserId-leading index so the "my points" read is a seek (PointsEntryConfiguration.cs:51-52). Unit-tested byPointsEntryTests.
UserSessionBookmark
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16· Level 7 · class (sealed)
- What it is: the aggregate root of the session-bookmark feature, one row per user's saved session (a personal-schedule entry). It holds two scalar foreign keys,
UserIdandSessionId, and nothing else: its whole behavior is a create/reactivate/delete lifecycle expressed through a single domain event. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(bound toUserSessionBookmarkIdentifierType),UserSessionBookmarkInvariants, theUserSessionBookmarkChangeddomain event,DomainEntityState,IdValueGeneratedAttribute, andResult. - Concept introduced, one domain event with a state enum (BR-60).
[Rubric §4, Domain-Driven Design]and[Rubric §6, CQRS and Event-Driven]assess whether aggregates own their invariants and announce state changes as events. This aggregate is the module's clearest example of the deliberate "one event, many states" choice: rather than separateBookmarkCreatedandBookmarkDeletedtypes, every lifecycle transition raises a singleUserSessionBookmarkChangedcarrying aDomainEntityStatediscriminator (AddedorDeleted), documented in the class remarks (UserSessionBookmark.cs:12-13). Downstream consumers subscribe to one signal and branch on the enum. That is the framework-wide taxonomy decision recorded in ADR-083;LeaderboardOptInfollows the same pattern. - Concept, the reactivation lifecycle (BR-135). Re-bookmarking a previously removed session must revive the soft-deleted row rather than insert a second one. The database half of that rule is
UserSessionBookmarkConfiguration's soft-delete-filtered unique index (UserSessionBookmarkConfiguration.cs:32-34, the convention behind it taught onSoftDeleteUniqueIndexConventionand decided in ADR-095); the decision half isBookmarkManagementDomainService. - Walkthrough
[IdValueGenerated](UserSessionBookmark.cs:15): marks the id as database-generated, so the factory leavesId = defaultand SQL Server'sIDENTITYfills it.UserId/SessionId(:19,:22):private setscalar FKs. They are not navigations: the referenced rows live in the Identity and Conference databases (database-per-service, ADR-006), so a navigation would cross a service boundary.- Two constructors, an EF-only parameterless one (
:25) and a private(userId, sessionId)one (:27-31). Neither is callable from outside: construction runs through the factory. Create(userId, sessionId)(:40-60): combines both invariants viaResult.Combine(:44-46); on failure re-wraps the errors asResult.Failure<UserSessionBookmark>(:47-48); on success builds the entity withId = default(:50-53) and raisesUserSessionBookmarkChanged(DomainEntityState.Added, ...)(:57). The comment above that call (:55-56) is the one subtle line in the file: the id is still 0 at this point because theIDENTITYvalue is assigned by theINSERT, and the event captures it by value, so consumers correlate on the user and the session rather than on the bookmark's own id.Reactivate()(:68-76): calls the inheritedUndelete()(:70) and, only if that succeeds, re-raises the sameAddedevent (:73). The row keeps its identity and audit trail. Note the contrast withLeaderboardOptIn.Reactivate, which takes a display name and refreshes it: a bookmark carries no snapshot to refresh, so this overload takes no arguments.Delete()(:83-91): overrides the base soft-delete, callsbase.Delete()first (:85) and, on success, raisesUserSessionBookmarkChanged(DomainEntityState.Deleted, ...)(:88).
- Why it's built this way: reactivation over delete-then-insert preserves referential continuity and the audit trail, consistent with the soft-delete-everywhere policy (ADR-005). Funnelling create and reactivate through the same
Addedevent means consumers see one uniform "this bookmark is now active" signal regardless of whether the row is new or revived. - Where it's used: created and reactivated by
BookmarkManagementDomainService; counted byBookmarkCountService; mapped byUserSessionBookmarkDTOMapper; persisted perUserSessionBookmarkConfiguration; the aggregate type parameter for the CRUD surface onBookmarksController. Unit-tested byUserSessionBookmarkTests.
AttendeeBadge
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Badges·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:18· Level 7 · class (sealed)
- What it is: the aggregate root holding an attendee's badge credential, the opaque value encoded into their QR badge and the only thing an organizer's scan carries.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>(the base,:18),AttendeeBadgeInvariants(:42),Result, andIdValueGeneratedAttribute(:17), which tells the persistence layer the database assigns the id. Externals:System.Guid. - Concept introduced, the opaque bearer credential.
[Rubric §11, Security]assesses how a credential is issued, verified and revoked. The design decision is written in the class comment (:7-11): the credential is server-verified on every scan, so it needs no signature and no expiry of its own, and revoking a leaked badge is aRegenerateaway. The most instructive line in the file is the private helper comment (:65-67):Guid.NewGuid()is chosen overGuid.CreateVersion7()precisely because a v7 value embeds its creation timestamp and orders monotonically, which is the property a guessable bearer value must not have. That is a case where the newer API is the wrong one, and the file says so rather than leaving the next reader to "modernize" it.[Rubric §4, DDD]covers the aggregate shape: private setters, a private EF constructor, a static factory returningResult<T>, and a named mutator instead of a public property setter. - Walkthrough
- Two properties:
UserId(:21), one badge per user, andCredential(:24), theGuidencoded into the QR. - The parameterless private constructor (
:27) exists for EF Core materialization; the private assigning constructor (:29-33) is what the factory uses. Create(userId)(:40) validates throughAttendeeBadgeInvariants(:42), returns the failure unchanged when it fails (:43-44), then builds the badge with a fresh credential andId = default(:46-49) so the store assigns the key. It raises no domain event, and the class comment (:12-15) explains that too: a badge existing says nothing a downstream module needs to react to, because the interesting fact is the check-in, whichCheckInpublishes.Regenerate()(:59-63) replaces the credential in place and returns success, invalidating every previously issued copy: a printout left on a table, a screenshot shared in a chat (:54-57).NewCredential()(:68) is the one place a credential is minted.
- Two properties:
- Why it's built this way: the badge is deliberately the thinnest possible aggregate, because everything expensive (who may scan, whether the event is running, whether this is a duplicate) belongs to the check-in write path rather than to the credential. Keeping the credential opaque and server-verified is the decision recorded in ADR-072, which also fixes the encoded form the scanner reads.
- Where it's used: minted on first use by
GetOrCreateMyBadgeHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:41) behindCheckInsController'smy-badgeendpoint; read back byCheckInAttendeeHandler, which first extracts the credential from the scanned payload throughBadgePayload(.../CheckInAttendee/CheckInAttendeeHandler.cs:43) and then resolves it to an attendee (:50). Persisted byAttendeeBadgeConfiguration(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:15), which puts a unique index on both the owner and the credential (:31,:35), and exposed as aDbSeton the module context (.../Persistence/DbContexts/ModuleApplicationDbContext.cs:30). It deliberately gets noINavigationPopulator(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:81-83) because it has no navigation. Covered byAttendeeBadgeTests. - Caveats / not-in-source:
Regenerate()has no production call site today. The only callers in the repository are the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Badges/AttendeeBadgeTests.cs:54,:67,:77): the revocation path exists on the model but no endpoint or handler invokes it.
BookmarkCountService
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/Services/BookmarkCountService.cs:11· Level 8 · class (sealed)
- What it is: the in-process implementation of the cross-module
IBookmarkCountService, answering "how many active bookmarks does this session have?" for the Conference module, one session at a time or a whole set at once. - Depends on:
IUnitOfWork,IQueryableExecutor,UserSessionBookmark,IBookmarkCountService. Externals: LINQ andSystem.Collections.Generic. - Concept, the cross-module read boundary.
[Rubric §7, Microservices Readiness]assesses whether modules talk through explicit, extractable contracts rather than direct references. Conference must show a per-session bookmark count but must not reference Engagement's domain; it depends only onIBookmarkCountService, which lives inMMCA.ADC.Engagement.Shared. In process this class satisfies that contract; once Engagement runs as its own service,BookmarkCountServiceGrpcAdaptersatisfies it over the wire and no Conference call site changes. This is one direction of the bidirectional Conference/Engagement pair (Engagement in turn calls Conference'sISessionBookmarkValidationService). - Concept introduced, the batch method that replaces a caller's fan-out.
[Rubric §12, Performance and Scalability]assesses whether read paths avoid N+1 round trips. The second method exists because the conference-day session list needs a count per session, and calling the single-session method in a loop would issue oneCOUNTper row.GetBookmarkCountsForSessionsAsyncpushes one groupedCOUNTfor the whole set (BookmarkCountService.cs:37-43) and then guarantees a complete result map, so the caller never has to distinguish "zero bookmarks" from "session missing from the response" (:47-50). - Walkthrough
- The primary constructor injects
IUnitOfWorkandIQueryableExecutor(:11). Note that no repository is constructor-injected: repositories are resolved per call off the unit of work, which is the framework's rule. GetBookmarkCountForSessionAsync(sessionId, cancellationToken)(:14-22): resolves the typed repository viaunitOfWork.GetRepository<UserSessionBookmark, UserSessionBookmarkIdentifierType>()(:18) and returnsbookmarkRepo.CountAsync(b => b.SessionId == sessionId, cancellationToken)(:19-21). The count is aCOUNTpushed to the database with no rows materialized, and the soft-delete global query filter means only active bookmarks count.GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken)(:25-51): guards null withArgumentNullException.ThrowIfNull(:29), short-circuits an empty request to an empty dictionary without touching the database (:30-33), then takes the read repository (:35) and composesTableNoTracking.Where(...).GroupBy(...).Select(...)(:39-42) handed toqueryableExecutor.ToListAsync(:38-43). The grouped rows become a dictionary (:45), and the final projection walks the requested idsDistinct()and fills a 0 for any session the group-by returned nothing for (:48-50).- The
IQueryableExecutorindirection is what keeps this Application-layer class free of a direct EF Core dependency while still materializing an EF query: the provider-specificToListAsynclives behind the abstraction.[Rubric §3, Clean Architecture].
- The primary constructor injects
- Why it's built this way: exposing purpose-built count methods (rather than letting Conference query bookmarks) keeps the read cheap and the coupling to two stable signatures, which is what makes the gRPC swap a thin adapter. The batch method is paired with a schema decision:
UserSessionBookmarkConfigurationadds aSessionId-leading index precisely because counting bySessionIdalone cannot seek the(UserId, SessionId)composite (UserSessionBookmarkConfiguration.cs:36-39). - Where it's used: registered
TryAddScopedby the Engagement Application DI (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:46); wrapped for the wire byBookmarkCountsGrpcService; consumed by Conference'sGetSessionBookmarkCountHandlerandGetSessionBookmarkCountsHandler. When Engagement is disabled in a host,DisabledBookmarkCountServicestands in until the gRPC adapter replaces the registration.
IBookmarkManagementDomainService
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/IBookmarkManagementDomainService.cs:11· Level 8 · interface
- What it is: a pure domain-service contract for the create-or-reactivate lifecycle of a session bookmark (BR-135). Given a possibly null previously soft-deleted bookmark plus the acting user and session, it returns the active bookmark, either the reactivated old row or a brand-new one (
IBookmarkManagementDomainService.cs:5-10). - Depends on:
Result,UserSessionBookmark, and theUserIdentifierType/SessionIdentifierTypealiases (solution-wideglobal using, see primer §2). No EF, no repository, noCancellationToken. - Concept introduced, the domain service.
[Rubric §4, Domain-Driven Design]assesses whether business logic lives in the model rather than leaking into handlers or infrastructure. A domain service captures a rule that does not sit naturally on one entity or value object but is still pure domain (no I/O). Here the rule "if a soft-deleted bookmark already exists for this user and session, revive it instead of inserting a second row" spans a persistence-shaped concern (a hidden row exists) yet is expressed entirely over domain entities the application layer has already fetched. Keeping it behind an interface makes it injectable and trivially unit-testable.[Rubric §1, SOLID]: the single method has one reason to change, the reactivate-versus-create decision. - Walkthrough: one method,
CreateOrReactivate(existingDeletedBookmark?, userId, sessionId)returningResult<UserSessionBookmark>(:21-24). The nullable first parameter is the whole design:nullmeans the application layer found no prior soft-deleted record, non-null means it found one (fetched withignoreQueryFilters: trueso the soft-delete filter does not hide it,CreateBookmarkHandler.cs:50-56). Everything the service needs is passed in, so it touches no repository and returns synchronously. - Why it's built this way: pushing the branch into the domain keeps the application handler thin (the handler does the query, the service makes the decision) and keeps the decision testable without a database. The service is deliberately infrastructure-free so it stays inside the Domain layer without violating the dependency rule (see primer §1).
[Rubric §14, Testability]. - Where it's used: implemented by
BookmarkManagementDomainService, registeredTryAddSingleton(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:38), and injected byCreateBookmarkHandler(CreateBookmarkHandler.cs:20).
UserSessionBookmarkDTOMapper
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.UserSessionBookmarks.DTOs·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DTOs/UserSessionBookmarkDTOMapper.cs:12· Level 8 · class (sealed partial)
- What it is: the Mapperly-generated mapper that projects a
UserSessionBookmarkaggregate to its wire-facingUserSessionBookmarkDTO. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>(closed over the bookmark triple,UserSessionBookmarkDTOMapper.cs:13),UserSessionBookmark,UserSessionBookmarkDTO. Externals:Riok.Mapperly.Abstractions(the[Mapper]source generator). - Concept, compile-time DTO mapping with Mapperly (ADR-001).
[Rubric §2, Design Patterns]and[Rubric §15, Best Practices and Code Quality]assess mapping that is explicit and allocation-cheap rather than reflection based. The[Mapper]attribute (:11) makes Mapperly generate the body of thepartialmethod at compile time, so there is no runtime reflection and a shape mismatch is a build error rather than a silent null (the framework-wide manual-mapping versus Mapperly rationale is taught in Group 12). - Walkthrough (
:12-24): the class implements the sharedIEntityDTOMappercontract.MapToDTO(entity)(:16) is declaredpartialand Mapperly writes the property-by-property copy.MapToDTOs(collection)(:19-23) is hand-written: it guards null withArgumentNullException.ThrowIfNulland returns[.. entityCollection.Select(MapToDTO)], a collection-expression materialization. - Why it's built this way: a source-generated single-item map plus a tiny hand-written collection wrapper keeps the hot path reflection-free while still satisfying the batch signature the query pipeline expects.
sealed partialis mandatory:partiallets the generator supply the method body,sealedkeeps the type closed. - Where it's used: auto-registered by the module's convention scan and injected directly by
CreateBookmarkHandler(CreateBookmarkHandler.cs:21, used at:90) andGetUserBookmarksHandler(GetUserBookmarksHandler.cs:21, used at:72); also resolved by the genericEntityQueryService<TEntity, TEntityDTO, TIdentifierType>registered for bookmarks (DependencyInjection.cs:42) behindBookmarksController. Unit-tested byUserSessionBookmarkDTOMapperTests.
BookmarkManagementDomainService
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/BookmarkManagementDomainService.cs:9· Level 9 · class (sealed)
- What it is: the single implementation of
IBookmarkManagementDomainService. Asealed, dependency-free class that decides between reactivating a soft-deleted bookmark and creating a fresh one. - Depends on:
IBookmarkManagementDomainService,UserSessionBookmark,Result. No injected collaborators: the constructor is implicit (BookmarkManagementDomainService.cs:9). - Concept, soft-delete meets a filtered unique index.
[Rubric §8, Data Architecture]assesses deliberate schema semantics.UserSessionBookmarkConfigurationdeclares a unique index on(UserId, SessionId)filtered to the soft-delete flag (UserSessionBookmarkConfiguration.cs:32-34), so a second active row for the same pair is impossible, but a soft-deleted row still occupies that pair's history. That is exactly why you cannot blindlyCreateon a re-bookmark: you must flip the existing row back to active. This service is the domain half of that dance and the index is the database half, applied automatically bySoftDeleteUniqueIndexConvention(ADR-095). The same pairing appears one aggregate over onLeaderboardOptIn, where the reactivate branch lives in the handler instead. - Walkthrough (
:13-28)- If
existingDeletedBookmark is not null(:18): callexistingDeletedBookmark.Reactivate()(:20), which on the aggregate calls the inheritedUndelete()and re-raisesUserSessionBookmarkChanged(DomainEntityState.Added, ...)(UserSessionBookmark.cs:68-76). If reactivation fails, its errors are propagated asResult.Failure<UserSessionBookmark>(reactivateResult.Errors)(:21-22); otherwise the revived entity is returned viaResult.Success(...)(:24). - If
null: delegate to the factoryUserSessionBookmark.Create(userId, sessionId)(:27), which validates invariants and raises the sameAddedevent (UserSessionBookmark.cs:40-60).
- If
- Why it's built this way: reactivation (not delete-then-insert) preserves the row's identity, its audit trail (
CreatedOn/By), and any scalar references that point at it, consistent with the soft-delete-everywhere policy (ADR-005). Both branches funnel through the sameAddeddomain event so downstream consumers see one uniform "bookmark is now active" signal (BR-60, a singleUserSessionBookmarkChangedcarryingDomainEntityStaterather than separate Created/Changed events, ADR-083). Being state-free is what lets it be registered as a singleton (DependencyInjection.cs:38). - Where it's used:
CreateBookmarkHandlercalls it after querying for a soft-deleted match (CreateBookmarkHandler.cs:59), and only adds the returned entity to the repository when there was no prior row to revive (CreateBookmarkHandler.cs:64-67). A concurrent insert that gets past the pre-check surfaces as the unique-index violation the handler translates back into the same conflict error viaDuplicateKeyDetection(CreateBookmarkHandler.cs:73-85).
CheckIn
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:28· Level 10 · class (sealed)
- What it is: the aggregate root recording that an attendee was checked in, by an organizer scanning their QR badge, through the manual fallback, or by the attendee themselves scanning a printed sponsor or room QR. One aggregate carries all three scopes.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>andIAuditedEntity(both on:28),CheckInInvariants(:99-103),CheckInScope(:34),CheckInScopeNames(:114),AttendeeCheckedIn(:112),ResultandIdValueGeneratedAttribute(:27). Externals:DateTimeOffset. - Concept introduced, one aggregate for a family of shapes.
[Rubric §4, Domain-Driven Design]assesses aggregate boundaries. The tempting alternative is three aggregates (session check-in, sponsor visit, room check-in), and the class comment (:10-15) argues against it from the behavior: the row, the idempotency rule and the attendance query are the same shape for each, only the required target differs, and self-recorded rows stay distinguishable byCheckedInByUserId. The cost of that choice is that "which target is legal for which scope" becomes an invariant instead of a type, which is exactly whatCheckInInvariants.EnsureTargetMatchesScopeexists for. - Concept, an integration event raised from inside the aggregate.
[Rubric §6, CQRS and Event-Driven]assesses where an announcement is produced.AddDomainEventis called inside the factory (:112-119), not by a handler, and the payload isAttendeeCheckedIn, which derives fromBaseIntegrationEventrather than from a plain domain event. Because the aggregate collects it beforeSaveChangesAsyncruns, the outbox captures the announcement in the same transaction as the row (ADR-003): the check-in and its cross-module event either both land or neither does. - Concept, the audit marker on an attendance assertion.
[Rubric §30, Compliance, Privacy and Data Governance]covers theIAuditedEntitymarker, whose reason is written out (:21-25): a check-in is an attendance assertion about a named person that feeds the points economy, so a disputed or revoked row needs a record of what it looked like before (ADR-075). This is the same markerPointsEntrycarries, for the same reason on the other side of the earn path. - Walkthrough (teaching order)
- Seven private-set properties:
UserId(:31),Scope(:34),EventId(:37, always set), the nullableSessionId(:40) andSponsorId(:43),CheckedInByUserId(:49) andCheckedInOn(:52). The two nullable targets plus the scope are the polymorphic part; everything else is present on every row. - The parameterless private constructor (
:55) is EF's; the assigning private constructor (:57-73) is the factory's. Create(:89-122) takes the scope explicitly and the sponsor id last with a default (:96), which the doc comment (:87) justifies: the scan and manual paths can never carry one, so they stay unchanged as sponsor visits were added.- Validation is one
Result.Combineof five invariants (:98-103), so a caller gets every violated rule at once rather than the first; a failure returns the errors unchanged (:104-105). - Construction sets
Id = default(:107-110) so the store assigns the key, matching the[IdValueGenerated]attribute on the class. AddDomainEvent(new AttendeeCheckedIn(...))(:112-119) projects the aggregate onto the wire contract, converting the enum scope to its stable string withCheckInScopeNames.ToName(:114) and passing the nullable session and sponsor ids straight through.- There is no mutator: a check-in is a fact, so the aggregate is create-only.
- Seven private-set properties:
- Why it's built this way: the factory doc comment (
:75-80) states the guarantee the whole points path leans on: because the event is added before the save, a persisted check-in has always published exactly one event, and because the handler's duplicate short-circuit never reaches this method, a repeat scan publishes none. The second scoping fact is in the class comment (:16-20): the conference runs door and arrival check-in through TicketLeap, so the Event scope is not a door process and session check-in is the working path (ADR-072). - Where it's used: created through
CheckInProcessoron behalf ofCheckInAttendeeHandlerandManualCheckInHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:70, after the duplicate short-circuit at:59-68), and directly byRecordSponsorVisitHandler(.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:97, scopeSponsor) andRecordRoomCheckInHandler(.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:99, scopeSessionwith the attendee as their own recorder); read byGetAttendanceStatsHandler(.../CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:25-35) and exported byUserEngagementExportService(.../Exports/UserEngagementExportService.cs:51-59). Persisted byCheckInConfiguration, which turns the scope rule into three filtered unique indexes (one event check-in per attendee per event, one per session, one per sponsor:MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:48-62) plus two non-unique indexes for the attendance rollup (:64-69). Its event feedsAttendeeCheckedInPointsHandler. Covered byCheckInTests. - Caveats / not-in-source: the duplicate-scan short-circuit the factory comment relies on lives in the use-case handlers and in
CheckInProcessor, not in this file. The once-per-sponsor cap that makes a shared deep link worth nothing beyond the first scan is stated in the EF configuration comment (CheckInConfiguration.cs:58-59).
AttendeeBadgeConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:15· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
AttendeeBadgeaggregate: two required columns and two unique indexes, one per identity the badge is looked up by (the attendee, and the scannable credential). - Depends on: the engine shim base
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>andAttendeeBadge. Externals: EF Core'sEntityTypeBuilder<T>. - Concept introduced, the module entity configuration over the engine shim.
[Rubric §8, Data Architecture]assesses whether the schema is designed for the queries and constraints the application actually needs rather than inherited from conventions;[Rubric §3, Clean Architecture]assesses whether persistence concerns stay out of the domain. Every Engagement configuration in this file group follows one shape: extendEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, callbase.Configure(builder)first (AttendeeBadgeConfiguration.cs:21), then declare only what is specific to this entity. The base applies the framework conventions once (table and schema resolution, the soft-delete global query filter, audit columns, the concurrency token); the full hierarchy is taught in Group 07. The engine is the choice of base class, so re-pointing an entity at the Cosmos or SQLite shim is a base-class swap with no body edits (ADR-018); every Engagement entity uses the SQL Server shim today. The domain entity itself carries no EF attributes, which is what keepsMMCA.ADC.Engagement.Domainfree of a persistence dependency. - Concept introduced, unique index as a race backstop.
[Rubric §12, Performance & Scalability]and[Rubric §11, Security]. Both indexes here are unique and unfiltered in the source:HasIndex(b => b.UserId).IsUnique()(:31-32) andHasIndex(b => b.Credential).IsUnique()(:35-36). The first is the concurrency guarantee the get-or-create path leans on (the inline comment at:29-30says so), the second makes a scan a single seek on aGuidcolumn (AttendeeBadge.Credential,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:24) and forbids two badges sharing a credential. Note what is NOT written here: neither index declaresHasSoftDeleteFilter(), becauseSoftDeleteUniqueIndexConventionalready stampsIsDeleted = 0onto every unique index of a soft-deletable entity at model finalizing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:60-70) and leaves an index that already carries the predicate untouched (:73-76). - Walkthrough (
AttendeeBadgeConfiguration.cs:19-37):base.Configure(builder)(:21);UserIdrequired (:23-24);Credentialrequired (:26-27); the uniqueUserIdindex (:31-32); the uniqueCredentialindex (:35-36). That is the whole file: five statements, each with a comment naming the behavior it protects. - Why it's built this way: the remarks (
:11-14) state the cross-module rule the whole module obeys,UserIdis a scalar reference into the Identity database and never a cross-database FK, because Engagement cannot see the Identity model (database-per-service, ADR-006).[Rubric §7, Microservices Readiness]. - Where it's used: discovered by assembly scanning and applied when the Engagement
SQLServerDbContextbuilds its model; the uniqueUserIdindex is whatGetOrCreateMyBadgeHandlerclassifies throughIUniqueConstraintViolationDetectorwhen two requests race (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:54).
LeaderboardOptInConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/LeaderboardOptInConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for
LeaderboardOptIn, the row that says an attendee agreed to appear on the public board and under which name. Same base shape asAttendeeBadgeConfiguration; its distinctive job is "one active opt-in per attendee". - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,LeaderboardOptIn, andIndexBuilderExtensionsforHasSoftDeleteFilter(). Externals: EF Core'sEntityTypeBuilder<T>. - Concept introduced,
HasSoftDeleteFilter()instead of a SQL literal.[Rubric §8, Data Architecture]and[Rubric §15, Best Practices & Code Quality]. The unique index is declared.IsUnique().HasSoftDeleteFilter()(LeaderboardOptInConfiguration.cs:33-35). That extension member (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:50-64) builds the predicate from the model rather than from a hand-typed"[IsDeleted] = 0", so the column name follows a rename and the quoting comes from the engine; it takes anengineparameter defaulting toDataSource.SQLServerand an optionaladditionalFilterthat is ANDed in front of the soft-delete predicate (IndexBuilderExtensions.cs:61-63). On a unique index the call is belt-and-braces (the convention would have added the same predicate), and it is idempotent in effect because the convention skips any index that already declares the predicate; on a non-unique index it is the only way to get the filter at all. - Walkthrough (
LeaderboardOptInConfiguration.cs:21-36):base.Configure(builder)(:23);UserIdrequired (:25-26);DisplayNamerequired withHasMaxLength(LeaderboardOptIn.DisplayNameMaxLength)(:28-30), the cap being the domain constant100(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:22), so the column width and the invariant inLeaderboardOptInInvariantscan never disagree; then the filtered unique index onUserId(:33-35). - Why it's built this way: the remarks (
:11-16) give both halves of the design. Filtering the unique index on the soft-delete flag is what makes rejoining reactivate the existing row instead of accumulating one dead row per toggle (the pathSetLeaderboardParticipationHandlerimplements). StoringDisplayNamehere at all is deliberate denormalization: the name is the snapshot the attendee published, so the leaderboard query never has to reach into Identity, which is a cross-database read this topology does not allow.[Rubric §7, Microservices Readiness]. - Where it's used: applied to the Engagement model by assembly scanning; the snapshot column is read by
GetLeaderboardHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:41), and the unique index is the constraintSetLeaderboardParticipationHandlerboth navigates and catches (SetLeaderboardParticipationHandler.cs:104-110).
LivePollVoteConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollVoteConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
LivePollVoteaggregate root, whose centerpiece is the filtered unique index guaranteeing one active vote per user per poll (BR-225). - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(the shared base that maps audit fields, soft-delete andRowVersion),LivePollVote, andIndexBuilderExtensionsforHasSoftDeleteFilter(). Externals: EF Core'sEntityTypeBuilder<T>. - Concept introduced, the filtered unique index as the backstop for a soft-delete aggregate.
[Rubric §8, Data Architecture]assesses whether uniqueness and concurrency are enforced at the storage layer rather than hoped for in application code. Because a vote toggles via soft-delete rather than hard-delete, a naive unique index would permanently block a user from re-voting: the deleted row keeps occupying its unique slot. Scoping the index to active rows is what makes the handler's create-or-reactivate dance race-safe (the remark atLivePollVoteConfiguration.cs:12-16says exactly that). Note how the scoping is expressed:.HasSoftDeleteFilter()(:37), the framework extension member atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:50-64, rather than a hand-typedHasFilter("[IsDeleted] = 0"). The extension builds the predicate from the model, so a renamed soft-delete column or a different engine follows automatically. On a unique index the call is belt and braces:SoftDeleteUniqueIndexConventionalready stamps the same predicate onto every unfiltered unique index of a soft-deletable entity at model finalizing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:56,:60-70) and recognizes the predicate it finds rather than doubling it (:74-75). Writing it explicitly keeps BR-225 legible in the file that owns it.[Rubric §2, Design Patterns]applies in that the database constraint and the domainReactivatemethod are two halves of one idempotent-write pattern; the identical shape guardsSessionQuestionUpvotethroughSessionQuestionUpvoteConfiguration. - Walkthrough (
LivePollVoteConfiguration.cs:21-41):Configure(:21) callsbase.Configure(:23), then requiresLivePollId(:25-26),OptionId(:28-29) andUserId(:31-32); note there are no navigations, because a vote is a separate aggregate by design and so carries scalar FKs only. The filtered unique index on{ LivePollId, UserId }(:35-37) is BR-225, one active vote per poll and user, with the soft-delete predicate applied throughHasSoftDeleteFilter()and the rule named in the comment above it (:34). The second, non-unique index on{ LivePollId, OptionId }(:40) supports the groupedCOUNTper option that the tally issues (:39). - Why it's built this way: the filter is what lets soft-delete and uniqueness coexist, routing it through the shared extension keeps every filtered index in the codebase producing byte-identical SQL, and the second index matches the tally query shape exactly, so results are computed from an index rather than by scanning the vote table.
- Where it's used: applied by the Engagement
SQLServerDbContextat model build;CastVoteHandlerrelies on the unique index as the final arbiter when two concurrent votes race, andLivePollResultsBuilderis the reader the second index serves. Covered byEngagementEntityConfigurationTests(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).
PointsEntryConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.Points·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:21· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for
PointsEntry, one row per awarded activity. Its composite unique index is not an optimization: it is the enforcement point for two separate rules at once. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,PointsEntry,PointsSubjectKeys(for the key length), andIndexBuilderExtensions. Externals: EF Core'sEntityTypeBuilder<T>. - Concept introduced, one index carrying two business rules.
[Rubric §8, Data Architecture]and[Rubric §6, CQRS & Event-Driven]. The unique index on(UserId, ActivityType, SubjectKey)(PointsEntryConfiguration.cs:46-48) is simultaneously the idempotency key for an at-least-once award (a replayed integration event collides with the row it already wrote) and the anti-farming rule (N questions or N answers for one session collapse onto one subject key, so they award once). The remarks (:12-20) spell out the two-layer interplay:PointsAwarderpre-checks with anExistsAsyncon exactly those three columns so the ordinary duplicate is a clean no-op (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:55-64), and a concurrent race that gets past the pre-check surfaces here as a provider exception the awarder classifies as already-awarded rather than as a failure (PointsAwarder.cs:75). The database is the arbiter of last resort, not the first line. - Walkthrough (
PointsEntryConfiguration.cs:25-53):base.Configure(builder)(:27);UserId(:29-30),ActivityType(:32-33) andPoints(:35-36) required;SubjectKeyrequired withHasMaxLength(PointsSubjectKeys.MaxLength)(:38-40), that is64characters (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:14), a bounded key being what makes it indexable at all;OccurredOnUtcrequired (:42-43); then the filtered unique triple (:46-48) and a second, non-uniqueUserIdindex also carryingHasSoftDeleteFilter()(:51-52) so the "my points" ledger read is a seek. That second index is the case the convention does not cover: it is not unique, so without the explicit call it would carry no filter. - Why it's built this way: pushing idempotency into a unique constraint means correctness does not depend on the broker delivering exactly once, which it does not.
[Rubric §29, Resilience & Business Continuity]. As everywhere in this module,UserIdstays a scalar column, never a cross-database FK (:18-19). - Where it's used: applied by assembly scanning; the index is the contract
PointsAwarderis written against, and the ledger it protects is read byGetLeaderboardHandlerand the my-points queries, and exported byUserEngagementExportService(UserEngagementExportService.cs:36-46).
SessionQuestionConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestions/SessionQuestionConfiguration.cs:16· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
SessionQuestionaggregate root (the audience-questions half of the live layer): scalar FK columns, a length constraint, and one query-shaped composite index. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionQuestion, andSessionQuestionInvariantsfor the shared length constant. Externals: EF Core'sEntityTypeBuilder<T>. - Walkthrough (
SessionQuestionConfiguration.cs:20-39):base.Configure(builder)(:22);SessionIdrequired (:24-25);UserIdrequired (:27-28);Textrequired withHasMaxLength(SessionQuestionInvariants.TextMaxLength)(:30-32), a constant that itself forwards to the DTO'sTextMaxLength(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:13), that is500characters (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDTO.cs:18), so the column width, the domain validation and the client-side hint share one source of truth;Statusrequired (:34-35); thenHasIndex(p => new { p.SessionId, p.Status })(:38) because both the attendee list and the moderation queue filter by session and status. This index is deliberately neither unique nor soft-delete-filtered: the question feed has no uniqueness rule to enforce, and the read that uses it is already narrowed by session. - Why it's built this way, cross-module FKs stay scalar: the remarks (
:10-15) state it plainly.SessionId/EventIdpoint at Conference-owned rows in another database andUserIdat an Identity-owned row, so they stay indexed scalar columns; consistency flows through the Conference gRPC validation boundary, never a cross-database constraint.[Rubric §7, Microservices Readiness],[Rubric §8, Data Architecture]. SeeAttendeeBadgeConfigurationfor the shared base-class story. - Where it's used: applied to the Engagement model by assembly scanning; the questions it maps are the second section of the data-subject export assembled by
UserEngagementExportService(UserEngagementExportService.cs:30-34).
SessionQuestionUpvoteConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestions/SessionQuestionUpvoteConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for
SessionQuestionUpvote. Same shape asSessionQuestionConfiguration, specialized to enforce one active upvote per question per user. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionQuestionUpvote,IndexBuilderExtensions. Externals: EF Core'sEntityTypeBuilder<T>. - Walkthrough (
SessionQuestionUpvoteConfiguration.cs:21-38):base.Configure(builder)(:23);SessionQuestionIdrequired (:25-26);UserIdrequired (:28-29); the filtered unique index on(SessionQuestionId, UserId)(:32-34), which is BR-235 as a database guarantee (named in the summary at:10and again at the index,:31), at most one active upvote per (question, user), while soft-deleted history may hold prior toggles; and a second, non-uniqueSessionQuestionIdindex (:37) for counting upvotes grouped by question. Note the asymmetry withPointsEntryConfiguration: that second index carries noHasSoftDeleteFilter(), so it indexes deleted rows too. - Why it's built this way: the filtered unique index is the database-level backstop behind the handler's create-or-reactivate logic (remarks,
:12-16), the same soft-delete-plus-unique-index interplayUserSessionBookmarkConfigurationsets up for bookmarks.[Rubric §8, Data Architecture]. - Caveats / not-in-source: the remarks (
:13-14) note upvotes reference their question by a scalar FK column with no navigations, they are a separate aggregate by design, so the index is the only structural link.
UserSessionBookmarkConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/UserSessionBookmarks/UserSessionBookmarkConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for
UserSessionBookmark, the module's founding aggregate. Same base shape as the siblings above; it enforces BR-21 (one active bookmark per user per session) and adds the index the conference-day count path depends on. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,UserSessionBookmark,IndexBuilderExtensions. Externals: EF Core'sEntityTypeBuilder<T>. - Concept, indexing for the hot read, not just for the constraint.
[Rubric §12, Performance & Scalability]. The second index (UserSessionBookmarkConfiguration.cs:38-39) exists becauseBookmarkCountServicecounts bySessionIdalone, and a leading-UserIdcomposite cannot seek on that predicate; the comment (:36-37) says exactly this. It is non-unique, soHasSoftDeleteFilter()is required rather than optional: the convention only stamps unique indexes (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:62-63), and the count query carries the soft-delete predicate, so an unfiltered index would not match it. - Walkthrough (
UserSessionBookmarkConfiguration.cs:21-40):base.Configure(builder)(:23);UserIdrequired (:25-26);SessionIdrequired (:28-29); the filtered unique index on(UserId, SessionId)(:32-34); the filteredSessionIdindex (:38-39). - Why it's built this way: filtering the unique index on
IsDeleted = 0lets a user remove and re-add the same bookmark any number of times while guaranteeing only one is active, which is precisely why the bookmark write path reactivates rather than inserts.[Rubric §8, Data Architecture]. - Caveats / not-in-source: the remarks (
:12-16) note the FK relationship toConference.Sessionis configured at the shared DbContext level (where both entity types are visible) rather than here, to avoid a cross-module Infrastructure-to-Domain dependency that would break module isolation. In the extracted topology those two entities live in different databases anyway, whereCrossDataSourceDegradeConventiondrops the constraint.
BookmarkCountServiceGrpcAdapter
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14· Level 9 · class (internal sealed)
- What it is: a hand-written client-side adapter that implements the C# interface
IBookmarkCountServiceon top of the proto-generated gRPC client. It is what Conference resolves forIBookmarkCountServicenow that Engagement runs as its own process. - Depends on:
IBookmarkCountServicefromMMCA.ADC.Engagement.Shared.UserSessionBookmarks(:2); the generatedBookmarkCountService.BookmarkCountServiceClientand the request/response messages frombookmark_count.proto(namespaceMMCA.ADC.Engagement.Contracts.V1,:1). Externals: the Grpc.Net client factory that supplies the injected client. - Concept, the gRPC adapter (same interface, different transport).
[Rubric §7, Microservices Readiness]assesses whether modules talk through explicit, extractable contracts;[Rubric §9, API & Contract Design]assesses stable wire contracts. The in-processBookmarkCountServiceand this remote adapter implement the same interface, so a consumer swaps transport by swapping one DI registration and never edits a call site (ADR-007, ADR-008). Watch the name overlap: the generated proto service type is also calledBookmarkCountService(in...Contracts.V1), and this adapter's primary constructor takes its nestedBookmarkCountServiceClient(:14-15); that generated type is distinct from the Application-layer class of the same name. - Concept, the per-call deadline as a second, tighter budget.
[Rubric §29, Resilience & Business Continuity].CallDeadlineis a staticTimeSpan.FromSeconds(5)(:20) applied to both calls (:32,:48), and the comment (:17-19) explains the reasoning: the shared resilience pipeline's 30s attempt / 90s total budget answers a refused peer well, but a hung peer would otherwise stall a caller's request, and bookmark counts sit inline in speaker-dashboard responses. A deadline turns "hung" into a fastDeadlineExceededstatus the caller's Polly pipeline can act on. - Walkthrough
GetBookmarkCountForSessionAsync(sessionId, cancellationToken)(:23-36): builds aGetBookmarkCountForSessionRequest { SessionId = sessionId }(:28-31), passesdeadline: DateTime.UtcNow.Add(CallDeadline)plus the caller's token (:32-33), and returnsresponse.Count(:35). The interface returns a plainint, not aResult<int>, so there is no failure trailer to parse: a transport fault surfaces as anRpcException.GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken)(:39-52): the batch form. It fills the repeatedSessionIdsfield withAddRange(:43-44), sends the same deadline (:48), and rebuilds a dictionary from the repeated response pairs withresponse.Counts.ToDictionary(c => c.SessionId, c => c.Count)(:51). This is the N+1 avoidance for a speaker dashboard listing many sessions: one round trip instead of one per session.
- Where it's used: registered by
DependencyInjection.AddEngagementBookmarkCountClient(), which the Conference service calls atMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350. Its server-side twin isBookmarkCountsGrpcService. - Caveats / not-in-source: the dictionary rebuild assumes the server never returns duplicate
SessionIdpairs;ToDictionarywould throw on a duplicate key. Nothing in this file enforces that, it rests on the server projecting from a dictionary in the first place (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:55-59).
BookmarkCountsGrpcService
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:24· Level 9 · class (public sealed)
- What it is: the server-side gRPC endpoint that publishes Engagement's in-process
IBookmarkCountServiceover the wire. It is the counterpart ofBookmarkCountServiceGrpcAdapter: the adapter runs inside Conference and speaks gRPC out, this service runs inside Engagement and answers. - Depends on:
IBookmarkCountService(injected via primary constructor,:24), and the generated proto baseBookmarkCountService.BookmarkCountServiceBaseplus the four messages frombookmark_count.proto(namespaceMMCA.ADC.Engagement.Contracts.V1,:2). Externals:Grpc.Core(ServerCallContext). - Concept, the server-side bridge.
[Rubric §7, Microservices Readiness],[Rubric §9, API & Contract Design]. This is the producer half of the pattern whose consumer half is taught in Group 13: the module keeps one C# interface, the proto generates an abstract server base, and this thin subclass forwards one to the other. Because the interface did not change when Engagement moved out of the monolith, neither Conference's call sites nor Engagement'sBookmarkCountServicehad to change; only this bridge and its client twin were added. It mirrors, on the Engagement side, Conference'sSessionBookmarksGrpcService, completing the bidirectional Conference-Engagement gRPC pair. - Concept, a documented unauthenticated trust boundary.
[Rubric §11, Security]assesses whether every exposed surface has a stated authorization posture. This endpoint is mapped without.RequireAuthorization()(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:334, with the reasoning restated at the call site,:326-332), and the class doc (:11-22) argues why the requirement is not addable rather than merely absent: the Conference callers serve[AllowAnonymous]output-cached REST endpoints (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:441and:460), so a cache-miss request from an anonymous visitor carries no bearer forJwtForwardingClientInterceptorto forward, and the public speaker pages would break. The exposure is bounded by the payload (aggregate counts per session id, no PII and no user ids on the wire) and by the network boundary: the Engagement container app's ingress isexternal: false(MMCA.ADC/infra/main.bicep:1351), so the endpoint stays on the internal service network and the Gateway never routes it. ContrastUserEngagementExportGrpcService, which is mapped.RequireAuthorization()(Program.cs:342) because it carries personal data. - Walkthrough
GetBookmarkCountForSession(request, context)(:28-40): null-guards both arguments withArgumentNullException.ThrowIfNull(:32-33), awaitsinner.GetBookmarkCountForSessionAsync(request.SessionId, context.CancellationToken)(:35-37), threading the gRPC call's own cancellation token (and therefore the client's deadline) through to the database, then wraps theintin a response message (:39).GetBookmarkCountsForSessions(request, context)(:43-61): the batch rpc. It materializes the repeated field with a collection expression,[.. request.SessionIds](:51), calls the interface's batch method, then projects the returned dictionary into repeatedSessionBookmarkCountmessages viaAddRange(... Select(...))(:55-59).
- Why it's built this way: keeping the wire endpoint a paper-thin forwarder means the real read logic (a
COUNTunder the soft-delete filter, over theSessionIdindexUserSessionBookmarkConfigurationdeclares) lives once and is reused identically in-process and over gRPC. The endpoint is published throughAddGrpcServiceDefaults()(Program.cs:298), which also installs theGrpcResultExceptionInterceptorand gRPC reflection; for these methods the interceptor has nothing to translate, since the interface returns bare values rather than aResult. - Where it's used: mapped at
MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:334, served over the service'sHttp2-only cleartext (h2c) endpoint (ADR-012). Its remote caller is Conference. - Caveats / not-in-source: the class doc's own citations have drifted. It names
SpeakersController.cs:386and:405(:15-16) andinfra/main.bicep:1289-1295(:20), while the current anonymous bookmark-count actions are atSpeakersController.cs:441and:460and the Engagement internal ingress is atinfra/main.bicep:1351. The argument still holds; only the line numbers are stale.
LivePollConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollConfiguration.cs:16· Level 9 · class (internal sealed)
- What it is: the EF Core mapping for the
LivePollaggregate root: column requirements, the question length limit, the two query indexes, and the access mode EF uses to materialize the encapsulated options collection. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(the shared base that maps audit fields, soft-delete andRowVersion),LivePoll, andLivePollInvariantsforQuestionMaxLength. Externals: EF Core'sEntityTypeBuilder<T>andPropertyAccessMode. - Concept introduced, the no-cross-database foreign key rule. The base class itself is taught in Group 07; what this file makes concrete is the remark at
LivePollConfiguration.cs:11-15:EventIdandSessionIdpoint at Conference-owned rows in a different database, so they stay plain indexed scalar columns and consistency flows through the Conference gRPC validation boundary, never through an FK constraint.[Rubric §7, Microservices Readiness]assesses schema independence between services, which is exactly what the scalar-reference choice buys.[Rubric §8, Data Architecture]assesses the persistence contract (nullability, lengths, indexes) and the database-per-service discipline (ADR-006); all of it is visible in a dozen lines here, and it is the same ruleAttendeeBadgeConfigurationstates forUserId. - Concept introduced, pinning EF's access mode for an encapsulated collection.
[Rubric §4, DDD]assesses whether the aggregate keeps its collections closed to outside mutation.LivePollexposesOptionsas a getter over a privateListreturned throughAsReadOnly(), which means EF must read and write the backing field, never the property, or materialization silently produces nothing. EF's convention already infers field access for this shape;:49-50states it anyway withbuilder.Navigation(p => p.Options).UsePropertyAccessMode(PropertyAccessMode.Field). The comment at:43-48gives the reason: stating it makes the mapping independent of that inference, so a later change to the property (a different projection, a computed wrapper) cannot silently turn materialization into a no-op.[Rubric §15, Best Practices & Code Quality]assesses exactly this kind of fail-loudly-later choice. It is an access mode only, no schema change: the relationship itself stays configured from the child side inLivePollOptionConfiguration. - Walkthrough (
LivePollConfiguration.cs:20-51):Configure(:20) callsbase.Configure(builder)first (:22) so the common conventions land before any override;EventIdrequired (:24-25);Questionrequired withHasMaxLength(LivePollInvariants.QuestionMaxLength)(:27-29), sourcing the length from the domain constant so schema and invariant can never disagree;Statusrequired (:31-32). ThenHasIndex(p => p.EventId)(:35), non-unique, because the Happening Now page and the organizer manage view both query polls by event (:34); andHasIndex(p => new { p.SessionId, p.Status })(:41), the composite added forGetOpenPollsHandler. Its comment (:37-40) is the performance record: that query filters on(SessionId, Status), runs once per attendee per session and again on every structural poll event, and onlyEventIdwas indexed, so it scanned. It deliberately mirrorsSessionQuestionConfiguration, which already indexes the same pair.[Rubric §12, Performance & Scalability]assesses whether indexes match the real query shapes of the hot path. The file closes with theNavigation(...).UsePropertyAccessMode(PropertyAccessMode.Field)call described above (:49-50). - Why it's built this way: one length constant sourced from the domain, scalar cross-service references per ADR-006, indexes derived from the two live-layer read shapes rather than guessed, and an explicitly stated access mode so the aggregate's encapsulation and EF's materialization cannot drift apart.
- Where it's used: discovered and applied at model-build time by the Engagement
SQLServerDbContextthrough EF Core's configuration scanning; it isinternal, so nothing else can reach it. Covered byEngagementEntityConfigurationTests(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).
LivePollOptionConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollOptionConfiguration.cs:10· Level 9 · class (internal sealed)
- What it is: the EF Core mapping for the
LivePollOptionchild entity: its text limit and its real relationship back toLivePoll. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,LivePollOption, andLivePollInvariantsforOptionTextMaxLength. Externals: EF Core'sEntityTypeBuilder<T>. - Concept reinforced, the in-aggregate child. Read this directly against
LivePollConfiguration: there, a cross-service reference stays a bare scalar; here, both ends are Engagement-owned and in the same database, so the option gets a genuine navigation and a genuine foreign key (LivePollOptionConfiguration.cs:22-25).[Rubric §8, Data Architecture]: the contrast is the lesson, an FK is correct precisely when the constraint can be enforced by one database. - Walkthrough (
LivePollOptionConfiguration.cs:14-26):Configure(:14) callsbase.Configure(:16), then makesTextrequired withHasMaxLength(LivePollInvariants.OptionTextMaxLength)(:18-20);HasOne(o => o.LivePoll).WithMany(p => p.Options).HasForeignKey(o => o.LivePollId).IsRequired()(:22-25) is the required one-poll-to-many-options relationship inside the aggregate boundary, configured from the child side, which is whyLivePollConfigurationonly has to state the collection's access mode. - Why it's built this way: an option has no meaning without its poll, so the database is allowed to say so; the length again comes from the domain constant rather than a repeated literal.
- Where it's used: applied by the Engagement
SQLServerDbContextat model build; the collection it maps is rehydrated on query paths byLivePollNavigationPopulatorand eager-loaded on the delete path byDeleteLivePollHandler. Covered byEngagementEntityConfigurationTests(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).
SetLeaderboardParticipationHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Points.UseCases.SetLeaderboardParticipation·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:31· Level 9 · class (public sealed partial)
- What it is: the command handler behind "put me on the leaderboard" and "take me off". One boolean in, a
Resultout, with all the interesting work in how it resolves the published name and how it reconciles with a soft-deleted prior opt-in. - Depends on:
IUnitOfWork,ICurrentUserServiceand itsCurrentUserServiceExtensions.RequireUserIdmember (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35),IUniqueConstraintViolationDetector,LeaderboardOptIn,SetLeaderboardParticipationRequest, and the framework contractsICommandHandler<in TCommand, TResult>,ResultandError. Externals:System.Security.Claims,Microsoft.Extensions.Loggingand its[LoggerMessage]source generator (which is why the class ispartial,:196-197). - Concept, server-side identity for a public surface.
[Rubric §11, Security]assesses whether trust decisions read from the token rather than the request body.SetLeaderboardParticipationRequestcarries a singlebool Participateand nothing else; the display name comes from the caller's claims (:64,:154-185). The class doc (:14-19) states the threat directly: a name in the body would let any caller publish a name they do not own, on a surface whose whole purpose is showing names to other people. - Concept, create-or-reactivate against a filtered unique index.
[Rubric §8, Data Architecture],[Rubric §4, DDD]. Because the unique index declared inLeaderboardOptInConfigurationis filtered on the soft-delete flag, a soft-deleted opt-in still occupies theUserIdslot for a filtered read but not for the constraint.JoinAsynctherefore reads throughFindIncludingDeletedAsync, which returns the active and the deleted matches as a tuple (:71-74), and the comment (:68-70) states the consequence of not doing so: every rejoin would insert a fresh row and collide with the index. State transitions stay in the aggregate (deleted.Reactivate(displayName)at:86,LeaderboardOptIn.Create(...)at:92), each returning aResultthe handler propagates. - Concept, classifying the lost insert race through an injected detector.
[Rubric §1, SOLID],[Rubric §29, Resilience & Business Continuity]. The save is wrapped in atrywhose exception filter iswhen (uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception))(:103). The detector is an injected abstraction (IUniqueConstraintViolationDetector) rather than an inline message match, so the provider-specific knowledge of what a duplicate-key failure looks like lives once in the infrastructure layer (SqlServerUniqueConstraintViolationDetector) and this handler stays provider-agnostic.PointsAwarderleans on the same collaborator (PointsAwarder.cs:75). - Walkthrough
HandleAsync(command, cancellationToken)(:37-53): null-guards the command (:41), callscurrentUserService.RequireUserId("Points.Forbidden")and returns its failure when there is no caller (:43-45), resolves the typed repository (:48), then dispatches oncommand.ParticipatetoJoinAsyncorLeaveAsync(:50-52).JoinAsync(:59-112): resolve the name first and bail on failure (:64-66); read both the active and the soft-deleted opt-ins for the user, tracked (:71-74); if any is active, return success without writing (:78-79), because re-publishing the name on every call would let a name change ride in on a no-op request (:76-77); otherwise reactivate the soft-deleted row (:84-89) or create the first one (:90-97); then save inside the detector-filteredtry(:99-109), which converts a lost insert race into the same success the already-on-board path returns, logging atDebug(:108). The comment (:105-107) frames it as intent satisfaction: being on the board is what was asked for.LeaveAsync(:118-136): note the parameter type,IEntityQuerier<TEntity, TIdentifierType>rather than the fullIRepository<TEntity, TIdentifierType>(:119), because this branch only reads and then mutates the aggregate it already holds. A filtered, tracked read (:123-126), success without writing when there is nothing active (:127-128), otherwiseactive.Delete()(:130) and save (:134). Soft-delete, not erasure, which is what leaves the row available for the next reactivate.ResolveDisplayName(user)(:154-185): triesClaimTypes.Name, then the rawname, thenunique_name(:156-158), because inbound claim mapping is not uniform across the hosts that serve this module (:142-145). An empty result is anError.Validation("Points.DisplayNameUnavailable", ...)(:163-168) rather than a placeholder, since "Attendee" or an email fragment would be meaningless or would leak something the attendee never chose to publish (:146-150). Over-long names are cut atLeaderboardOptIn.DisplayNameMaxLength(:170-184), backing off one index when the cut would split a surrogate pair (:179-182); the comment (:175-177) names the visible symptom it prevents, aU+FFFDin the public leaderboard broadcast.FindClaimValue(user, claimType)(:193-194): a null-tolerant one-liner overClaimsPrincipal.
- Why it's built this way: every branch that finds the world already in the requested state returns
Result.Success()without writing, so the command is idempotent from the client's point of view and safe to retry.[Rubric §6, CQRS & Event-Driven]. Note what the handler does not do: it carries no transactional marker, so the CQRS decorator pipeline opens no transaction around it, which is consistent with each branch performing at most one save. - Where it's used: injected into
PointsControllerasICommandHandler<SetLeaderboardParticipationRequest, Result>(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:39, action at:103-104); reached from the UI throughPointsService.SetLeaderboardParticipationAsync(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:59) off theMyPointspage (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/MyPoints.razor.cs:117). Covered bySetLeaderboardParticipationHandlerTests. - Caveats / not-in-source: the display-name cap is applied by truncation, not rejection, so a caller with a very long token name silently publishes a shortened one; nothing surfaces that to the user. Whether the resulting name is unique on the board is not enforced anywhere: the unique index is on
UserId, not onDisplayName, so two attendees with the same token name appear identically.
CheckInConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.CheckIns·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:19· Level 11 · class (internal sealed)
- What it is: the EF Core mapping for
CheckIn, the one aggregate that carries all three check-in scopes (event arrival, session attendance, sponsor booth visit). It is the most constraint-dense configuration in the module: three scope-partitioned unique indexes plus two read indexes. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,CheckIn,CheckInScope(by value, not by reference, see below), andIndexBuilderExtensions. Externals: EF Core'sEntityTypeBuilder<T>. - Concept introduced, partitioning one table by a discriminator through filtered indexes.
[Rubric §8, Data Architecture],[Rubric §4, DDD]. One aggregate covers every scope because the row shape, the idempotency rule and the attendance query are identical and only the required target differs (CheckInScopedocuments this, andCheckInInvariantsenforces it). The cost is that a single unique index cannot express "one per attendee per event, and separately one per attendee per session": the three uniqueness rules must not collide with each other. Each index therefore carries its own scope predicate throughHasSoftDeleteFilter(additionalFilter: ...)(CheckInConfiguration.cs:51,:56,:62), and the extension ANDs that predicate in front of the soft-delete one (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:61-63). The three scope literals are hoisted intoprivate const stringfields (:24-26) with a comment explaining why they are literals at all (:22-23): an index filter is SQL, so it cannot read the C# enum. That is also why the enum's numeric values are documented as load-bearing and never renumbered. - Concept, the database as the concurrency backstop behind an application-level check.
[Rubric §29, Resilience & Business Continuity],[Rubric §6, CQRS & Event-Driven]. The remarks (:11-18) describe the two layers together:CheckInProcessormakes a repeat scan a no-op by looking for the existing row first (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:71-75), and these indexes make a concurrent double scan aDbUpdateExceptioninstead of two rows, so the broker retry that follows lands on the no-op path. - Walkthrough (
CheckInConfiguration.cs:29-70):base.Configure(builder)(:31); five required properties,UserId(:33-34),Scope(:36-37),EventId(:39-40),CheckedInByUserId(:42-43),CheckedInOn(:45-46), note thatSessionIdandSponsorIdare deliberately not required, since each is meaningful only in its own scope; then the three filtered unique indexes,(UserId, EventId)under the event scope (:49-51),(UserId, SessionId)under the session scope (:54-56), and(UserId, SponsorId)under the sponsor scope (:60-62), the last being the once-per-sponsor cap that makes a shared deep link worth nothing beyond the first scan (:58-59); finally two non-unique, soft-delete-filtered read indexes onEventId(:65-66) andSessionId(:68-69), because attendance stats read by event and group by session (:64). - Why it's built this way: sponsor visits are self-recorded by the attendee from a printed QR, so the cap has to be structural rather than procedural. Keeping the identifiers scalar (
:16-17) is the same database-per-service rule the rest of the module follows (ADR-006). - Where it's used: applied to the Engagement model by assembly scanning. Its indexes are the contract every check-in writer is written against: all four handlers route through
CheckInProcessor, the organizer pathsCheckInAttendeeHandler(CheckInAttendeeHandler.cs:60) andManualCheckInHandler(ManualCheckInHandler.cs:40) throughExecuteAsync(CheckInProcessor.cs:109), and the self-service pathsRecordRoomCheckInHandler(RecordRoomCheckInHandler.cs:74) andRecordSponsorVisitHandler(RecordSponsorVisitHandler.cs:72) throughRecordAsync(CheckInProcessor.cs:58). The rows it maps are exported byUserEngagementExportServiceand travel to Identity throughUserEngagementExportGrpcService.
UserEngagementExportGrpcService
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:23· Level 12 · class (public sealed)
- What it is: the server-side gRPC endpoint that publishes Engagement's in-process
IUserEngagementExportServiceto the Identity service, so Identity can fold a user's Engagement-owned personal data into the aggregated data-subject export document. Same server-bridge shape asBookmarkCountsGrpcService, with the extra concern that timestamps and enums have to survive a proto3 wire crossing. - Depends on:
IUserEngagementExportService(primary constructor,:23) and its DTOs fromMMCA.ADC.Engagement.Shared.Exports(:4); the generated baseUserEngagementExportService.UserEngagementExportServiceBaseand the messages fromuser_engagement_export.proto(namespaceMMCA.ADC.Engagement.Contracts.V1,:3). Externals:Grpc.Core(ServerCallContext),System.Globalization(:1). - Concept, cross-service data-subject export as a purpose-built read contract.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a real data-subject-access and portability path exists;[Rubric §7, Microservices Readiness]assesses talking through explicit contracts. Identity owns the export document but not Engagement's data, so it depends only on the narrow interface; this class is the producer half over the wire, twin to the client-sideUserEngagementExportServiceGrpcAdapter. The class doc (:9-15) enumerates what Engagement contributes: session bookmarks, submitted session questions, the points ledger, leaderboard participation, and check-in history. Unlike the bookmark-count endpoint, this one is mapped.RequireAuthorization()(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:342, with the reasoning at:338-340), which is the posture difference personal data justifies.[Rubric §11, Security]. - Concept, proto3 has no nulls and no
DateTime.[Rubric §9, API & Contract Design]. Three encoding decisions are made here and mirrored exactly on the client:- Timestamps: SQL Server hands back
DateTimeKind.Unspecifiedvalues, and the round-trip "O" format omits theZmarker for that kind, so each value is stampedDateTime.SpecifyKind(..., DateTimeKind.Utc)before formatting (:49,:55,:64). The doc comment (:16-21) is careful about what this is: the stored values are already UTC, so this restores a marker rather than converting an instant. The oneDateTimeOffseton the contract,CheckedInOn, already carries its offset and is formatted as-is (:78-80). - Absent values: an absent leaderboard name travels as the empty string (
:42-44) and an absentSessionId/SponsorIdas0(:73-76), each with the comment naming the client-side inverse. - Enums:
ActivityType(:59-61) andScope(:68-70) cross as their numeric values, justified in place, the points activity numbers are part of the idempotency key and the scope numbers are named literally in the SQL index filters (CheckInConfiguration,CheckInConfiguration.cs:24-26), so neither is ever renumbered.
- Timestamps: SQL Server hands back
- Walkthrough: one overridden method,
GetUserEngagementExport(request, context)(:27-84). It null-guards both arguments (:31-32), awaitsinner.GetUserEngagementExportAsync(request.UserId, context.CancellationToken)(:34-36), then builds the response: the two scalar leaderboard fields in the initializer (:38-45), then fourAddRange(... Select(...))projections filling the repeated fields, bookmarks (:46-50), submitted questions (:51-56), points entries (:57-65) and check-ins (:66-81). - Why it's built this way: keeping the endpoint a thin forwarder leaves the real work (server-side projections that the soft-delete query filter already narrows to active rows) in
UserEngagementExportService, reused identically in-process and over gRPC. Published viaAddGrpcServiceDefaults()(Program.cs:298); because the read returns a DTO rather than aResult, theGrpcResultExceptionInterceptorhas no failure trailer to translate and a transport fault becomes a gRPC status the caller's resilience pipeline handles. - Where it's used: mapped at
MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:342. Its remote caller is Identity'sExportUserDataHandlerviaUserEngagementExportServiceGrpcAdapter. Covered byUserEngagementExportGrpcServiceTests.
UserEngagementExportServiceGrpcAdapter
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:18· Level 12 · class (internal sealed)
- What it is: the client-side gRPC adapter for the data-subject export, the remote twin of the in-process
UserEngagementExportService. It implementsIUserEngagementExportServiceon top of the generated export client and is what Identity resolves. - Depends on:
IUserEngagementExportServiceand its DTOs fromMMCA.ADC.Engagement.Shared.Exports, plusCheckInScopeandPointsActivityTypefrom the module's Shared project (:3-5); the generatedUserEngagementExportService.UserEngagementExportServiceClientand its messages (:2). Externals:System.Globalization(:1), the Grpc.Net client factory. - Concept, the same-interface transport swap plus wire rehydration.
[Rubric §7, Microservices Readiness],[Rubric §9, API & Contract Design]. This is the export sibling ofBookmarkCountServiceGrpcAdapter: the same interface as the in-process implementation, so consumers swap one DI registration (ADR-007). It also owns the inverse of every encoding decision the server made, and each inverse is written next to the decision it undoes. - Concept, parsing defensively across a rolling deploy.
[Rubric §29, Resilience & Business Continuity]. The comment onParseRoundtripUtc(:84-90) is the most instructive block in the file. It parses withDateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal(:91-95) so that a peer replica still emitting the marker-less form (new Identity code running against an old Engagement replica during a rolling deploy) does not yieldKind=Unspecified, andAssumeUniversalalone would yieldKind=Local. It also records whyRoundtripKindis deliberately absent:DateTime.Parserejects it alongside eitherAssume*orAdjustToUniversalwith anArgumentException, and its purpose (preserving a non-UTC kind) is the opposite of what this contract wants.ParseRoundtripOffset(:100-104) keeps the offset the server emitted rather than folding it into UTC, withAssumeUniversalcovering an offset-less value from an old peer (:97-99). - Walkthrough
CallDeadline(:24), a 5-second static, applied at:36. The comment (:21-23) gives the same reasoning as the bookmark adapter with an export-specific twist: the aggregation is best-effort per section, so a hung peer should degrade its section quickly rather than stall the export request.GetUserEngagementExportAsync(userId, cancellationToken)(:27-82): sends aGetUserEngagementExportRequest { UserId = userId }with the deadline and the caller's token (:31-37), then rebuildsUserEngagementExportDTOfrom the four repeated fields with collection expressions: bookmarks (:41-45), submitted questions (:46-51), points entries (:52-60, casting the numericActivityTypeback to its enum at:56), and check-ins (:61-72, castingScopeback at:65, mapping a0SessionId/SponsorIdback tonullat:69-70, and parsingCheckedInOnas aDateTimeOffsetat:71). FinallyIsOnLeaderboard(:73) and the empty-string-to-null mapping forLeaderboardDisplayName(:78-80), whose comment (:75-77) notes the same mapping also covers an old peer that never sets the field at all.
- Why it's built this way: the class doc (
:9-17) states the resilience posture, transport failures propagate to the caller (Identity'sExportUserDataHandler), which degrades the Engagement section toAvailable = falserather than failing the whole export. Best-effort cross-service aggregation: a peer outage never sinks an entire data-subject export.[Rubric §30, Compliance, Privacy & Data Governance]. - Where it's used: registered by
DependencyInjection.AddEngagementUserExportClient(), called fromMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:290.
DependencyInjection
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16· Level 13 · class (public static)
What it is: the Engagement
*.ContractsDI facade (one of severalDependencyInjectionclasses in this module; this is the extraction-wiring one). Two C#extension(IServiceCollection)members a consuming host calls to swap Engagement's in-process cross-module registrations for their gRPC-backed adapters: bookmark counts for Conference, the data-subject export for Identity.Depends on: the generated
BookmarkCountServiceClient/UserEngagementExportServiceClient,BookmarkCountServiceGrpcAdapter,UserEngagementExportServiceGrpcAdapter,IBookmarkCountService,IUserEngagementExportService, andAddTypedGrpcClient<T>fromMMCA.Common.Grpc(:6, defined atMMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:87). Externals:Microsoft.Extensions.DependencyInjectionand itsServiceCollectionDescriptorExtensions.Replace(:2).Concept, the adapter-swap wiring lives in the contracts package.
[Rubric §7, Microservices Readiness],[Rubric §17, DevOps & Deployment],[Rubric §15, Best Practices & Code Quality]. ADR-007 requires each extracted service to publish a*.Contractsproject holding the proto definitions and the DI wiring, so a consumer depends on that thin package rather than on the full module. The C#extension(IServiceCollection services)block (:18) adds the methods directly onto the collection (see primer §4).Concept,
Replacerather thanTryAdd.[Rubric §2, Design Patterns]. Both methods do exactly two things: register a typed client, then replace one interface binding.Method File:Line What it swaps AddEngagementBookmarkCountClient(serviceName = "engagement")DependencyInjection.cs:43-52registers BookmarkCountServiceClient(:45), thenservices.Replace(ServiceDescriptor.Scoped<IBookmarkCountService, BookmarkCountServiceGrpcAdapter>())(:49)AddEngagementUserExportClient(serviceName = "engagement")DependencyInjection.cs:76-85registers UserEngagementExportServiceClient(:78), thenservices.Replace(ServiceDescriptor.Scoped<IUserEngagementExportService, UserEngagementExportServiceGrpcAdapter>())(:82)At the moment of the call the container already holds some binding for the interface: either the real in-process implementation (when the Engagement module is enabled in that host) or the disabled stub (
DisabledBookmarkCountService/DisabledUserEngagementExportService, registered byEngagementModule.RegisterDisabledStubs,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:30-34).TryAddwould silently lose to it;Replaceguarantees the gRPC adapter wins either way, which the inline comments state at:47-48and:80-81.Walkthrough:
AddTypedGrpcClient<T>is what makes each method two lines instead of twenty, it wires the client to Aspire service discovery (http://{serviceName}), theJwtForwardingClientInterceptorand the Polly resilience handler, all fromMMCA.Common.Grpc(documented at:20-25and:54-59). Both methods returnIServiceCollectionfor chaining (:51,:84).Why it's built this way: the default
serviceName = "engagement"matches the AppHost resource name, so the common call site is argument-free.[Rubric §33, Developer Experience]. The XML docs on both methods (:35-39,:67-72) spell out the one ordering requirement: call them afterModuleLoader.DiscoverAndRegister(...), so the in-process or stub registration is already in the container forReplaceto find. That is an ordering constraint the compiler cannot enforce, which is why it is written twice.Where it's used:
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350callsAddEngagementBookmarkCountClient();MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:290callsAddEngagementUserExportClient(). The AppHost documents both edges of the resulting topology, including why the Conference-to-Engagement reverse edge gets no reciprocalWaitFor(MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:271-273, against the Engagement-to-Conference edge at:270), and the Identity-to-Engagement edge at:291.Caveats / not-in-source: neither method sets a per-call deadline; the 5-second budgets live in the two adapters themselves (
BookmarkCountServiceGrpcAdapter.cs:20,UserEngagementExportServiceGrpcAdapter.cs:24), so a host that registered a different implementation of the same interfaces would not inherit them.
LiveEventListener
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Components·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Components/LiveEventListener.razor.cs:27· Level 10 · class (public partial component)
- What it is: the module's invisible layout component. It renders no markup; on the first render of an authenticated circuit it resolves the current event and, while that event is inside its live window, joins the event-wide live channel so a poll opening reaches the attendee on whatever page they happen to be on. The
.razorhalf is only a comment wrapped inCascadingAuthenticationState/AuthorizeView/Authorized(LiveEventListener.razor:4-10); all the behavior lives in the code-behind, which does its own authentication check rather than relying on that wrapper. - Depends on:
ILiveEventUIServiceand theLiveEventContextit returns,NotificationHubService(injected as the concrete sealed class, there is no interface in front of it),IToastServiceandToastSeverity,LivePollChannelandLivePollOpenedPayloadfromMMCA.ADC.Engagement.Shared,IBatteryStatusServiceandIAccessibilityAnnouncerfromMMCA.Common.UI.Services.Capabilities, andEngagementRoutePaths. Externals: Blazor'sNavigationManagerand the cascadingTask<AuthenticationState>,IStringLocalizer<LiveEventListener>(injected in the markup,LiveEventListener.razor:2),System.Text.Json, and the[LoggerMessage]source generator (:188-189). - Concept, the ambient listener that must never throw.
[Rubric §29, Resilience & Business Continuity]assesses whether a non-essential capability degrades instead of taking the user's session down with it. This component is contributed as a layout component and so is live on every page, and noErrorBoundarysits above it, which the class remarks state directly (:18-25): an exception escaping its render lifecycle tears down the Blazor circuit and forces a full page reload wherever the user happens to be. The first-render pass is consequently guarded end to end:OperationCanceledExceptionis swallowed as the expected disposal signal (:89-92), and everything else is logged and absorbed behind an inlineCA1031suppression whose justification is written into the pragma itself (:93). The remarks also name the motivating failure mode: the API client's shared resilience pipeline surfaces Polly'sTimeoutRejectedException/BrokenCircuitExceptionduring a backend brownout, and the service layer's deliberately narrowHttpRequestExceptioncatch does not cover those. - Concept, two concurrent holders of one channel.
[Rubric §19, State Management]assesses how a shared per-circuit resource is owned when more than one component holds it at once. This listener joinsLivePollChannel.ForEvent(liveEvent.EventId)(:70,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-25), and the Happening Now page joins the identical key for the same event. Membership therefore cannot be a set: it is reference-counted per key insideNotificationHubService(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:18-21,:186-189,:217-219), so navigating away from Happening Now releases one join and leaves this listener still receiving events onevent:{id}. - Concept, capability-aware live UX.
[Rubric §21, Accessibility]and[Rubric §27, Internationalization]. Every user-visible string is a resource key resolved through the injected localizer (LiveEventListener.razor:2, ADR-027), and each poll-opened toast is mirrored to the platform's assistive channel throughIAccessibilityAnnouncer(:160) so a screen-reader user gets the same event a sighted user sees. - Walkthrough
PayloadJsonOptions(:28): a staticJsonSerializerOptions.Web, so payloads are read with the same web (camelCase) conventions the publisher serialized them with.- Seven
[Inject]properties (:30-36): the live-event service, the hub service, the toast service,NavigationManager, the battery-status and accessibility-announcer capabilities, and the typed logger. The[CascadingParameter] Task<AuthenticationState>? AuthState(:38-39) is nullable because the component can be rendered outside aCascadingAuthenticationState. _subscriptionand_channelKey(:41-42): the only mutable state, and exactly what disposal needs.OnAfterRenderAsync(firstRender)(:44-101) is the whole setup path, written as a sequence of early exits: not the first render (:46-49), no cascading auth state (:51-54), an unauthenticated principal (:59-62), and no current event or an event outside its live window perliveEvent.IsLiveAt(DateTime.UtcNow)(:64-68, andMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventContext.cs:22-23for the half-open window it tests). Only past those does it compute_channelKey(:70).- The energy-saver branch (
:75-85): whenBattery.IsEnergySaverOn, the auto-join is skipped and anInfotoast offers a "join anyway" action whose callback isJoinChannelGuardedAsync(:79-83), then the method returns without joining. The inline comment (:72-74) is explicit that only the lossy live channel is deferred and the core notification connection is untouched (ADR-042 Wave 3). Otherwise it simply awaitsJoinChannelAsync()(:87). JoinChannelGuardedAsync()(:107-123): the same two catches again, because the toast action fires outside the render callback that registered it and inherits none of that callback's guarding (:103-106,:77-78).IToastService.ShowActiondocuments the same rule from the other side: exceptions are the caller's to handle (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:96-101).JoinChannelAsync()(:125-134): an idempotence guard first (_channelKey is null || _subscription is not null,:127-130) so a toast-triggered join after an auto-join cannot double-subscribe, thenHubService.OnChannelEvent(_channelKey, HandleChannelEventAsync)(:132) followed byHubService.JoinChannelAsync(_channelKey)(:133). Both calls are required and in that order: subscribing does not join the server-side group, and joining does not register a handler.HandleChannelEventAsync(eventName, payloadJson)(:136-174): filters toLivePollChannel.PollOpened(the literalpoll.opened,LivePollChannel.cs:14) with an ordinal comparison (:138-141), deserializes aLivePollOpenedPayloadand returns silently on aJsonExceptionor a null result (:143-156), announces the poll with the question text (:160), then marshals back to the renderer withInvokeAsyncto raise the toast (:164-173). That call passesToastSeverity.InfoandrequireInteraction: true(:172-173), so the toast is pinned open rather than expiring on the host timer, and it carries an action that navigates toEngagementRoutePaths.HappeningNow, that is/happening-now(:169,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:11).DisposeAsync()(:177-186): disposes the subscription handle, then leaves the channel when_channelKeywas set (:179-183), and callsGC.SuppressFinalize(this)(:185). The two steps are separate because the hub service tracks handlers and reference-counted membership independently.LogLiveChannelSetupFailed(:188-189): a source-generatedError-level log whose message states the degraded outcome ("the live channel stays dormant for this session"). The catch-all is deliberately silent in the UI, since this component renders nothing and has no place to show a message (:97-98).[Rubric §13, Observability].
- Why it's built this way: the live layer is best-effort by design, so every failure on this path degrades to "no live channel" rather than surfacing to the user, and the authoritative data is always one page load away. The energy-saver deferral and the screen-reader announcement are the two device-capability touch points, both reached through interfaces whose web fallbacks are no-ops, so the same component runs unchanged on Blazor Server, WebAssembly, and the MAUI hybrid head.
- Where it's used: contributed by
EngagementUIModuleas its singleLayoutComponentTypesentry (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:31) and rendered by the sharedMainLayouton every page; regression-tested byLiveEventListenerResilienceTests(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Components/LiveEventListenerResilienceTests.cs). - Caveats / not-in-source: the battery state is read once, on first render (
:75), and the component never subscribes to an energy-saver-changed notification, so turning energy saver off later does not auto-join; the toast action is the only way in for that session. The leave call inDisposeAsynccarries no try/catch of its own, it relies on the hub service handling its own failures. The localizerLis declared only in the markup (LiveEventListener.razor:2), so it is invisible in the code-behind despite being used throughout it.
CheckInDTOMapper
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.CheckIns.DTOs·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/DTOs/CheckInDTOMapper.cs:12· Level 11 · class (public sealed partial)
- What it is: the Mapperly-generated mapper from the
CheckInentity toCheckInDTO, following the module-wide{Entity}DTOMapperconvention. - Depends on:
CheckIn,CheckInDTO, and the framework contractIEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. Externals:Riok.Mapperly.Abstractions([Mapper],CheckInDTOMapper.cs:11). - Concept, source-generated mapping over reflection.
[Rubric §12, Performance & Scalability],[Rubric §15, Best Practices & Code Quality]. The[Mapper]attribute plus apartialmethod declaration (:16) is the whole single-item mapping: Mapperly generates the property-by-property assignment at compile time, so there is no reflection at runtime and a property whose name or type stops matching becomes a build error rather than a silent null. This is the same pattern taught for the rest of the module's mappers (ADR-001). - Walkthrough (
CheckInDTOMapper.cs:12-23): the class declarespartial CheckInDTO MapToDTO(CheckIn entity)(:16), leaving the body to the generator, and hand-writes the collection overloadMapToDTOs(:19-23) as a null-guarded[.. entityCollection.Select(MapToDTO)]. The collection form is written by hand because the interface fixes its signature (IReadOnlyCollection<T>in and out) and the guard belongs in code the analyzers can see. - Why it's built this way: implementing the framework interface is what makes the mapper discoverable,
ScanModuleApplicationServices<TAssemblyMarker>()registers everyIEntityDTOMapperimplementation as scoped when the module registers (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), so no per-mapper DI line exists anywhere.[Rubric §15, Best Practices & Code Quality]. - Where it's used: nothing in the ADC source injects this mapper or names
CheckInDTOtoday: the check-in write paths returnCheckInResultDTOand the read paths return purpose-shaped rollup and export DTOs. The type exists to complete the entity-DTO-mapper triple for the aggregate and is registered by the convention scan regardless. - Caveats / not-in-source:
CheckInDTOexposes noSponsorIdmember (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInDTO.cs:8-30) although the entity has one (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:43), so a sponsor-scoped check-in mapped through here would lose its target. Nothing observes that today, given the absence of call sites.
UserEngagementExportService
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:17· Level 11 · class (internal sealed)
- What it is: the in-process implementation of
IUserEngagementExportService. It gathers everything the Engagement module holds about one user (bookmarks, submitted questions, points ledger, check-in history, leaderboard participation) into a singleUserEngagementExportDTOfor the cross-service data-subject export (PRIVACY.md §7). - Depends on:
IUnitOfWorkand the repositories it hands out; the aggregatesUserSessionBookmark,SessionQuestion,PointsEntry,CheckInandLeaderboardOptIn; the contractIUserEngagementExportServiceplus its DTO family fromMMCA.ADC.Engagement.Shared.Exports(UserEngagementExportDTO,UserEngagementBookmarkExportDTO,UserEngagementSubmittedQuestionExportDTO,UserEngagementPointsEntryExportDTO,UserEngagementCheckInExportDTO). - Concept, the data-subject export as five server-side projections.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether there is a real, exercised access and portability path for personal data rather than a policy page; this class is the Engagement half of one.[Rubric §12, Performance & Scalability]assesses whether reads are shaped for the database: every collection is fetched withGetProjectedAsync, whoseselectexpression is translated to SQL (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:105-110), so the server returns only the columns the export publishes and no aggregate is ever materialized.[Rubric §7, Microservices Readiness]: Identity owns the export document but not Engagement's data, so it depends only on the interface, satisfied in-process here and over the wire byUserEngagementExportServiceGrpcAdapter. Same shape asBookmarkCountService. - Concept, the soft-delete filter is part of the privacy semantics.
[Rubric §8, Data Architecture]. None of the five reads passesignoreQueryFilters, so the EF global soft-delete filter stays in force and only active personal data is exported (UserEngagementExportService.cs:14-15). That is a deliberate definition of "your data", not an oversight: it is why an opt-in the user has since left reports as not on the leaderboard rather than as a historical membership. - Walkthrough (
UserEngagementExportService.cs:17-83)- The primary constructor injects
IUnitOfWork(:17), the only collaborator. Each read then resolves its own typed repository from it (:24,:30,:36,:51,:67) rather than injectingIRepository<TEntity, TIdentifierType>directly, which is the framework rule for staying on one unit of work per request. - Bookmarks (
:24-28): projects toUserEngagementBookmarkExportDTO { SessionId, CreatedOn }filtered tob.UserId == userId. - Submitted questions (
:30-34): projects toUserEngagementSubmittedQuestionExportDTO { QuestionId = q.Id, SessionId, CreatedOn }, again filtered by user. Only the questions this user authored are read; no other attendee's text ever enters the document. - Points ledger (
:36-46): projects toUserEngagementPointsEntryExportDTO { ActivityType, Points, SubjectKey, CreatedOn }.Pointstravels as the value awarded at the time (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementPointsEntryExportDTO.cs:14-15), so a later change to the configured points-per-activity does not retroactively rewrite the subject's history. - Check-ins (
:51-62): projects toUserEngagementCheckInExportDTO { Scope, EventId, SessionId, SponsorId, CheckedInOn }. The inline comment (:48-50) states why this is a separate read rather than something derived from the ledger, and the code it points at backs it up:PointsAwarderwrites nothing when a rule's configured value is<= 0(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:43-51) and returns a clean no-op on a repeat (:55-64), so the ledger is no proxy for the attendance record the data subject is entitled to. - Leaderboard participation (
:67-71): projects to the barestringDisplayNamerather than to a DTO, because the export carries only a boolean plus a name. The comment (:64-66) records the invariant that makes the check safe: a user holds at most one active opt-in, since rejoining reactivates the soft-deleted row (BR-135,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:87) instead of inserting a second one. - Assembly (
:73-81): oneUserEngagementExportDTObuilt with collection expressions ([.. bookmarks]and friends),IsOnLeaderboard = leaderboardNames.Count > 0(:79) andLeaderboardDisplayName = leaderboardNames.FirstOrDefault()(:80), which is null when the collection is empty. - The five reads are sequential awaits on one unit of work, not a
Task.WhenAll: a single EF context is not thread-safe for concurrent queries, so this is the correct shape, and the cost is five round trips on a request that runs once per data-subject request.
- The primary constructor injects
- Why it's built this way: projecting to ids, enums, and dates server-side keeps the export cheap to build and cheap to ship across a service boundary, and it structurally prevents content authored by other users from leaking into one subject's document (
UserEngagementExportDTOstates that rule explicitly,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:3-8). Returning a plainTask<T>rather than aResult<T>is what lets the aggregating side degrade: there is no business failure to model here, so an unreachable peer surfaces as an exception the caller turns into a missing section (ADR-007). - Where it's used: registered by the Engagement Application DI (
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:50,TryAddScoped); exposed over the wire byUserEngagementExportGrpcService, which wraps this in-process instance (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:23). The consumer is Identity'sEngagementUserDataExportSection(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:19), which reaches it throughUserEngagementExportServiceGrpcAdapterand re-projects the DTO into the Identity export document, renderingActivityTypeandScopeas their readable names (EngagementUserDataExportSection.cs:51,:60), behindExportUserDataHandler. When the module is switched off in a host,DisabledUserEngagementExportServicestands in (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:33). - Caveats / not-in-source: erasure is not this class's job and is not visible here, it only reads. Whether the export is additionally rate-limited or audited when it runs in production is not determinable from this file.
ModuleApplicationDbContext
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19· Level 12 · class (abstract)
- What it is: the Engagement module's EF Core context declaration. It is an
abstractclass over the framework'sApplicationDbContextthat names, as typedDbSet<T>properties, every entity this bounded context owns: the bookmark aggregate, the badge, the check-in, the three live-poll types, the two question types, and the two points types. - Depends on:
ApplicationDbContext,IEntityConfigurationAssemblyProviderandPhysicalDataSource(all forwarded to the base constructor), plus the ten Engagement entities:UserSessionBookmark,AttendeeBadge,CheckIn,LivePoll,LivePollOption,LivePollVote,SessionQuestion,SessionQuestionUpvote,PointsEntry,LeaderboardOptIn. Externals: EF Core'sDbContext,DbSet<T>andDbContextOptions. - Concept introduced, the module's entity inventory is a declaration, not the runtime context.
[Rubric §8, Data Architecture]assesses whether persistence structure is deliberate rather than accidental, and[Rubric §3, Clean Architecture]assesses where infrastructure concerns are allowed to live (here: only in*.Infrastructure, never in the domain entities). The instructive detail is what this class does not do. Nothing in the repository derives from it: a repo-wide search for the name finds exactly three declarations, this one plus the sibling per-module classes in Conference (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20) and Identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15), with no subclass and no consumer anywhere inMMCA.ADC/SourceorMMCA.ADC/Tests. The context that actually runs is Common'ssealedSQLServerDbContext, which extendsApplicationDbContextdirectly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:15-20) and builds its model by scanning assemblies for entity configurations, not by readingDbSet<T>properties: itsOnModelCreatingcallsApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder)(SQLServerDbContext.cs:85-89), which picks the engine's configuration interfaceIEntityTypeConfigurationSQLServer<,>and walksassemblyProvider.GetConfigurationAssemblies(), applying every matching configuration whose entity routes to this context's data source (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:690-717). That is why a module shipsUserSessionBookmarkConfigurationand its siblings rather than a context of its own, and it is exactly the ADR-006 rule the workspace states as "one sealed context class per engine, never per module". - Walkthrough
- The primary constructor (
ModuleApplicationDbContext.cs:19-24) takesDbContextOptions,IServiceProvider,IEntityConfigurationAssemblyProviderandPhysicalDataSourceand forwards all four toApplicationDbContextunchanged (:24). It adds no state and overrides nothing, so audit stamping and domain event capture stay on the framework'sSaveChangesinterceptors, which the base resolves and registers inOnConfiguring(ApplicationDbContext.cs:256-270), while the soft-delete global filter, the tenant filter, the concurrency tokens, and the outbox and inbox tables stay in the base'sOnModelCreating(ApplicationDbContext.cs:331-358). - Ten
internal DbSet<T>properties, one per owned entity:UserSessionBookmarks(:27),AttendeeBadges(:30),CheckIns(:33),LivePolls(:36),LivePollOptions(:39),LivePollVotes(:42),SessionQuestions(:45),SessionQuestionUpvotes(:48),PointsEntries(:51, annotated in its own doc comment as the append-only points ledger), andLeaderboardOptIns(:54). internal, notpublic, is the visibility choice: even if a host did derive from this class, the sets would stay invisible outside the Engagement Infrastructure assembly, so no application-layer code could reach pastIUnitOfWorkand its repositories into raw EF.- Read as documentation, the list is the module's data footprint in one screen: one personal-schedule aggregate, one badge, one attendance record, the conference-day live layer, and the points game. Everything
UserEngagementExportServicehas to gather for a data-subject export is on this list.
- The primary constructor (
- Why it's built this way: database-per-service (ADR-006) gives Engagement its own
ADC_Engagementdatabase with its own outbox, so it never races another service for outbox rows, and cross-module references (UserId,SessionId,EventId,SponsorId) stay scalar columns rather than cross-database foreign keys. The base class is written to be instantiated once per physical data source, keyed byDataSourceModelCacheKeyFactoryso each database builds a model containing only its own entities (ApplicationDbContext.cs:300-303). Keeping the module rungabstractand empty of behavior means the engine choice lives entirely in the framework: swapping SQL Server for the Cosmos or SQLite context (ADR-018) is a configuration-plus-configuration-base-class change, not a per-module context rewrite. - Where it's used: as a type, nowhere. The Engagement database is scaffolded by
MMCA.ADC.Migrations.SqlServer.Engagement, whose design-time factory builds a CommonSQLServerDbContextand registers the Engagement Infrastructure assembly as a configuration source (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:14-17,:45); the project reference toMMCA.ADC.Engagement.Infrastructurein that csproj (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/MMCA.ADC.Migrations.SqlServer.Engagement.csproj:15) is what puts this file's assembly (and, with it, the entity configurations) in scope. - Caveats / not-in-source: the class exists as a declaration only. It compiles, documents the module's entity set, and mirrors the shape of the Conference and Identity module contexts, but no code path instantiates or inherits it today. Whether it is retained deliberately (as the module-owned inventory, and as the rung a future per-engine subclass would use) or is simply vestigial is not determinable from source: neither the file's doc comment nor an ADR says.
⬅ ADC Conference - UI • Index • ADC Engagement Live Layer (Real-Time Polls & Session Q&A) ➡