Onboarding guide
22. ADC Engagement Module (Session Bookmarks)
What this group covers. Engagement is the ADC bounded context that lets an authenticated attendee build a personal schedule and give feedback. This chapter walks the part of that context that ships as the module's steady core: the session-bookmark aggregate and everything wrapped around it (use cases, persistence, REST API, cross-service gRPC contracts and adapters, and the feedback pages in the module's Blazor UI). The module also hosts the conference-day live layer (LivePolls and SessionQuestions), which is a large enough surface that it is documented on its own in Group 23; here the live layer appears only where it touches the shared plumbing, for example the feature flags in EngagementFeatures, the permission grants in EngagementPermissions, and the outbound publish queue in ILiveChannelPublishQueue. Engagement is one of four extracted service hosts in MMCA.ADC, so most of this chapter is a worked example of the framework patterns taught earlier (Result, CQRS, domain events, database-per-service, gRPC extraction) applied to one small aggregate. Read Group 02 for the entity primitives and primer §2 for the styles before you go deep here.
The aggregate is deliberately tiny. UserSessionBookmark (MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16) is a sealed AuditableAggregateRootEntity<TIdentifierType> carrying only two scalar foreign keys, UserId and SessionId (UserSessionBookmark.cs:19, :22), both stored as plain columns rather than cross-database relationships because the user lives in the Identity database and the session in the Conference database (ADR-006, database-per-service). A private constructor (UserSessionBookmark.cs:27) plus a static Create factory returning Result<UserSessionBookmark> (UserSessionBookmark.cs:40) is the only way in: the factory runs Result.Combine over UserSessionBookmarkInvariants (UserSessionBookmark.cs:44), which delegate to CommonInvariants.EnsureIdIsNotDefault (UserSessionBookmarkInvariants.cs:11, :14), so an invalid bookmark cannot be constructed. Instead of separate Created and Deleted events, the aggregate raises one UserSessionBookmarkChanged event carrying a DomainEntityState discriminator (UserSessionBookmark.cs:55, :71, :86), documented in-code as BR-60. A Reactivate() method (UserSessionBookmark.cs:66) undeletes a soft-deleted row and re-raises the Added event, which is the domain half of the re-bookmarking story (BR-135); the Delete() override (UserSessionBookmark.cs:81) soft-deletes and raises the Deleted variant.
The write path. BookmarksController (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:32) is the thin REST edge: every action injects a handler and delegates, mapping Result failures to RFC 9457 Problem Details through ApiControllerBase.HandleFailure. The POST first performs its own ownership bind, since the owner arrives in the request body rather than in a query argument: a caller who does not hold the Organizer role may only create a bookmark whose UserId equals their user_id claim, otherwise the action returns Error.Forbidden (BookmarksController.cs:56-63, drift D9, ADR-033). It then routes into CreateBookmarkHandler (MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:17), the most instructive type in the module because it shows how one slice coordinates a cross-module call, a uniqueness check, and a create-or-reactivate decision. It asks Conference's ISessionBookmarkValidationService whether the session may be bookmarked at all (CreateBookmarkHandler.cs:30, enforcing BR-49 and BR-91 across the service boundary), checks for an existing active bookmark and returns Error.Conflict on a duplicate (CreateBookmarkHandler.cs:37-47, BR-21), then loads any soft-deleted row with ignoreQueryFilters: true (CreateBookmarkHandler.cs:50-56) and hands the create-or-reactivate choice to the pure BookmarkManagementDomainService behind the IBookmarkManagementDomainService contract (CreateBookmarkHandler.cs:59). That domain service (BookmarkManagementDomainService.cs:12-28) either calls Reactivate() on the found row or UserSessionBookmark.Create(...), so BR-135 lives in the domain rather than smeared across the handler. Only a genuinely new entity is added to the repository (CreateBookmarkHandler.cs:64-67); persistence and domain-event dispatch happen at unitOfWork.SaveChangesAsync (CreateBookmarkHandler.cs:69), and the entity is projected through the Mapperly-generated UserSessionBookmarkDTOMapper (UserSessionBookmarkDTOMapper.cs:12, ADR-001) into a UserSessionBookmarkDTO. Shape validation of the inbound CreateBookmarkRequest happens earlier, in the pipeline, via CreateBookmarkRequestValidator (CreateBookmarkRequestValidator.cs:9).
The read path and its authorization. Two query slices back the personal-schedule views. GetUserBookmarksHandler (GetUserBookmarksHandler.cs:16) returns a paged, CreatedOn-descending PagedCollectionResult<T> of DTOs: the page size is capped at 500 before anything touches the database (GetUserBookmarksHandler.cs:28, BR-11), the total is a server-side COUNT (:55), and ordering plus OFFSET/FETCH paging are pushed into SQL through IQueryableExecutor (:58-64). An optional event filter is resolved by asking Conference for that event's session ids (GetUserBookmarksHandler.cs:40, BR-58); if that cross-service call fails, the failure propagates as a Result failure so the request degrades instead of throwing a raw 500. GetBookmarkedSessionIdsHandler (GetBookmarkedSessionIdsHandler.cs:12) is the lightweight sibling: a projected read of session id and bookmark id pairs (:22-27) that the unified Sessions page turns into per-row filled or empty bookmark icons. Both list endpoints are authorization-scoped by the shared OwnerOrAdminFilter applied as a [ServiceFilter] (BookmarksController.cs:77, :98): the caller's userId query argument must equal their user_id claim unless they hold the Organizer bypass role. That vocabulary is not hard-coded in the filter, it is configured for ADC through OwnerOrAdminFilterOptions in AddModuleEngagementAPI (MMCA.ADC.Engagement.API/DependencyInjection.cs:44-49). 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:124-141), then runs the generic DeleteEntityCommand<TEntity, TIdentifierType>, which soft-deletes.
Persistence. Engagement's ModuleApplicationDbContext (MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:16) is an abstract context declaring the module's six DbSets (bookmarks plus the live-layer poll and question entities, :24-39) and inheriting auditing, soft-delete query filters, and domain-event dispatch from the common ApplicationDbContext; the concrete per-engine class (SQLServerDbContext today) sits under it, one instance per physical database (ADR-006). The bookmark table shape is pinned by UserSessionBookmarkConfiguration (UserSessionBookmarkConfiguration.cs:17), which enforces BR-21 as a filtered unique index over (UserId, SessionId) restricted to non-deleted rows (:32-34) and adds a second SessionId-leading filtered index (:38-39) so the conference-day bookmark count is an index seek rather than a scan of the composite. The FK to Conference's Session is deliberately not configured here (UserSessionBookmarkConfiguration.cs:12-16 records why: it would create a cross-module Infrastructure-to-Domain dependency). The module owns its own ADC_Engagement database with its own dbo.OutboxMessages, so the UserSessionBookmarkChanged events serialized during SaveChangesAsync never race another service's outbox 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 (BookmarkCountService.cs:11) answers "how many people bookmarked this session" for Conference's session cards, with a single-session count (:14) and a batched variant that does one grouped COUNT in SQL for a whole set and back-fills zeros for sessions nobody bookmarked (:38-50), replacing the caller's per-session fan-out. That interface goes on the wire through BookmarkCountsGrpcService (BookmarkCountsGrpcService.cs:12), and Conference consumes it through the client-side BookmarkCountServiceGrpcAdapter (BookmarkCountServiceGrpcAdapter.cs:14), which re-implements the same C# IBookmarkCountService interface so calling code never knows it crossed a process, and pins a 5-second per-call deadline (:20) far tighter than the shared resilience budget because the count sits inline in a user request. The identical bridge carries the privacy export: UserEngagementExportService (UserEngagementExportService.cs:14) projects a user's bookmarks and submitted session questions to id-and-date DTOs server-side (:22-31), with soft-deleted rows excluded by the global query filter, served by UserEngagementExportGrpcService (UserEngagementExportGrpcService.cs:16) and consumed by Identity through UserEngagementExportServiceGrpcAdapter so Identity can assemble the cross-service data-subject export (PRIVACY.md §7, ADR-005). The Contracts-layer DependencyInjection (MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16) is what swaps in-process for remote: it registers the typed client and Replaces (never TryAdds) the existing registration, so it overwrites both the real service and the disabled stub, and it must run after ModuleLoader.DiscoverAndRegister.
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), whose TryEnqueue returns false rather than throwing when the bounded queue is full. LiveChannelPublishQueue is the single concrete channel, registered once as a singleton and exposed to handlers via the interface (MMCA.ADC.Engagement.Application/DependencyInjection.cs:54-55), and LiveChannelPublishProcessor (MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:21) is the single-reader BackgroundService drain that forwards items to ILiveChannelPublisher in FIFO order (:29-39), resolving the scoped gRPC adapter per item and logging-and-swallowing every failure (:47-51). One reader preserves per-session ordering; the swallow keeps the best-effort contract (BR-229, ADR-039). The details of what gets published live in Group 23.
Module wiring and feature gating. EngagementModule (EngagementModule.cs:14) is the IModule entry point discovered by ModuleLoader and registered in topological order; it declares a dependency on Conference and RequiresDependencies => true (EngagementModule.cs:20, :23), and its Register is a one-liner into AddEngagementModule, which chains the Application, Infrastructure, and API registrations in dependency order (MMCA.ADC.Engagement.API/DependencyInjection.cs:25-27). 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 without a live Engagement backend. The whole bookmark surface sits behind a controller-level [FeatureGate] keyed to EngagementFeatures.SessionBookmarks (BookmarksController.cs:30, EngagementFeatures.cs:15), so disabling the flag turns every bookmark route into a 404, and the live-layer endpoints are additionally gated by the capability grants declared in AddModuleEngagementAPI from EngagementPermissions (DependencyInjection.cs:51-55, granting Organizer and Admin the full set, ADR-020). The service host itself is Http2-only on cleartext for cross-service gRPC, with an optional HTTP/1.1-only health-probe listener added by KestrelConfiguration when HealthProbe:Port is configured (KestrelConfiguration.cs:33-38, ADR-012).
The feedback UI. The module's Blazor UI contributes two feedback pages, EventFeedback (EventFeedback.razor.cs:16) and SessionFeedback (SessionFeedback.razor.cs:16). They render a dynamic question form (Rating, Text, Email) whose definitions and answers belong to the Conference context: the pages inject IQuestionLookupService, IEventFeedbackUIService, and ISessionFeedbackUIService (IFeedbackUIService.cs:10, :21, :43) and submit one answer per question using a POST-as-upsert convention (BR-107), so revisiting the page edits existing answers instead of duplicating them. Per-question values live in a mutable AnswerState holder for two-way binding (EventFeedback.razor.cs:271), and the presence of existing answer ids flips the button between Submit and Update (:33, :35-46). Two details are worth copying into new pages: pre-fill skips answers belonging to another event, since feedback questions are shared across events and would otherwise match by question id alone (EventFeedback.razor.cs:107-121), and the submit loop is failure-aware, stopping at the first failed call, reporting how much was saved, and keeping the form dirty so the same button retries (:157-175). The page also calls StateHasChanged() before navigating so the unsaved-changes guard sees the cleared dirty flag (:180).
Bookmarks, reminders, and navigation on the client. Two UI services front the same REST resource for different callers: BookmarkService (BookmarkService.cs:17) implements the page-facing IBookmarkUIService create/list/delete contract, while SessionBookmarkUIService (SessionBookmarkUIService.cs:17) implements the cross-module ISessionBookmarkUIService that the Conference UI uses to toggle bookmarks inline on a session list. The latter also feeds the reminder layer: an authoritative bookmark map triggers a resync so reminders heal drift from other devices (SessionBookmarkUIService.cs:43-45). SessionReminderCoordinator (SessionReminderCoordinator.cs:16) keeps the tracked session set in device preferences and cancel-then-reschedules the diff, no-opping on hosts without local notifications, and SessionReminderPlanner (SessionReminderPlanner.cs:29) does the pure planning (ADR-042 Wave 2): it converts each session's event-local wall-clock start to UTC, shifting spring-forward gap times forward one hour and resolving fall-back ambiguity to the standard offset (:91-104), skips sessions already started (:67), fires immediately (plus 30 seconds) for a session already inside the lead window (:72-77), and derives a stable notification id from the session id so rescheduling replaces rather than duplicates (:37), emitting SessionReminder records (:13) that carry a deep-link route from EngagementRoutePaths. The EngagementUIModule descriptor (EngagementUIModule.cs:14) contributes the module's Blazor assembly, the Happening Now nav item as a resource key (:18-21, ADR-027), and the invisible LiveEventListener layout component (:23).
Rubric lens. This module is a compact end-to-end illustration of most of Part A. The aggregate and its invariants are the [Rubric §4, Domain-Driven Design] story (rules enforced inside a factory, a single state-carrying domain event); the controller-handler-domain-service split is [Rubric §5, Vertical Slice] and [Rubric §6, CQRS and Event-Driven]; 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, filtered indexes, and its own outbox are [Rubric §8, Data Architecture] and [Rubric §12, Performance and Scalability]; the ownership checks on all three mutating and listing paths plus the 404-not-403 delete are [Rubric §11, Security]; the best-effort publish queue is [Rubric §29, Resilience]; and the export projection with soft-delete exclusion is [Rubric §30, Compliance and Privacy]. The feedback pages and reminder planner extend the story into Part B ([Rubric §18, UI Architecture] and [Rubric §24, Forms/Validation/UX Safety]). Relevant ADRs, in the order they surface above: ADR-001 (Mapperly mapping), ADR-003 (outbox), ADR-005 (soft-delete vs erasure), ADR-006 (database-per-service), ADR-007 and ADR-008 (gRPC extraction and topology), ADR-012 (mixed Kestrel endpoint profile), ADR-020 (permission registry), ADR-027 (resource-key navigation), ADR-033 (owner-or-admin filter), ADR-039 (best-effort live publish), and ADR-042 (MAUI 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 Infrastructure, Shared, Domain, 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), used as a stable anchor whenever something needs to say "scan the assembly this type belongs to."Type File:Line Notes (what differs) AssemblyReference(API)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.APIAssemblyReference(Application)MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.ApplicationNothing else differs: both files are twelve lines long and declare the same two fields at the same line numbers.
Depends on:
System.Reflection.Assembly(BCL) only, imported atAssemblyReference.cs:1. No first-party dependencies, which is why it sits at Level 0.Concept, assembly-marker types for convention scanning. This is the module-local instance of the anchor pattern the framework introduces in
AssemblyReference.[Rubric §2, Design Patterns]assesses whether recurring problems are solved with a recognised idiom: handing anAssemblyto a scanner through a purpose-built token, rather than through some incidental business class, is that idiom.[Rubric §1, SOLID](DIP): registration code depends on this deliberate marker, not ontypeof(SomeHandler).Assembly, so moving or renaming a real type never breaks the scan.Walkthrough: two
public static readonlyfields resolved once at type initialization.Assembly(AssemblyReference.cs:7) istypeof(AssemblyReference).Assembly;AssemblyName(AssemblyReference.cs:8) isAssembly.GetName().Namewith a?? string.Emptyfallback, so the field is never null even if the runtime returns no simple name. There are no methods and no other state.Why it's built this way: a self-describing anchor lets each layer be scanned without cross-layer references, and the pattern repeats in every layer of every module and package (see the Common-layer copy in group-14).
Where it's used: the companion
ClassReference(not the static class itself) is what the Application-layerDependencyInjectionpasses toScanModuleApplicationServices<ClassReference>()(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:71); the static handle is the shape architecture-fitness maps use to pin an assembly viatypeof(...AssemblyReference).Assembly.
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 used wherever a generic type parameter needs an assembly anchor and a static class will not satisfy the constraint.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; nothing from the BCL beyond
object.Concept: the companion half of the marker pattern introduced under
AssemblyReference. C# static classes cannot be used as generic type arguments, so a registration helper that takes a marker as<TAssemblyMarker>needs an instantiable type;ClassReferencefills that slot without weakeningAssemblyReference's static-ness.[Rubric §33, Developer Experience]assesses how easy the inner loop is: a single conventional token is the whole registration ceremony a module's application layer needs.Walkthrough:
public class ClassReference { }(AssemblyReference.cs:11in both copies), no members. Its only meaningful property is the assembly it belongs to, read viatypeof(ClassReference).Assemblyinside the scanner.Why it's built this way: a separate non-static anchor sidesteps the static-class generic-argument restriction while leaving
AssemblyReferenceimpossible to instantiate accidentally. Each Engagement layer declares its own copy so it can scan itself by passing its local token.Where it's used: as the marker argument in
ScanModuleApplicationServices<ClassReference>()inside the Application-layerDependencyInjection(DependencyInjection.cs:71).Caveats / not-in-source: the API-layer copy has no reader inside the Engagement API project; only the Application-layer copy is passed to the scanner in the files read here.
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
ErrorLocalizerinfrastructure (through theIErrorLocalizercontract) and theAddErrorResources<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 failingResult's code to a translated message once the host registers this type viaAddErrorResources<EngagementErrorResources>(). That is ADR-027 (Website/docs-src/adr/027-multi-locale-i18n.md) 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 sharedErrorLocalizerwhen mapping a domain error code to a message. - 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.
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 returns a map from session id to bookmark id so a listing page can show per-row bookmark state. - Depends on: the
UserIdentifierTypealias (an identifier type alias linked solution-wide, see primer §4); paired withGetBookmarkedSessionIdsHandlerthrough 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 group-05).[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 theUserIdkeeps the request minimal. - Where it's used: dispatched to
GetBookmarkedSessionIdsHandler; the resulting session-id-to-bookmark-id map drives the star/bookmark indicator per row on the unified Sessions page.
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; paired withGetUserBookmarksHandler. - 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 join 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 nullableEventIdevent filter, 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 a magic value. - Where it's used: dispatched to
GetUserBookmarksHandler, which returns aPagedCollectionResult<T>ofUserSessionBookmarkDTO; surfaced by the Bookmarks list endpoint onBookmarksController.
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 byILiveChannelPublishQueueand, 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 (Website/docs-src/adr/039-live-channel-push.md). - Walkthrough:
public sealed record LiveChannelPublishWorkItem(string ChannelKey, string EventName, string PayloadJson)(ILiveChannelPublishQueue.cs:10).ChannelKeyis the session or event channel key (the doc points atLivePollChannel,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
CastVoteHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:142-143) andCloseLivePollHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:91-92); drained byLiveChannelPublishProcessor.
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
LiveChannelPublishWorkItemto this queue instead of awaiting a gRPC publish inline. - Depends on:
LiveChannelPublishWorkItemonly. Its implementation isLiveChannelPublishQueueand its drain isLiveChannelPublishProcessor, which forwards toILiveChannelPublisher. - 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-19): 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 rejected enqueue) is logged by the caller or drain and never fails the command.[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 the contract's insistence that rejection be observable:TryEnqueuereturns a bool rather than silently swallowing. - Walkthrough: a single member.
bool TryEnqueue(LiveChannelPublishWorkItem workItem)(ILiveChannelPublishQueue.cs:28) attempts a non-blocking enqueue. Its doc (ILiveChannelPublishQueue.cs:23-27) pins the contract precisely: it returnsfalseonly when the bounded queue rejects the item, and callers are expected to log and move on. 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
CastVoteHandler(CastVoteHandler.cs:23) andCloseLivePollHandler(CloseLivePollHandler.cs:22); registered against the singleton implementation in the Application-layerDependencyInjection(DependencyInjection.cs:55).
DependencyInjection
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:14· Level 2 · 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-layerDependencyInjectionandAddModuleEngagementInfrastructure), the sharedOwnerOrAdminFilterOptions/OwnerOrAdminFilter, and the auth vocabularyRoleNames,AuthorizationPolicies, andEngagementPermissions(the last fed into theIPermissionRegistrythroughAddPermissions). - Concept, C#
extension(T)DI blocks composing a module top-down. The class body is anextension(IServiceCollection services)block (DependencyInjection.cs:16), 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:25-27), 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'sOwnerOrAdminFilterand supplies only ADC's field names (ADR-033), then wires the module's permission grants through the shared registry (ADR-020) so the live layer's[HasPermission(...)]endpoints resolve. - Walkthrough
AddEngagementModule(ApplicationSettings applicationSettings)(DependencyInjection.cs:23) callsAddModuleEngagementApplication(applicationSettings),AddModuleEngagementInfrastructure(), thenAddModuleEngagementAPI()(DependencyInjection.cs:25-27) and returnsservicesfor chaining. This is the single methodEngagementModule.Registerinvokes.AddModuleEngagementAPI()(DependencyInjection.cs:42) does two things.- Configures
OwnerOrAdminFilterOptions(DependencyInjection.cs:44-49) withOwnerClaimType = "user_id"(line 46, the custom claim the token service emits),BypassRole = RoleNames.Organizer(line 47, the role that skips the ownership check), andOwnerParameterName = "userId"(line 48, the query argument the Bookmarks list endpoints bind). The filter itself is registered upstream by Common'sAddAPI; this call injects only the module's vocabulary. - Calls
AddPermissions(...)(DependencyInjection.cs:51) and grants bothRoleNames.OrganizerandRoleNames.Adminthe fullEngagementPermissions.Allset through a collection spread (DependencyInjection.cs:53-54). The doc comment (DependencyInjection.cs:32-40) records the complement: attendee-facing endpoints stay onAuthorizationPolicies.RequireAuthenticatedrather than a permission gate.
- Configures
- 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:32-40) is the authoritative note on which ADC field maps to which option. - Where it's used:
AddEngagementModuleis called byEngagementModule.Register;AddModuleEngagementAPIruns inside it. The configuredOwnerOrAdminFilterguards the list endpoints onBookmarksController; the granted permissions gate the live-layer endpoints covered in group-23. - Caveats / not-in-source:
AddModuleEngagementInfrastructurelives in the Infrastructure project and is covered by that layer's section, not here.
LiveChannelPublishQueue
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:13· Level 2 · class (sealed)
- What it is: the in-process implementation of
ILiveChannelPublishQueue, a boundedSystem.Threading.Channelschannel holding up to 1024 pending broadcasts, plus the reader handle the hosted drain consumes. - Depends on:
ILiveChannelPublishQueueandLiveChannelPublishWorkItemfirst-party;System.Threading.Channels(Channel,BoundedChannelOptions,BoundedChannelFullMode,ChannelReader<T>) andArgumentNullExceptionfrom the BCL (LiveChannelPublishQueue.cs:1). - 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:17). The interesting design choice is what happens when it fills:BoundedChannelFullMode.DropOldest(LiveChannelPublishQueue.cs:22), so a full queue discards the oldest item and still accepts the new one, which is whyTryEnqueuereturningfalseis the rare case its contract describes. The comment states the reasoning (LiveChannelPublishQueue.cs:8-11): 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:15-16), 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:17), the bound, with the sizing rationale in the comment above it.- The
_channelfield (LiveChannelPublishQueue.cs:19-25) is created byChannel.CreateBounded<LiveChannelPublishWorkItem>with three options:FullMode = BoundedChannelFullMode.DropOldest(line 22),SingleReader = true(line 23, matching the one hosted drain, which is what makes delivery FIFO and per-session order preserving), andSingleWriter = false(line 24, because many concurrent request threads enqueue). public ChannelReader<LiveChannelPublishWorkItem> Reader => _channel.Reader(LiveChannelPublishQueue.cs:28) exposes only the read side to the drain worker; the writer side stays private, so nothing outside this class can bypassTryEnqueue.TryEnqueue(LiveChannelPublishWorkItem workItem)(LiveChannelPublishQueue.cs:31-35) null-guards withArgumentNullException.ThrowIfNull(workItem)(line 33) and then returns_channel.Writer.TryWrite(workItem)(line 34).TryWritenever blocks, which is the whole point of the contract.
- 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,DependencyInjection.cs:54-55): handlers only need the interface, but the drain needs the concrete reader, and both must resolve to the same singleton instance. - Where it's used: registered as a singleton in the Application-layer
DependencyInjection(DependencyInjection.cs:54-55); itsReaderis consumed byLiveChannelPublishProcessorin the Infrastructure layer, which is added withAddHostedService<LiveChannelPublishProcessor>()(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21).
EngagementModule
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:14· Level 4 · class (sealed)
- What it is: the
IModuleimplementation for the Engagement bounded context, the typeModuleLoaderdiscovers by reflection and uses to slot Engagement into the application in dependency order. - Depends on:
IModule(the contract),ApplicationSettings, the API-layerDependencyInjectionextension (AddEngagementModule), and the two disabled-stub pairs it registers when Engagement is not co-hosted:IBookmarkCountService/DisabledBookmarkCountServiceandIUserEngagementExportService/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 APIDependencyInjection. 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,IBookmarkCountServiceasDisabledBookmarkCountService(line 32) andIUserEngagementExportServiceasDisabledUserEngagementExportService(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
ModuleLoaderduring host startup; itsRegisterroutes into the APIDependencyInjection.
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. - Depends on:
IUnitOfWork(constructor-injected), theUserSessionBookmarkaggregate it reads, theIReadRepository<TEntity, TIdentifierType>it obtains from it, andResult. It implementsIQueryHandler<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 SQL selects two columns rather than whole rows. It resolves a read repository (GetReadRepository,GetBookmarkedSessionIdsHandler.cs:20) rather than the writable one, signalling intent and keeping the read off the change tracker.[Rubric §6, CQRS & Event-Driven]and[Rubric §5, Vertical Slice]: the handler sits beside its query in the same use-case folder and returns aResultrather than throwing. - Walkthrough
- The primary constructor injects
IUnitOfWork(GetBookmarkedSessionIdsHandler.cs:12-13); the class closes the query-handler interface overResult<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>>. HandleAsync(GetBookmarkedSessionIdsHandler.cs:16-18) obtains a read repository forUserSessionBookmark(line 20), then callsGetProjectedAsyncwithselect: b => new { b.SessionId, b.Id }andwhere: b => b.UserId == query.UserId(GetBookmarkedSessionIdsHandler.cs:22-25), so only the session id and bookmark id for that user come back.- It materializes the projected pairs into a dictionary keyed by
SessionIdwith valueId(GetBookmarkedSessionIdsHandler.cs:27) and wraps it inResult.Success<IReadOnlyDictionary<...>>(line 29). There is no failure branch: the query cannot fail, so theResultis always a success.
- The primary constructor injects
- 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. - Where it's used: dispatched through the
IQueryHandler<in TQuery, TResult>pipeline from the Sessions page path; the sibling write path creates bookmarks throughCreateBookmarkHandler.
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:16· Level 8 · 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-moduleISessionBookmarkValidationService,IQueryableExecutor, andUserSessionBookmarkDTOMapper(all constructor-injected,GetUserBookmarksHandler.cs:16-20). It reads theUserSessionBookmarkaggregate, producesUserSessionBookmarkDTOs, and returns aPagedCollectionResult<T>carryingPaginationMetadata. It implementsIQueryHandler<in TQuery, TResult>. - 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 (database-per-service, ADR-006), so the handler fetches the event's session ids throughISessionBookmarkValidationService(a gRPC client once extracted) and filters on that id set.[Rubric §8, Data Architecture]shows in the hard page-size ceiling, and[Rubric §29, Resilience & Business Continuity]in how a Conference outage is handled: the code comment (GetUserBookmarksHandler.cs:37-39) states that a failure across the gRPC boundary propagates as aResultfailure so the request degrades gracefully instead of surfacing a raw 500. - Walkthrough
HandleAsync(GetUserBookmarksHandler.cs:23-25) first caps the page size:var pageSize = Math.Min(query.PageSize, 500)(line 28, BR-11), so an over-large request is clamped rather than honored.- It resolves the writable repository (line 30) and declares the filter expression (line 33). When
query.EventId.HasValue(line 35) it callssessionValidationService.GetSessionIdsByEventAsync(...)across the module boundary (lines 40-41), returns early withResult.Failureif that call failed (lines 42-43), materializes the ids into a list (line 45), and filters bookmarks to that user and that session-id set (line 47, BR-58). Otherwise the filter is justb.UserId == query.UserId(line 51). - It counts server-side with
bookmarkRepo.CountAsync(filter, cancellationToken)(line 55, translated to SQLCOUNT), then orders and pages server-side by pushingTableNoTracking.Where(filter).OrderByDescending(b => b.CreatedOn).Skip((query.PageNumber - 1) * pageSize).Take(pageSize)throughqueryableExecutor.ToListAsync(...)(lines 58-64, translated to SQLORDER BYplusOFFSET/FETCH). - It maps the page with
UserSessionBookmarkDTOMapper.MapToDTOs(line 66), buildsPaginationMetadata(totalCount, pageSize, query.PageNumber)(line 67), and returnsResult.Success(new PagedCollectionResult<UserSessionBookmarkDTO>(dtos, metadata))(line 69).
- 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 / ADR-007). The
Math.Minclamp enforces BR-11 regardless of what the caller sends, andTableNoTrackingkeeps the paged read off the change tracker. - Where it's used: dispatched through the
IQueryHandler<in TQuery, TResult>pipeline behind the Bookmarks list endpoint onBookmarksController, whose ownership is guarded by theOwnerOrAdminFilterconfigured in the APIDependencyInjection. - Caveats / not-in-source: whether
ISessionBookmarkValidationServiceresolves to an in-process implementation or a gRPC client depends on the host composition, not on this file.
DependencyInjection
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:27· Level 11 · 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 plus the two live-layer aggregates), 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), the shared generic services (INavigationPopulator<in TEntity>/NullNavigationPopulator<TEntity>,IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>/EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, theDeleteEntityCommand<TEntity, TIdentifierType>/DeleteEntityHandler<TEntity, TIdentifierType>pairing bound throughICommandHandler<in TCommand, TResult>), the live-layer types it also wires (LivePoll,LivePollVote,LivePollNavigationPopulator,LivePollResultsBuilder,SessionQuestion,SessionQuestionUpvote,SessionQuestionViewBuilder), and theClassReferencemarker. - Concept, explicit registration plus convention scanning, with idempotent
TryAdd.[Rubric §2, Design Patterns]assesses recognised 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:71): a new use case is picked up as soon as its handler exists, with no registration edit. 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. - Walkthrough
AddModuleEngagementApplication(ApplicationSettings applicationSettings)(DependencyInjection.cs:31) opens with_ = applicationSettings;and the comment "Reserved for future use" (line 33), acknowledging the parameter is part of the uniform module signature but unused today.- Domain service:
TryAddSingleton<IBookmarkManagementDomainService, BookmarkManagementDomainService>()(line 36). - Per-aggregate services for
UserSessionBookmark: aNullNavigationPopulator(line 39, the bookmark aggregate needs no navigation loading), anEntityQueryServiceoverUserSessionBookmarkDTOfor its read surface (line 40), and aDeleteEntityHandlerbound toICommandHandler<DeleteEntityCommand<UserSessionBookmark, ...>, Result>(line 41), so delete reuses the framework's generic handler. - Cross-module services:
TryAddScoped<IBookmarkCountService, BookmarkCountService>()(line 44), the count Conference consumes, andTryAddScoped<IUserEngagementExportService, UserEngagementExportService>()(line 48), the Engagement half of the data-subject export that Identity aggregates across services. - Live-channel publish queue (
DependencyInjection.cs:54-55): the concreteLiveChannelPublishQueueis registered as a singleton (line 54) andILiveChannelPublishQueueis registered as a factory resolving that same instance (line 55). The double registration is deliberate and the comment above it (lines 50-53) explains why: handlers consume the interface, while the Infrastructure-hosted drain needs the concrete type'sReader, and both must share one instance. - Live-layer
LivePollaggregate (DependencyInjection.cs:58-62): a realLivePollNavigationPopulator(line 58, unlike the bookmark aggregate this one has navigations to load), aNullNavigationPopulatorforLivePollVote(line 59), anEntityQueryServiceforLivePoll(line 60), aDeleteEntityHandlerforLivePoll(line 61), and theLivePollResultsBuilderprojection helper (line 62). - Live-layer
SessionQuestionaggregate (DependencyInjection.cs:65-67):NullNavigationPopulators forSessionQuestion(line 65) andSessionQuestionUpvote(line 66), plus theSessionQuestionViewBuilder(line 67). services.ScanModuleApplicationServices<ClassReference>()(line 71): convention-scans this assembly for domain-event handlers, DTO and request mappers, command and query handlers (includingGetUserBookmarksHandlerandGetBookmarkedSessionIdsHandler), and validators.
- 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 markerClassReferencerather 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 APIDependencyInjection, which is in turn invoked byEngagementModule.Registerduring host startup.
AssemblyReference
MMCA.ADC.Engagement.Domain / MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Domain,MMCA.ADC.Engagement.Infrastructure·MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5· Level 0 · class (static)One byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies.
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(Domain)MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.DomainAssemblyReference(Infrastructure)MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5namespace MMCA.ADC.Engagement.InfrastructureDepends on:
System.Reflection.Assembly(BCL) only. No first-party dependencies, which is why both copies sit at Level 0.Concept introduced: assembly-marker types for convention scanning. The framework-level explanation lives with the shared copy in group 14; 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 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.Engagement.Domain/AssemblyReference.cs:7,MMCA.ADC.Engagement.Infrastructure/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.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.Where it's used: the Infrastructure copy is the EF configuration anchor for the design-time migrations factories:
options.AddConfigurationAssembly(typeof(MMCA.ADC.Engagement.Infrastructure.AssemblyReference).Assembly)inSource/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:27(and the frozen combined archive atSource/Hosting/MMCA.ADC.Migrations.SqlServer/DesignTimeSQLServerDbContextFactory.cs:31), which is how the per-serviceADC_Engagementdatabase picks up this layer'sIEntityTypeConfigurationclasses.Caveats / not-in-source: the Domain copy has no call site in the repo today. The Engagement Domain assembly is pinned in the architecture-fitness map by a real domain type instead (
Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:38usestypeof(Engagement.Domain.UserSessionBookmarks.UserSessionBookmark).Assembly), so the Domain marker exists for convention symmetry rather than for a current consumer.
ClassReference
MMCA.ADC.Engagement.Domain / MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Domain,MMCA.ADC.Engagement.Infrastructure·MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11· Level 0 · classOne byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies.
What it is: the non-static companion to AssemblyReference, an empty instantiable class declared in the same file. It supplies an assembly anchor for the APIs that need a generic type argument or a constructible
Type, which a static class can never satisfy.Type File:Line Notes (what differs) ClassReference(Domain)MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.DomainClassReference(Infrastructure)MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11namespace MMCA.ADC.Engagement.InfrastructureDepends on: nothing first-party, and nothing from the BCL beyond
object.Concept introduced: the companion half of the marker pattern introduced under AssemblyReference. C# forbids a static class as a generic type argument, and several registration helpers are constrained to an instantiable reference type (
TAssemblyMarker : class), so a second, non-static token fills that slot without weakeningAssemblyReference'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.
Caveats / not-in-source: neither of these two copies is referenced anywhere in the repo today (the API and Application copies are the ones passed to
ScanModuleApplicationServices<ClassReference>()). Both exist to keep the per-layer convention uniform.
EngagementFeatures
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared·MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:8· Level 0 · class (static)
- What it is: the catalog of feature-flag names for the Engagement module. Each constant is the exact key an operator sets under the
FeatureManagementconfiguration section to switch a whole capability on or off. - Depends on: nothing (three bare
const stringvalues). - Concept introduced:
[Rubric §10, Cross-Cutting]assesses whether concerns like toggling live outside business code, and[Rubric §11, Security]cares that a disabled capability is invisible rather than merely forbidden: a gated-off route returns 404, not 403. The framework-level explanation of the flag mechanism lives with the sibling catalogs ConferenceFeatures and NotificationFeatures; the keys here are consumed by[FeatureGate]attributes on controllers and by the IFeatureGated marker. - Walkthrough: three
const stringkeys, each gating one capability's endpoint family:SessionBookmarks = "Engagement.SessionBookmarks"(MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:15) gates the create/list/delete bookmark routes.LivePolls = "Engagement.LivePolls"(MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:22) gates the live-poll routes (author, open, close, vote, results).SessionQA = "Engagement.SessionQA"(MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:29) gates the session Q&A routes (submit, list, moderate, upvote).
- Why it's built this way: the three Engagement capabilities can be dark-launched or retired independently, for example bookmarks on while the conference-day live layer stays off until the morning of the event. Centralizing the keys as constants keeps every
[FeatureGate]call site and the config section spelling the flag identically. - Where it's used:
[FeatureGate(EngagementFeatures.SessionBookmarks)]on the bookmarks controller (Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:30),[FeatureGate(EngagementFeatures.LivePolls)]on the live-polls controller (Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:39), and[FeatureGate(EngagementFeatures.SessionQA)]on the session-questions controller (Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:34).
EngagementPermissions
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Authorization·MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:9· Level 0 · class (static)
- What it is: the Engagement module's capability-permission vocabulary. Endpoints demand a permission string rather than a role name, so who-can-do-what is decided by role-to-permission grants declared once at module registration instead of being scattered across controllers.
- Depends on:
System.Collections.Generic.IReadOnlyList<string>(BCL). Consumed through the framework's HasPermissionAttribute. - Concept introduced:
[Rubric §11, Security]assesses whether authorization is capability-based and centrally governed. This class is the capability half of that model: a permission is a stable string identifier (it can travel in tokens and logs, hence the deliberately stable value), and the role-to-permission mapping is a separate, single-location decision.[Rubric §16, Maintainability]benefits too: adding an endpoint means picking an existing capability, not inventing a new role check. - Walkthrough: one capability constant today,
LiveManage = "engagement:live:manage"(MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:16), which authorizes authoring, opening, closing, and deleting live polls (the doc comment notes Q&A moderation joins it in Wave 2).All(MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:19) is anIReadOnlyList<string>initialized with a collection expression containing every permission, so an entire capability set can be granted to a role in one line. - Why it's built this way: speaker-scoped rights are handled by a separate speaker-assignment check rather than by a coarse permission, which keeps
LiveManagea clean organizer-level capability. KeepingAllas the single enumeration means a future permission is picked up automatically by every "grant everything" call site. - Where it's used:
[HasPermission(EngagementPermissions.LiveManage)]on the live-poll mutation endpoints (Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:107and:126), and the grants declared during API registration:permissions.Grant(RoleNames.Organizer, [.. EngagementPermissions.All])plus the same forRoleNames.Admin(Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:53-54).
KestrelConfiguration
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service·MMCA.ADC.Engagement.Service/KestrelConfiguration.cs:11· Level 0 · class (internal static)
- What it is: the Engagement service host's Kestrel endpoint wiring: HTTP/2-only cleartext (h2c) defaults for cross-service gRPC, plus an optional dedicated HTTP/1.1 listener that exists solely so the Azure Container Apps health probes can reach the real health endpoints.
- Depends on:
Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols,WebApplicationBuilder, andIConfiguration.GetValue<T>(ASP.NET Core / BCL). No first-party types. - Concept introduced: the h2c prior-knowledge transport convention. On a cleartext endpoint there is no TLS, therefore no ALPN, therefore no protocol negotiation:
Http1AndHttp2effectively degrades to HTTP/1.1 and Kestrel answers gRPC frames withGOAWAY HTTP_1_1_REQUIRED. ForcingHttpProtocols.Http2makes the listener assume HTTP/2 without negotiating, which is what letsAddTypedGrpcClientpeers reachhttp://engagement. The trade is that the same listener then rejects ordinary HTTP/1.1 requests, which is exactly why the probe listener below exists.[Rubric §13, Observability & Operability]assesses whether the platform can actually observe health: before the probe listener, the ACA probes had to be TCP-only and never consulted the database-aware health checks.[Rubric §7, Microservices Readiness]covers the transport side. - Walkthrough: one method,
ConfigureHttp2WithHealthProbe(WebApplicationBuilder builder)(MMCA.ADC.Engagement.Service/KestrelConfiguration.cs:27).ArgumentNullException.ThrowIfNull(builder)(line 29) guards the public entry point.k.ConfigureEndpointDefaults(o => o.Protocols = HttpProtocols.Http2)(line 33) makes every endpoint, including the container's implicitASPNETCORE_HTTP_PORTSbinding, h2c-only.builder.Configuration.GetValue<int?>("HealthProbe:Port") is int probePort(line 35) is the opt-in: the key is injected only in Azure (asHealthProbe__Port) and is deliberately absent locally so Aspire's dynamic ports keep working and co-hosted services cannot collide on a fixed port.- When it is present, the method takes over binding explicitly:
k.ListenAnyIP(8080, ... Http2)re-declares the main service endpoint (an explicitListencall overrides the container default binding, so 8080 has to be restated), andk.ListenAnyIP(probePort, ... Http1)adds the probe-only HTTP/1.1 listener (lines 37-38).
- Why it's built this way: ADR-012 (
Website/docs-src/adr/012-grpc-host-transport.md) sets the h2c-only convention for the REST/gRPC service hosts.MapDefaultEndpointsmaps/health,/alive, and/health/readyon every listener, so the extra HTTP/1.1 listener serves the genuine health pipeline rather than a stub, and the probe port stays off the ACA ingress because the platform probes address it directly. The file exists separately fromProgram.csso the top-level statements stay inside the S1541 cyclomatic-complexity budget the analyzers enforce. - Where it's used: called once during host startup at
Source/Services/MMCA.ADC.Engagement.Service/Program.cs:59, before logging and module registration.
UserEngagementBookmarkExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC.Engagement.Shared/Exports/UserEngagementBookmarkExportDTO.cs:7· Level 0 · record
- What it is: one session-bookmark row inside a user's Engagement data export: which session was bookmarked and when.
- Depends on: the
SessionIdentifierTypealias (see identifier aliases in the primer);System.DateTime(BCL). - Concept introduced:
[Rubric §30, Compliance/Privacy/Data Governance]assesses whether data-subject rights are implemented rather than promised. Portability means a user can obtain the personal data the system holds about them, and this DTO is a single line item in that document (PRIVACY.md §7). Note what it does not carry: no session title, no other attendee's data, only an id and a timestamp. - Walkthrough: a
sealed record classwith tworequired initmembers:SessionId(MMCA.ADC.Engagement.Shared/Exports/UserEngagementBookmarkExportDTO.cs:10) andCreatedOn, a UTCDateTime(:13).sealedplusrecordgives value semantics and forbids derivation of the export shape;requiredmeans a row cannot be constructed half-populated. - Why it's built this way: minimizing the export to ids and dates keeps the portability path from becoming a second, uncontrolled copy of the conference catalog, and guarantees the document contains nothing authored by anyone but the requesting user.
- Where it's used: collected into the
Bookmarkslist of UserEngagementExportDTO.
UserEngagementSubmittedQuestionExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC.Engagement.Shared/Exports/UserEngagementSubmittedQuestionExportDTO.cs:7· Level 0 · record
- What it is: one submitted-question row in a user's Engagement export: the question id, the session it was asked in, and the submission date. The question text is deliberately left out.
- Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases (primer);System.DateTime(BCL). - Concept introduced: the same
[Rubric §30, Compliance/Privacy/Data Governance]export shape as UserEngagementBookmarkExportDTO. The teaching point specific to this record is data minimization by design: the doc comment states that the question text stays out of the summary, so the export proves that a user submitted a question without re-publishing free text through a second channel. - Walkthrough: a
sealed record classwith threerequired initmembers:QuestionId(MMCA.ADC.Engagement.Shared/Exports/UserEngagementSubmittedQuestionExportDTO.cs:10),SessionId(:13), andCreatedOnin UTC (:16). - Why it's built this way: the ids let a user correlate their submissions with what they see in the app, while the omitted body keeps the export small and free of moderated or since-edited content.
- Where it's used: collected into the
SubmittedQuestionslist of UserEngagementExportDTO, populated from the live layer's SessionQuestion aggregate.
UserEngagementExportDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:8· Level 1 · record
- What it is: the complete portable export of the personal data the Engagement module holds for one user: their session bookmarks and the session questions they submitted.
- Depends on: UserEngagementBookmarkExportDTO and UserEngagementSubmittedQuestionExportDTO;
IReadOnlyList<T>(BCL). - Concept introduced: the per-module contribution to a cross-service export document. Each module owns and shapes what it discloses about a user (PRIVACY.md §7), and a central handler concatenates the contributions; no module reaches into another's tables to build the export. This is the data-governance counterpart of database-per-service (ADR-006).
- Walkthrough: a
sealed record classwith twoIReadOnlyList<...>members, each defaulting to an empty collection expression[]:Bookmarks(MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:11) andSubmittedQuestions(:14). Neither isrequired, sonew UserEngagementExportDTO()is itself a valid empty document, and a user with no Engagement activity serializes to empty arrays rather than nulls. - Why it's built this way: read-only collection types plus non-null empty defaults give the export a stable serialized shape and make the disabled-module case free (the stub simply returns the parameterless instance).
- Where it's used: returned by IUserEngagementExportService, produced for real by UserEngagementExportService, returned empty by DisabledUserEngagementExportService, and carried across the process boundary by UserEngagementExportServiceGrpcAdapter.
IUserEngagementExportService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC.Engagement.Shared/Exports/IUserEngagementExportService.cs:11· Level 2 · interface
- What it is: the cross-module contract for exporting the personal data Engagement holds about one user, so a central export path can assemble a full document without referencing Engagement's domain or database.
- Depends on: UserEngagementExportDTO; the
UserIdentifierTypealias (primer). - Concept introduced: provider-owns-the-contract, applied to privacy.
[Rubric §1, SOLID](Dependency Inversion),[Rubric §3, Clean Architecture], and[Rubric §7, Microservices Readiness]all converge here: the interface lives in the provider's Shared layer, so the consumer (Identity) references onlyEngagement.Shared, and the topology decides the implementation. In the Engagement service an in-process implementation is registered; everywhere else a gRPC adapter inMMCA.ADC.Engagement.Contractssatisfies the same interface (ADR-007). The doc comment calls this out explicitly as the same extraction pattern as IBookmarkCountService. Consumer code is byte-identical in both topologies. - Walkthrough: one method,
GetUserEngagementExportAsync(UserIdentifierType userId, CancellationToken cancellationToken)returningTask<UserEngagementExportDTO>(MMCA.ADC.Engagement.Shared/Exports/IUserEngagementExportService.cs:20). The cancellation token is the trailing parameter and is not optional, per the codebase convention for service-to-service contracts. - Why it's built this way: routing every module's disclosure through one uniformly shaped interface lets the aggregating handler stay ignorant of how many modules exist and of where their data lives, and keeps each module the single author of what it reveals about a user.
- Where it's used: consumed by Identity's ExportUserDataHandler; satisfied by UserEngagementExportService in-process, by UserEngagementExportServiceGrpcAdapter across services (served by UserEngagementExportGrpcService), and by DisabledUserEngagementExportService when the module is off.
DisabledUserEngagementExportService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.Exports·MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:7· Level 3 · class (sealed)
- What it is: the null-object stub for IUserEngagementExportService, registered when the Engagement module is disabled in a host. It returns an empty export document.
- Depends on: IUserEngagementExportService and UserEngagementExportDTO;
System.Threading.Tasks.Task(BCL). - Concept introduced: the module system's disabled-stub mechanism (Null Object at a module boundary). A module that is switched off still gets to bind its cross-module contracts to safe no-ops through
IModule.RegisterDisabledStubs, so a dependent module never fails to resolve a dependency and never needs anif (engagementEnabled)branch. See IModule for the mechanism and DisabledBookmarkCountService for the sibling stub. - Walkthrough:
sealed, implements the interface, andGetUserEngagementExportAsync(...)(MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:10) is an expression body returningTask.FromResult(new UserEngagementExportDTO()): a completed task wrapping the empty-collections default document. No I/O, no allocation of a running task, and the parameters are ignored. - Why it's built this way: in the extracted topology the Identity service runs with Engagement disabled, yet the data-subject export must still complete. An empty Engagement section (no bookmarks, no questions) is the correct, side-effect-free contribution from a module that is not present.
- Where it's used: registered as
services.AddSingleton<IUserEngagementExportService, DisabledUserEngagementExportService>()in EngagementModule'sRegisterDisabledStubs(Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:33), alongside the bookmark-count stub on the line above.
DependencyInjection
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure·MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:9· Level 4 · class (static)
- What it is: the Engagement Infrastructure layer's DI entry point, exposing a single
AddModuleEngagementInfrastructure()registration method onIServiceCollection. - Depends on: LiveChannelPublishProcessor (from
MMCA.ADC.Engagement.Infrastructure.Live);IServiceCollectionandAddHostedService(Microsoft.Extensions.DependencyInjection / Hosting). - Concept introduced: the
extension(T)DI-registration style used across the codebase. Instead of a classicstatic IServiceCollection AddX(this IServiceCollection ...), the method is declared inside anextension(IServiceCollection services)block (MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:11), so the receiver is namedservicesin scope for every member in the block and callers still writeservices.AddModuleEngagementInfrastructure().[Rubric §10, Cross-Cutting]assesses whether composition is uniform: every layer of every module publishes a same-shapedAddModule{Name}{Layer}hook, so the composition root calls them identically. - Walkthrough: one method,
AddModuleEngagementInfrastructure()(MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:19). Its body registers exactly one thing,services.AddHostedService<LiveChannelPublishProcessor>()(line 21), then returnsservicesso the call stays chainable (line 23). The hosted service is the single-reader drain that forwards queued best-effort live-channel broadcasts (BR-229) to ILiveChannelPublisher off the command hot path; the queue it reads, LiveChannelPublishQueue, is registered by the Application layer, not here. - Why it's built this way: the drain is host infrastructure (a
BackgroundService), so it belongs to the Infrastructure registration rather than to the Application layer that owns the queue abstraction. Splitting queue-registration from drain-registration is what lets a host enqueue without necessarily draining, and keeps the best-effort publish path out of the command'sSaveChangesAsynccritical section (ADR-039 posture: a failed broadcast is logged, never fatal). - Where it's used: called by
AddEngagementModulein the API layer's DependencyInjection (Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:26), which EngagementModule invokes during host composition (the module system is covered in group 14).
AnswerState
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.Feedback·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:263· Level 0 · class (private sealed, nested)
- What it is: a tiny mutable holder for one question's in-progress answer, used as the two-way binding target on the feedback forms. The exact same private nested class is declared independently inside both
EventFeedback(EventFeedback.razor.cs:263) andSessionFeedback(SessionFeedback.razor.cs:296); it is not a shared DTO. - Depends on: nothing first-party (two BCL primitives only).
- Concept introduced, mutable form-state holder for two-way binding.
[Rubric §19, State Management]assesses where transient UI state lives; here each page keeps the working answer set in aDictionary<QuestionIdentifierType, AnswerState>(EventFeedback.razor.cs:51) rather than in the DTO it will eventually submit.[Rubric §24, Forms, Validation & UX Safety]assesses form-input handling: MudBlazor'sMudRatingandMudTextFieldbind with@bind-Value, which requires a settable property, so arecordwithinit-only members would not work as the bind target. Making the holder a small mutable class is the deliberate consequence. - Walkthrough: two auto-properties (
EventFeedback.razor.cs:265-266):TextValue(string?) backs open-text and email questions;RatingValue(int, default0) backs star-rating questions. The page'sGetAnswerValue(EventFeedback.razor.cs:250) reads whichever field matches the question type, treatingRatingValue == 0as "unanswered" so a zero-star rating serializes tonulland is skipped on submit. - Why it's built this way: the class is
private sealedand duplicated per page on purpose: its only job is to carry transient form state before submission, so elevating it to a shared, non-private type would add coupling for no behavioral gain. The two copies are structurally identical. - Where it's used: populated in
PreFillExistingAnswersand read inSubmitFeedbackAsync/GetAnswerValueon both feedback pages.
EngagementRoutePaths
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:6· Level 0 · class (static)
- What it is: a static class of route constants and route-building helpers for the Engagement UI: the "Happening Now" live entry point, three session-live routes, and the two feedback routes.
- Depends on: nothing first-party (uses the
SessionIdentifierTypeandEventIdentifierTypealiases). - Concept, centralized route paths.
[Rubric §25, Navigation & Information Architecture]assesses whether URL strings are defined once or scattered acrossNavigateTocall sites. This class is the single source of truth for Engagement's URLs, so a route rename touches one file. The same route-constants pattern appears across the other UI modules. - Walkthrough: one
const string HappeningNow = "/happening-now"(EngagementRoutePaths.cs:9); the rest are static methods returning interpolated strings:SessionDetails/SessionLive/SessionPresentfor the live layer (EngagementRoutePaths.cs:10-12), andEventFeedback(eventId) => "/feedback/event/{eventId}"(EngagementRoutePaths.cs:15) andSessionFeedback(sessionId) => "/feedback/session/{sessionId}"(EngagementRoutePaths.cs:16). The feedback method outputs exactly match the@pagetemplates onEventFeedback(/feedback/event/{EventId}) andSessionFeedback(/feedback/session/{SessionId}). - Why it's built this way: methods (not raw consts) are used where the route embeds an id, keeping id interpolation in one place; the plain
constis used for the parameterless "Happening Now" route consumed by the nav item below. - Where it's used:
HappeningNowseedsEngagementUIModule's nav item (EngagementUIModule.cs:20); the feedback builders are called from Conference UI session/event pages that link an attendee to the feedback forms after a session ends.
EngagementUIModule
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:14· Level 3 · class (sealed)
- What it is: the Engagement module's UI descriptor: it implements
IUIModuleto contribute one navigation item ("Happening Now"), one invisible layout component (LiveEventListener), and its own assembly for Blazor component discovery. - Depends on:
IUIModuleandNavItem(both fromMMCA.Common.UI.Common),EngagementRoutePaths,LiveEventListener(an Engagement UI component), and MudBlazor'sIcons. - 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. Each module ships anIUIModule; the shared shell discovers them and merges their nav items, layout components, and assemblies.[Rubric §25, Navigation & IA]: the nav contribution is declarative data, not imperative menu wiring.[Rubric §27, Internationalization]: per the ADR-027 comment (EngagementUIModule.cs:16-17), the navTitleis a resource key ("Nav.HappeningNow"), resolved by the sharedNavMenuagainst the co-locatedEngagementUIModule.resxpair at render time via theTitleResourcetype pointer, so the menu label is localizable rather than a hard-coded English string. - Walkthrough
NavItems(EngagementUIModule.cs:18-21): a one-element collection built withnew("Nav.HappeningNow", EngagementRoutePaths.HappeningNow, Icons.Material.Filled.Podcasts, TitleResource: typeof(EngagementUIModule)), pairing the resource key, the centralized route, an icon, and the resource-owning type.LayoutComponentTypes(EngagementUIModule.cs:23):[typeof(LiveEventListener)], an invisible layout component the shell renders on every page; per the class doc comment (EngagementUIModule.cs:9-13) it joins the event's live channel while the event is live.Assembly(EngagementUIModule.cs:25):typeof(EngagementUIModule).Assembly, exposed so Blazor's router and component discovery can find this module's routable pages.
- Why it's built this way: surfacing nav, layout components, and the assembly as read-only properties lets the host treat every module uniformly and keeps the Engagement menu/discovery contract co-located with the module it describes.
- Where it's used: registered as a singleton
IUIModulebyDependencyInjection'sAddEngagementUI(DependencyInjection.cs:49); consumed by the shared shell'sNavMenuand router.
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:16· Level 3 · class (partial)
- What it is: the code-behind for the attendee event-feedback page (
@page "/feedback/event/{EventId}"). 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,EventFeedback.razor.cs:12-15). - Depends on:
IQuestionLookupService,IEventFeedbackUIService,IEventLookupService,QuestionDTO,EventQuestionAnswerDTO,AnswerState, andDomainHelper(theParse<T>extension). Externals: BlazorNavigationManager, MudBlazorISnackbar/MudForm/BreadcrumbItem, and (from the.razormarkup) an injectedIStringLocalizer<EventFeedback>Lplus theUnsavedChangesGuardcomponent. - Concept introduced, the code-behind feedback page over dynamic questions.
[Rubric §18, UI Architecture]assesses component/logic separation: the markup lives inEventFeedback.razorand the behavior in thispartial class, so the page's flow is reviewable apart from markup.[Rubric §24, Forms, Validation & UX Safety]assesses form correctness and loss-prevention: the page validates throughMudFormbefore submit and wraps the form inUnsavedChangesGuard IsDirty="_isDirty"(markup,EventFeedback.razor:10) so navigating away with unsaved edits prompts.[Rubric §19, State Management]covers the working answer map.[Rubric §27, i18n]: every user-visible string is anL["…"]resource lookup, not a literal. - Walkthrough (teaching order)
- Injected services and the
[Parameter] string EventId(EventFeedback.razor.cs:18-24) arrive from the route; the private state fields (EventFeedback.razor.cs:48-55) hold the loaded questions, the_answersmap, the_existingAnswerIdsmap, theMudFormreference, and the_isDirtyflag. SubmitButtonText(EventFeedback.razor.cs:35-46) andHasExistingFeedback(EventFeedback.razor.cs:33) drive the button label between "Submit" and "Update" based on whether prior answers exist, a small UX affordance.OnInitializedAsync(EventFeedback.razor.cs:58) builds the breadcrumb trail, resolves the event name viaEventLookup.GetAllAsync(setting_loadErrorif the event is missing), loads"Event"questions ordered bySort, seeds oneAnswerStateper question, then pre-fills from the server. Every await passes_cts.Token;OperationCanceledExceptionis swallowed as expected on disposal, and any other exception becomes a localized_loadError.PreFillExistingAnswers(EventFeedback.razor.cs:107) is defense-in-depth: because feedback questions are shared across events, it skips any answer whoseEventIddoes not equal the current event (EventFeedback.razor.cs:113) before mapping a rating (culture-invariantint.TryParse) or text value onto the holder.SubmitFeedbackAsync(EventFeedback.razor.cs:137) validates the form, then loops questions, computes eachanswerValueviaGetAnswerValue, skips blanks, and callsFeedbackService.SubmitAnswerAsyncper non-empty answer (the upsert). On success it clears_isDirty, snackbars a count, then callsStateHasChanged()beforeNavigateBack()(EventFeedback.razor.cs:172-173), the inline comment explains this flushes the re-render soUnsavedChangesGuard'sIsDirtyparameter updates beforeNavigateTofires theLocationChanginghandler (avoiding a false "unsaved changes" prompt).DeleteAnswerAsync(EventFeedback.razor.cs:189) removes one previously-saved answer (delete commits immediately server-side, so_isDirtyis cleared).ParseEventId(EventFeedback.razor.cs:220) converts the route string toEventIdentifierTypeviaEventId.Parse<EventIdentifierType>(), theDomainHelperextension, avoiding a hand-rolled parse.Dispose(bool)/Dispose()(EventFeedback.razor.cs:228-248) implement the standard disposable pattern, cancelling and disposing_ctsso in-flight loads stop when the user leaves.
- Injected services and the
- Why it's built this way: the page owns orchestration only; all I/O goes through injected UI service interfaces, keeping it testable and swappable. The cancellation-token discipline plus the
StateHasChanged-before-navigate ordering are deliberate Blazor-lifecycle correctness details, not incidental. - Where it's used: rendered at
/feedback/event/{EventId}; reached from Conference event pages after an event concludes. - Caveats / not-in-source: the markup, resource strings, and the concrete
UnsavedChangesGuardbehavior live outside this.csfile (inEventFeedback.razorand the shell); this section covers the code-behind only.
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:16· Level 5 · class (partial)
- What it is: the code-behind for the attendee session-feedback page (
@page "/feedback/session/{SessionId}"). It mirrorsEventFeedbackbut first enforces three business preconditions before it will render the question form. - Depends on:
IQuestionLookupService,ISessionFeedbackUIService,IEntityService<TEntityDTO, TIdentifierType>(bound asIEntityService<SessionDTO, SessionIdentifierType>),SessionDTO,QuestionDTO,SessionQuestionAnswerDTO,AnswerState, andDomainHelper. Externals as inEventFeedback(NavigationManager,ISnackbar,MudForm,IStringLocalizer<SessionFeedback>,UnsavedChangesGuard). - Concept, precondition-gated feedback. The submission/pre-fill machinery is identical to
EventFeedback(same_answersmap,SubmitFeedbackAsync,DeleteAnswerAsync,GetAnswerValue,Dispose, and the sameStateHasChanged-before-NavigateBackordering,SessionFeedback.razor.cs:203-205), so that shape is not re-taught here. The distinguishing logic isCheckPreconditions, which is the[Rubric §24, Forms, Validation & UX Safety]and[Rubric §11, Security]story: the form is not shown at all when feedback is not permitted. - Walkthrough of what differs
OnInitializedAsync(SessionFeedback.razor.cs:59) loads the session viaSessionService.GetByIdAsync(SessionFeedback.razor.cs:73); a missing session sets_loadError. It then runsCheckPreconditions(session)and, if that returns a message, stores it in_preconditionMessageand returns before loading questions (SessionFeedback.razor.cs:82-87).CheckPreconditions(SessionFeedback.razor.cs:114) encodes three rules, each returning a localized message: BR-91 rejects service sessions (IsServiceSession, line 117); BR-49 rejects sessions whoseStatusis set and not"Accepted"(line 123); BR-16 rejects unscheduled sessions whereStartsAtorEndsAtis null (line 131), a time-gated action guard. Returningnullmeans feedback is allowed.PreFillExistingAnswers(SessionFeedback.razor.cs:139) applies the same cross-entity guard as the event page, skipping answers whoseSessionIddoes not match (SessionFeedback.razor.cs:145).ParseSessionId(SessionFeedback.razor.cs:253) usesSessionId.Parse<SessionIdentifierType>().
- Why it's built this way: pushing the three business rules into one
CheckPreconditionsmethod keeps the render path clean (either a precondition message or the form) and makes each BR individually reviewable. Sharing the submission shape withEventFeedbackby convention (not a shared base) keeps each page self-contained, the same choice made for the duplicatedAnswerState. - Where it's used: rendered at
/feedback/session/{SessionId}; linked from Conference session detail pages. - Caveats / not-in-source: as with
EventFeedback, the markup and resource strings live inSessionFeedback.razor, not this code-behind.
DependencyInjection
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/DependencyInjection.cs:13· Level 8 · class (static)
- What it is: the Engagement UI composition root: a single static class exposing an
AddEngagementUI()extension onIServiceCollectionthat registers every Engagement UI service (bookmarks, feedback, lookups, the live layer) plus theEngagementUIModuledescriptor. - Depends on:
IServiceCollection(BCL DI), the Engagement UI service interfaces and implementations (bookmarks, feedback, lookups, live),SessionReminderCoordinator, andIUIModule. - Concept,
extension(IServiceCollection)registration.[Rubric §5, Vertical Slice]and[Rubric §1, SOLID]assess whether a slice owns its own wiring; this class is the one place the Engagement UI slice declares its services, so the host callsAddEngagementUI()and stays ignorant of the internals. The C# previewextension(IServiceCollection services)block (DependencyInjection.cs:15) is the same DI-registration idiom used across the codebase (see the primer onextension(T)members); it letsAddEngagementUIread as an instance method on the collection. - Walkthrough:
AddEngagementUI()(DependencyInjection.cs:21) registers, in groups:- Bookmarks (
DependencyInjection.cs:24-25):IBookmarkUIService→BookmarkService,ISessionBookmarkUIService→SessionBookmarkUIService, both scoped. SessionReminderCoordinator(DependencyInjection.cs:29): per the inline comment (ADR-042 Wave 2), it keeps local notifications in sync with bookmarks and no-ops on hosts whoseILocalNotificationServiceis unsupported.- Feedback (
DependencyInjection.cs:32-34):IQuestionLookupService→QuestionLookupService,IEventFeedbackUIService→EventFeedbackService,ISessionFeedbackUIService→SessionFeedbackService, the services the two feedback pages above inject. - Cross-module lookup (
DependencyInjection.cs:37):ISessionLookupService. - Live layer (
DependencyInjection.cs:40-46):ILivePollUIService,ILiveEventUIService, and the Wave 2ISessionQuestionUIService/ISessionLiveUIService. - Module descriptor (
DependencyInjection.cs:49):services.AddSingleton<IUIModule, EngagementUIModule>(), a singleton because the descriptor is immutable metadata; everything else is scoped (per-circuit) since it holds request/circuit state. - Returns
services(DependencyInjection.cs:51) for fluent chaining.
- Bookmarks (
- Why it's built this way: one extension method per module UI keeps registration cohesive and lets the host compose modules by convention; the scoped-vs-singleton split matches each service's lifetime (per-circuit UI services vs. immutable module metadata).
- Where it's used: called by the Blazor UI host's startup when the Engagement module is enabled, alongside the other modules'
AddXUI()calls.
SessionReminder
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionReminderPlanner.cs:13· 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 records in the primer), 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:13-19): six positional members,NotificationId(int, line 14),SessionId(line 15),SessionTitle(line 16),StartsAtUtc(line 17),DeliverAt(line 18), andDeepLinkRoute(line 19). The doc comment (lines 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; consumed bySessionReminderCoordinator, which turns each one into aLocalNotificationRequest.
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 · sealed class
- What it is: the FluentValidation rule set for
CreateBookmarkRequest, run automatically beforeCreateBookmarkHandlersees the command. - Depends on:
CreateBookmarkRequest, and theUserIdentifierType/SessionIdentifierTypealiases. Externals: FluentValidation (AbstractValidator<T>). - Concept, validation as a pipeline stage.
[Rubric §24, Forms/Validation/UX Safety]and[Rubric §10, Cross-Cutting]assess whether input rules are declared once and enforced consistently rather than scattered through handlers. This validator holds no logic beyond field rules; theValidatingCommandDecorator(see the CQRS decorator pipeline in the primer) resolves it, runs it before the transaction opens, and short-circuits with a 400 if it fails, so the handler only ever runs against structurally valid input. - Walkthrough (
CreateBookmarkRequestValidator.cs:11-22): two rules,UserIdmust not equaldefault(UserIdentifierType)(lines 13-16) andSessionIdmust not equaldefault(SessionIdentifierType)(lines 18-21). Each attaches a human message and a machine-readableWithErrorCode(UserSessionBookmark.UserId.Required,UserSessionBookmark.SessionId.Required) so clients can branch on a stable code rather than parse prose. - Why it's built this way: this is pure structural guarding (are the ids present?); the business rules (BR-21 uniqueness, BR-49/BR-91 eligibility, BR-135 reactivation) live in
CreateBookmarkHandlerand the domain, because they need repository and cross-module lookups the validator deliberately avoids. - Where it's used: auto-registered by Scrutor assembly scanning and invoked by the validating decorator around
CreateBookmarkHandler.
SessionReminderPlanner
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionReminderPlanner.cs:29· 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(line 51), 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, i18n/Localization]also applies: session times are wall-clock local to the event's IANA time zone, never UTC in the DTOs, so correct conversion is a first-class concern here, not an afterthought. - Walkthrough:
NotificationIdSeed(line 34) is a private constant offset that keeps reminder ids clear of any other local-notification ids the app might use.GetNotificationId(sessionId)(lines 37-38) shifts the seed and XORs the session id's hashunchecked, producing the stable id theSessionReminderreplace-by-id contract relies on.Plan(bookmarkedSessions, timeZoneId, leadTime, now)(lines 47-89) guards its args (53-54), resolves theTimeZoneInfo(56), then for each session skips those with no start time (61-64) or already started (67-70), computesdeliverAt = startsAtUtc - leadTimeand, if that instant has already passed, fires an immediate reminder 30 seconds out rather than dropping it (72-77), and finally emits aSessionReminderwith the tap route fromEngagementRoutePaths.SessionDetails(79-86).ToUtc(localWallClock, timeZone)(lines 91-104) is the DST-correct converter: it marks the wall timeUnspecified, shifts a spring-forward invalid time forward one hour (96-99), and letsGetUtcOffsetresolve ambiguous fall-back times to the standard offset (102-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).
- Where it's used: called by
SessionReminderCoordinator.ReplanAsync;GetNotificationIdis also called by the coordinator to cancel reminders for sessions that left the tracked set.
IBookmarkUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/IBookmarkUIService.cs:9· Level 2 · interface
- What it is: the UI-side contract for the
bookmarksWebAPI resource as the Engagement UI consumes it, create, list-by-user, and delete. - Depends on:
CreateBookmarkRequest,UserSessionBookmarkDTO, and theUserIdentifierType/EventIdentifierType/UserSessionBookmarkIdentifierTypealiases. Externals: BCLTask. - Concept, 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 and the transport (auth, retry, error translation) stays hidden behind the contract. - Walkthrough (
IBookmarkUIService.cs:11-27):CreateAsync(request, ct)returns a nullableUserSessionBookmarkDTO(lines 12-14);GetUserBookmarksAsync(userId, eventId?, pageNumber, pageSize, ct)returns a tuple of the page items plus the total count (lines 17-22), witheventIdoptional and paging defaulted topageNumber = 1/pageSize = 10;DeleteAsync(id, ct)returns abool(lines 25-27) so the caller can distinguish a real delete from a not-found without catching an exception. - Where it's used: implemented by
BookmarkService; consumed by the Engagement bookmark pages and components.
IEventFeedbackUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/IFeedbackUIService.cs:21· Level 2 · interface
- What it is: the UI contract for submitting and reading a user's own answers to event feedback questions.
- Depends on:
EventQuestionAnswerDTOand theEventIdentifierType/QuestionIdentifierType/EventQuestionAnswerIdentifierTypealiases. Externals: BCLTask. - Concept, POST-as-upsert (BR-107).
[Rubric §9, API & Contract Design]assesses how idempotent write semantics are expressed to clients.SubmitAnswerAsync(lines 23-27) is a single method for both first answer and edit: the server upserts on(eventId, questionId)for the user, so the UI never branches on create-vs-update. This is the same UI-service abstraction taught atIBookmarkUIService, specialized to feedback. - Walkthrough (
IFeedbackUIService.cs:23-36):SubmitAnswerAsync(eventId, questionId, answerValue, ct)returns the persistedEventQuestionAnswerDTO(23-27);GetMyAnswersAsync(eventId, ct)returns the caller's answers for one event (29-31);DeleteAnswerAsync(eventId, answerId, ct)removes one (33-36). - Where it's used: implemented by
EventFeedbackService; consumed by the event feedback UI.
IQuestionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/IFeedbackUIService.cs:10· Level 2 · 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 type). Externals: BCLTask. - Concept, 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 sharedQuestionDTOcontract crosses the boundary. - Walkthrough (
IFeedbackUIService.cs:12-14): one method,GetQuestionsAsync(questionEntity, ct), wherequestionEntityis the string discriminator ("Event"or"Session"), returning a read-only list ofQuestionDTO. - Where it's used: implemented by
QuestionLookupService; consumed by both the event and session feedback pages to build their question lists.
ISessionFeedbackUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/IFeedbackUIService.cs:43· Level 2 · interface
- What it is: the session counterpart of
IEventFeedbackUIService, submit/read/delete a user's own answers to session feedback questions. Structurally identical, scoped to a session id instead of an event id. - Depends on:
SessionQuestionAnswerDTOand theSessionIdentifierType/QuestionIdentifierType/SessionQuestionAnswerIdentifierTypealiases. - Walkthrough (
IFeedbackUIService.cs:45-58):SubmitAnswerAsync(sessionId, questionId, answerValue, ct)(45-49),GetMyAnswersAsync(sessionId, ct)(51-53),DeleteAnswerAsync(sessionId, answerId, ct)(55-58), all mirroringIEventFeedbackUIServicewith the same POST-as-upsert semantics (BR-107). See that section for the upsert concept. - Where it's used: implemented by
SessionFeedbackService; consumed by the session feedback UI.
SessionReminderCoordinator
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionReminderCoordinator.cs:16· Level 2 · sealed partial class
- What it is: the stateful orchestrator that keeps on-device session reminders in sync with the user's bookmarks. It holds the tracked-session set and settings in device preferences, replans via the pure
SessionReminderPlanner, and cancel-then-reschedules the diff through the platform notification service. - Depends on:
ILocalNotificationService,IDevicePreferences,ISessionLookupService,ILiveEventUIService,SessionReminderPlanner,SessionReminder,LocalNotificationRequest. Externals:IStringLocalizer<T>,ILogger<T>. - Concept, best-effort client-side state sync.
[Rubric §19, State Management]and[Rubric §29, Resilience & Business Continuity]. The authoritative reminder state is the tracked set persisted inIDevicePreferences; every public entry point mutates it, then recomputes the whole plan, which self-heals drift from changes made on other devices. Crucially, reminders are a convenience: every path is wrapped so a failure logs and returns rather than failing the bookmark operation that triggered it. On hosts without local notifications (web heads) the whole class no-ops via theIsSupportedguard.[Rubric §27, i18n/Localization]: notification title and body come from the injectedIStringLocalizer. - Walkthrough:
- Constants (lines 25-33): the preference keys
EnabledKey,LeadMinutesKey, and the privateTrackedSessionsKey, plusDefaultLeadMinutes = 15. IsSupported(line 36) delegates to the notification service so UI can hide the settings when reminders are impossible.NotifyBookmarkAddedAsync(39-40) andNotifyBookmarkRemovedAsync(43-44) both funnel through the privateMutateTrackedAsync, adding-distinct or filtering the session id.ResyncAsync(bookmarkedSessionIds, ct)(50-51) replaces the tracked set wholesale with an authoritative bookmark map, the drift-healing path.ApplySettingsAsync(enabled, leadMinutes, ct)(54-72) persists the two settings then replans, all inside atry/catchthat swallows everything exceptOperationCanceledExceptionand logs viaLogReminderSyncFailed.GetSettingsAsync(75-80) reads the current settings for the settings UI.MutateTrackedAsync(mutate, ct)(82-110) is the shared core: guard onIsSupported, load the tracked set, apply the mutation, persist it, cancel notifications for ids that left the set (98-102), thenReplanAsync.ReplanAsync(tracked, ct)(112-163): short-circuits (cancelling all when disabled), requests OS permission at most once (128-131), resolves the current live event and its time zone, filters tracked sessions to that event, callsSessionReminderPlanner.Plan(...)withDateTimeOffset.UtcNow(147-151), and schedules each resultingSessionReminderas aLocalNotificationRequest(153-162).LogReminderSyncFailed(165-166) is a source-generated[LoggerMessage]warning.[Rubric §13, Observability & Operability]: high-performance structured logging with no allocation on the happy path.
- Constants (lines 25-33): the preference keys
- Why it's built this way: pushing all the calendar math into the pure planner leaves this class as a thin, resilient adapter over device capabilities, and the cancel-diff-then-reschedule approach relies on the planner's stable
SessionReminder.NotificationIdso rescheduling never duplicates (ADR-042 Wave 2). - Where it's used: injected into
SessionBookmarkUIService, which calls its notify/resync methods after every successful bookmark read or mutation; the device-settings page callsGetSettingsAsync/ApplySettingsAsync.
UserSessionBookmarkChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:15· Level 2 · sealed record class
- What it is: the single domain event raised whenever a
UserSessionBookmarkis created or soft-deleted, carrying which transition happened plus the identifiers. - Depends on:
BaseDomainEvent(base),DomainEntityState(the Added/Deleted discriminator), and theUserSessionBookmarkIdentifierType/UserIdentifierType/SessionIdentifierTypealiases. - Concept, one event with a state discriminator (BR-60).
[Rubric §6, CQRS & Event-Driven]assesses whether state changes are announced as first-class events, and[Rubric §4, DDD]whether the aggregate owns that announcement. Rather than separateBookmarkCreated/BookmarkDeletedevents, this design uses one event whoseDomainEntityStatepayload names the transition, so a downstream handler subscribes once and switches on the state. Domain events and the outbox pattern are taught in Group 04. - Walkthrough (
UserSessionBookmarkChanged.cs:15-20): a positionalsealed record classinheritingBaseDomainEventwith four members,State(line 16),BookmarkId(17),UserId(18),SessionId(19). The XML docs (lines 6-14) state the BR-60 rationale explicitly. - Why it's built this way: both create and reactivate funnel through the
Addedvariant (seeBookmarkManagementDomainService), so consumers see a uniform "bookmark is now active" signal regardless of whether the row was fresh or revived. - Where it's used: raised inside the
UserSessionBookmarkaggregate's factory andDelete/Reactivatemethods; dispatched afterSaveChangesAsyncby the framework's domain-event dispatcher.
BookmarkService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/BookmarkService.cs:15· Level 3 · sealed class
- What it is: the HTTP implementation of
IBookmarkUIService, talking to thebookmarksWebAPI resource through the Gateway with authenticated, retried requests. - Depends on:
IBookmarkUIService,AuthenticatedServiceBase(base),ITokenStorageService,ServiceExceptionHelper,CreateBookmarkRequest,UserSessionBookmarkDTO,PagedCollectionResult<T>(fromMMCA.Common.Shared.Abstractions,BookmarkService.cs:5). Externals:IHttpClientFactory. - Concept, the authenticated HTTP UI service.
[Rubric §18, UI Architecture],[Rubric §29, Resilience],[Rubric §26, Front-End Security]. Every method builds its client via the inheritedCreateAuthenticatedClientAsync()(which attaches the bearer token fromITokenStorageService), wraps the call in the baseRetryPolicy, and routes non-success responses throughServiceExceptionHelper.ThrowIfDomainExceptionAsyncso an RFC 9457 Problem Details body becomes a typed domain exception rather than a bare status code. The base class (MMCA.Common.UI) is taught in Group 15. - Walkthrough (
BookmarkService.cs:21-93):CreateAsyncPOSTs theCreateBookmarkRequestas JSON and deserializes the createdUserSessionBookmarkDTO(21-36);GetUserBookmarksAsynccomposes the query string with invariant-culture formatting (userId, paging, optional eventId, lines 47-57), reads aPagedCollectionResult<T>ofUserSessionBookmarkDTO, and returns items plusPaginationMetadata.TotalItemCount(38-73);DeleteAsyncmaps a 404 tofalse(85-86) and any other failure through the exception helper, returningtrueon success (75-93).Endpointis the const"bookmarks"(line 19). - Why it's built this way: the doc comment (lines 11-14) notes this uses custom methods matching the Bookmarks API contract rather than the generic
EntityServiceBaseCRUD, because the bookmark endpoints (session-scoped create, list-by-user) do not fit the standard resource shape. - Where it's used: registered in the Engagement UI DI and injected into bookmark pages via
IBookmarkUIService.
EventFeedbackService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/EventFeedbackService.cs:13· Level 3 · sealed class
- What it is: the HTTP implementation of
IEventFeedbackUIService, backed by theeventquestionanswersresource, using POST-as-upsert (BR-107). - Depends on:
IEventFeedbackUIService,AuthenticatedServiceBase,ITokenStorageService,EventQuestionAnswerDTO,PagedCollectionResult<T>,ServiceExceptionHelper. Externals:IHttpClientFactory. - Concept: the same authenticated-retried-HTTP shape introduced at
BookmarkService; see it for the auth + retry + exception-translation story.[Rubric §9, API & Contract Design],[Rubric §18, UI Architecture]. - Walkthrough (
EventFeedbackService.cs:19-74):SubmitAnswerAsyncPOSTs an anonymous{ EventId, QuestionId, AnswerValue }object and returns the persistedEventQuestionAnswerDTO(19-38);GetMyAnswersAsyncbuilds an invariant-culture filtered paged URL (filters[EventId].operator=equals&...&pageSize=100&includeChildren=false) and returns the caller's answers (40-57);DeleteAnswerAsyncDELETEs by answer id with theeventIdas a query parameter (59-74).Endpointis"eventquestionanswers"(line 17). - Where it's used: registered in the Engagement UI DI and consumed by the event feedback page via
IEventFeedbackUIService.
LiveChannelPublishProcessor
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Live·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:21· Level 3 · sealed partial class
- What it is: a hosted background service that drains
LiveChannelPublishQueuewith exactly one reader and forwards each queued broadcast toILiveChannelPublisher, off the command hot path. - Depends on:
LiveChannelPublishQueue(the in-process channel it reads),ILiveChannelPublisher(the Notification-side ingress, satisfied by the gRPC adapter when extracted). Externals:BackgroundServicefromMicrosoft.Extensions.Hosting,IServiceScopeFactory,ILogger<T>. - Concept, the single-reader queue drain.
[Rubric §12, Performance & Scalability]assesses whether slow or remote work is kept off the request path: a command that changes live state enqueues a work item and returns, while this processor does the network call afterwards, so a hung Notification peer costs the command nothing.[Rubric §29, Resilience & Business Continuity]assesses failure containment: the publish contract here is best-effort (BR-229/ADR-039), so every publish failure is logged and swallowed rather than rethrown, and the drain loop survives. Consuming with one reader is the load-bearing detail: FIFO through a single consumer preserves per-session event ordering, so successivepoll.results-changedtallies for the same session can never arrive out of order (the class doc says this at lines 9-19). - Walkthrough:
- The primary constructor (lines 21-24) takes the queue, an
IServiceScopeFactory, and a logger, and the class derives fromBackgroundService, so the host starts and stops it with the application. ExecuteAsync(stoppingToken)(27-53) awaitsqueue.Reader.ReadAllAsync(stoppingToken)in anawait foreach(line 29): one loop, one reader, items handled in enqueue order.- Per item it creates an async DI scope and resolves
ILiveChannelPublisherfrom it (33-34). The resolution is per item deliberately, because the gRPC adapter is registered scoped and there is no ambient request scope inside a hosted service. PublishAsync(workItem.ChannelKey, workItem.EventName, workItem.PayloadJson, stoppingToken)(35-39) is the actual forward.- Cancellation is separated from failure: an
OperationCanceledExceptionraised whilestoppingToken.IsCancellationRequestedreturns quietly (41-45), which is host shutdown mid-publish, not an error. - Any other exception is caught wholesale (47-51, with a scoped
CA1031suppression justifying the broad catch as the best-effort contract) and reported through the source-generatedLogPublishFailedwarning (55-56).[Rubric §13, Observability & Operability]: the failure is structured withChannelKeyandEventNameso a dropped broadcast is traceable even though it is never retried.
- The primary constructor (lines 21-24) takes the queue, an
- Why it's built this way: the live layer's broadcasts are a UX nicety layered on top of already-committed state, so ADR-039 makes them fire-and-forget after
SaveChangesAsync. Pushing them through an in-process channel plus this drain gives the ordering guarantee a raw fire-and-forgetTask.Runwould not, while keeping the command handler free of any Notification-service coupling or latency. - Where it's used: registered with
services.AddHostedService<LiveChannelPublishProcessor>()inMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21, so it only runs where the module's infrastructure is wired. The producer side is the singletonLiveChannelPublishQueue(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:54), written by the live command handlers throughILiveChannelPublishQueue(for exampleCastVoteHandler,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:23). - Caveats / not-in-source: a dropped item is not retried or dead-lettered anywhere in this file; the warning log is the only trace. Note also the asymmetry in the dependency, the processor takes the concrete
LiveChannelPublishQueue(line 22) to reachReader, while producers take the narrowerILiveChannelPublishQueuewrite contract.
QuestionLookupService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/QuestionLookupService.cs:12· Level 3 · sealed class
- What it is: the HTTP implementation of
IQuestionLookupService; it fetches the user-authored feedback questions for a given entity type from the Conferencequestionsresource. - Depends on:
IQuestionLookupService,AuthenticatedServiceBase,ITokenStorageService,QuestionDTO,PagedCollectionResult<T>. Externals:IHttpClientFactory. - Concept: same authenticated-HTTP shape as
BookmarkService, used here to reach a Conference-owned read endpoint from the Engagement UI (the cross-module read discussed atIQuestionLookupService).[Rubric §7, Microservices Readiness]. - Walkthrough (
QuestionLookupService.cs:16-33):GetQuestionsAsync(questionEntity, ct)builds a paged, filtered URL that constrainsQuestionEntityto the (URL-escaped) argument andQuestionSourcetoUser, withpageSize=100(line 22), reads aPagedCollectionResult<QuestionDTO>, and returns its items (or an empty list). Unlike the feedback services it does not route throughServiceExceptionHelper; it justEnsureSuccessStatusCode()s (line 27), since a read has no domain-exception body to translate. - Where it's used: registered in the Engagement UI DI; consumed by both the event and session feedback pages to render their question lists.
SessionBookmarkUIService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionBookmarkUIService.cs:17· Level 3 · sealed class
- What it is: a cross-module HTTP service implementing
ISessionBookmarkUIService, used by the Conference UI to check bookmark state and toggle bookmarks inline. Beyond the HTTP calls, every successful read or mutation also feeds the session-reminder layer. - Depends on:
ISessionBookmarkUIService,AuthenticatedServiceBase,ITokenStorageService,SessionReminderCoordinator,CreateBookmarkRequest,UserSessionBookmarkDTO. Externals:IHttpClientFactory. - Concept, the reminder side effect on the bookmark path.
[Rubric §18, UI Architecture]and[Rubric §19, State Management]. This is where the pure/stateful split pays off: the service does its HTTP work first, then, only on success, callsSessionReminderCoordinatorso reminders track bookmarks no matter which page toggled them. The coordinator's best-effort contract means those calls can never surface a reminder failure to the bookmark caller (the class doc, lines 10-16, states this). - Walkthrough (
SessionBookmarkUIService.cs:25-99):GetBookmarkedSessionIdsAsyncreads thebookmarks/session-idsmap for the user and, when non-null, callsreminderCoordinator.ResyncAsync([.. result.Keys])to heal reminder drift from other devices (25-49, resync at 43-46);CreateBookmarkAsyncPOSTs aCreateBookmarkRequestand, only on a non-null created DTO, callsNotifyBookmarkAddedAsync(sessionId)(52-75, notify at 69-72);DeleteBookmarkAsyncmaps 404 tofalse(89-90), and on success callsNotifyBookmarkRemovedAsync(sessionId)before returningtrue(78-99, notify at 97). All three build authenticated clients and wrap calls in the inheritedRetryPolicy.Endpointis the const"bookmarks"(line 22). - Why it's built this way: keeping the reminder wiring in this one service (rather than in every calling component) guarantees the tracked set stays consistent with the server regardless of entry point (ADR-042 Wave 2).
- Where it's used: registered in the Engagement UI DI as
ISessionBookmarkUIService; injected into Conference session-list and session-detail components for inline bookmark toggles.
SessionFeedbackService
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionFeedbackService.cs:13· Level 3 · sealed class
- What it is: the HTTP implementation of
ISessionFeedbackUIService, backed by thesessionquestionanswersresource. The session-scoped twin ofEventFeedbackService. - Depends on:
ISessionFeedbackUIService,AuthenticatedServiceBase,ITokenStorageService,SessionQuestionAnswerDTO,PagedCollectionResult<T>. Externals:IHttpClientFactory. - Walkthrough (
SessionFeedbackService.cs:19-74): identical in shape toEventFeedbackService, scoped toSessionId,SubmitAnswerAsyncPOSTs{ SessionId, QuestionId, AnswerValue }(19-38),GetMyAnswersAsyncreads a filtered paged list (40-57), andDeleteAnswerAsyncDELETEs by answer id withsessionIdas a query parameter (59-74).Endpointis"sessionquestionanswers"(line 17). SeeEventFeedbackServicefor the shared auth/retry/upsert story. - Where it's used: registered in the Engagement UI DI; consumed by the session feedback page via
ISessionFeedbackUIService.
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:17· Level 8 · sealed partial class
- What it is: the command handler for creating a session bookmark. It orchestrates the full create-or-reactivate flow, cross-module eligibility check, duplicate guard, soft-deleted-row lookup, and delegation of the actual decision to the domain service.
- Depends on:
IUnitOfWork,ISessionBookmarkValidationService(Conference, via gRPC when extracted),IBookmarkManagementDomainService,UserSessionBookmarkDTOMapper,UserSessionBookmark,CreateBookmarkRequest,UserSessionBookmarkDTO,Result<T>. Externals:ILogger<T>. - Concept, the CQRS command handler over a cross-module boundary.
[Rubric §6, CQRS & Event-Driven]assesses whether writes flow through single-purpose handlers,[Rubric §7, Microservices Readiness]whether cross-module rules travel through explicit contracts, and[Rubric §4, DDD]whether the domain owns the decision. This handler is the coordination layer only: it validates eligibility through the Conference contract, does the two repository queries, and hands the create-vs-reactivate decision toIBookmarkManagementDomainService. It implementsICommandHandler<CreateBookmarkRequest, Result<UserSessionBookmarkDTO>>and is wrapped by the decorator pipeline (validate/transaction/logging, see the primer). - Walkthrough (
CreateBookmarkHandler.cs:25-74):- BR-49/BR-91 eligibility (30-32): calls
sessionValidationService.ValidateSessionForBookmarkAsync(command.SessionId, ...); a failure returns its errors verbatim as aResult.Failure. This is the cross-module gRPC read into Conference. - BR-21 duplicate guard (34-47): resolves the typed repository (34) and
ExistsAsyncon(UserId, SessionId)against active rows; if one exists, returnsError.Conflict("UserSessionBookmark.Duplicate", ...)(42-46). - BR-135 reactivation lookup (49-56): re-queries with
ignoreQueryFilters: trueandasTracking: trueso the soft-delete filter does not hide a prior row, then picks the firstIsDeletedmatch (56). - Delegate the decision (58-63):
bookmarkManagementDomainService.CreateOrReactivate(deletedBookmark, command.UserId, command.SessionId); on failure, propagate errors (60-61). - Persist (64-69): only a brand-new bookmark is
AddAsynced (a reactivated one is already tracked), thenSaveChangesAsync(...).ConfigureAwait(false)commits and dispatches theUserSessionBookmarkChangedevent. - Log and map (71-73): the source-generated
LogBookmarkCreated(76-77) records the create, and the handler returns the mappedUserSessionBookmarkDTOviaUserSessionBookmarkDTOMapper.
- BR-49/BR-91 eligibility (30-32): calls
- Why it's built this way: keeping eligibility behind
ISessionBookmarkValidationService(not a direct Conference reference) is what lets Engagement run as its own service, and keeping the create-vs-reactivate branch inIBookmarkManagementDomainServicekeeps this handler thin and the decision unit-testable without a database. The reactivation approach preserves the soft-deleted row's identity and audit trail (ADR-005). - Where it's used: resolved by the Bookmarks REST controller (through the decorator pipeline) on
POST /bookmarks.
UserSessionBookmarkInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmarkInvariants.cs:9· Level 4 · 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(fromMMCA.Common.Domain.Invariants,UserSessionBookmarkInvariants.cs:1),Result, and the module identifier aliasesUserIdentifierType/SessionIdentifierType(solution-wideglobal using, see primer §2). - Concept, the shared-constants invariant class.
[Rubric §4, Domain-Driven Design]assesses whether business rules live in the model rather than leaking into handlers or the database schema. This is the same idiom the value-object invariants use in Group 02: a static class of methods that each return aResultand delegate down to the reusableCommonInvariantstoolbox, so a bookmark's FK-not-default rule reads identically to every other entity's.[Rubric §1, SOLID]: each method has exactly one reason to change. - Walkthrough: two methods, both one line.
EnsureUserIdIsValid(userId, source)(UserSessionBookmarkInvariants.cs:11-12): forwards toCommonInvariants.EnsureIdIsNotDefaultwith the error code"UserSessionBookmark.UserId.Invalid"and the message"User ID must be provided.". Adefaultid (a zerointor emptyGuid, whatever the alias resolves to) fails the check.EnsureSessionIdIsValid(sessionId, source)(UserSessionBookmarkInvariants.cs:14-15): the same shape forSessionId, error code"UserSessionBookmark.SessionId.Invalid".- Both take a
sourcestring that the caller passes as its own method name, so a failure carries the originating call site without a stack trace.
- Why it's built this way: enforcing "a bookmark must reference a real user and a real session" at the domain level (not only via a database NOT NULL) means an invalid bookmark can never be materialized in the first place. Because
SessionIdandUserIdpoint 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, combined throughResult.Combine.
UserSessionBookmark
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.UserSessionBookmarks·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:17· Level 5 · 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>(the base rung, bound toUserSessionBookmarkIdentifierType),UserSessionBookmarkInvariants, theUserSessionBookmarkChangeddomain event,DomainEntityState,IdValueGeneratedAttribute, andResult. - Concept, one domain event with a state enum (BR-60).
[Rubric §4, Domain-Driven Design]and[Rubric §6, CQRS & Event-Driven]assess whether aggregates own their invariants and announce state changes as events. This aggregate is the codebase's clearest example of the deliberate "one event, many states" choice: rather than separateBookmarkCreated/BookmarkDeletedtypes, every lifecycle transition raises a singleUserSessionBookmarkChangedcarrying aDomainEntityStatediscriminator (AddedorDeleted), documented in the class remarks (UserSessionBookmark.cs:13-14). Downstream consumers subscribe to one signal and branch on the enum. - Concept, the reactivation lifecycle (BR-135). This is the only domain type in the module that exposes a
Reactivate()method. It exists because re-bookmarking a previously-removed session must revive the soft-deleted row rather than insert a second one (the database half of that rule isUserSessionBookmarkConfiguration's filtered unique index; the decision half isBookmarkManagementDomainService). - Walkthrough
[IdValueGenerated](UserSessionBookmark.cs:16): marks the id as database-generated, so the factory leavesId = defaultand SQL Server'sIDENTITYfills it (seeIdValueGeneratedAttribute).UserId/SessionId(UserSessionBookmark.cs:20,23):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 (
UserSessionBookmark.cs:26) and a private(userId, sessionId)one (UserSessionBookmark.cs:28-32). Neither is callable from outside, construction runs through the factory. Create(userId, sessionId)(UserSessionBookmark.cs:41-59): combines both invariants viaResult.Combine(lines 45-47); on failure re-wraps the errors asResult.Failure<UserSessionBookmark>(lines 48-49); on success builds the entity withId = default(lines 51-54) and raisesUserSessionBookmarkChanged(DomainEntityState.Added, ...)(line 56).Reactivate()(UserSessionBookmark.cs:67-75): calls the inheritedUndelete()(line 69) and, only if that succeeds, re-raises the sameAddedevent (line 72). The row keeps its identity and audit trail.Delete()(UserSessionBookmark.cs:82-90): overrides the base soft-delete, callsbase.Delete()first (line 84) and, on success, raisesUserSessionBookmarkChanged(DomainEntityState.Deleted, ...)(line 87).
- 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.
BookmarksController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:32· Level 6 · class (sealed)
- What it is: the REST surface for session bookmarks (UC-11), mounted at
/bookmarks. Four endpoints: create a bookmark, list a user's bookmarks (paged, optional event filter), get the set of a user's bookmarked session ids, and delete a bookmark. - Depends on:
ApiControllerBase, the CQRS handler interfacesICommandHandler/IQueryHandler,IEntityQueryService<UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkIdentifierType>,ICurrentUserService,DeleteEntityCommand<UserSessionBookmark, UserSessionBookmarkIdentifierType>,OwnerOrAdminFilter,UserSessionBookmarkDTO,PagedCollectionResult<T>,Result, andEngagementFeatures. Externals: ASP.NET Core MVC,Asp.Versioning,Microsoft.FeatureManagement.Mvc. - Concept, the thin controller over injected handlers.
[Rubric §9, API & Contract Design]assesses whether controllers stay thin and delegate to the application layer. The controller injects handlers directly through its primary constructor (BookmarksController.cs:32-38) and every action does the same three-step move: dispatch to a handler, thenHandleFailure(result.Errors)on failure or the appropriate2xxon success. No business logic lives here. - Concept, layered authorization (attribute filter plus inline check).
[Rubric §11, Security]and[Rubric §9, API & Contract Design]. The class carries five class-level attributes (BookmarksController.cs:27-31):[ApiController],[Route("[controller]")](mounting it at/bookmarks),[ApiVersion("1.0")], a[FeatureGate(EngagementFeatures.SessionBookmarks)]that short-circuits the whole controller when the feature flag is off, and[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]so all endpoints require a token (BR-42). The two list endpoints then add[ServiceFilter(typeof(OwnerOrAdminFilter))](BookmarksController.cs:61,82), the shared MMCA.Common filter (drift D9, ADR-033) configured with ADC's vocabulary in the module registration: theuserIdquery argument must match the caller'suser_idclaim unless the caller is anOrganizer. The delete endpoint deliberately does not use that filter, it keeps a DB-backed inline ownership check instead (see below). - Walkthrough
CreateAsync(BookmarksController.cs:45-54):[HttpPost], dispatches theCreateBookmarkRequestto its command handler and returns201 Createdwith a relative/bookmarks/{id}location on success.GetUserBookmarksAsync(BookmarksController.cs:63-77):[HttpGet], guarded byOwnerOrAdminFilter. Binds a requireduserId, an optionaleventId, andpageNumber/pageSize(both[Range(1, int.MaxValue)], defaults 1 and 10), then returns aPagedCollectionResult<UserSessionBookmarkDTO>.GetBookmarkedSessionIdsAsync(BookmarksController.cs:84-95):[HttpGet("session-ids")], also owner-guarded. Returns anIReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>so the UI can render bookmark toggles in one round-trip.DeleteAsync(BookmarksController.cs:102-134):[HttpDelete("{id}")]. When the caller is not anOrganizer(currentUserService.IsInRole(RoleNames.Organizer)is false, line 108), it resolves the caller'sUserId, returns403 Forbiddenif there is none (lines 110-114), and otherwise runs an ownership existence probe viabookmarkQueryService.ExistsAsync(b => b.Id.Equals(id) && b.UserId == userId.Value, ...)(lines 116-118). A non-owner getsError.NotFound(lines 119-124), deliberately 404, not 403, to avoid leaking that the bookmark exists. Only then does it dispatch theDeleteEntityCommandand return204 No Content.
- Why it's built this way: the two read endpoints use the reusable
OwnerOrAdminFilterbecause query-argument-vs-claim is exactly what that filter generalizes; the delete keeps an inline 404-not-403 check (the same per-mutation pattern Store'sOrdersControllerkeeps inline) because existence-hiding on a mutation is a per-endpoint judgement the generic filter does not make. - Where it's used: mapped by the Engagement service host and reached through the YARP Gateway's
/Bookmarksroute (ADR-008). This section was refreshed against current source; the earlier tier edition omitted the file:line and the inline 404-not-403 delete check.
IBookmarkManagementDomainService
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Services/IBookmarkManagementDomainService.cs:12· Level 6 · 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.
- Depends on:
Result<T>,UserSessionBookmark, and the module identifier aliasesUserIdentifierType/SessionIdentifierType(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+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-vs-create decision. - Walkthrough: one method,
CreateOrReactivate(existingDeletedBookmark?, userId, sessionId)returningResult<UserSessionBookmark>(IBookmarkManagementDomainService.cs: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). 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 (it does the query, the service does 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).
- Where it's used: implemented by
BookmarkManagementDomainService; injected by the Engagement Application-layerCreateBookmarkHandler.
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 6 · class (sealed partial)
- What it is: the Mapperly-generated mapper that projects a
UserSessionBookmarkaggregate to its wire-facingUserSessionBookmarkDTO. - Depends on:
IEntityDTOMapper<UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkIdentifierType>,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 & Code Quality]assess mapping that is explicit and allocation-cheap rather than reflection-based. The[Mapper]attribute (UserSessionBookmarkDTOMapper.cs: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, not a silent null (the framework-wide manual-mapping-vs-Mapperly rationale is taught in Group 12). - Walkthrough (
UserSessionBookmarkDTOMapper.cs:12-24): the class implements the sharedIEntityDTOMappercontract.MapToDTO(entity)(line 16) is declaredpartial, Mapperly writes the property-by-property copy.MapToDTOs(collection)(lines 19-23) is hand-written: it guards null withArgumentNullException.ThrowIfNulland returns[.. collection.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 Scrutor and resolved by the Engagement query pipeline (the
IEntityQueryServicesurface behindBookmarksController) to turn queried bookmark aggregates into DTOs.
BookmarkManagementDomainService
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Services/BookmarkManagementDomainService.cs:10· Level 7 · 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<T>. No injected collaborators, the constructor is implicit. - Concept, soft-delete meets a filtered unique index.
[Rubric §8, Data Architecture]assesses deliberate schema semantics.UserSessionBookmarkConfigurationdeclares a unique index on(UserId, SessionId)filtered toIsDeleted = 0, 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; the index is the database half. - Walkthrough (
BookmarkManagementDomainService.cs:13-28):- If
existingDeletedBookmark is not null(line 18): callexistingDeletedBookmark.Reactivate()(line 20).Reactivate()on the aggregate calls the inheritedUndelete()and re-raisesUserSessionBookmarkChanged(DomainEntityState.Added, ...)(UserSessionBookmark.cs:67-75). If reactivation fails, its errors are propagated asResult.Failure<UserSessionBookmark>(reactivateResult.Errors)(lines 21-22); otherwise the revived entity is returned viaResult.Success(...)(line 24). - If
null: delegate to the factoryUserSessionBookmark.Create(userId, sessionId)(line 27), which validates invariants and raisesUserSessionBookmarkChanged(DomainEntityState.Added, ...)(UserSessionBookmark.cs:41-59).
- 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 singleUserSessionBookmarkChangedevent carryingDomainEntityStaterather than separate Created/Changed events). - Where it's used:
CreateBookmarkHandlercalls it after querying for a soft-deleted match.
ModuleApplicationDbContext
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:16· Level 7 · class (abstract)
- What it is: the Engagement module's EF Core
DbContextbase. It declares the typedDbSet<T>surface for every Engagement entity and inherits auditing, soft-delete query filters, and domain-event dispatch from the framework'sApplicationDbContextvia EF interceptors. - Depends on:
ApplicationDbContext,IEntityConfigurationAssemblyProvider,PhysicalDataSource, and the six Engagement entities:UserSessionBookmark,LivePoll,LivePollOption,LivePollVote,SessionQuestion,SessionQuestionUpvote. Externals: EF Core'sDbContext/DbSet<T>andDbContextOptions. - Concept, module DbContext layering (one context class per engine, ADR-006).
[Rubric §8, Data Architecture]assesses how persistence is structured. The context is a three-rung inheritance chain: thisabstractModuleApplicationDbContext, then the concrete engine classSQLServerDbContext(fromMMCA.Common.Infrastructure), then the frameworkApplicationDbContextbase.abstract(not sealed) is the load-bearing choice: it lets the engine-specific concrete class extend it through ordinary C# inheritance while this rung contributes only the module'sDbSet<T>map. The primary constructor (lines 16-21) acceptsDbContextOptions,IServiceProvider,IEntityConfigurationAssemblyProvider, andPhysicalDataSourceand forwards them all to the base, so warm-up conventions, audit stamping, and outbox capture live once in the framework, not per module. - Walkthrough: six
internal DbSet<T>properties (lines 24-39):UserSessionBookmarks(24),LivePolls(27),LivePollOptions(30),LivePollVotes(33),SessionQuestions(36),SessionQuestionUpvotes(39).internalvisibility keeps the sets invisible outside the Infrastructure assembly, only in-module persistence code touches them. The bookmark aggregate sits alongside the conference-day live layer (polls and questions) because they share one bounded context and therefore one database. - Why it's built this way: database-per-service (ADR-006) gives Engagement its own
ADC_Engagementdatabase plus its owndbo.OutboxMessagestable, so it never races another service for outbox rows. Cross-module references (UserSessionBookmark.UserId/.SessionId, and the SessionQuestion FKs) are scalar columns, not cross-database foreign keys, consistency flows through gRPC + integration events (see the entity configs below). - Where it's used: the
MMCA.ADC.Migrations.SqlServer.Engagementproject references this context to build migration snapshots; the Engagement service host registers the concreteSQLServerDbContextover it and self-applies migrations at startup (ADR-030).
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. - Depends on:
IUnitOfWork,UserSessionBookmark,IBookmarkCountService. - 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 is extracted,BookmarkCountServiceGrpcAdaptersatisfies it over the wire, no Conference call site changes. This is one direction of the bidirectional Conference-Engagement pair (Engagement in turn calls Conference'sISessionBookmarkValidationService). - Walkthrough (
BookmarkCountService.cs:11-22): the primary constructor injectsIUnitOfWork(line 11).GetBookmarkCountForSessionAsync(sessionId, cancellationToken)(lines 14-22) resolves the typed repository viaunitOfWork.GetRepository<UserSessionBookmark, UserSessionBookmarkIdentifierType>()(line 18), then returnsbookmarkRepo.CountAsync(b => b.SessionId == sessionId, cancellationToken)(lines 19-21). The count is aCOUNT(*)pushed to the database (no rows materialized), and the soft-delete global query filter means only active bookmarks are counted. - Why it's built this way: exposing a purpose-built count method (rather than letting Conference query bookmarks) keeps the read cheap and the coupling to a single stable signature, which is what makes the gRPC swap a one-line adapter.
- Where it's used: registered by the Engagement Application DI; wrapped for the wire by
BookmarkCountsGrpcService; consumed by Conference's session read path. When Engagement is disabled in a host,DisabledBookmarkCountServicestands in until the gRPC adapter replaces it.
SessionQuestionConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestionConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
SessionQuestionaggregate root (the audience-questions feature of the live layer): scalar FK columns, a length constraint, and a query-shaped composite index. - Depends on: the engine shim base
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionQuestion, andSessionQuestionInvariants(for the sharedTextMaxLengthconstant). Externals: EF Core'sEntityTypeBuilder<T>. - Concept, module EF configuration over the engine shim.
[Rubric §8, Data Architecture](deliberate schema design) and[Rubric §3, Clean Architecture](EF lives only in Infrastructure; the domain entity carries no persistence attributes). Every Engagement config extendsEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>and callsbase.Configure(builder)first (line 23); the base applies the framework conventions in one place, table name from the entity, schema from the module namespace, the soft-delete query filter, audit columns, and theRowVersionconcurrency token (the full configuration hierarchy is taught in Group 07). The engine choice is the base class itself, swapping it to the Cosmos or SQLite shim would re-point the entity with no body edits (ADR-018), but every current Engagement entity uses the SQL Server shim. - Walkthrough (
SessionQuestionConfiguration.cs:21-40):SessionIdrequired (25-26),UserIdrequired (28-29),Textrequired withHasMaxLength(SessionQuestionInvariants.TextMaxLength)(31-33), the length constant is reused from the domain invariants so schema and validation share one source of truth,Statusrequired (35-36). ThenHasIndex(p => new { p.SessionId, p.Status })(line 39) because both the attendee list and the moderation queue filter by session and status. - Why it's built this way, cross-module FKs stay scalar. The remarks (lines 11-16) state it plainly:
SessionId/EventIdpoint at Conference-owned rows in another database andUserIdat an Identity-owned row (database-per-service), so they remain plain indexed FK columns, not enforced foreign keys; consistency flows through the Conference gRPC validation boundary, never a cross-database constraint.[Rubric §7, Microservices Readiness]. - Where it's used: discovered by assembly scanning and applied when the Engagement
SQLServerDbContextbuilds its model.
SessionQuestionUpvoteConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestionUpvoteConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
SessionQuestionUpvoteaggregate root. Structurally the same shape asSessionQuestionConfiguration(see it for the sharedbase.Configure+ engine-shim story), specialized to enforce one active upvote per question per user. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionQuestionUpvote. Externals: EF Core'sEntityTypeBuilder<T>. - Walkthrough (
SessionQuestionUpvoteConfiguration.cs:21-38):base.Configure(builder)(23),SessionQuestionIdrequired (25-26),UserIdrequired (28-29). The headline is the filtered unique index on(SessionQuestionId, UserId)with.HasFilter("[IsDeleted] = 0")(lines 32-34), which is BR-235 as a database guarantee: at most one active upvote per (question, user), while soft-deleted history may hold prior toggles. A second, non-uniqueHasIndex(v => v.SessionQuestionId)(line 37) supports counting upvotes grouped by question. - Why it's built this way: the filtered unique index is the database-level backstop behind the handler's own create-or-reactivate logic, the same soft-delete + unique-index interplay
BookmarkManagementDomainServicenavigates for bookmarks.[Rubric §8, Data Architecture]. - Caveats / not-in-source: the remarks (lines 12-16) note upvotes reference their question by a scalar FK column with no navigation, they are a separate aggregate by design, so the index is the only structural link.
UserEngagementExportService
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.Exports·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:14· Level 8 · class (sealed)
- What it is: the Engagement half of the cross-service data-subject export (PRIVACY.md §7). It gathers one user's Engagement-owned personal data, their session bookmarks and their submitted session questions, projected to ids + dates, for the aggregated
GET /users/{userId}/exportdocument. - Depends on:
IUnitOfWork,UserSessionBookmark,SessionQuestion, and theIUserEngagementExportServicecontract plus its DTOs fromMMCA.ADC.Engagement.Shared.Exports. - Concept, cross-service data-subject export as a purpose-built read contract.
[Rubric §30, Compliance, Privacy & Data Governance]assesses a real data-subject-access / portability path, and[Rubric §7, Microservices Readiness]assesses talking through explicit contracts. Identity owns the export document but does not own Engagement's data, so it depends only on theIUserEngagementExportServiceinterface; this class satisfies it in-process, andUserEngagementExportServiceGrpcAdaptersatisfies it over the wire once Engagement is a separate service. Same shape asBookmarkCountService, a narrow, stable read contract that survives extraction unchanged. - Walkthrough (
UserEngagementExportService.cs:14-38): the primary constructor injectsIUnitOfWork(line 14).GetUserEngagementExportAsync(userId, cancellationToken)runs two server-side projections: bookmarks viabookmarkRepo.GetProjectedAsync(...)selectingSessionId+CreatedOnfiltered tob.UserId == userId(lines 21-25), and questions viaquestionRepo.GetProjectedAsync(...)selectingQuestionId+SessionId+CreatedOnfiltered toq.UserId == userId(lines 27-31). Both projections run in the database (no aggregates materialized), and because the soft-delete global query filter is in force, only active personal data is exported (documented in the class remarks, lines 8-12). The results are collected into aUserEngagementExportDTOvia collection expressions (lines 33-37). - Why it's built this way: projecting to ids + dates server-side keeps the export cheap and avoids shipping whole aggregates over a service boundary; excluding soft-deleted rows means a data-subject export reflects the user's current active footprint, not their deletion history.
- Where it's used: registered by the Engagement Application DI and consumed (over gRPC, via
UserEngagementExportServiceGrpcAdapter) by Identity'sExportUserDataHandler; when Engagement is disabled,DisabledUserEngagementExportServicestands in.
UserSessionBookmarkConfiguration
MMCA.ADC.Engagement.Infrastructure ·
MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/UserSessionBookmarkConfiguration.cs:17· Level 8 · class (internal sealed)
- What it is: the EF Core mapping for the
UserSessionBookmarkaggregate. Same base shape asSessionQuestionConfiguration; its distinctive job is enforcing BR-21 (one active bookmark per user per session). - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,UserSessionBookmark. Externals: EF Core'sEntityTypeBuilder<T>. - Walkthrough (
UserSessionBookmarkConfiguration.cs:21-35):base.Configure(builder)(23),UserIdrequired (25-26),SessionIdrequired (28-29), then the filtered unique index on(UserId, SessionId)with.HasFilter("[IsDeleted] = 0")(lines 32-34). This is precisely the constraintBookmarkManagementDomainServiceis written around: because a soft-deleted bookmark still occupies the(UserId, SessionId)pair, re-bookmarking must reactivate rather than insert. - Why it's built this way: filtering on
IsDeleted = 0lets a user delete and re-add the same bookmark any number of times over its lifetime while guaranteeing only one is active at a time.[Rubric §8, Data Architecture]. - Caveats / not-in-source: the remarks (lines 12-16) note the FK relationship to
Conference.Sessionis configured at the sharedApplicationDbContextlevel (where both entity types are visible) rather than here, to avoid a cross-module Infrastructure to Domain dependency that would break module isolation.
BookmarkCountServiceGrpcAdapter
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14· Level 9 · class (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 forIBookmarkCountServiceonce Engagement runs as its own process. - Depends on:
IBookmarkCountService; the generatedBookmarkCountService.BookmarkCountServiceClientand the request/response messages frombookmark_count.proto(namespaceMMCA.ADC.Engagement.Contracts.V1). Externals: Grpc.Net client factory. - Concept, the gRPC adapter (Strangler-Fig extraction).
[Rubric §7, Microservices Readiness]and[Rubric §9, API & Contract Design]. The in-processBookmarkCountServiceand this remote adapter implement the same interface, so a consumer swaps transport by swapping one DI registration, not by editing call sites (ADR-007, ADR-008). Note the name overlap: the generated proto service type is also calledBookmarkCountService(in...Contracts.V1), and this adapter's constructor takes its nestedBookmarkCountServiceClient(BookmarkCountServiceGrpcAdapter.cs:14-15); that generated type is distinct from the Application-layer class of the same name. - Walkthrough (
BookmarkCountServiceGrpcAdapter.cs:18-30):GetBookmarkCountForSessionAsync(sessionId, cancellationToken)builds aGetBookmarkCountForSessionRequest { SessionId = sessionId }(lines 22-27), awaits the generated client call, and returnsresponse.Count(line 29). The interface returns a plainint(not aResult<int>), so there is no error trailer to parse; a transient transport fault surfaces as anRpcException, handled upstream by the Polly retry + circuit-breaker pipeline thatAddTypedGrpcClient<T>wraps the client in. - Where it's used: registered in
MMCA.ADC.Conference.Service/Program.cs:254via theAddEngagementBookmarkCountClientDI helper, so Conference calls Engagement's bookmark count over gRPC.
UserEngagementExportServiceGrpcAdapter
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:16· Level 9 · class (sealed)
- What it is: the client-side gRPC adapter for the data-subject export, the remote twin of
UserEngagementExportService. It implementsIUserEngagementExportServiceon top of the generated export client and is what Identity resolves once Engagement is a separate process. - Depends on:
IUserEngagementExportServiceand its DTOs fromMMCA.ADC.Engagement.Shared.Exports; the generatedUserEngagementExportService.UserEngagementExportServiceClientand messages from the export.proto(namespaceMMCA.ADC.Engagement.Contracts.V1). Externals: Grpc.Net client factory,System.Globalization. - Concept, the same-interface transport swap plus wire-date rehydration.
[Rubric §7, Microservices Readiness]and[Rubric §9, API & Contract Design]. This is the export sibling ofBookmarkCountServiceGrpcAdapter: same interface as the in-processUserEngagementExportService, so consumers swap one DI registration (ADR-007). The extra concern here is that the proto carries dates as strings, so the adapter must re-parse them back intoDateTime. - Walkthrough (
UserEngagementExportServiceGrpcAdapter.cs:20-48):GetUserEngagementExportAsync(userId, cancellationToken)sends aGetUserEngagementExportRequest { UserId = userId }(lines 24-29), then maps the response back into aUserEngagementExportDTO, projecting each bookmark and each submitted question into its shared DTO (lines 31-44). EachCreatedOnstring is rehydrated by the privateParseRoundtripUtchelper (lines 47-48), which callsDateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), culture-invariant, round-trip-kind parsing so the UTC instant survives the wire crossing unambiguously. - Why it's built this way: the class doc comment (lines 7-15) states the resilience posture, transport failures propagate to the caller (Identity's
ExportUserDataHandler), which degrades the Engagement export section toAvailable = falserather than failing the whole export. This is best-effort cross-service aggregation: a peer outage never sinks the entire data-subject export. - Where it's used: registered in
MMCA.ADC.Identity.Service/Program.cs:203via theAddEngagementUserExportClientDI helper. - Caveats / not-in-source: this type has no prior tier-edition section; it was authored fresh against current source.
DependencyInjection
MMCA.ADC.Engagement.Contracts ·
MMCA.ADC.Engagement.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16· Level 10 · class (static)
What it is: the Engagement
*.ContractsDI facade (one of severalDependencyInjectionclasses in the module; this is the extraction-wiring one). A static class with two C#extension(IServiceCollection)members that a consuming host calls to swap Engagement's in-process cross-module registrations for their gRPC-backed adapters: bookmark counts (for Conference) and the data-subject export (for Identity).Depends on: the generated
BookmarkCountService.BookmarkCountServiceClient/UserEngagementExportService.UserEngagementExportServiceClient,BookmarkCountServiceGrpcAdapter,UserEngagementExportServiceGrpcAdapter,IBookmarkCountService,IUserEngagementExportService, andAddTypedGrpcClient<T>fromMMCA.Common.Grpc. Externals:Microsoft.Extensions.DependencyInjection(ServiceCollectionDescriptorExtensions.Replace).Concept, the gRPC adapter-swap wiring.
[Rubric §7, Microservices Readiness],[Rubric §17, DevOps & Deployment],[Rubric §16, Maintainability]. ADR-007 requires each extracted service to publish a*.Contractsproject holding the proto definitions and the DI wiring, so consumers depend only on that thin package, not the full module. The C#extension(IServiceCollection)syntax adds the methods directly onto the collection (see primer §4).Walkthrough: one
extension(IServiceCollection services)block (DependencyInjection.cs:18) holding two near-identical methods, each doing exactly two things (register a typed client, thenReplacethe interface binding):Method File:Line What it swaps AddEngagementBookmarkCountClient(serviceName = "engagement")DependencyInjection.cs:43-52registers BookmarkCountServiceClient(line 45), thenservices.Replace(ServiceDescriptor.Scoped<IBookmarkCountService, BookmarkCountServiceGrpcAdapter>())(line 49)AddEngagementUserExportClient(serviceName = "engagement")DependencyInjection.cs:76-85registers UserEngagementExportServiceClient(line 78), thenservices.Replace(ServiceDescriptor.Scoped<IUserEngagementExportService, UserEngagementExportServiceGrpcAdapter>())(line 82)AddTypedGrpcClient<T>wires each client to Aspire service discovery (http://engagement), the JWT-forwarding interceptor, and the Polly resilience handler, all fromMMCA.Common.Grpc. Both useReplace(notTryAdd) so the gRPC adapter wins over whichever binding is already present, either the real in-process implementation (when Engagement is enabled in the host) or the disabled stub (DisabledBookmarkCountService/DisabledUserEngagementExportService, registered byEngagementModule.RegisterDisabledStubswhen it is disabled).TryAddwould silently lose to the already-registered binding;Replaceprevents that ordering bug (the inline comments at lines 47-48 and 80-81 say exactly this).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](discoverable convention, zero config). The XML docs on both methods (lines 36-39 and 67-72) spell out the ordering requirement: call them afterModuleLoader.DiscoverAndRegister(...)so the in-process/stub registration is already in the container forReplaceto find.Where it's used:
MMCA.ADC.Conference.Service/Program.cs:254callsAddEngagementBookmarkCountClient();MMCA.ADC.Identity.Service/Program.cs:203callsAddEngagementUserExportClient().Caveats / not-in-source: this section was refreshed against current source, the earlier edition cited only the bookmark-count method; the second export method (
AddEngagementUserExportClient) and its adapter were added since.
BookmarkCountsGrpcService
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:12· Level 9 · class (sealed)
- What it is: the server-side gRPC endpoint that publishes Engagement's in-process
IBookmarkCountServiceover the wire. It is the counterpart of the client-sideBookmarkCountServiceGrpcAdapter: the adapter runs inside Conference and speaks gRPC out, this service runs inside Engagement and answers it. - Depends on:
IBookmarkCountService(injected via primary constructor), and the generated proto baseBookmarkCountService.BookmarkCountServiceBaseplus the request/response messagesGetBookmarkCountForSessionRequest/GetBookmarkCountForSessionResponsefrombookmark_count.proto(namespaceMMCA.ADC.Engagement.Contracts.V1,BookmarkCountsGrpcService.cs:2). Externals:Grpc.Core(ServerCallContext). - Concept, the server-side gRPC bridge (Strangler-Fig extraction).
[Rubric §7, Microservices Readiness]assesses whether modules talk through explicit, extractable contracts rather than direct references;[Rubric §9, API & Contract Design]assesses stable, versioned wire contracts. This class is the producer half of the same pattern the consumer half taught in Group 13: the module keeps one C# interface (IBookmarkCountService), the proto generates an abstract server base (BookmarkCountServiceBase), and this thin subclass forwards one to the other. Because the interface never changed when Engagement moved out of the monolith, neither Conference's call sites nor Engagement'sBookmarkCountServiceimplementation had to change, only this bridge and its client twin were added (ADR-007, ADR-008). It is the exact mirror, on the Engagement side, of Conference'sSessionBookmarksGrpcService, which completes the bidirectional Conference-Engagement gRPC pair. - Walkthrough: the primary constructor takes the in-process
innerservice (BookmarkCountsGrpcService.cs:12) and the class extends the generatedBookmarkCountService.BookmarkCountServiceBase(line 13). One overridden method,GetBookmarkCountForSession(request, context)(lines 16-28): it null-guards both arguments withArgumentNullException.ThrowIfNull(lines 20-21), awaitsinner.GetBookmarkCountForSessionAsync(request.SessionId, context.CancellationToken)(lines 23-25), threading the gRPC call's own cancellation token through to the database, then wraps the plainintresult innew GetBookmarkCountForSessionResponse { Count = count }(line 27). There is noResult<T>to unwrap here: the interface returns a bareint, so a fault surfaces as an exception, not a failure trailer. - Why it's built this way: keeping the wire endpoint a paper-thin forwarder means all the real read logic (
COUNT(*)under the soft-delete filter) lives once inBookmarkCountServiceand is reused identically in-process and over gRPC. The endpoint is published throughAddGrpcServiceDefaults()(fromMMCA.Common.Grpc, wired inMMCA.ADC.Engagement.Service/Program.cs:210), which also installs theGrpcResultExceptionInterceptorand gRPC reflection; for this method the interceptor has nothing to translate because the call never yields aResultfailure, but any thrown exception still becomes a gRPC status the client's Polly retry + circuit-breaker pipeline can react to. - Where it's used: mapped at
MMCA.ADC.Engagement.Service/Program.cs:239(app.MapGrpcService<BookmarkCountsGrpcService>()), served over the service'sHttp2-only cleartext (h2c) endpoint. Its remote caller is Conference, throughBookmarkCountServiceGrpcAdapter.
UserEngagementExportGrpcService
MMCA.ADC.Engagement.Service ·
MMCA.ADC.Engagement.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:16· Level 9 · class (sealed)
- What it is: the server-side gRPC endpoint that publishes Engagement's in-process
IUserEngagementExportServiceover the wire to the Identity service, so Identity can fold a user's Engagement-owned personal data (session bookmarks + submitted session questions) into the aggregated data-subject export document (PRIVACY.md §7). Same server-bridge shape asBookmarkCountsGrpcService, with one extra concern: dates must be serialized to strings for the wire. - Depends on:
IUserEngagementExportService(injected via primary constructor) and its DTOs fromMMCA.ADC.Engagement.Shared.Exports(UserEngagementExportGrpcService.cs:4); the generated proto baseUserEngagementExportService.UserEngagementExportServiceBaseand the messagesGetUserEngagementExportRequest/GetUserEngagementExportResponse/EngagementBookmarkExportItem/EngagementSubmittedQuestionExportItemfromuser_engagement_export.proto(namespaceMMCA.ADC.Engagement.Contracts.V1, line 3). Externals:Grpc.Core(ServerCallContext),System.Globalization(line 1). - Concept, cross-service data-subject export as a purpose-built read contract.
[Rubric §30, Compliance, Privacy & Data Governance]assesses a real data-subject-access / portability path, and[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 narrowIUserEngagementExportServiceinterface; this class is the producer half over the wire, twin to the client-sideUserEngagementExportServiceGrpcAdapterthat Identity resolves. It is the same server-bridge pattern asBookmarkCountsGrpcService(its own class doc calls that out,UserEngagementExportGrpcService.cs:12-14); the additional[Rubric §9, API & Contract Design]wrinkle is that the proto carries timestamps as strings, so the two ends must agree on a serialization format that round-trips a UTC instant losslessly. - Walkthrough: primary constructor takes the in-process
innerservice (UserEngagementExportGrpcService.cs:16), class extendsUserEngagementExportService.UserEngagementExportServiceBase(line 17). One overridden method,GetUserEngagementExport(request, context)(lines 20-45): null-guards both arguments (lines 22-23), awaitsinner.GetUserEngagementExportAsync(request.UserId, context.CancellationToken)(lines 27-29), then builds aGetUserEngagementExportResponse(line 31) by projecting each source item into its wire message. Bookmarks mapSessionIdplus aCreatedOnserialized with.ToString("O", CultureInfo.InvariantCulture)(the round-trip "O" format, lines 32-36); submitted questions mapQuestionId+SessionId+ the same round-tripCreatedOn(lines 37-42). Both useAddRange(... Select(...))to fill the response's repeated fields. The client adapter re-parses those strings back intoDateTimewith matching round-trip, culture-invariant parsing (seeUserEngagementExportServiceGrpcAdapter). - Why it's built this way: the "O" round-trip format plus
CultureInfo.InvariantCultureis the deliberate contract between the two ends, it encodes the exact UTC instant unambiguously regardless of the server's or client's locale, so a date survives the wire crossing without drift. Keeping the endpoint a thin forwarder leaves the real work (server-side projections filtered to active, non-soft-deleted rows) inUserEngagementExportService, reused identically in-process and over gRPC. As with the bookmark-count endpoint, it is published viaAddGrpcServiceDefaults(); because the read returns a DTO (not aResult), theGrpcResultExceptionInterceptorhas no failure trailer to translate, and a transport fault becomes a gRPC status handled by the caller's resilience pipeline. - Where it's used: mapped at
MMCA.ADC.Engagement.Service/Program.cs:244(app.MapGrpcService<UserEngagementExportGrpcService>()). Its remote caller is Identity'sExportUserDataHandlerviaUserEngagementExportServiceGrpcAdapter; the submitted-question half of the export originates from the live-layerSessionQuestionaggregate. - Caveats / not-in-source: this type has no prior tier-edition section; it was authored fresh against current source.
⬅ ADC Conference - UI • Index • ADC Engagement Live Layer (Real-Time Polls & Session Q&A) ➡