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:5 namespace MMCA.ADC.Engagement.API
    AssemblyReference (Application) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.Application

    Nothing 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 at AssemblyReference.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 an Assembly to 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 on typeof(SomeHandler).Assembly, so moving or renaming a real type never breaks the scan.

  • Walkthrough: two public static readonly fields resolved once at type initialization. Assembly (AssemblyReference.cs:7) is typeof(AssemblyReference).Assembly; AssemblyName (AssemblyReference.cs:8) is Assembly.GetName().Name with a ?? string.Empty fallback, 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-layer DependencyInjection passes to ScanModuleApplicationServices<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 via typeof(...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 · class

One 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:11 namespace MMCA.ADC.Engagement.API
    ClassReference (Application) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.Application
  • Depends 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; ClassReference fills that slot without weakening AssemblyReference'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:11 in both copies), no members. Its only meaningful property is the assembly it belongs to, read via typeof(ClassReference).Assembly inside the scanner.

  • Why it's built this way: a separate non-static anchor sidesteps the static-class generic-argument restriction while leaving AssemblyReference impossible 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-layer DependencyInjection (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 .resx family that sits beside it.
  • Depends on: nothing first-party in code. Its behavioral partners are the shared ErrorLocalizer infrastructure (through the IErrorLocalizer contract) and the AddErrorResources<T>() host registration helper, both named in the type's doc comment.
  • Concept, resource-anchor types for per-module error localization. [Rubric §27, Internationalization] assesses whether user-facing strings are externalized and culture-resolved rather than hard-coded. The doc comment (EngagementErrorResources.cs:3-8) states the mechanism: the type's .resx siblings (EngagementErrorResources.resx for the invariant culture, EngagementErrorResources.es.resx for Spanish) are keyed by a domain error's Code, and the shared IErrorLocalizer resolves a failing Result's code to a translated message once the host registers this type via AddErrorResources<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 EngagementErrorResources with an empty body (EngagementErrorResources.cs:9-11), no members. sealed because it is never meant to be subclassed, it exists purely as a typeof handle for the resource manager.
  • Why it's built this way: one anchor per module keeps error strings out of a single monolithic resource file, so each bounded context owns its own translation table (ADR-027). The type lives in the API layer because that is where errors become HTTP responses.
  • Where it's used: registered by the Engagement service host through AddErrorResources<EngagementErrorResources>(); read by the shared ErrorLocalizer when mapping a domain error code to a message.
  • Caveats / not-in-source: the .resx files themselves and the host registration call live outside this .cs file; 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 UserIdentifierType alias (an identifier type alias linked solution-wide, see primer §4); paired with GetBookmarkedSessionIdsHandler through the CQRS query contract.
  • Concept, the CQRS query as an immutable record. This is a vertical-slice read: the query type and its handler live in the same UseCases/GetBookmarkedSessionIds/ folder. [Rubric §6, CQRS & Event-Driven] assesses whether reads and writes are modeled as distinct messages; this is a pure read, so it carries no validator and no transaction (the query pipeline skips both, see the decorator order in 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 the UserId keeps 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 UserIdentifierType and EventIdentifierType aliases; paired with GetUserBookmarksHandler.
  • Concept, a paged query with defaulted parameters. [Rubric §9, API & Contract Design] assesses whether list endpoints expose paging and filtering as first-class inputs; this query does, defaulting PageNumber to 1 and PageSize to 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 optional EventIdentifierType? 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 required UserId, the nullable EventId event 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 EventId nullable models "all events" as the absence of a filter rather than a magic value.
  • Where it's used: dispatched to GetUserBookmarksHandler, which returns a PagedCollectionResult<T> of UserSessionBookmarkDTO; surfaced by the Bookmarks list endpoint on BookmarksController.

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, no DbContext, and no scope attached. Consumed by ILiveChannelPublishQueue and, on the other side, ILiveChannelPublisher.
  • Concept, the deferred-work message. [Rubric §12, Performance & Scalability] assesses whether slow out-of-process work is kept off the request path: representing a broadcast as data (rather than as an in-flight await) is what makes deferral possible at all. [Rubric §29, Resilience & Business Continuity] assesses failure containment: because the payload is pre-serialized into PayloadJson before enqueueing, the drain never needs the originating scope, its entities, or its DbContext, 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). ChannelKey is the session or event channel key (the doc points at LivePollChannel, ILiveChannelPublishQueue.cs:7), EventName is the channel event name such as poll.results-changed (ILiveChannelPublishQueue.cs:8), and PayloadJson is 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) and CloseLivePollHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:91-92); drained by LiveChannelPublishProcessor.

ILiveChannelPublishQueue

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Live · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:21 · Level 1 · interface

  • What it is: the one-method boundary between a command handler and the live-channel broadcast path. Handlers hand a LiveChannelPublishWorkItem to this queue instead of awaiting a gRPC publish inline.
  • Depends on: LiveChannelPublishWorkItem only. Its implementation is LiveChannelPublishQueue and its drain is LiveChannelPublishProcessor, which forwards to ILiveChannelPublisher.
  • Concept introduced, the non-blocking hand-off that decouples a hot path from a remote peer. The failure mode this exists to prevent is stated in the doc comment (ILiveChannelPublishQueue.cs:12-19): a hung (not refused) Notification peer would otherwise stall a user request for as long as the publish takes, because an inline await inherits 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: TryEnqueue returns 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 returns false only 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) and CloseLivePollHandler (CloseLivePollHandler.cs:22); registered against the singleton implementation in the Application-layer DependencyInjection (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, and AddModuleEngagementAPI() 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 (AddModuleEngagementApplication on the Application-layer DependencyInjection and AddModuleEngagementInfrastructure), the shared OwnerOrAdminFilterOptions / OwnerOrAdminFilter, and the auth vocabulary RoleNames, AuthorizationPolicies, and EngagementPermissions (the last fed into the IPermissionRegistry through AddPermissions).
  • Concept, C# extension(T) DI blocks composing a module top-down. The class body is an extension(IServiceCollection services) block (DependencyInjection.cs:16), the codebase-wide registration idiom (introduced in the primer): the members inside read as instance methods on any IServiceCollection. [Rubric §3, Clean Architecture] assesses whether layering is honored at composition time; AddEngagementModule registers 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, AddModuleEngagementAPI reuses MMCA.Common's OwnerOrAdminFilter and supplies only ADC's field names (ADR-033), then wires the module's permission grants through the shared registry (ADR-020) so the live layer's [HasPermission(...)] endpoints resolve.
  • Walkthrough
    • AddEngagementModule(ApplicationSettings applicationSettings) (DependencyInjection.cs:23) calls AddModuleEngagementApplication(applicationSettings), AddModuleEngagementInfrastructure(), then AddModuleEngagementAPI() (DependencyInjection.cs:25-27) and returns services for chaining. This is the single method EngagementModule.Register invokes.
    • AddModuleEngagementAPI() (DependencyInjection.cs:42) does two things.
      • Configures OwnerOrAdminFilterOptions (DependencyInjection.cs:44-49) with OwnerClaimType = "user_id" (line 46, the custom claim the token service emits), BypassRole = RoleNames.Organizer (line 47, the role that skips the ownership check), and OwnerParameterName = "userId" (line 48, the query argument the Bookmarks list endpoints bind). The filter itself is registered upstream by Common's AddAPI; this call injects only the module's vocabulary.
      • Calls AddPermissions(...) (DependencyInjection.cs:51) and grants both RoleNames.Organizer and RoleNames.Admin the full EngagementPermissions.All set through a collection spread (DependencyInjection.cs:53-54). The doc comment (DependencyInjection.cs:32-40) records the complement: attendee-facing endpoints stay on AuthorizationPolicies.RequireAuthenticated rather than a permission gate.
  • Why it's built this way: one AddEngagementModule entry 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: AddEngagementModule is called by EngagementModule.Register; AddModuleEngagementAPI runs inside it. The configured OwnerOrAdminFilter guards the list endpoints on BookmarksController; the granted permissions gate the live-layer endpoints covered in group-23.
  • Caveats / not-in-source: AddModuleEngagementInfrastructure lives 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 bounded System.Threading.Channels channel holding up to 1024 pending broadcasts, plus the reader handle the hosted drain consumes.
  • Depends on: ILiveChannelPublishQueue and LiveChannelPublishWorkItem first-party; System.Threading.Channels (Channel, BoundedChannelOptions, BoundedChannelFullMode, ChannelReader<T>) and ArgumentNullException from 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 why TryEnqueue returning false is 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 _channel field (LiveChannelPublishQueue.cs:19-25) is created by Channel.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), and SingleWriter = 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 bypass TryEnqueue.
    • TryEnqueue(LiveChannelPublishWorkItem workItem) (LiveChannelPublishQueue.cs:31-35) null-guards with ArgumentNullException.ThrowIfNull(workItem) (line 33) and then returns _channel.Writer.TryWrite(workItem) (line 34). TryWrite never 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. The Reader property 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); its Reader is consumed by LiveChannelPublishProcessor in the Infrastructure layer, which is added with AddHostedService<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 IModule implementation for the Engagement bounded context, the type ModuleLoader discovers by reflection and uses to slot Engagement into the application in dependency order.
  • Depends on: IModule (the contract), ApplicationSettings, the API-layer DependencyInjection extension (AddEngagementModule), and the two disabled-stub pairs it registers when Engagement is not co-hosted: IBookmarkCountService / DisabledBookmarkCountService and IUserEngagementExportService / DisabledUserEngagementExportService.
  • Concept, the module descriptor and its declared dependency on Conference. [Rubric §7, Microservices Readiness] assesses whether a modular monolith can be split into services without a rewrite; a module that declares its dependencies and can register disabled stubs for modules that are not co-hosted is exactly that boundary. Engagement names Conference as a dependency (EngagementModule.cs:20) and sets RequiresDependencies => 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, RegisterDisabledStubs supplies 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 to services.AddEngagementModule(applicationSettings) in the API DependencyInjection. Note the signature also receives an IConfigurationBuilder, which this module does not use.
    • RegisterDisabledStubs(...) (EngagementModule.cs:30-34): registers two singletons for hosts where Engagement is off, IBookmarkCountService as DisabledBookmarkCountService (line 32) and IUserEngagementExportService as DisabledUserEngagementExportService (line 33).
  • Why it's built this way: keeping the module's shape (name, dependencies, register, disabled stubs) behind one contract lets the loader treat every module uniformly, and lets a module run in-process or as an extracted service without changing its business code (ADR-007 / ADR-008). The doc comment (EngagementModule.cs:10-13) records that the module depends on Conference for session data.
  • Where it's used: instantiated and invoked by ModuleLoader during host startup; its Register routes into the API DependencyInjection.

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), the UserSessionBookmark aggregate it reads, the IReadRepository<TEntity, TIdentifierType> it obtains from it, and Result. It implements IQueryHandler<in TQuery, TResult>.
  • Concept, a projection read that never materializes the entity. [Rubric §12, Performance & Scalability] assesses whether reads pull only the columns they need; this handler projects, so 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 a Result rather than throwing.
  • Walkthrough
    • The primary constructor injects IUnitOfWork (GetBookmarkedSessionIdsHandler.cs:12-13); the class closes the query-handler interface over Result<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>>.
    • HandleAsync (GetBookmarkedSessionIdsHandler.cs:16-18) obtains a read repository for UserSessionBookmark (line 20), then calls GetProjectedAsync with select: b => new { b.SessionId, b.Id } and where: 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 SessionId with value Id (GetBookmarkedSessionIdsHandler.cs:27) and wraps it in Result.Success<IReadOnlyDictionary<...>> (line 29). There is no failure branch: the query cannot fail, so the Result is always a success.
  • 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 through CreateBookmarkHandler.

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-module ISessionBookmarkValidationService, IQueryableExecutor, and UserSessionBookmarkDTOMapper (all constructor-injected, GetUserBookmarksHandler.cs:16-20). It reads the UserSessionBookmark aggregate, produces UserSessionBookmarkDTOs, and returns a PagedCollectionResult<T> carrying PaginationMetadata. It implements IQueryHandler<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 through ISessionBookmarkValidationService (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 a Result failure 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 calls sessionValidationService.GetSessionIdsByEventAsync(...) across the module boundary (lines 40-41), returns early with Result.Failure if 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 just b.UserId == query.UserId (line 51).
    • It counts server-side with bookmarkRepo.CountAsync(filter, cancellationToken) (line 55, translated to SQL COUNT), then orders and pages server-side by pushing TableNoTracking.Where(filter).OrderByDescending(b => b.CreatedOn).Skip((query.PageNumber - 1) * pageSize).Take(pageSize) through queryableExecutor.ToListAsync(...) (lines 58-64, translated to SQL ORDER BY plus OFFSET/FETCH).
    • It maps the page with UserSessionBookmarkDTOMapper.MapToDTOs (line 66), builds PaginationMetadata(totalCount, pageSize, query.PageNumber) (line 67), and returns Result.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.Min clamp enforces BR-11 regardless of what the caller sends, and TableNoTracking keeps the paged read off the change tracker.
  • Where it's used: dispatched through the IQueryHandler<in TQuery, TResult> pipeline behind the Bookmarks list endpoint on BookmarksController, whose ownership is guarded by the OwnerOrAdminFilter configured in the API DependencyInjection.
  • Caveats / not-in-source: whether ISessionBookmarkValidationService resolves to an in-process implementation or a gRPC client depends on the host composition, not on this file.

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>, the DeleteEntityCommand<TEntity, TIdentifierType> / DeleteEntityHandler<TEntity, TIdentifierType> pairing bound through ICommandHandler<in TCommand, TResult>), the live-layer types it also wires (LivePoll, LivePollVote, LivePollNavigationPopulator, LivePollResultsBuilder, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder), and the ClassReference marker.
  • 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 in ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:71): a new use case is picked up as soon as its handler exists, with no registration edit. Every explicit line uses TryAdd* rather than Add*, 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: a NullNavigationPopulator (line 39, the bookmark aggregate needs no navigation loading), an EntityQueryService over UserSessionBookmarkDTO for its read surface (line 40), and a DeleteEntityHandler bound to ICommandHandler<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, and TryAddScoped<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 concrete LiveChannelPublishQueue is registered as a singleton (line 54) and ILiveChannelPublishQueue is 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's Reader, and both must share one instance.
    • Live-layer LivePoll aggregate (DependencyInjection.cs:58-62): a real LivePollNavigationPopulator (line 58, unlike the bookmark aggregate this one has navigations to load), a NullNavigationPopulator for LivePollVote (line 59), an EntityQueryService for LivePoll (line 60), a DeleteEntityHandler for LivePoll (line 61), and the LivePollResultsBuilder projection helper (line 62).
    • Live-layer SessionQuestion aggregate (DependencyInjection.cs:65-67): NullNavigationPopulators for SessionQuestion (line 65) and SessionQuestionUpvote (line 66), plus the SessionQuestionViewBuilder (line 67).
    • services.ScanModuleApplicationServices<ClassReference>() (line 71): convention-scans this assembly for domain-event handlers, DTO and request mappers, command and query handlers (including GetUserBookmarksHandler and GetBookmarkedSessionIdsHandler), and validators.
  • Why it's built this way: explicit TryAdd registrations pin the lifetimes and closed generics the scanner cannot infer, while the single scan removes the per-handler registration ceremony; together they keep module wiring short and additive. Passing the assembly marker ClassReference rather than a business type keeps the scan decoupled from any specific handler. The live-layer aggregates are registered here even though their behavior is taught in group-23, because a module registers all of its aggregates from one Application-layer entry point.
  • Where it's used: called by AddEngagementModule in the API DependencyInjection, which is in turn invoked by EngagementModule.Register during 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:5 namespace MMCA.ADC.Engagement.Domain
    AssemblyReference (Infrastructure) MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.Infrastructure
  • Depends 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 an Assembly to 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 on typeof(SomeHandler).Assembly, so renaming or moving a real type never silently breaks a scan.

  • Walkthrough: two public static readonly fields, 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) captures typeof(AssemblyReference).Assembly. AssemblyName (line 8 of each file) reads Assembly.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 typeof cannot 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) in Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:27 (and the frozen combined archive at Source/Hosting/MMCA.ADC.Migrations.SqlServer/DesignTimeSQLServerDbContextFactory.cs:31), which is how the per-service ADC_Engagement database picks up this layer's IEntityTypeConfiguration classes.

  • 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:38 uses typeof(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 · class

One 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:11 namespace MMCA.ADC.Engagement.Domain
    ClassReference (Infrastructure) MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.Infrastructure
  • Depends 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 weakening AssemblyReference's static-ness. [Rubric §33, Developer Experience] assesses how much ceremony the inner loop costs: one conventional token per layer is the whole of it.

  • Walkthrough: public class ClassReference { } at line 11 of both files. No members, no behavior; its only meaningful property is the assembly it belongs to, read as typeof(ClassReference).Assembly by 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 FeatureManagement configuration section to switch a whole capability on or off.
  • Depends on: nothing (three bare const string values).
  • 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 string keys, 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 an IReadOnlyList<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 LiveManage a clean organizer-level capability. Keeping All as 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:107 and :126), and the grants declared during API registration: permissions.Grant(RoleNames.Organizer, [.. EngagementPermissions.All]) plus the same for RoleNames.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, and IConfiguration.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: Http1AndHttp2 effectively degrades to HTTP/1.1 and Kestrel answers gRPC frames with GOAWAY HTTP_1_1_REQUIRED. Forcing HttpProtocols.Http2 makes the listener assume HTTP/2 without negotiating, which is what lets AddTypedGrpcClient peers reach http://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 implicit ASPNETCORE_HTTP_PORTS binding, h2c-only.
    • builder.Configuration.GetValue<int?>("HealthProbe:Port") is int probePort (line 35) is the opt-in: the key is injected only in Azure (as HealthProbe__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 explicit Listen call overrides the container default binding, so 8080 has to be restated), and k.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. MapDefaultEndpoints maps /health, /alive, and /health/ready on 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 from Program.cs so 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 SessionIdentifierType alias (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 class with two required init members: SessionId (MMCA.ADC.Engagement.Shared/Exports/UserEngagementBookmarkExportDTO.cs:10) and CreatedOn, a UTC DateTime (:13). sealed plus record gives value semantics and forbids derivation of the export shape; required means 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 Bookmarks list 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 SessionQuestionIdentifierType and SessionIdentifierType aliases (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 class with three required init members: QuestionId (MMCA.ADC.Engagement.Shared/Exports/UserEngagementSubmittedQuestionExportDTO.cs:10), SessionId (:13), and CreatedOn in 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 SubmittedQuestions list 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 class with two IReadOnlyList<...> members, each defaulting to an empty collection expression []: Bookmarks (MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:11) and SubmittedQuestions (:14). Neither is required, so new 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 UserIdentifierType alias (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 only Engagement.Shared, and the topology decides the implementation. In the Engagement service an in-process implementation is registered; everywhere else a gRPC adapter in MMCA.ADC.Engagement.Contracts satisfies 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) returning Task<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 an if (engagementEnabled) branch. See IModule for the mechanism and DisabledBookmarkCountService for the sibling stub.
  • Walkthrough: sealed, implements the interface, and GetUserEngagementExportAsync(...) (MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:10) is an expression body returning Task.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's RegisterDisabledStubs (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 on IServiceCollection.
  • Depends on: LiveChannelPublishProcessor (from MMCA.ADC.Engagement.Infrastructure.Live); IServiceCollection and AddHostedService (Microsoft.Extensions.DependencyInjection / Hosting).
  • Concept introduced: the extension(T) DI-registration style used across the codebase. Instead of a classic static IServiceCollection AddX(this IServiceCollection ...), the method is declared inside an extension(IServiceCollection services) block (MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:11), so the receiver is named services in scope for every member in the block and callers still write services.AddModuleEngagementInfrastructure(). [Rubric §10, Cross-Cutting] assesses whether composition is uniform: every layer of every module publishes a same-shaped AddModule{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 returns services so 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's SaveChangesAsync critical section (ADR-039 posture: a failed broadcast is logged, never fatal).
  • Where it's used: called by AddEngagementModule in 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) and SessionFeedback (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 a Dictionary<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's MudRating and MudTextField bind with @bind-Value, which requires a settable property, so a record with init-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, default 0) backs star-rating questions. The page's GetAnswerValue (EventFeedback.razor.cs:250) reads whichever field matches the question type, treating RatingValue == 0 as "unanswered" so a zero-star rating serializes to null and is skipped on submit.
  • Why it's built this way: the class is private sealed and 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 PreFillExistingAnswers and read in SubmitFeedbackAsync / GetAnswerValue on 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 SessionIdentifierType and EventIdentifierType aliases).
  • Concept, centralized route paths. [Rubric §25, Navigation & Information Architecture] assesses whether URL strings are defined once or scattered across NavigateTo call 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/SessionPresent for the live layer (EngagementRoutePaths.cs:10-12), and EventFeedback(eventId) => "/feedback/event/{eventId}" (EngagementRoutePaths.cs:15) and SessionFeedback(sessionId) => "/feedback/session/{sessionId}" (EngagementRoutePaths.cs:16). The feedback method outputs exactly match the @page templates on EventFeedback (/feedback/event/{EventId}) and SessionFeedback (/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 const is used for the parameterless "Happening Now" route consumed by the nav item below.
  • Where it's used: HappeningNow seeds EngagementUIModule'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 IUIModule to contribute one navigation item ("Happening Now"), one invisible layout component (LiveEventListener), and its own assembly for Blazor component discovery.
  • Depends on: IUIModule and NavItem (both from MMCA.Common.UI.Common), EngagementRoutePaths, LiveEventListener (an Engagement UI component), and MudBlazor's Icons.
  • 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 an IUIModule; 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 nav Title is a resource key ("Nav.HappeningNow"), resolved by the shared NavMenu against the co-located EngagementUIModule.resx pair at render time via the TitleResource type 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 with new("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 IUIModule by DependencyInjection's AddEngagementUI (DependencyInjection.cs:49); consumed by the shared shell's NavMenu and 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, and DomainHelper (the Parse<T> extension). Externals: Blazor NavigationManager, MudBlazor ISnackbar/MudForm/BreadcrumbItem, and (from the .razor markup) an injected IStringLocalizer<EventFeedback> L plus the UnsavedChangesGuard component.
  • Concept introduced, the code-behind feedback page over dynamic questions. [Rubric §18, UI Architecture] assesses component/logic separation: the markup lives in EventFeedback.razor and the behavior in this partial 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 through MudForm before submit and wraps the form in UnsavedChangesGuard 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 an L["…"] 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 _answers map, the _existingAnswerIds map, the MudForm reference, and the _isDirty flag.
    • SubmitButtonText (EventFeedback.razor.cs:35-46) and HasExistingFeedback (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 via EventLookup.GetAllAsync (setting _loadError if the event is missing), loads "Event" questions ordered by Sort, seeds one AnswerState per question, then pre-fills from the server. Every await passes _cts.Token; OperationCanceledException is 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 whose EventId does not equal the current event (EventFeedback.razor.cs:113) before mapping a rating (culture-invariant int.TryParse) or text value onto the holder.
    • SubmitFeedbackAsync (EventFeedback.razor.cs:137) validates the form, then loops questions, computes each answerValue via GetAnswerValue, skips blanks, and calls FeedbackService.SubmitAnswerAsync per non-empty answer (the upsert). On success it clears _isDirty, snackbars a count, then calls StateHasChanged() before NavigateBack() (EventFeedback.razor.cs:172-173), the inline comment explains this flushes the re-render so UnsavedChangesGuard's IsDirty parameter updates before NavigateTo fires the LocationChanging handler (avoiding a false "unsaved changes" prompt).
    • DeleteAnswerAsync (EventFeedback.razor.cs:189) removes one previously-saved answer (delete commits immediately server-side, so _isDirty is cleared).
    • ParseEventId (EventFeedback.razor.cs:220) converts the route string to EventIdentifierType via EventId.Parse<EventIdentifierType>(), the DomainHelper extension, avoiding a hand-rolled parse.
    • Dispose(bool) / Dispose() (EventFeedback.razor.cs:228-248) implement the standard disposable pattern, cancelling and disposing _cts so in-flight loads stop when the user leaves.
  • 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 UnsavedChangesGuard behavior live outside this .cs file (in EventFeedback.razor and 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 mirrors EventFeedback but first enforces three business preconditions before it will render the question form.
  • Depends on: IQuestionLookupService, ISessionFeedbackUIService, IEntityService<TEntityDTO, TIdentifierType> (bound as IEntityService<SessionDTO, SessionIdentifierType>), SessionDTO, QuestionDTO, SessionQuestionAnswerDTO, AnswerState, and DomainHelper. Externals as in EventFeedback (NavigationManager, ISnackbar, MudForm, IStringLocalizer<SessionFeedback>, UnsavedChangesGuard).
  • Concept, precondition-gated feedback. The submission/pre-fill machinery is identical to EventFeedback (same _answers map, SubmitFeedbackAsync, DeleteAnswerAsync, GetAnswerValue, Dispose, and the same StateHasChanged-before-NavigateBack ordering, SessionFeedback.razor.cs:203-205), so that shape is not re-taught here. The distinguishing logic is CheckPreconditions, 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 via SessionService.GetByIdAsync (SessionFeedback.razor.cs:73); a missing session sets _loadError. It then runs CheckPreconditions(session) and, if that returns a message, stores it in _preconditionMessage and 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 whose Status is set and not "Accepted" (line 123); BR-16 rejects unscheduled sessions where StartsAt or EndsAt is null (line 131), a time-gated action guard. Returning null means feedback is allowed.
    • PreFillExistingAnswers (SessionFeedback.razor.cs:139) applies the same cross-entity guard as the event page, skipping answers whose SessionId does not match (SessionFeedback.razor.cs:145).
    • ParseSessionId (SessionFeedback.razor.cs:253) uses SessionId.Parse<SessionIdentifierType>().
  • Why it's built this way: pushing the three business rules into one CheckPreconditions method keeps the render path clean (either a precondition message or the form) and makes each BR individually reviewable. Sharing the submission shape with EventFeedback by convention (not a shared base) keeps each page self-contained, the same choice made for the duplicated AnswerState.
  • 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 in SessionFeedback.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 on IServiceCollection that registers every Engagement UI service (bookmarks, feedback, lookups, the live layer) plus the EngagementUIModule descriptor.
  • Depends on: IServiceCollection (BCL DI), the Engagement UI service interfaces and implementations (bookmarks, feedback, lookups, live), SessionReminderCoordinator, and IUIModule.
  • 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 calls AddEngagementUI() and stays ignorant of the internals. The C# preview extension(IServiceCollection services) block (DependencyInjection.cs:15) is the same DI-registration idiom used across the codebase (see the primer on extension(T) members); it lets AddEngagementUI read as an instance method on the collection.
  • Walkthrough: AddEngagementUI() (DependencyInjection.cs:21) registers, in groups:
  • 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 SessionIdentifierType alias (solution-wide global using, see primer §2). Externals: BCL DateTimeOffset. 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. A sealed record gives value equality and init-only positional members for free (see records in the primer), so a planned reminder is a snapshot that cannot drift after SessionReminderPlanner computes 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), and DeepLinkRoute (line 19). The doc comment (lines 3-12) is load-bearing intent: NotificationId is derived deterministically from the session id so rescheduling replaces an existing OS notification instead of stacking a duplicate, DeliverAt is the fire instant (start minus lead, clamped to now), and DeepLinkRoute is 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 by SessionReminderCoordinator, which turns each one into a LocalNotificationRequest.

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 before CreateBookmarkHandler sees the command.
  • Depends on: CreateBookmarkRequest, and the UserIdentifierType / SessionIdentifierType aliases. 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; the ValidatingCommandDecorator (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, UserId must not equal default(UserIdentifierType) (lines 13-16) and SessionId must not equal default(SessionIdentifierType) (lines 18-21). Each attaches a human message and a machine-readable WithErrorCode (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 CreateBookmarkHandler and 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 SessionReminder values. 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 the SessionIdentifierType alias. Externals: BCL TimeZoneInfo, 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, including now (line 51), the current instant, which the caller supplies rather than the planner reading DateTimeOffset.UtcNow itself. 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 hash unchecked, producing the stable id the SessionReminder replace-by-id contract relies on.
    • Plan(bookmarkedSessions, timeZoneId, leadTime, now) (lines 47-89) guards its args (53-54), resolves the TimeZoneInfo (56), then for each session skips those with no start time (61-64) or already started (67-70), computes deliverAt = startsAtUtc - leadTime and, if that instant has already passed, fires an immediate reminder 30 seconds out rather than dropping it (72-77), and finally emits a SessionReminder with the tap route from EngagementRoutePaths.SessionDetails (79-86).
    • ToUtc(localWallClock, timeZone) (lines 91-104) is the DST-correct converter: it marks the wall time Unspecified, shifts a spring-forward invalid time forward one hour (96-99), and lets GetUtcOffset resolve 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; GetNotificationId is 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 bookmarks WebAPI resource as the Engagement UI consumes it, create, list-by-user, and delete.
  • Depends on: CreateBookmarkRequest, UserSessionBookmarkDTO, and the UserIdentifierType / EventIdentifierType / UserSessionBookmarkIdentifierType aliases. Externals: BCL Task.
  • Concept, the UI service abstraction. [Rubric §18, UI Architecture] assesses whether components talk to typed service interfaces rather than raw HttpClients. Components depend on this interface, not on BookmarkService, 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 nullable UserSessionBookmarkDTO (lines 12-14); GetUserBookmarksAsync(userId, eventId?, pageNumber, pageSize, ct) returns a tuple of the page items plus the total count (lines 17-22), with eventId optional and paging defaulted to pageNumber = 1 / pageSize = 10; DeleteAsync(id, ct) returns a bool (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: EventQuestionAnswerDTO and the EventIdentifierType / QuestionIdentifierType / EventQuestionAnswerIdentifierType aliases. Externals: BCL Task.
  • 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 at IBookmarkUIService, specialized to feedback.
  • Walkthrough (IFeedbackUIService.cs:23-36): SubmitAnswerAsync(eventId, questionId, answerValue, ct) returns the persisted EventQuestionAnswerDTO (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: BCL Task.
  • 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 shared QuestionDTO contract crosses the boundary.
  • Walkthrough (IFeedbackUIService.cs:12-14): one method, GetQuestionsAsync(questionEntity, ct), where questionEntity is the string discriminator ("Event" or "Session"), returning a read-only list of QuestionDTO.
  • 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: SessionQuestionAnswerDTO and the SessionIdentifierType / QuestionIdentifierType / SessionQuestionAnswerIdentifierType aliases.
  • Walkthrough (IFeedbackUIService.cs:45-58): SubmitAnswerAsync(sessionId, questionId, answerValue, ct) (45-49), GetMyAnswersAsync(sessionId, ct) (51-53), DeleteAnswerAsync(sessionId, answerId, ct) (55-58), all mirroring IEventFeedbackUIService with 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 in IDevicePreferences; 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 the IsSupported guard. [Rubric §27, i18n/Localization]: notification title and body come from the injected IStringLocalizer.
  • Walkthrough:
    • Constants (lines 25-33): the preference keys EnabledKey, LeadMinutesKey, and the private TrackedSessionsKey, plus DefaultLeadMinutes = 15.
    • IsSupported (line 36) delegates to the notification service so UI can hide the settings when reminders are impossible.
    • NotifyBookmarkAddedAsync (39-40) and NotifyBookmarkRemovedAsync (43-44) both funnel through the private MutateTrackedAsync, 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 a try/catch that swallows everything except OperationCanceledException and logs via LogReminderSyncFailed.
    • GetSettingsAsync (75-80) reads the current settings for the settings UI.
    • MutateTrackedAsync(mutate, ct) (82-110) is the shared core: guard on IsSupported, load the tracked set, apply the mutation, persist it, cancel notifications for ids that left the set (98-102), then ReplanAsync.
    • 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, calls SessionReminderPlanner.Plan(...) with DateTimeOffset.UtcNow (147-151), and schedules each resulting SessionReminder as a LocalNotificationRequest (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.
  • 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.NotificationId so 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 calls GetSettingsAsync/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 UserSessionBookmark is created or soft-deleted, carrying which transition happened plus the identifiers.
  • Depends on: BaseDomainEvent (base), DomainEntityState (the Added/Deleted discriminator), and the UserSessionBookmarkIdentifierType / UserIdentifierType / SessionIdentifierType aliases.
  • 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 separate BookmarkCreated / BookmarkDeleted events, this design uses one event whose DomainEntityState payload 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 positional sealed record class inheriting BaseDomainEvent with 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 Added variant (see BookmarkManagementDomainService), 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 UserSessionBookmark aggregate's factory and Delete/Reactivate methods; dispatched after SaveChangesAsync by 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 the bookmarks WebAPI resource through the Gateway with authenticated, retried requests.
  • Depends on: IBookmarkUIService, AuthenticatedServiceBase (base), ITokenStorageService, ServiceExceptionHelper, CreateBookmarkRequest, UserSessionBookmarkDTO, PagedCollectionResult<T> (from MMCA.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 inherited CreateAuthenticatedClientAsync() (which attaches the bearer token from ITokenStorageService), wraps the call in the base RetryPolicy, and routes non-success responses through ServiceExceptionHelper.ThrowIfDomainExceptionAsync so 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): CreateAsync POSTs the CreateBookmarkRequest as JSON and deserializes the created UserSessionBookmarkDTO (21-36); GetUserBookmarksAsync composes the query string with invariant-culture formatting (userId, paging, optional eventId, lines 47-57), reads a PagedCollectionResult<T> of UserSessionBookmarkDTO, and returns items plus PaginationMetadata.TotalItemCount (38-73); DeleteAsync maps a 404 to false (85-86) and any other failure through the exception helper, returning true on success (75-93). Endpoint is 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 EntityServiceBase CRUD, 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 the eventquestionanswers resource, 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): SubmitAnswerAsync POSTs an anonymous { EventId, QuestionId, AnswerValue } object and returns the persisted EventQuestionAnswerDTO (19-38); GetMyAnswersAsync builds an invariant-culture filtered paged URL (filters[EventId].operator=equals&...&pageSize=100&includeChildren=false) and returns the caller's answers (40-57); DeleteAnswerAsync DELETEs by answer id with the eventId as a query parameter (59-74). Endpoint is "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 LiveChannelPublishQueue with exactly one reader and forwards each queued broadcast to ILiveChannelPublisher, 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: BackgroundService from Microsoft.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 successive poll.results-changed tallies 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 from BackgroundService, so the host starts and stops it with the application.
    • ExecuteAsync(stoppingToken) (27-53) awaits queue.Reader.ReadAllAsync(stoppingToken) in an await foreach (line 29): one loop, one reader, items handled in enqueue order.
    • Per item it creates an async DI scope and resolves ILiveChannelPublisher from 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 OperationCanceledException raised while stoppingToken.IsCancellationRequested returns quietly (41-45), which is host shutdown mid-publish, not an error.
    • Any other exception is caught wholesale (47-51, with a scoped CA1031 suppression justifying the broad catch as the best-effort contract) and reported through the source-generated LogPublishFailed warning (55-56). [Rubric §13, Observability & Operability]: the failure is structured with ChannelKey and EventName so a dropped broadcast is traceable even though it is never retried.
  • 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-forget Task.Run would not, while keeping the command handler free of any Notification-service coupling or latency.
  • Where it's used: registered with services.AddHostedService<LiveChannelPublishProcessor>() in MMCA.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 singleton LiveChannelPublishQueue (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:54), written by the live command handlers through ILiveChannelPublishQueue (for example CastVoteHandler, 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 reach Reader, while producers take the narrower ILiveChannelPublishQueue write 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 Conference questions resource.
  • 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 at IQuestionLookupService). [Rubric §7, Microservices Readiness].
  • Walkthrough (QuestionLookupService.cs:16-33): GetQuestionsAsync(questionEntity, ct) builds a paged, filtered URL that constrains QuestionEntity to the (URL-escaped) argument and QuestionSource to User, with pageSize=100 (line 22), reads a PagedCollectionResult<QuestionDTO>, and returns its items (or an empty list). Unlike the feedback services it does not route through ServiceExceptionHelper; it just EnsureSuccessStatusCode()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, calls SessionReminderCoordinator so 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): GetBookmarkedSessionIdsAsync reads the bookmarks/session-ids map for the user and, when non-null, calls reminderCoordinator.ResyncAsync([.. result.Keys]) to heal reminder drift from other devices (25-49, resync at 43-46); CreateBookmarkAsync POSTs a CreateBookmarkRequest and, only on a non-null created DTO, calls NotifyBookmarkAddedAsync(sessionId) (52-75, notify at 69-72); DeleteBookmarkAsync maps 404 to false (89-90), and on success calls NotifyBookmarkRemovedAsync(sessionId) before returning true (78-99, notify at 97). All three build authenticated clients and wrap calls in the inherited RetryPolicy. Endpoint is 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


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 to IBookmarkManagementDomainService. It implements ICommandHandler<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 a Result.Failure. This is the cross-module gRPC read into Conference.
    • BR-21 duplicate guard (34-47): resolves the typed repository (34) and ExistsAsync on (UserId, SessionId) against active rows; if one exists, returns Error.Conflict("UserSessionBookmark.Duplicate", ...) (42-46).
    • BR-135 reactivation lookup (49-56): re-queries with ignoreQueryFilters: true and asTracking: true so the soft-delete filter does not hide a prior row, then picks the first IsDeleted match (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), then SaveChangesAsync(...).ConfigureAwait(false) commits and dispatches the UserSessionBookmarkChanged event.
    • Log and map (71-73): the source-generated LogBookmarkCreated (76-77) records the create, and the handler returns the mapped UserSessionBookmarkDTO via UserSessionBookmarkDTOMapper.
  • 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 in IBookmarkManagementDomainService keeps 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 (from MMCA.Common.Domain.Invariants, UserSessionBookmarkInvariants.cs:1), Result, and the module identifier aliases UserIdentifierType / SessionIdentifierType (solution-wide global 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 a Result and delegate down to the reusable CommonInvariants toolbox, 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 to CommonInvariants.EnsureIdIsNotDefault with the error code "UserSessionBookmark.UserId.Invalid" and the message "User ID must be provided.". A default id (a zero int or empty Guid, whatever the alias resolves to) fails the check.
    • EnsureSessionIdIsValid(sessionId, source) (UserSessionBookmarkInvariants.cs:14-15): the same shape for SessionId, error code "UserSessionBookmark.SessionId.Invalid".
    • Both take a source string 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 SessionId and UserId point 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 through Result.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, UserId and SessionId, 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 to UserSessionBookmarkIdentifierType), UserSessionBookmarkInvariants, the UserSessionBookmarkChanged domain event, DomainEntityState, IdValueGeneratedAttribute, and Result.
  • 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 separate BookmarkCreated / BookmarkDeleted types, every lifecycle transition raises a single UserSessionBookmarkChanged carrying a DomainEntityState discriminator (Added or Deleted), 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 is UserSessionBookmarkConfiguration's filtered unique index; the decision half is BookmarkManagementDomainService).
  • Walkthrough
    • [IdValueGenerated] (UserSessionBookmark.cs:16): marks the id as database-generated, so the factory leaves Id = default and SQL Server's IDENTITY fills it (see IdValueGeneratedAttribute).
    • UserId / SessionId (UserSessionBookmark.cs:20,23): private set scalar 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 via Result.Combine (lines 45-47); on failure re-wraps the errors as Result.Failure<UserSessionBookmark> (lines 48-49); on success builds the entity with Id = default (lines 51-54) and raises UserSessionBookmarkChanged(DomainEntityState.Added, ...) (line 56).
    • Reactivate() (UserSessionBookmark.cs:67-75): calls the inherited Undelete() (line 69) and, only if that succeeds, re-raises the same Added event (line 72). The row keeps its identity and audit trail.
    • Delete() (UserSessionBookmark.cs:82-90): overrides the base soft-delete, calls base.Delete() first (line 84) and, on success, raises UserSessionBookmarkChanged(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 Added event 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 by BookmarkCountService; mapped by UserSessionBookmarkDTOMapper; persisted per UserSessionBookmarkConfiguration; the aggregate type parameter for the CRUD surface on BookmarksController.

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 interfaces ICommandHandler / IQueryHandler, IEntityQueryService<UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkIdentifierType>, ICurrentUserService, DeleteEntityCommand<UserSessionBookmark, UserSessionBookmarkIdentifierType>, OwnerOrAdminFilter, UserSessionBookmarkDTO, PagedCollectionResult<T>, Result, and EngagementFeatures. 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, then HandleFailure(result.Errors) on failure or the appropriate 2xx on 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: the userId query argument must match the caller's user_id claim unless the caller is an Organizer. 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 the CreateBookmarkRequest to its command handler and returns 201 Created with a relative /bookmarks/{id} location on success.
    • GetUserBookmarksAsync (BookmarksController.cs:63-77): [HttpGet], guarded by OwnerOrAdminFilter. Binds a required userId, an optional eventId, and pageNumber / pageSize (both [Range(1, int.MaxValue)], defaults 1 and 10), then returns a PagedCollectionResult<UserSessionBookmarkDTO>.
    • GetBookmarkedSessionIdsAsync (BookmarksController.cs:84-95): [HttpGet("session-ids")], also owner-guarded. Returns an IReadOnlyDictionary<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 an Organizer (currentUserService.IsInRole(RoleNames.Organizer) is false, line 108), it resolves the caller's UserId, returns 403 Forbidden if there is none (lines 110-114), and otherwise runs an ownership existence probe via bookmarkQueryService.ExistsAsync(b => b.Id.Equals(id) && b.UserId == userId.Value, ...) (lines 116-118). A non-owner gets Error.NotFound (lines 119-124), deliberately 404, not 403, to avoid leaking that the bookmark exists. Only then does it dispatch the DeleteEntityCommand and return 204 No Content.
  • Why it's built this way: the two read endpoints use the reusable OwnerOrAdminFilter because 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's OrdersController keeps 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 /Bookmarks route (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 aliases UserIdentifierType / SessionIdentifierType (solution-wide global using, see primer §2). No EF, no repository, no CancellationToken.
  • 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) returning Result<UserSessionBookmark> (IBookmarkManagementDomainService.cs:21-24). The nullable first parameter is the whole design: null means the application layer found no prior soft-deleted record, non-null means it found one (fetched with ignoreQueryFilters: true so 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-layer CreateBookmarkHandler.

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 UserSessionBookmark aggregate to its wire-facing UserSessionBookmarkDTO.
  • 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 the partial method 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 shared IEntityDTOMapper contract. MapToDTO(entity) (line 16) is declared partial, Mapperly writes the property-by-property copy. MapToDTOs(collection) (lines 19-23) is hand-written: it guards null with ArgumentNullException.ThrowIfNull and 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 partial is mandatory, partial lets the generator supply the method body, sealed keeps the type closed.
  • Where it's used: auto-registered by Scrutor and resolved by the Engagement query pipeline (the IEntityQueryService surface behind BookmarksController) 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. A sealed, 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. UserSessionBookmarkConfiguration declares a unique index on (UserId, SessionId) filtered to IsDeleted = 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 blindly Create on 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): call existingDeletedBookmark.Reactivate() (line 20). Reactivate() on the aggregate calls the inherited Undelete() and re-raises UserSessionBookmarkChanged(DomainEntityState.Added, ...) (UserSessionBookmark.cs:67-75). If reactivation fails, its errors are propagated as Result.Failure<UserSessionBookmark>(reactivateResult.Errors) (lines 21-22); otherwise the revived entity is returned via Result.Success(...) (line 24).
    • If null: delegate to the factory UserSessionBookmark.Create(userId, sessionId) (line 27), which validates invariants and raises UserSessionBookmarkChanged(DomainEntityState.Added, ...) (UserSessionBookmark.cs:41-59).
  • 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 same Added domain event so downstream consumers see one uniform "bookmark is now active" signal (BR-60, a single UserSessionBookmarkChanged event carrying DomainEntityState rather than separate Created/Changed events).
  • Where it's used: CreateBookmarkHandler calls 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 DbContext base. It declares the typed DbSet<T> surface for every Engagement entity and inherits auditing, soft-delete query filters, and domain-event dispatch from the framework's ApplicationDbContext via EF interceptors.
  • Depends on: ApplicationDbContext, IEntityConfigurationAssemblyProvider, PhysicalDataSource, and the six Engagement entities: UserSessionBookmark, LivePoll, LivePollOption, LivePollVote, SessionQuestion, SessionQuestionUpvote. Externals: EF Core's DbContext / DbSet<T> and DbContextOptions.
  • 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: this abstract ModuleApplicationDbContext, then the concrete engine class SQLServerDbContext (from MMCA.Common.Infrastructure), then the framework ApplicationDbContext base. 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's DbSet<T> map. The primary constructor (lines 16-21) accepts DbContextOptions, IServiceProvider, IEntityConfigurationAssemblyProvider, and PhysicalDataSource and 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). internal visibility 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_Engagement database plus its own dbo.OutboxMessages table, 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.Engagement project references this context to build migration snapshots; the Engagement service host registers the concrete SQLServerDbContext over 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 on IBookmarkCountService, which lives in MMCA.ADC.Engagement.Shared. In-process this class satisfies that contract; once Engagement is extracted, BookmarkCountServiceGrpcAdapter satisfies it over the wire, no Conference call site changes. This is one direction of the bidirectional Conference-Engagement pair (Engagement in turn calls Conference's ISessionBookmarkValidationService).
  • Walkthrough (BookmarkCountService.cs:11-22): the primary constructor injects IUnitOfWork (line 11). GetBookmarkCountForSessionAsync(sessionId, cancellationToken) (lines 14-22) resolves the typed repository via unitOfWork.GetRepository<UserSessionBookmark, UserSessionBookmarkIdentifierType>() (line 18), then returns bookmarkRepo.CountAsync(b => b.SessionId == sessionId, cancellationToken) (lines 19-21). The count is a COUNT(*) 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, DisabledBookmarkCountService stands 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 SessionQuestion aggregate 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, and SessionQuestionInvariants (for the shared TextMaxLength constant). Externals: EF Core's EntityTypeBuilder<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 extends EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> and calls base.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 the RowVersion concurrency 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): SessionId required (25-26), UserId required (28-29), Text required with HasMaxLength(SessionQuestionInvariants.TextMaxLength) (31-33), the length constant is reused from the domain invariants so schema and validation share one source of truth, Status required (35-36). Then HasIndex(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/EventId point at Conference-owned rows in another database and UserId at 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 SQLServerDbContext builds 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 SessionQuestionUpvote aggregate root. Structurally the same shape as SessionQuestionConfiguration (see it for the shared base.Configure + engine-shim story), specialized to enforce one active upvote per question per user.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionQuestionUpvote. Externals: EF Core's EntityTypeBuilder<T>.
  • Walkthrough (SessionQuestionUpvoteConfiguration.cs:21-38): base.Configure(builder) (23), SessionQuestionId required (25-26), UserId required (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-unique HasIndex(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 BookmarkManagementDomainService navigates 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}/export document.
  • Depends on: IUnitOfWork, UserSessionBookmark, SessionQuestion, and the IUserEngagementExportService contract plus its DTOs from MMCA.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 the IUserEngagementExportService interface; this class satisfies it in-process, and UserEngagementExportServiceGrpcAdapter satisfies it over the wire once Engagement is a separate service. Same shape as BookmarkCountService, a narrow, stable read contract that survives extraction unchanged.
  • Walkthrough (UserEngagementExportService.cs:14-38): the primary constructor injects IUnitOfWork (line 14). GetUserEngagementExportAsync(userId, cancellationToken) runs two server-side projections: bookmarks via bookmarkRepo.GetProjectedAsync(...) selecting SessionId + CreatedOn filtered to b.UserId == userId (lines 21-25), and questions via questionRepo.GetProjectedAsync(...) selecting QuestionId + SessionId + CreatedOn filtered to q.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 a UserEngagementExportDTO via 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's ExportUserDataHandler; when Engagement is disabled, DisabledUserEngagementExportService stands 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 UserSessionBookmark aggregate. Same base shape as SessionQuestionConfiguration; its distinctive job is enforcing BR-21 (one active bookmark per user per session).
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, UserSessionBookmark. Externals: EF Core's EntityTypeBuilder<T>.
  • Walkthrough (UserSessionBookmarkConfiguration.cs:21-35): base.Configure(builder) (23), UserId required (25-26), SessionId required (28-29), then the filtered unique index on (UserId, SessionId) with .HasFilter("[IsDeleted] = 0") (lines 32-34). This is precisely the constraint BookmarkManagementDomainService is 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 = 0 lets 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.Session is configured at the shared ApplicationDbContext level (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 IBookmarkCountService on top of the proto-generated gRPC client. It is what Conference resolves for IBookmarkCountService once Engagement runs as its own process.
  • Depends on: IBookmarkCountService; the generated BookmarkCountService.BookmarkCountServiceClient and the request/response messages from bookmark_count.proto (namespace MMCA.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-process BookmarkCountService and 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 called BookmarkCountService (in ...Contracts.V1), and this adapter's constructor takes its nested BookmarkCountServiceClient (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 a GetBookmarkCountForSessionRequest { SessionId = sessionId } (lines 22-27), awaits the generated client call, and returns response.Count (line 29). The interface returns a plain int (not a Result<int>), so there is no error trailer to parse; a transient transport fault surfaces as an RpcException, handled upstream by the Polly retry + circuit-breaker pipeline that AddTypedGrpcClient<T> wraps the client in.
  • Where it's used: registered in MMCA.ADC.Conference.Service/Program.cs:254 via the AddEngagementBookmarkCountClient DI 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 implements IUserEngagementExportService on top of the generated export client and is what Identity resolves once Engagement is a separate process.
  • Depends on: IUserEngagementExportService and its DTOs from MMCA.ADC.Engagement.Shared.Exports; the generated UserEngagementExportService.UserEngagementExportServiceClient and messages from the export .proto (namespace MMCA.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 of BookmarkCountServiceGrpcAdapter: same interface as the in-process UserEngagementExportService, 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 into DateTime.
  • Walkthrough (UserEngagementExportServiceGrpcAdapter.cs:20-48): GetUserEngagementExportAsync(userId, cancellationToken) sends a GetUserEngagementExportRequest { UserId = userId } (lines 24-29), then maps the response back into a UserEngagementExportDTO, projecting each bookmark and each submitted question into its shared DTO (lines 31-44). Each CreatedOn string is rehydrated by the private ParseRoundtripUtc helper (lines 47-48), which calls DateTime.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 to Available = false rather 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:203 via the AddEngagementUserExportClient DI 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 *.Contracts DI facade (one of several DependencyInjection classes 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, and AddTypedGrpcClient<T> from MMCA.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 *.Contracts project 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, then Replace the interface binding):

    Method File:Line What it swaps
    AddEngagementBookmarkCountClient(serviceName = "engagement") DependencyInjection.cs:43-52 registers BookmarkCountServiceClient (line 45), then services.Replace(ServiceDescriptor.Scoped<IBookmarkCountService, BookmarkCountServiceGrpcAdapter>()) (line 49)
    AddEngagementUserExportClient(serviceName = "engagement") DependencyInjection.cs:76-85 registers UserEngagementExportServiceClient (line 78), then services.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 from MMCA.Common.Grpc. Both use Replace (not TryAdd) 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 by EngagementModule.RegisterDisabledStubs when it is disabled). TryAdd would silently lose to the already-registered binding; Replace prevents 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 after ModuleLoader.DiscoverAndRegister(...) so the in-process/stub registration is already in the container for Replace to find.

  • Where it's used: MMCA.ADC.Conference.Service/Program.cs:254 calls AddEngagementBookmarkCountClient(); MMCA.ADC.Identity.Service/Program.cs:203 calls AddEngagementUserExportClient().

  • 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 IBookmarkCountService over the wire. It is the counterpart of the client-side BookmarkCountServiceGrpcAdapter: 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 base BookmarkCountService.BookmarkCountServiceBase plus the request/response messages GetBookmarkCountForSessionRequest / GetBookmarkCountForSessionResponse from bookmark_count.proto (namespace MMCA.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's BookmarkCountService implementation 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's SessionBookmarksGrpcService, which completes the bidirectional Conference-Engagement gRPC pair.
  • Walkthrough: the primary constructor takes the in-process inner service (BookmarkCountsGrpcService.cs:12) and the class extends the generated BookmarkCountService.BookmarkCountServiceBase (line 13). One overridden method, GetBookmarkCountForSession(request, context) (lines 16-28): it null-guards both arguments with ArgumentNullException.ThrowIfNull (lines 20-21), awaits inner.GetBookmarkCountForSessionAsync(request.SessionId, context.CancellationToken) (lines 23-25), threading the gRPC call's own cancellation token through to the database, then wraps the plain int result in new GetBookmarkCountForSessionResponse { Count = count } (line 27). There is no Result<T> to unwrap here: the interface returns a bare int, 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 in BookmarkCountService and is reused identically in-process and over gRPC. The endpoint is published through AddGrpcServiceDefaults() (from MMCA.Common.Grpc, wired in MMCA.ADC.Engagement.Service/Program.cs:210), which also installs the GrpcResultExceptionInterceptor and gRPC reflection; for this method the interceptor has nothing to translate because the call never yields a Result failure, 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's Http2-only cleartext (h2c) endpoint. Its remote caller is Conference, through BookmarkCountServiceGrpcAdapter.

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 IUserEngagementExportService over 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 as BookmarkCountsGrpcService, with one extra concern: dates must be serialized to strings for the wire.
  • Depends on: IUserEngagementExportService (injected via primary constructor) and its DTOs from MMCA.ADC.Engagement.Shared.Exports (UserEngagementExportGrpcService.cs:4); the generated proto base UserEngagementExportService.UserEngagementExportServiceBase and the messages GetUserEngagementExportRequest / GetUserEngagementExportResponse / EngagementBookmarkExportItem / EngagementSubmittedQuestionExportItem from user_engagement_export.proto (namespace MMCA.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 narrow IUserEngagementExportService interface; this class is the producer half over the wire, twin to the client-side UserEngagementExportServiceGrpcAdapter that Identity resolves. It is the same server-bridge pattern as BookmarkCountsGrpcService (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 inner service (UserEngagementExportGrpcService.cs:16), class extends UserEngagementExportService.UserEngagementExportServiceBase (line 17). One overridden method, GetUserEngagementExport(request, context) (lines 20-45): null-guards both arguments (lines 22-23), awaits inner.GetUserEngagementExportAsync(request.UserId, context.CancellationToken) (lines 27-29), then builds a GetUserEngagementExportResponse (line 31) by projecting each source item into its wire message. Bookmarks map SessionId plus a CreatedOn serialized with .ToString("O", CultureInfo.InvariantCulture) (the round-trip "O" format, lines 32-36); submitted questions map QuestionId + SessionId + the same round-trip CreatedOn (lines 37-42). Both use AddRange(... Select(...)) to fill the response's repeated fields. The client adapter re-parses those strings back into DateTime with matching round-trip, culture-invariant parsing (see UserEngagementExportServiceGrpcAdapter).
  • Why it's built this way: the "O" round-trip format plus CultureInfo.InvariantCulture is 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) in UserEngagementExportService, reused identically in-process and over gRPC. As with the bookmark-count endpoint, it is published via AddGrpcServiceDefaults(); because the read returns a DTO (not a Result), the GrpcResultExceptionInterceptor has 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's ExportUserDataHandler via UserEngagementExportServiceGrpcAdapter; the submitted-question half of the export originates from the live-layer SessionQuestion aggregate.
  • Caveats / not-in-source: this type has no prior tier-edition section; it was authored fresh against current source.

⬅ ADC Conference - UIIndexADC Engagement Live Layer (Real-Time Polls & Session Q&A) ➡