Onboarding guide
23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)
What this chapter covers. This is the conference-day layer of the Engagement bounded context:
the features that only matter while an event is actually happening in the room. There are two of
them, and they share one shape. Live polls, LivePoll
(MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18), let an organizer or a session's speaker
open a multiple-choice question for the audience, collect votes in real time, and project a running
tally. Session Q&A, SessionQuestion
(MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:19), lets attendees submit
questions to a live session, upvote each other's, and lets a moderator approve, dismiss, or
mark-answered from a queue. Three Blazor surfaces read them: the event-wide
HappeningNow board
(MMCA.ADC.Engagement.UI/Pages/HappeningNow/HappeningNow.razor.cs:24), the routed per-session
SessionLive page (.../Pages/SessionLive/SessionLive.razor.cs:26), and the
speaker-facing PresenterView (.../Pages/SessionLive/PresenterView.razor.cs:19).
What makes the layer distinct from the rest of Engagement (the bookmarks and points of
Group 22) is that state changes must fan out to every open page
while the room is still looking at the screen, so the whole chapter is really about one transport
decision: how a vote cast on one phone lights up the tally on two hundred others.
That transport is the SignalR hub-channel push introduced by ADR-039, and it is deliberately the opposite of the durable notification pipeline (ADR-024) that the same hub also carries. A durable notification writes a per-user inbox row and is worth finding minutes later; a live tally is broadcast to whoever is looking right now and is worthless a second later, so it is never persisted and carries no delivery guarantee. Everything in this chapter treats a channel event as a cache-invalidation hint over fetchable state, not as the state itself: if a client connects late and misses an event, its next fetch still shows the truth. That single design rule explains most of the code you will read here.
The two aggregates and their invariants
Both aggregates are sealed
AuditableAggregateRootEntity<TIdentifierType>
subclasses that follow the framework's factory-plus-Result
discipline (primer §2). LivePoll holds an EventId, an optional SessionId (null for an
event-wide poll, BR-230, LivePoll.cs:24), a question, its authored
LivePollOption children, and a strict lifecycle Status
(LivePollStatus): Draft to Open to Closed, no reopen (BR-221, guarded at
LivePoll.cs:110-117 and :143-150). Its Create factory (LivePoll.cs:64) combines four checks
from LivePollInvariants (.../LivePolls/LivePollInvariants.cs:10), and
Open / Close / Delete each guard their transition: an open poll cannot be deleted (BR-228,
LivePoll.cs:210-219), and a successful delete cascades a soft-delete over the options before
raising the event (LivePoll.cs:223-228). SessionQuestion holds a SessionId, a denormalized
EventId (deliberately not validated, since the disabled-stub extension point can report a
default, SessionQuestion.cs:24, :64-67), the submitter's UserId (never exposed on a DTO,
BR-238, :27-28), the text, a QuestionStatus
(Pending/Approved/Dismissed), and an IsAnswered flag; Approve (SessionQuestion.cs:121),
Dismiss (:145), and MarkAnswered (:168) are the moderation transitions (BR-234), each
rejecting the no-op repeat, and Create refuses any initial status other than Pending or Approved
(SessionQuestion.cs:92-99).
The size limits themselves are worth a look, because they are declared once, in the lowest layer
every consumer can reach. LivePollInvariants.QuestionMaxLength is an alias for
LivePollDTO.QuestionMaxLength (LivePollInvariants.cs:13), and the number, 200,
lives on the Shared DTO (MMCA.ADC.Engagement.Shared/LivePolls/LivePollDTO.cs:16) alongside
MinOptions = 2 (:22) and MaxOptions = 10 (:28); option text caps at 100 on
LivePollOptionDTO (.../LivePolls/LivePollOptionDTO.cs:14), and question
text at 500 on SessionQuestionDTO
(.../SessionQuestions/SessionQuestionDTO.cs:18, aliased by
SessionQuestionInvariants at
.../SessionQuestions/SessionQuestionInvariants.cs:13). The domain enforces the rule, the UI caps
its input, and EF sizes the column
(MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollConfiguration.cs:29)
from the same constant, so the three can never drift apart. Option-text uniqueness is compared
case-insensitively (LivePollInvariants.cs:57-64).
The one design idea worth internalizing early is the live-window snapshot. When a poll is opened
(LivePoll.Open, LivePoll.cs:108, stamping LiveWindowEndUtc at :129) or a question is
submitted (SessionQuestion.Create, :77, taking the window end as a parameter at :83), the
event's live-window end is copied onto the aggregate. From then on the aggregate answers "is this
vote still allowed?" (CanAcceptVote, LivePoll.cs:167) or "is this upvote still allowed?"
(CanAcceptUpvote, SessionQuestion.cs:201) against its own snapshotted field, with no
cross-service call per vote (BR-224/BR-237). That matters because votes and upvotes are the
high-frequency operations; paying a gRPC hop on each one would not scale. And like the bookmark
aggregate, both use a single domain event carrying a
DomainEntityState discriminator,
LivePollChanged and SessionQuestionChanged (BR-60,
raised at LivePoll.cs:94, :131, :154, :228), rather than separate per-transition events.
Those domain events are durable BaseDomainEvents
captured by the outbox
(ADR-003).
A vote and an upvote are themselves small aggregates, LivePollVote and
SessionQuestionUpvote, each with a "one active row per (poll/question,
user)" rule enforced by a filtered unique index
(.../EntityConfiguration/LivePollVoteConfiguration.cs:34-37) and the same
reactivate-instead-of-reinsert dance (BR-225/BR-135) the bookmark module uses:
CastVoteHandler reads active and soft-deleted rows in one call, then updates a
live vote, revives a deleted one, or inserts a new one
(MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:52-78), so a user
who changes their mind never piles up tombstones. ToggleUpvoteHandler does
the mirror image and additionally refuses to let an author upvote their own question (BR-235,
.../SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:40-48). Both tables are indexed
for the way they are actually read: the vote table carries a second (LivePollId, OptionId) index
for the grouped tally (LivePollVoteConfiguration.cs:40).
The write path, and where the realtime broadcast actually happens
Each operation is a vertical slice under Application/{LivePolls|SessionQuestions}/UseCases/{Op}/.
The three lifecycle commands do not hand-roll their load-mutate-save sequence at all: they derive
from the framework's
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>
and fill in four template hooks, EntityId, RowVersion, MutateAsync, and the post-save
OnMutatedAsync (OpenLivePollHandler
(.../UseCases/Open/OpenLivePollHandler.cs:26-35, :38-76, :83-91),
CloseLivePollHandler (.../UseCases/Close/CloseLivePollHandler.cs:24-33,
:36-61, :68-76), and ModerateQuestionHandler
(.../UseCases/Moderate/ModerateQuestionHandler.cs:28, :41-48, :51-78, :85-89)). That base is
also where the ADR-035 concurrency token is applied: each handler's RowVersion override hands back
the token the caller stated, so a transition decided against a stale view fails the save
(OpenLivePollHandler.cs:35, CloseLivePollHandler.cs:33, ModerateQuestionHandler.cs:48). What
differs between slices, and what is worth studying, is how the ephemeral broadcast leaves the
process. There are three shapes in the code today, and the differences are deliberate.
The hot paths raise a domain event and broadcast from the handler for it. Casting a vote
(CastVoteHandler.cs:19) and toggling an upvote (ToggleUpvoteHandler.cs:17) publish nothing
themselves. The vote and upvote aggregates raise LivePollVoteChanged and
SessionQuestionUpvoteChanged, and the matching domain-event
handlers, LivePollVoteChangedHandler
(.../LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:38) and
SessionQuestionUpvoteChangedHandler
(.../SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:39), rebuild the
fresh tally and hand a
LiveChannelPublishWorkItem to
ILiveChannelPublishQueue
(LivePollVoteChangedHandler.cs:79-82, SessionQuestionUpvoteChangedHandler.cs:80-83). Both
in-code rationales are worth reading (LivePollVoteChangedHandler.cs:18-24,
SessionQuestionUpvoteChangedHandler.cs:18-25): domain-event dispatch inside a transactional
command is deferred until after the commit and dropped on rollback, so clients are never told about
a vote that never persisted, and the request never awaits a gRPC publish, so a hung Notification
peer cannot add its latency to every upvote. Both handlers are singletons that open their own DI
scope (LivePollVoteChangedHandler.cs:53, SessionQuestionUpvoteChangedHandler.cs:54), and neither
hand-rolls a catch: the whole body runs inside
BestEffort.ExecuteAsync
(LivePollVoteChangedHandler.cs:51, SessionQuestionUpvoteChangedHandler.cs:52), the framework
helper that turns a failed side effect into exactly one Warning plus one increment of
besteffort.dispatch.failed while still rethrowing the caller's own cancellation. A broadcast path
that has quietly stopped working is therefore countable, not just loggable.
The two poll-lifecycle handlers show the same queue used directly from OnMutatedAsync:
CloseLivePollHandler.EnqueueClosed picks the session or event channel key, serializes a
LivePollClosedPayload, and calls Enqueue
(CloseLivePollHandler.cs:84-96) with no guard at all, because Enqueue cannot fail and the queue
never refuses an item (.../Live/ILiveChannelPublishQueue.cs:23-30); the only log left on that path
is the Information "live poll closed" line the base emits through LogMutated
(CloseLivePollHandler.cs:64-65, :98-99). OpenLivePollHandler is identical in shape
(OpenLivePollHandler.cs:99-111). The two question command handlers add the best-effort wrapper
back, because their enqueue block also reads the database:
SubmitQuestionHandler
(.../UseCases/Submit/SubmitQuestionHandler.cs:130-160) and ModerateQuestionHandler
(ModerateQuestionHandler.cs:106-158) resolve the session channel key, serialize a small payload
record to JSON, and enqueue, but their Pending branch first counts the session's pending questions
(SubmitQuestionHandler.cs:148-151, ModerateQuestionHandler.cs:145-148) and that read must never
fail a command that has already committed. Both route through BestEffort.ExecuteAsync
(SubmitQuestionHandler.cs:131, ModerateQuestionHandler.cs:138) and both deliberately withhold the
caller's cancellation token, so an abandoned request cannot turn a saved question into a cancelled
broadcast (SubmitQuestionHandler.cs:119-128, ModerateQuestionHandler.cs:97-104). One detail in
ModerateQuestionHandler is worth copying: the action-to-payload switch is built outside the guard
(:118-136) so an unknown moderation action faults loudly as an ArgumentOutOfRangeException
(:135) instead of being swallowed as a missed broadcast.
CreateLivePollHandler broadcasts nothing at all and takes no queue in its
constructor (.../UseCases/Create/CreateLivePollHandler.cs:20-24): a poll is created as Draft and
there is nothing for an audience to see yet. Channel keys come from the two contract classes shared
by publisher and subscriber, LivePollChannel (ForEvent gives event:1,
ForSession gives session:123, both delegating to Common's
NotificationScopeKey,
MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30) and
SessionQuestionChannel; questions reuse the session key, so a session's
polls and questions ride one channel
(MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionChannel.cs:6-10).
Two rules govern what is allowed on the channel. First, broadcasts never carry per-user data
(BR-229): the results broadcast is built with userId: null so MyVoteOptionId stays null
(LivePollVoteChangedHandler.cs:71-73), and the upvote broadcast carries only the fresh count
(SessionQuestionUpvoteChangedHandler.cs:75-83). Second, pending question content is never
broadcast (BR-238): full text rides the channel only on the approved payload
(SessionQuestionApprovedPayload), so when a pending question is
submitted or leaves the queue the channel carries a question.pending-count-changed count instead
and moderators see the badge move without unmoderated text leaking
(SubmitQuestionHandler.cs:145-159, ModerateQuestionHandler.cs:142-156).
Server-side guards round out the write path. The two hot paths cannot rely on a rowversion conflict,
because a vote only touches the LivePollVote row and never the poll row, so they add an explicit
TOCTOU re-check instead: the handler re-reads the aggregate immediately before saving and documents
the accepted millisecond residue (CastVoteHandler.cs:80-82, :96-120;
ToggleUpvoteHandler.cs:75-77, :96-120). Only the upvote-on path re-checks; clearing an upvote is
deliberately still allowed after a dismissal or after the window closes
(ToggleUpvoteHandler.cs:71-78). And question submission carries a spam cap: a user may hold at most
ten open (non-dismissed) questions per session (SessionQuestionInvariants.cs:15-22, enforced at
SubmitQuestionHandler.cs:72-83), so an auto-approving event default cannot be used to flood the
channel. Both the constant and its enforcement document themselves as a soft cap: the count and
the insert are not one atomic step, so concurrent submits from the same user can briefly exceed it
and moderation drains the overflow (SessionQuestionInvariants.cs:15-21,
SubmitQuestionHandler.cs:66-71).
One WebSocket, one publisher port, and a cross-service ingress
The transport itself is framework-owned (ADR-039, Group 10). The single
NotificationHub carries both durable notifications and
channel events on one connection, and the application-layer port
ILiveChannelPublisher keeps the handlers
transport-free. Which implementation resolves tells you the deployment topology, the same
"resolvable everywhere, active only where configured" convention as the rest of the framework:
SignalRLiveChannelPublisher group-sends
over the hub in a host that maps it, and
NullLiveChannelPublisher is the no-op default.
In ADC the twist is that the Engagement service does not map the hub (the Notification service
does), so Engagement's composition root replaces the registration with a gRPC adapter,
LiveChannelPublisherGrpcAdapter, that
forwards the pre-serialized JSON payload to the Notification service's
LiveChannelGrpcService ingress, which then does
the real group send. The host states that as one .Register(...) step in its application-pipeline
builder (MMCA.ADC.Engagement.Service/Program.cs:283, rationale at :238-245), and the extension
behind that line does a Replace, not a TryAdd, so the adapter beats the framework's Null default
(MMCA.ADC.Notification.Contracts/DependencyInjection.cs:42-51, the Replace itself at :48). This
is exactly the "a host that does not map the hub can replace the registration with its own
transport" extension point ADR-039 anticipates, and it rides the
ADR-012 mixed-endpoint gRPC
profile (Notification serves a dedicated Http2-only endpoint for this ingress alongside its
WebSocket endpoint). Because the payload is an opaque string at every hop, no serializer dependency
crosses the wire.
The queue between the handlers and that adapter is the part to understand before you trust the
latency story. LiveChannelPublishQueue is a bounded System.Threading.Channels channel of capacity
1024 with FullMode = DropOldest and SingleReader = true
(MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:18, :33-40): under sustained
backpressure the freshest broadcast wins, which is the right trade for ephemeral data, and every
discard is counted and logged as a Warning through the channel's itemDropped callback (:40,
:61-70), because TryWrite under DropOldest can never report the drop itself (:30-32). The
single reader is
LiveChannelPublishProcessor
(MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30), a BackgroundService
that resolves the scoped publisher per item (:50-51) and wraps each publish in BestEffort keyed
by the event name (:45-46), so a peer that stops accepting broadcasts is visible on the same meter;
a shutdown mid-publish stops the drain quietly (:60-65). FIFO through one reader is what preserves
per-session event ordering (:13-14).
On the browser side, NotificationHubService
(Common, Group 15) owns the one connection and exposes
JoinChannelAsync / LeaveChannelAsync / a multicast OnChannelEvent subscription, and it
re-joins every tracked channel on reconnect (SignalR group membership does not survive an automatic
reconnect). SessionLive and HappeningNow no longer talk to it directly: they hold a
LiveChannelSubscription helper that owns
the join, the multicast handle, the already-joined flag, and the teardown as one disposable unit
(SessionLive.razor.cs:56, :143, :329; HappeningNow.razor.cs:51, :124-127, :276).
PresenterView still wires the three calls by hand, which is the shape the other two grew out of
(PresenterView.razor.cs:100-110, teardown at :181-185). The join is deliberately not
firstRender-gated: the first render fires at the first await in OnInitializedAsync while the
session is still null, so a firstRender-only join never attached; the subscription's IsJoined
doubles as the already-joined guard, and the RendererInfo.IsInteractive check keeps the prerender
pass and the bUnit suite from dialing the hub (SessionLive.razor.cs:132-137, and the same reasoning
at HappeningNow.razor.cs:109-113 and PresenterView.razor.cs:100-102). SessionLive and
PresenterView also skip their data loads entirely on the prerender pass, since the interactive
instance re-runs OnInitializedAsync and nothing here is cache-served for a logged-in user
(SessionLive.razor.cs:67-73, PresenterView.razor.cs:55-57); HappeningNow knowingly does not,
and says why in a NOTE at HappeningNow.razor.cs:65-66.
The read path and how the UI reacts
Reads do not go through the generic entity-query machinery; the live views need shaped projections.
LivePollResultsBuilder
(MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12) computes tallies
for a whole set of polls in a fixed number of round trips: one grouped COUNT pushed into SQL over
every poll in the set (:59-66) plus, only when a caller is present, one set-wide read of that
caller's own votes (:70-85), which broadcast payloads skip entirely by passing userId: null. The
per-poll loop that shape replaced issued two queries per poll, so a session with a dozen open polls
cost two dozen round trips (:33-38). Votes cast on an option later removed are excluded so the
per-option numbers still add up to the total (:95-107, :114-116), and the poll's concurrency
token travels back on the results DTO so a surface fed only by tallies can still issue an open or
close (:119-121). SessionQuestionViewBuilder
(.../SessionQuestions/Services/SessionQuestionViewBuilder.cs:12) is its mirror for questions
(:36-44), adding per-caller MyUpvote/IsMine flags (:48-58). Those two feed the query handlers
behind GET /livepolls/open, /livepolls/{id}/results, /sessionquestions, and
/sessionquestions/moderation. GetOpenPollsHandler requires an explicit
event or session scope (.../GetOpenPolls/GetOpenPollsHandler.cs:24-30), excludes session-scoped
polls from the event-wide list (BR-230, :41), and then makes one batched build call for the whole
listing (:45-50). Both question reads are bounded server-side so a flooded session cannot produce
an unbounded payload, and the attendee read is the more interesting of the two:
GetSessionQuestionsHandler spends two separate budgets, 200
approved questions ranked by upvote count in the database before the cap applies (a correlated
COUNT subquery over the upvote table, since question and upvote are separate aggregates with no
navigation between them, .../GetSessionQuestions/GetSessionQuestionsHandler.cs:32, :45-58) plus
25 of the caller's own non-approved questions taken newest first (:35, :60-69), because one
shared budget filled by oldest id let a flood of low-value questions push both the most upvoted
question and the caller's own newest submission out of the payload (:17-24). The moderation read
caps at 200 and orders Pending first
(.../GetModerationQueue/GetModerationQueueHandler.cs:26, :46-47, :53-54).
LivePollNavigationPopulator declares the poll's Options child
load for query-service paths EF cannot .Include()
(ADR-002,
.../LivePolls/Services/LivePollNavigationPopulator.cs:11-22), the EF configurations
(LivePollConfiguration and siblings) keep the Conference references as
scalar FK columns under database-per-service
(ADR-006,
.../EntityConfiguration/LivePollConfiguration.cs:11-15) and index the conference-day hot filter
(SessionId, Status) (:37-41), and entity-to-DTO mapping is a compile-time Mapperly mapper,
LivePollDTOMapper
(ADR-001,
.../LivePolls/DTOs/LivePollDTOMapper.cs:12-14).
When a channel event arrives, the page decides between patch-in-place and reload, and this is
the chapter's key performance lesson. The two high-frequency tally events (poll.results-changed,
question.upvote-changed) already carry the fresh counts in their payload, so the page patches its
in-memory model through the shared
LiveBroadcastPatch helper and calls
StateHasChanged with no HTTP refetch (SessionLive.razor.cs:185-209), preserving this
circuit's own vote marker across the patch because the broadcast strips per-user data
(preserveMyVote: true, :190), and falling back to a targeted reload when the payload cannot be
applied (:203-206). The comment there records why: reloading on every broadcast turned V voters
times C viewers into V times C authenticated refetches per hot poll, which collided with the per-user
rate limiter under burst voting (SessionLive.razor.cs:148-152). PresenterView calls the same
helper with preserveMyVote: false, because the projector surface has no vote of its own to keep
(PresenterView.razor.cs:120). Structural events (opened, closed, approved, answered, dismissed,
pending-count-changed) are rarer and do trigger a targeted reload of the affected list
(SessionLive.razor.cs:158-177), and every reload path funnels through one RefreshAsync that names
the lists it wants, stops at the first failure, and degrades a failed background refresh to a snackbar
rather than crashing the page (:211-239). SessionLive itself is the container that owns the
lists, the channel subscription, and the shared saving flag, while the three sections render through
the presentational SessionLivePollPanel,
SessionLiveQuestionPanel, and
SessionLiveModerationPanel children (SessionLive.razor.cs:20-24).
The single session it renders is a point read through
ISessionLookupService rather than a full catalog fetch
(SessionLive.razor.cs:86-88, PresenterView.razor.cs:66, contract at
MMCA.ADC.Engagement.UI/Services/Lookups/ISessionLookupService.cs:19, :30-35). Whether the layer is even
active is decided by LiveEventService
(MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventService.cs:14): it fetches the current-or-next published
event through CurrentEventSelector and
computes its live window with the same math the backend enforces (:27-46), degrading to null on
an API failure (:48-52) so the live surfaces simply stay dormant rather than error; HappeningNow
joins the event channel only while IsLiveAt is true
(MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventContext.cs:22-23, HappeningNow.razor.cs:118-121). The
cross-module ISessionLiveUIService /
SessionLiveUIService contract is what lets a Conference session page light
up its "Live" button when Engagement is deployed
(MMCA.ADC.Engagement.UI/Services/SessionLive/SessionLiveUIService.cs:10-14).
Authorization, feature gating, and the cross-service dependency on Conference
Both controllers, LivePollsController
(MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:45) and
SessionQuestionsController
(.../Controllers/SessionQuestionsController.cs:37), sit behind
ApiControllerBase and are gated two ways: a
plain [Authorize] (no anonymous participation, LivePollsController.cs:43,
SessionQuestionsController.cs:36) and a [FeatureGate] per feature
(EngagementFeatures LivePolls / SessionQA)
that makes the whole surface vanish when toggled off (LivePollsController.cs:42,
SessionQuestionsController.cs:35). The finer authoring/moderation rights (BR-236) are enforced in
the handlers, not by an attribute, through the shared
LivePollAuthorization check
(.../LivePolls/Services/LivePollAuthorization.cs:22-44): organizers and admins manage everything
(:28-31), and a speaker manages only content scoped to a session they are assigned to (matched
against the SessionLiveInfo.SpeakerIds list from
Conference, :33-38). The organizer-only manage list and the delete endpoint additionally carry
[HasPermission(EngagementPermissions.LiveManage)]
(HasPermissionAttribute,
ADR-020,
LivePollsController.cs:149, :168). Crucially, the caller's identity (user id, speaker_id claim,
roles) is always bound from the token via
ICurrentUserService, never from the request body
(LivePollsController.cs:284-293). Two Common API behaviors show up on these routes as well: every
mutating endpoint is marked [Idempotent]
(ADR-017) so a conference-day
retry over flaky wifi replays the first response instead of creating a second poll, question, or vote
(LivePollsController.cs:63, :91, :125, :260, SessionQuestionsController.cs:52, :133,
:159, :185, :205), and the five lifecycle POSTs add
[SupportsIfMatch], which makes the
conditional write mandatory: the action reads the token with
SupportsIfMatchAttribute.RequiredToken(HttpContext), a request with no If-Match header answers
428 Precondition Required and never reaches the handler, and a stale token answers 412 Precondition
Failed (LivePollsController.cs:92, :104, :126, SessionQuestionsController.cs:134, :160,
:186, rationale at LivePollsController.cs:82-88). Casting a vote and submitting a question carry
no such token, because neither touches the aggregate row it read.
This makes the live layer dependent on Conference, the same modular-monolith boundary Group 22
demonstrated (ADR-007 /
ADR-008). Engagement
calls Conference's
IEventLiveValidationService to fetch
the live window, the session's assigned speakers, the published flag, and the event's moderation
default (QuestionModerationDefault,
consumed at SubmitQuestionHandler.cs:41-64, :86-88); it resolves in-process when co-hosted and
over gRPC when extracted (MMCA.ADC.Engagement.Service/Program.cs:282, rationale at :236-237). On
the client side the Conference session page reaches back through the Engagement UI's
ISessionLiveUIService implementation for the Live route. The
EngagementModule declares the dependency, and the
same disabled-stub registrations keep every interface resolvable in a single-module service host,
which is why SessionQuestion.Create tolerates a default EventId (SessionQuestion.cs:24,
:64-67). The UI clients (LivePollUIService,
SessionQuestionUIService) extend Common's
AuthenticatedServiceBase and go back
through the Gateway's public REST routes, not a back channel
(MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:15-19).
Rubric lenses this chapter exercises. [Rubric §4, DDD] (two aggregates with lifecycle state
machines, invariant guards, the live-window snapshot, and the single-event-with-state design);
[Rubric §6, CQRS & Event-Driven] (command/query slices over a shared mutate-entity base, durable
domain events through the outbox, and the separate ephemeral channel broadcast that two of those
domain events trigger); [Rubric §7, Microservices Readiness] (the ILiveChannelPublisher port with
a SignalR implementation, a Null default, and a gRPC forwarding adapter swapped in by Replace, plus
the Conference validation boundary); [Rubric §8, Data Architecture] (filtered unique indexes behind
the create-or-reactivate rule, the (SessionId, Status) conference-day index, and cross-context
references kept as scalar FK columns); [Rubric §12, Performance & Scalability] (per-vote checks
against a snapshotted window with no cross-service hop, set-wide grouped-COUNT tallies instead of
two queries per poll, database-side ranking before a cap, a bounded drop-oldest publish queue off the
request path, and patch-in-place tally updates that avoid the V-times-C refetch storm against the
rate limiter); [Rubric §11, Security] (authentication plus feature gates plus handler-enforced
speaker-scoped rights plus HasPermission, identity from token, anonymous question display, the
open-question spam cap, and pending text kept off the channel, BR-238); [Rubric §9, API & Contract Design] (feature-gated, versioned REST endpoints returning Problem Details, idempotent mutations,
and a mandatory If-Match on every lifecycle transition); [Rubric §18/§19, UI Architecture / State Management] (three live surfaces over one multicast hub subscription, a reusable subscription
helper, a container page with presentational panels, patch-vs-reload event handling, re-join on
reconnect, prerender-skipped loads); [Rubric §29, Resilience] (post-commit best-effort broadcasts
that never fail the command, a drain that swallows every publish failure, and a UI that treats
channel events as hints over fetchable state, degrading to dormant on failure); and [Rubric §13, Observability] (every failed broadcast counted on besteffort.dispatch.failed and every broadcast
discarded under backpressure logged with a running total). Each is taught in full at the relevant
per-type section below.
CastVoteCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote·MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommand.cs:11· Level 0 · record
- What it is: the CQRS command an attendee sends to cast (or change) a vote on an open poll. A
sealed recordcarrying three values:PollId,OptionId, and the votingUserId(MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommand.cs:11-14). - Depends on: nothing first-party (three identifier-type aliases,
LivePollIdentifierType,LivePollOptionIdentifierType,UserIdentifierType, plus the BCLrecord). It is dispatched to CastVoteHandler through the CQRS pipeline. - Concept introduced, the token-bound caller identity.
[Rubric §11, Security](assesses whether identity and authorization derive from a trusted source rather than from client-supplied data). The doc comment is emphatic (CastVoteCommand.cs:3-7):UserIdis bound from the caller's token at the API edge, never from the request body. That is literally what the controller does: the vote action reads the authenticated subject and passes it positionally, while the request body contributes only the chosen option (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:277). The same shape recurs across every live-layer message in this group, so a client cannot vote as (or moderate on behalf of) someone else by forging a field.[Rubric §6, CQRS & Event-Driven]: this is a command (it mutates state and answers with a Result); the read-side counterparts in this group are the...Queryrecords. - Walkthrough: three positional members.
PollId(CastVoteCommand.cs:12) andOptionId(:13) name the vote target;UserId(:14) is the token-bound voter. There is no method here, a command is a pure data message; the behavior lives in its handler and its validator. Note what the record does not carry: noRowVersion, because a vote writes aLivePollVoterow and never touches the poll row, so there is no poll-level precondition to state (see CastVoteHandler). - Why it's built this way: a
recordgives value equality and immutability for free, and keeping the message a flat DTO is the vertical-slice convention (command, validator, and handler co-located under oneUseCases/CastVote/folder,[Rubric §5, Vertical Slice]). - Where it's used: constructed at the Engagement REST edge
(
MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:277, on thePOST /api/livepolls/{id}/votesaction at:259, which is[Idempotent]at:260so a retried vote replays rather than re-runs) and handled by CastVoteHandler; shape-validated first by CastVoteCommandValidator.
CloseLivePollCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Close·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollCommand.cs:12· Level 0 · record
- What it is: the command that closes an open poll (BR-221, no reopen). A
sealed recordcarrying the targetPollId, the two caller-rights fields, and the caller's concurrency token (MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollCommand.cs:12-16). - Depends on: nothing first-party; handled by CloseLivePollHandler.
- Concept introduced, the caller-rights pair plus a stated precondition. This record and its
sibling OpenLivePollCommand share a byte-identical shape:
(LivePollIdentifierType PollId, SpeakerIdentifierType? CallerSpeakerId, bool CallerIsOrganizer, byte[] RowVersion).CallerIsOrganizer(CloseLivePollCommand.cs:15) says whether the caller holds the Organizer or Admin role, andCallerSpeakerId?(:14) is the caller'sspeaker_idclaim when present; both are token-bound exactly like CastVoteCommand'sUserId. The doc comment states the BR-236 rule they feed (CloseLivePollCommand.cs:3-7): event-wide polls require an organizer or admin, session polls also allow the session's assigned speakers.[Rubric §11, Security]: the authorization inputs are declared on the command and the decision is made centrally by LivePollAuthorization inside the handler, not scattered per controller. The fourth member,RowVersion(:16), is the caller's last-observed optimistic-concurrency token, documented at:11as read from the request'sIf-Matchheader (ADR-035).[Rubric §9, API & Contract Design]: making the precondition an explicit field of the message means the handler never has to reach into HTTP to learn it. - Walkthrough: four positional members (
:13-16); no methods. - Why it's built this way: passing the caller's facts (role flag, speaker id) rather than the
caller's decision keeps the authorization rule in one testable place, so open and close cannot
drift apart. Carrying
RowVersionon the command is what lets the shared write workflow stamp it without any handler-specific plumbing (see MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>). - Where it's used: built by the close action at
MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:140fromSupportsIfMatchAttribute.RequiredToken(HttpContext)(:138), on an endpoint marked[Idempotent]and[SupportsIfMatch](:124-126) so a missing header answers 428 and a stale token answers 412; handled by CloseLivePollHandler.
GetEventPollsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetEventPolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetEventPolls/GetEventPollsQuery.cs:7· Level 0 · record
- What it is: the read-side query for the organizer manage view: all of an event's polls regardless
of status. A one-field
sealed recordoverEventId(MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetEventPolls/GetEventPollsQuery.cs:7). - Depends on: nothing first-party; handled by GetEventPollsHandler.
- Concept:
[Rubric §6, CQRS & Event-Driven]: a query is side-effect-free and returns data. Unlike the attendee-facing read queries in this group it carries noUserId, because the manage view surfaces no per-user state (no "my vote"), and unlike GetSessionManagePollsQuery it carries no caller-rights fields either, because its endpoint is gated by a permission attribute instead. - Walkthrough: a single positional
EventIdmember (GetEventPollsQuery.cs:7). - Why it's built this way: the manage tab wants every poll (Draft, Open, Closed), so the query is
deliberately unfiltered by status (doc comment,
:3-5), and its authorization is applied at the controller edge with[HasPermission(EngagementPermissions.LiveManage)](MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:169) rather than inside the handler. - Where it's used: constructed on the
GET /api/livepolls?eventId=action (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:168,175) and handled by GetEventPollsHandler, which maps to LivePollDTO.
GetOpenPollsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetOpenPolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetOpenPolls/GetOpenPollsQuery.cs:11· Level 0 · record
- What it is: the attendee-facing query for open polls with tallies and the caller's own vote. A
sealed recordcarrying two nullable scopes and aUserId(MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetOpenPolls/GetOpenPollsQuery.cs:11-14). - Depends on: nothing first-party; handled by GetOpenPollsHandler.
- Concept introduced, the exactly-one-scope query. The doc comment (
GetOpenPollsQuery.cs:3-7) states the contract: exactly one scope applies. WithSessionIdset it returns that session's open polls, otherwise the event-wide open polls ofEventId, with session-scoped polls excluded. Both are nullable (EventId?at:12,SessionId?at:13) so one message type serves both the event-wide board and the per-session live page.UserId(:14) is the token-bound caller, so each returned poll can surface that user's own vote.[Rubric §9, API & Contract Design]: one flexible read contract rather than two near-duplicate endpoints. - Walkthrough: three positional members (
:12-14); the "which scope wins" decision is enforced by GetOpenPollsHandler, not by the type. - Why it's built this way: collapsing the two scopes into one nullable pair keeps every live surface calling a single handler; the handler, not the record, rejects the "neither scope" case, so the message stays a plain data carrier.
- Where it's used: constructed on the
GET /api/livepolls/openaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:209,224) and handled by GetOpenPollsHandler, which returns LivePollResultsDTO tallies.
GetPollResultsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetPollResults·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsQuery.cs:9· Level 0 · record
- What it is: the query for one poll's tallies (any status), with the caller's own vote. A
two-field
sealed recordoverPollIdandUserId(MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsQuery.cs:9). - Depends on: nothing first-party; handled by GetPollResultsHandler.
- Concept: this is the single-poll refresh read, and it is the client half of the ADR-039
push-then-pull rule. The doc comment names its trigger (
GetPollResultsQuery.cs:3-6): the UI calls it to refresh one card when apoll.results-changedchannel event arrives (the event name is the constant atMMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:20).[Rubric §12, Performance & Scalability]: the live push carries a signal that something changed and the client pulls the authoritative tally for just the affected poll, so a broadcast never has to fan out per-user vote state. - Walkthrough: two positional members,
PollIdand the token-boundUserId, on one line (:9). - Why it's built this way: refreshing one card by id (rather than re-listing every open poll) is the cheap reaction to a push signal, and it re-reads this caller's vote, which the shared broadcast deliberately omits.
- Where it's used: constructed on the
GET /api/livepolls/{id}/resultsaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:233,247) and handled by GetPollResultsHandler.
GetSessionManagePollsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetSessionManagePolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetSessionManagePolls/GetSessionManagePollsQuery.cs:12· Level 0 · record
- What it is: the query behind the per-session moderation panel: all of one session's polls
regardless of status. A
sealed recordoverSessionIdplus the caller-rights pair (MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetSessionManagePolls/GetSessionManagePollsQuery.cs:12-15). - Depends on: nothing first-party; handled by GetSessionManagePollsHandler.
- Concept introduced, the authorization-gated read. This is the read-side twin of the caller-rights
shape CloseLivePollCommand introduces: it carries
CallerSpeakerId?(:14) andCallerIsOrganizer(:15) so the handler can apply BR-236 to a query. The doc comment spells out why (GetSessionManagePollsQuery.cs:3-8): organizers and admins see everything, and a speaker sees the polls of a session they are assigned to.[Rubric §11, Security]. The reason a query needs its own caller facts at all is the pipeline: the CQRS decorator chain (ADR-014) wraps commands with a validating decorator, but a read that must be gated has to check its own rights inside the handler. Contrast GetEventPollsQuery, whose endpoint is gated by[HasPermission(EngagementPermissions.LiveManage)]and therefore needs no caller fields. - Walkthrough: three positional members,
SessionIdfirst (:13), then the caller-rights pair (:14-15). No methods. - Why it's built this way: the controller's own doc comment is the rationale
(
MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:182-188): themanageendpoint is deliberately not behind the organizer-onlyLiveManagepermission, because a role-based gate would answer a session's assigned speaker with a 403 they then have to work around. Moving the decision into the handler (where the Conference module's assigned-speaker list is reachable) makes the rule expressible. - Where it's used: constructed on the
GET /api/livepolls/manage?sessionId=action (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:189,197) and handled by GetSessionManagePollsHandler.
OpenLivePollCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Open·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollCommand.cs:12· Level 0 · record
- What it is: the command that opens a Draft poll for voting (BR-221/BR-223). A
sealed recordstructurally identical to CloseLivePollCommand:PollId, the caller-rights pair, andRowVersion(MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollCommand.cs:12-16). - Depends on: nothing first-party; handled by OpenLivePollHandler.
- Concept: same caller-rights pair and same
If-Matchprecondition as CloseLivePollCommand (see there for the BR-236 rule and ADR-035; the identical doc comments sit atOpenLivePollCommand.cs:3-11). What differs between open and close is the handler's behavior, not the message shape: open must additionally fetch and snapshot the live window. - Walkthrough: four positional members,
PollId(:13),CallerSpeakerId?(:14),CallerIsOrganizer(:15),RowVersion(:16); no methods. - Why it's built this way: keeping open and close as separate one-purpose commands (rather than a
single "SetStatus" command) makes each transition's rights and side effects explicit and
independently testable, which is the vertical-slice convention (
[Rubric §5, Vertical Slice]). - Where it's used: built by the open action at
MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:106from the requiredIf-Matchtoken (:104), on an endpoint marked[Idempotent]and[SupportsIfMatch](:90-92); handled by OpenLivePollHandler.
CastVoteCommandValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote·MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommandValidator.cs:8· Level 1 · class
- What it is: the FluentValidation validator that shape-checks a
CastVoteCommand before the handler runs. A
sealed class : AbstractValidator<CastVoteCommand>(MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommandValidator.cs:8). - Depends on:
FluentValidation.AbstractValidator<T>(NuGet, imported at:1, see primer §3) and the command it validates. - Concept introduced, structural validation in the pipeline.
[Rubric §24, Forms, Validation & UX Safety](assesses whether input is validated before mutation, with actionable, coded errors). Per ADR-014 the CQRS chain wraps a command handler with ValidatingCommandDecorator<TCommand, TResult>, which runs the registered validator before the handler and its transaction, so a malformed command never reaches the domain. Each rule pairs a human message with a stable error code (WithErrorCode), which the localization edge keys on. This is shape validation only: the business rules (poll open, inside the live window, option belongs to the poll) live in the domain, onLivePoll.CanAcceptVote(MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:167-197), not here.[Rubric §15, Best Practices & Code Quality]: that split is what keeps a validator from slowly becoming a second, divergent copy of the invariants. - Walkthrough: the parameterless constructor (
:10) declares threeRuleFor(...).NotEqual(default(...))chains, one per command field, each with a message and a code:PollId(:12-15, codeLivePollVote.PollId.Required),OptionId(:17-20, codeLivePollVote.OptionId.Required), andUserId(:22-25, codeLivePollVote.UserId.Required).NotEqual(default(...))is the idiomatic check for the identifier-type aliases, which are value types, so "missing" means "zero" rather than "null". - Why it's built this way: FluentValidation validators are discovered by assembly scanning and run
by the pipeline decorator, so "is the input well-formed" stays out of the handler (
[Rubric §5, Vertical Slice]and[Rubric §6, CQRS & Event-Driven Design]). Note that the lifecycle commands in this unit ship no validator: OpenLivePollCommand and CloseLivePollCommand have nothing to shape-check beyond an id the route already bound, and their real guards are the domain transition plus the BR-236 rights check. - Where it's used: resolved and run by the CQRS validating decorator ahead of CastVoteHandler.
CastVoteHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote·MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:19· Level 10 · class
What it is: the command handler that records (or changes) a vote on an open poll and answers with the fresh tallies. It broadcasts nothing itself: the
poll.results-changedpush is raised as a domain event and enqueued post-commit by LivePollVoteChangedHandler (stated in the doc comment,MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:11-18).Depends on: IUnitOfWork, LivePollResultsBuilder, the BCL
TimeProvider, andILogger<T>(primary constructor,:20-23); it implements ICommandHandler<in TCommand, TResult> asICommandHandler<CastVoteCommand, Result<LivePollResultsDTO>>(:23), and it works over LivePoll, LivePollVote, and LivePollResultsDTO. Note two things that are absent: there is no live-channel dependency at all (neither publisher port nor ILiveChannelPublishQueue), and unlike its lifecycle siblings this handler does not derive from MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>, because the aggregate it writes (the vote) is not the aggregate the command names (the poll).Concept introduced, the one-active-vote soft-delete dance (BR-225/BR-135).
[Rubric §8, Data Architecture](assesses how soft-delete coexists with uniqueness without duplicate rows). A user may vote, change their vote, retract it, then vote again; the invariant is exactly one active vote per (poll, user), backed by a filtered unique index. The handler realizes that with a three-way branch (:52-78):- Load all rows for (poll, user) through the repository's
FindIncludingDeletedAsync(:52-55), whose contract returns a named tuple of(Active, SoftDeleted)collections (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:215), so the soft-deleted row is visible without a caller having to remember anignoreQueryFiltersflag. The two heads are taken at:56-57, and the load is tracked (asTracking: true,:54) because the branch below mutates them. - If an active vote exists,
activeVote.ChangeOption(command.OptionId)(:61) updates the row in place. - Else if a soft-deleted vote exists,
deletedVote.Reactivate(command.OptionId)(:67) un-deletes and re-points it, rather than inserting a duplicate that would collide with the index. - Else
LivePollVote.Create(...)builds a fresh vote andAddAsyncstages it (:73-77).
Every branch propagates its Result failure rather than throwing (
:62-63,:68-69,:74-75). This is the exact pattern the bookmark feature (BR-135) established, reused so a hot, re-votable poll never accumulates dead rows.- Load all rows for (poll, user) through the repository's
Concept introduced, the TOCTOU re-check.
[Rubric §29, Resilience & Business Continuity]. Between the eligibility check and the save, a concurrent close can commit. A rowversion conflict cannot catch that race, and the handler's own doc comment explains exactly why (:96-105): casting a vote only inserts or updates aLivePollVoterow and never touches the poll row, so the poll's concurrency token is never part of this unit of work. InsteadRecheckPollAcceptsVoteAsync(:106-120) re-reads the poll fresh immediately before saving (:111-115) and re-runsCanAcceptVote(:119). The comment is honest that a millisecond window remains and is accepted, since such a vote is indistinguishable from one cast just before the close.Concept reinforced, broadcast privacy (BR-229), enforced one layer out.
[Rubric §11, Security]and[Rubric §12, Performance]. The command returns the caller's full tally including their own vote (:91), while the fan-out payload must not leak one user's choice to every subscriber. Neither concern is settled here: the vote aggregate raises LivePollVoteChanged, and LivePollVoteChangedHandler rebuilds the tally withuserId: nullsoMyVoteOptionIdstays null before enqueueing (MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:71-82). The comment left behind here (CastVoteHandler.cs:88-90) names the reason for the move: enqueuing inside the command would publish tallies for a vote that a later rollback discards. Each client then refreshes its own card via GetPollResultsQuery, which re-reads its vote.Walkthrough: resolve the poll repository and load the poll with its
Options, no-tracking (:30-35); NotFound guard (:37-41); the domain gatepoll.CanAcceptVote(timeProvider.GetUtcNow().UtcDateTime, command.OptionId)(:43), which enforces open plus inside the snapshotted window plus option-belongs-to-poll (MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:169-194), with a short-circuit on failure (:44-45); resolve the vote repository (:47); the three-way vote branch (:52-78); the TOCTOU re-check (:80-82);SaveChangesAsync(:84); a source-generatedLoggerMessageat Information level (:86, declared:122-123),[Rubric §13, Observability]; then rebuild the caller's tallies through LivePollResultsBuilder.BuildAsync (:91) and return them (:93).Why it's built this way: including soft-deleted rows in the lookup is load-bearing, because without it a re-vote would try to insert a second row and hit the unique index. Moving the broadcast to the domain-event handler keeps a shared push from carrying anyone's individual vote and keeps it from ever describing a vote that never committed (ADR-039).
Where it's used: dispatched by the attendee vote endpoint (
MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:260,277); shape-checked first by CastVoteCommandValidator.
CloseLivePollHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Close·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:19· Level 10 · class
What it is: the command handler for the Open to Closed transition (BR-221). It authorizes the caller, drives the domain transition, and after the commit enqueues a
poll.closedchannel event best-effort for the off-request-path drain worker (doc comment,MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:14-18).Depends on: IUnitOfWork, IEventLiveValidationService (the Conference gRPC boundary), ILiveChannelPublishQueue (
:22), andILogger<T>(:20-23); it derives from MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over(CloseLivePollCommand, LivePoll, LivePollIdentifierType)(:24). It also uses LivePollAuthorization, LivePoll, LivePollChannel, LiveChannelPublishWorkItem, and the BCLSystem.Text.Json.Concept introduced, the write handler as a set of template-method hooks.
[Rubric §2, Design Patterns]and[Rubric §15, Best Practices & Code Quality]. This handler writes noHandleAsyncat all. The base class owns the whole load-mutate-save workflow (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-309) and the subclass fills in only what is specific to closing a poll:EntityId(CloseLivePollHandler.cs:27) tells the base which key to load. The base resolves the repository and issues a by-id load using itsIncludesandAsTrackingdefaults (empty andtruerespectively,MutateEntityHandlerBase.cs:70,76,280-281); a missing aggregate becomesError.NotFoundwithout a line of handler code (:281-282).RowVersion(CloseLivePollHandler.cs:33) hands the basecommand.RowVersion, and the base stamps it as the entity's original token (MutateEntityHandlerBase.cs:291-292) so a close decided against a stale view fails the save. The intent is spelled out both in the base (:284-289) and in the handler's own comment (CloseLivePollHandler.cs:29-30): 412 Precondition Failed instead of silent last-write-wins (ADR-035).MutateAsync(CloseLivePollHandler.cs:36-61) is the only place the domain rule lives, and it is async precisely so a cross-service rights lookup fits inside it (MutateEntityHandlerBase.cs:101-105).LogMutated(CloseLivePollHandler.cs:64-65) fires the module's own[LoggerMessage]partial (:98-99) after a successful save,[Rubric §13, Observability].OnMutatedAsync(:68-76) is the post-commit hook the base documents for best-effort work that must never fail the command (MutateEntityHandlerBase.cs:203-214); here it callsEnqueueClosed.
The base runs them in exactly that order and skips both post-save hooks when the mutation short-circuited (
MutateEntityHandlerBase.cs:294-308), and itsHandleAsyncflattens the workflow'sResult<LivePoll>down to the bareResultthis verb-style command answers with (:326-331).Concept, the cross-module rights lookup.
[Rubric §7, Microservices Readiness]. InsideMutateAsyncthe handler branches on scope. A session-scoped poll first fetches the session's live info from Conference over the typed gRPC clientGetSessionLiveInfoAsync(:43), propagating that call's failure verbatim (:44-45), then calls LivePollAuthorization.EnsureCanManage with that session's assigned-speaker list (:47-48). An event-wide poll passessessionInfo: null(:54-55) so only organizers and admins pass. Engagement never reaches into Conference's tables, it asks across the boundary (ADR-007). Only after rights are settled does it returnpoll.Close()(:60), which enforces "only an Open poll can close" and raises the aggregate's domain event.The best-effort guarantee (BR-229).
[Rubric §29, Resilience & Business Continuity]. There is notry/catcharound the enqueue, and that is a property of the port rather than an oversight:Enqueuereturnsvoidand never rejects, so there is no failure for the handler to branch on. The method's doc comment states the contract (:78-83): the request never awaits the gRPC publish, so a hung Notification peer cannot stall the close, and under backpressure the queue discards the oldest pending broadcast and logs that. The live push is a transient convenience (SignalR fan-out, ADR-039), not the source of truth: if Notification is momentarily unreachable, the close already committed and clients recover on their next fetch.Walkthrough of
EnqueueClosed(:84-96): build the channel key,LivePollChannel.ForSession(sessionId)for a session poll or.ForEvent(poll.EventId)for an event-wide one (:86-88, the helpers atMMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30); serialize aLivePollClosedPayload(poll.Id, poll.EventId)withJsonSerializerOptions.Web(:90-92); hand aLiveChannelPublishWorkItemcarrying that key, theLivePollChannel.PollClosedevent name ("poll.closed",LivePollChannel.cs:17), and the payload to ILiveChannelPublishQueue (:94-95).Why it's built this way: separating the durable state change (committed transactionally, with a domain event on the outbox) from the transient UI push (queued in process, forwarded to the hub by the LiveChannelPublishProcessor drain) keeps correctness and request latency independent of the real-time layer's availability (ADR-039, ADR-024). Deriving from the shared base means the ADR-035 stamping, the NotFound mapping, and the "log and post-process only after a real save" ordering are decided once for the whole codebase, not re-typed per handler.
Where it's used: dispatched by the
POST /api/livepolls/{id}/closeaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:125,140); a member of the poll-lifecycle family with OpenLivePollHandler.
GetEventPollsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetEventPolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetEventPolls/GetEventPollsHandler.cs:14· Level 10 · class
- What it is: the read handler backing the organizer Manage tab. It returns every poll of an event
(Draft, Open, and Closed) with its options, newest first
(
MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetEventPolls/GetEventPollsHandler.cs:14). - Depends on: IUnitOfWork and
LivePollDTOMapper (
:14-16); it implements IQueryHandler<in TQuery, TResult> asIQueryHandler<GetEventPollsQuery, Result<IReadOnlyList<LivePollDTO>>>(:16) over LivePoll and LivePollDTO. - Concept:
[Rubric §6, CQRS & Event-Driven]: a query handler returns data and never mutates.[Rubric §1, SOLID]: the DTO projection is delegated to the injected Mapperly mapper rather than hand-written inline, so the handler is only about fetching (ADR-001). - Walkthrough:
HandleAsync(:19-21) resolves the poll repository (:23) and callsGetAllAsync([nameof(LivePoll.Options)], where: p => p.EventId == query.EventId, asTracking: false, ...)(:24-28), eager-loading the options and reading no-tracking because this is a read path ([Rubric §12, Performance]). It then orders newest-first and maps in one collection expression:[.. polls.OrderByDescending(p => p.Id).Select(dtoMapper.MapToDTO)](:30), and wraps the list inResult.Success(:32). - Why it's built this way:
OrderByDescending(p => p.Id)gives newest-first cheaply on the identity key without an extra timestamp column. The doc comment is honest about scale (:10-13): there is no paging, because the observed conference volume does not need it, a deliberate simplification rather than an oversight. - Where it's used: dispatched by the
GET /api/livepolls?eventId=action, which is gated by[HasPermission(EngagementPermissions.LiveManage)](MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:168-169,175). Its per-session counterpart is GetSessionManagePollsHandler.
GetOpenPollsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetOpenPolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetOpenPolls/GetOpenPollsHandler.cs:15· Level 10 · class
- What it is: the read handler returning the open polls for a scope, with live tallies and the
caller's own vote
(
MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetOpenPolls/GetOpenPollsHandler.cs:15). - Depends on: IUnitOfWork and
LivePollResultsBuilder (
:15-17); it implements IQueryHandler<in TQuery, TResult> asIQueryHandler<GetOpenPollsQuery, Result<IReadOnlyList<LivePollResultsDTO>>>(:17) over LivePoll, LivePollStatus, and LivePollResultsDTO. - Concept, in-handler scope validation.
[Rubric §24, Forms, Validation & UX Safety]. Because a query has no validating decorator, this handler opens by rejecting the "neither scope" case itself: if bothSessionIdandEventIdare null it returnsError.Validation(code: "LivePoll.Scope.Required", ...)(:24-30), a coded failure the API edge can map and localize like any other. It then picks the query shape by scope: session scope filtersp.SessionId == sessionId && p.Status == LivePollStatus.Open(:34-38), event scope filtersp.EventId == query.EventId && p.SessionId == null && p.Status == LivePollStatus.Open(:39-43). Thatp.SessionId == nullclause is what BR-230's "session-scoped polls are excluded from the event-wide feed" means in code (doc comment,:10-14). - Concept, the batched tally.
[Rubric §12, Performance & Scalability]. The whole listing's tallies are computed in a single call to LivePollResultsBuilder.BuildManyAsync (:47-50), not in a per-poll loop. The comment states the budget (:45-46): three queries total for the listing, the poll read plus the builder's two set-wide reads, never two per poll. Inside the builder those two reads are one groupedCOUNTover every poll in the set (MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:61-66) and, only when a caller is present, one read of that caller's votes across the same set (:75-79). This is the handler where an N+1 would hurt most (it backs the page the whole room refreshes), and it is the one place the code spends effort to avoid it. - Walkthrough: scope guard (
:24-30); resolve the repository (:32); scope-selectedGetAllAsynceager-loadingOptionsno-tracking (:33-43); order by id and hand the whole set toBuildManyAsyncwith the caller'sUserId(:47-50); return the results (:52). - Why it's built this way: reusing LivePollResultsBuilder means the
event-wide board, the session live page, and the post-vote response all compute tallies identically,
so a pushed change and a pulled refresh can never disagree (
[Rubric §1, SOLID]). - Where it's used: dispatched by the
GET /api/livepolls/openaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:209,224), which is the attendee-facing poll list on both live surfaces.
GetPollResultsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetPollResults·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsHandler.cs:13· Level 10 · class
- What it is: the read handler returning one poll's live tallies (any status) with the caller's own
vote
(
MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsHandler.cs:13). - Depends on: IUnitOfWork and
LivePollResultsBuilder (
:13-15); it implements IQueryHandler<in TQuery, TResult> asIQueryHandler<GetPollResultsQuery, Result<LivePollResultsDTO>>(:15) over LivePoll and LivePollResultsDTO. - Concept: this is the single-poll refresh a client runs when a
poll.results-changedpush arrives (see GetPollResultsQuery).[Rubric §12, Performance]: it re-reads exactly one card rather than the whole open-poll list, which is the pull half of ADR-039's push-a-signal-then-fetch model. - Walkthrough: resolve the repository (
:22); load the poll withOptions, no-tracking (:23-27); NotFound guard carrying source and target on the error (:29-33); delegate the tally to LivePollResultsBuilder.BuildAsync(poll, query.UserId, ...) (:35) and return it (:37). It applies no status filter, so results stay readable for a Closed poll, which is why the UI can still show a final tally after a poll closes. - Why it's built this way: the compact "load one, build the tally, return" shape is the read half of
the same LivePollResultsBuilder that
CastVoteHandler writes through, and passing
query.UserId(rather thannull, as the broadcast path does) is exactly what makes this the personalized view of numbers the shared push deliberately depersonalizes. - Where it's used: dispatched by the
GET /api/livepolls/{id}/resultsaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:233,247).
GetSessionManagePollsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetSessionManagePolls·MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetSessionManagePolls/GetSessionManagePollsHandler.cs:20· Level 10 · class
- What it is: the read handler for the per-session moderation panel. It returns all of one session's
polls (Draft, Open, and Closed) with their options, newest first, after enforcing the BR-236 rights
rule itself
(
MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetSessionManagePolls/GetSessionManagePollsHandler.cs:20). - Depends on: IUnitOfWork,
IEventLiveValidationService, and
LivePollDTOMapper (
:20-23); it implements IQueryHandler<in TQuery, TResult> asIQueryHandler<GetSessionManagePollsQuery, Result<IReadOnlyList<LivePollDTO>>>(:23) and uses LivePollAuthorization, LivePoll, and LivePollDTO. - Concept introduced, authorization on the read path.
[Rubric §11, Security]and[Rubric §7, Microservices Readiness]. This is the one query in the poll family that gates itself, and its own comment says why it has to reach across a module boundary to do it (:32-33): the assigned-speaker list is the Conference module's fact, so the rights check reads it exactly as the create, open, and close paths do. The sequence is:GetSessionLiveInfoAsync(query.SessionId, ...)(:34-36) with a verbatim propagation of that call's failure (:37-40), then LivePollAuthorization.EnsureCanManage with the caller's role flag, speaker claim, and the fetched session info (:42-46), short-circuiting on refusal (:47-50). The rule itself is small and shared: an organizer or admin passes unconditionally, a speaker passes when the session'sSpeakerIdscontains their claim, everyone else getsError.Forbiddenwith the codeLivePoll.NotAuthorized(MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:28-43). - Concept, the read repository. Once past the gate the handler asks for
GetReadRepository<LivePoll, LivePollIdentifierType>()(:52) rather than the read-writeGetRepositoryits sibling GetEventPollsHandler uses.[Rubric §8, Data Architecture]: a query that cannot write is easier to reason about than one that merely chooses not to. - Walkthrough:
ArgumentNullException.ThrowIfNull(query)(:30); the two-step authorization (:34-50);GetAllAsync([nameof(LivePoll.Options)], where: p => p.SessionId == query.SessionId, asTracking: false, ...)(:53-57); then the same ordering and projection as the event-wide list,[.. polls.OrderByDescending(p => p.Id).Select(dtoMapper.MapToDTO)](:60), with the comment spelling out that the match is intentional so both moderation surfaces agree (:59). - Why it's built this way: the class doc comment states the design decision plainly (
:12-19). Rights here follow BR-236 rather than the organizer-onlyLiveManagecapability the event-wide manage list carries, so a speaker moderating their own session gets the real list instead of a 403 they have to work around. That is a case where a coarse role-based gate at the controller edge could not express the rule, and the handler is the only layer that can reach both the caller's claims (on the query) and the Conference-owned speaker assignment. - Where it's used: dispatched by the
GET /api/livepolls/manage?sessionId=action (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:189,197), which deliberately carries no[HasPermission]attribute (:181-190).
OpenLivePollHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Open·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:20· Level 10 · class
- What it is: the command handler for the Draft to Open transition. It authorizes the caller,
fetches the live window from Conference and snapshots it onto the poll, and after the commit enqueues
poll.openedbest-effort (doc comment,MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:14-19). - Depends on: the same set as CloseLivePollHandler, including
ILiveChannelPublishQueue (
:23), plus a BCLTimeProviderfor the current instant (:24); it derives from the same MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over(OpenLivePollCommand, LivePoll, LivePollIdentifierType)(:26). - Concept introduced, snapshotting a cross-service window to avoid per-vote chatter.
[Rubric §12, Performance & Scalability]and[Rubric §7, Microservices Readiness]. This handler overrides the same four hooks as CloseLivePollHandler (EntityIdat:29,RowVersionat:35,LogMutatedat:79-80,OnMutatedAsyncat:83-91), with one added responsibility insideMutateAsync: before opening, it resolves the live window. For a session poll it reuses theSessionLiveInfoit already fetched for authorization, readingLiveWindowStartUtcandLiveWindowEndUtcoff it (:47,51-58); for an event-wide poll it authorizes first and then makes a second gRPC call,GetEventLiveInfoAsync(poll.EventId, ...)(:67), reading the window off that (:71-72). It then returnspoll.Open(timeProvider.GetUtcNow().UtcDateTime, windowStartUtc, windowEndUtc)(:75). Inside the domain,Openstores the live-window end on the poll, so that afterwards every vote can be window-checked locally byCanAcceptVote(MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:178-185) with no further cross-service call. That is the performance point: one lookup at open time replaces one lookup per vote, on the highest frequency operation in the whole layer. - Walkthrough: the base runs load, NotFound guard, and ADR-035 rowversion stamping before this class
sees anything (
MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:280-292, fed byEntityIdat:29andRowVersionat:35).MutateAsync(:38-76) declares the two window locals (:43-44), branches onpoll.SessionId(:45), and in each arm authorizes through LivePollAuthorization.EnsureCanManage (:52-55for a session,:62-65for an event) before resolving the window; it ends with the domain call at:75, whose failure the base turns into the handler's failure without saving (MutateEntityHandlerBase.cs:294-296). After the commit the base callsLogMutated(:79-80, the[LoggerMessage]partial at:113-114) and thenOnMutatedAsync(:83-91), which callsEnqueueOpened(:99-111): same channel-key choice as the close path (:101-103), but the payload is aLivePollOpenedPayload(poll.Id, poll.EventId, poll.Question)(:106), carrying the question so a subscriber can render the new card without a fetch, published under theLivePollChannel.PollOpenedevent name ("poll.opened",MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:14). As with the close path there is no swallow-and-log guard, because the enqueue never rejects (doc comment,:93-98). - Why it's built this way: snapshotting the window end at Open is the ADR-039 live-layer design. It keeps the hot vote path free of Conference round-trips and makes vote acceptance deterministic even if Conference is briefly unreachable, at the accepted cost that a window changed after the open is not reflected on an already-open poll.
- Caveats / not-in-source: the session arm reuses the
SessionLiveInfofetched for the rights check and the event arm issues a separateGetEventLiveInfoAsync, so an event-wide open costs one gRPC call and a session open also costs one; whether those two live-window sources can ever disagree for the same session is a Conference-side question and is not determinable from this file. - Where it's used: dispatched by the
POST /api/livepolls/{id}/openaction (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:91,106); paired with CloseLivePollHandler.
GetModerationQueueQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.GetModerationQueue·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetModerationQueue/GetModerationQueueQuery.cs:11· Level 0 · record
- What it is: the read message behind the moderator's Q&A queue for one session. It asks for a session's questions in every status (BR-236) and carries the caller's rights alongside the target, as a
sealed recordwith three positional members (GetModerationQueueQuery.cs:11-14). - Depends on: the identifier aliases
SessionIdentifierTypeandSpeakerIdentifierType?(GetModerationQueueQuery.cs:12-13), the per-moduleglobal usingaliases introduced in the primer. No first-party class dependencies: a query record is data only. It is answered by GetModerationQueueHandler through the read side of the CQRS pipeline (IQueryHandler<in TQuery, TResult>). - Concept introduced: carrying the caller's authorization facts on the message instead of resolving them in the handler. The two extra members are not filters, they are the inputs to a rights check:
CallerSpeakerIdis the caller'sspeaker_idclaim when present, andCallerIsOrganizersays whether the caller holds the Organizer or Admin role (GetModerationQueueQuery.cs:9-10). Both are bound from the token at the API edge (:5-6), so the handler never reaches for ambientHttpContextstate and stays a pure function of its message.[Rubric §11, Security]assesses whether identity is derived from a trustworthy source; the claims come from the validated token in SessionQuestionsController rather than from the request body, so a client cannot self-declare itself an organizer.[Rubric §14, Testability]assesses how easily a unit can be exercised; because the rights inputs are message fields, every BR-236 branch is a plain constructor argument in a unit test with no auth stack to stand up. - Walkthrough:
SessionId(GetModerationQueueQuery.cs:12) selects the session;CallerSpeakerId(:13) is the nullable speaker claim;CallerIsOrganizer(:14) is the role flag. Positional record members areinit-only, so the query is immutable once built. - Why it's built this way: the moderation view is a different projection of the same table than the attendee view, with a different audience, so it gets its own message rather than a
bool includeAllflag on GetSessionQuestionsQuery. Two messages keep each read's authorization contract explicit. - Where it's used: constructed in SessionQuestionsController on the
GET /SessionQuestions/moderationroute (MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:104-113) and dispatched to GetModerationQueueHandler.
GetSessionQuestionsQuery
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.GetSessionQuestions·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetSessionQuestions/GetSessionQuestionsQuery.cs:11· Level 0 · record
- What it is: the read message for the attendee view of a session's Q&A. It asks for one session's questions from the calling user's perspective, as a
sealed recordwith two positional members (GetSessionQuestionsQuery.cs:11-13). - Depends on: the aliases
SessionIdentifierTypeandUserIdentifierType(GetSessionQuestionsQuery.cs:12-13). No first-party class dependencies; it is answered by GetSessionQuestionsHandler. - Concept introduced: the caller-scoped read. The same session yields a different result set per caller: the handler returns every Approved question plus only the caller's own Pending and Dismissed ones, so authors can track their submissions (
GetSessionQuestionsQuery.cs:4-6). The XML summary is careful about provenance: that visibility split is a contract of this read rather than a numbered business rule, because BR-238 covers anonymity only (:6-8). CarryingUserIdin the message rather than reading ambient context inside the handler keeps the read pure and testable.[Rubric §6, CQRS and Event-Driven]assesses whether reads and writes are cleanly separated; this record is a read message with no mutation surface and no handler-side state.[Rubric §11, Security]assesses data scoping;UserIdis not a client-supplied field: the controller takes it fromcurrentUserService.UserIdand rejects an unauthenticated caller before building the query (MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:85-92), so a user cannot request another user's private submissions. - Walkthrough:
SessionId(GetSessionQuestionsQuery.cs:12) selects the session;UserId(:13) scopes both the personal non-approved rows and the caller's own-upvote flags. Both members areinit-only. - Why it's built this way: a per-caller read cannot be output-cached the way the anonymous Conference reads are, so it is modeled as a plain live query; two fields are the minimum needed to express "this session, as seen by this user."
- Where it's used: constructed in SessionQuestionsController on the
GET /SessionQuestionsroute (MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:78-92) and dispatched to GetSessionQuestionsHandler behind the session live Q&A panel.
SubmitQuestionCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Submit·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionCommand.cs:11· Level 0 · record
- What it is: the write message for asking a question in a live session (BR-231/BR-233). A
sealed recordwith three positional members (SubmitQuestionCommand.cs:11-14). - Depends on: the aliases
SessionIdentifierTypeandUserIdentifierTypeplus astring Textpayload (SubmitQuestionCommand.cs:12-14). Consumed by SubmitQuestionCommandValidator and SubmitQuestionHandler. - Concept introduced: the first Q&A command message, and with it the trust boundary the XML summary states outright (
SubmitQuestionCommand.cs:4-6):UserIdis bound from the caller's token at the API edge, never from the request body. The controller builds the command from the authenticated principal (MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:66), so a client cannot post as another author.[Rubric §11, Security]assesses trust boundaries around identity; the author id is deliberately kept off the client-controlled surface.[Rubric §6, CQRS and Event-Driven]assesses read/write separation; this is a mutation message that flows through the validating and transactional decorators (see group-05) that queries skip. - Walkthrough:
SessionId(SubmitQuestionCommand.cs:12) is the target session;Text(:13) is the question body, documented as 1 to 500 characters per BR-231 (:9) but enforced by the validator and the domain, not by the record;UserId(:14) is the token-bound author. - Why it's built this way: length and presence rules live in a FluentValidation validator so the message stays a plain data carrier and the rules run in the pipeline's validating stage before a transaction opens or a cross-service lookup is made.
- Where it's used: validated by SubmitQuestionCommandValidator, handled by SubmitQuestionHandler, dispatched from the
POST /SessionQuestionsroute of SessionQuestionsController.
ToggleUpvoteCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.ToggleUpvote·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteCommand.cs:11· Level 0 · record
- What it is: the write message that sets or clears the caller's upvote on a session question (BR-235/BR-237). A
sealed recordwith three positional members (ToggleUpvoteCommand.cs:11-14). - Depends on:
SessionQuestionIdentifierType(the question being voted on),UserIdentifierType, and abool Upvoteintent flag (ToggleUpvoteCommand.cs:12-14). Consumed by ToggleUpvoteCommandValidator and ToggleUpvoteHandler. - Concept introduced: the explicit desired-state toggle. Instead of separate "add upvote" and "remove upvote" commands, one command carries a
boolnaming the state the caller wants to end up in, and the summary makes the idempotency contract explicit: "Toggling to a state the caller is already in is a no-op success" (ToggleUpvoteCommand.cs:6). That shape lets a flaky mobile client retry safely, since the second identical tap changes nothing and still returns success (the handler's no-op branches atToggleUpvoteHandler.cs:135-138and:167-170are where the contract is honored). As with the other write messages,UserIdis token-bound, never from the body (ToggleUpvoteCommand.cs:4-6).[Rubric §9, API and Contract Design]assesses whether a contract is safe to call twice; the desired-state design makes the operation naturally idempotent without a request id.[Rubric §11, Security]again: the voter identity is not client-supplied. - Walkthrough:
QuestionId(ToggleUpvoteCommand.cs:12) targets the question;UserId(:13) is the token-bound voter;Upvote(:14) istrueto upvote andfalseto remove. - Why it's built this way: a single toggle keeps both the HTTP surface and the client state machine small, and pushes the "already in that state" branch into the handler where the SessionQuestionUpvote soft-delete and reactivate dance already lives.
- Where it's used: validated by ToggleUpvoteCommandValidator, handled by ToggleUpvoteHandler, dispatched from the
POST /SessionQuestions/{id}/upvotesroute of SessionQuestionsController (MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:204-250).
ToggleUpvoteCommandValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.ToggleUpvote·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteCommandValidator.cs:8· Level 1 · class
- What it is: the FluentValidation rule set for ToggleUpvoteCommand, run before the handler by the validating decorator. A
sealed classextendingAbstractValidator<ToggleUpvoteCommand>(ToggleUpvoteCommandValidator.cs:8). - Depends on: FluentValidation's
AbstractValidator<T>(NuGet,ToggleUpvoteCommandValidator.cs:1) and the identifier aliases the rules compare against. It is discovered by assembly scanning and invoked by the validating stage of the command pipeline (see group-06 and group-05). - Concept introduced: the two-part message convention every validator in this codebase follows. Each rule attaches both a human
WithMessageand a machineWithErrorCode, and the codes are namespaced by feature:SessionQuestionUpvote.QuestionId.Required(ToggleUpvoteCommandValidator.cs:15) andSessionQuestionUpvote.UserId.Required(:20). A client or a test asserts on the stable code, never on the prose.[Rubric §24, Forms/Validation/UX Safety]assesses whether input is rejected structurally and legibly; the paired message and code do both.[Rubric §6, CQRS and Event-Driven]assesses pipeline discipline; validation is a decorator concern here, not hand-rolled inside the handler. - Walkthrough: the constructor declares exactly two rules (
ToggleUpvoteCommandValidator.cs:10-21).QuestionIdmust not equaldefault(SessionQuestionIdentifierType)(:12-15) andUserIdmust not equaldefault(UserIdentifierType)(:17-20). Both are structural presence guards. The behavioral rules (an author cannot upvote their own question, the question must be Approved, the live window must still be open) deliberately live in ToggleUpvoteHandler and on the SessionQuestion aggregate, because they need loaded state a validator does not have. - Why it's built this way: cheap stateless guards run first so a malformed command never reaches a repository or opens a transaction; anything that needs the persisted question is left to the handler.
- Where it's used: resolved and executed by the validating command decorator for ToggleUpvoteCommand.
SubmitQuestionCommandValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Submit·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionCommandValidator.cs:9· Level 7 · class
- What it is: the FluentValidation rule set for SubmitQuestionCommand (BR-231). A
sealed classextendingAbstractValidator<SubmitQuestionCommand>(SubmitQuestionCommandValidator.cs:9). - Depends on: FluentValidation's
AbstractValidator<T>(NuGet,SubmitQuestionCommandValidator.cs:1) and, notably, the domain type SessionQuestionInvariants fromMMCA.ADC.Engagement.Domain.SessionQuestions(:2). - Concept introduced: one number, one home. The text-length rule does not hardcode 500. It reads
SessionQuestionInvariants.TextMaxLengthboth for theMaximumLength(...)call and for the interpolated message (SubmitQuestionCommandValidator.cs:22-23), and that constant is itself an alias ofSessionQuestionDTO.TextMaxLength(MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:13), where the literal500is declared once in the Shared layer that the UI, the application validator, and the domain factory can all reach (MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDTO.cs:18). Application-layer validation and the domain'sEnsureTextIsValid(SessionQuestionInvariants.cs:30-38) therefore enforce the same bound and cannot drift.[Rubric §4, DDD]assesses whether rules belong to the domain; the limit is owned there and merely referenced here.[Rubric §15, Best Practices & Code Quality]assesses duplication of business constants; the chain from DTO to invariant to validator means changing the cap is a one-line edit. - Walkthrough: three rules in the constructor (
SubmitQuestionCommandValidator.cs:11-30).SessionIdmust not bedefault, codeSessionQuestion.SessionId.Required(:13-16).Textmust beNotEmpty(codeSessionQuestion.Text.Required) and withinSessionQuestionInvariants.TextMaxLength(codeSessionQuestion.Text.Invalid), with the "1-N characters" message built from the same constant (:18-24).UserIdmust not bedefault, codeSessionQuestion.UserId.Required(:26-29). - Why it's built this way: the presence checks fail fast, before SubmitQuestionHandler spends a cross-service gRPC call resolving the session; reading the max length from the domain keeps the validator honest if the business limit moves.
- Where it's used: executed by the validating command decorator ahead of SubmitQuestionHandler.
ToggleUpvoteHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.ToggleUpvote·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:17· Level 8 · class
- What it is: the command handler that applies an upvote toggle, enforces the Q&A upvote rules, and returns the fresh active-upvote count. It broadcasts nothing itself: the
question.upvote-changedpush is raised as a domain event by the aggregate and enqueued post-commit by SessionQuestionUpvoteChangedHandler (ToggleUpvoteHandler.cs:13-15). Asealed partial classimplementing ICommandHandler<in TCommand, TResult> asICommandHandler<ToggleUpvoteCommand, Result<int>>(ToggleUpvoteHandler.cs:17-20). - Depends on: three primary-constructor parameters (
ToggleUpvoteHandler.cs:17-20): IUnitOfWork for repositories,TimeProvider(BCL) for a testable clock, andILogger<ToggleUpvoteHandler>. Note what is absent: no live-channel dependency at all, neither ILiveChannelPublisher nor the ILiveChannelPublishQueue that its sibling SubmitQuestionHandler takes. It works with the SessionQuestion and SessionQuestionUpvote aggregates and returns Error failures through Result. - Concept introduced: the soft-delete and reactivate toggle behind a filtered unique index, called the BR-135 dance in the summary (
ToggleUpvoteHandler.cs:10-12). A user may hold at most one active upvote per question. Un-upvoting soft-deletes the row instead of hard-deleting it, and a later re-upvote reactivates that same row rather than inserting a duplicate the unique index would reject. That is why the load uses the repository's resurrection read,FindIncludingDeletedAsync, which returns the matching rows already partitioned into active and soft-deleted in one round trip (:55-58, contract atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:215-219).[Rubric §8, Data Architecture]assesses soft-delete discipline and uniqueness; reactivation plus the filtered index keep at most one live vote without churning keys (see ADR-005).[Rubric §12, Performance and Scalability]assesses hot-path write cost; upvotes never touch the question row, so a popular question does not serialize its voters behind one rowversion. - Walkthrough: load the question untracked by id (
ToggleUpvoteHandler.cs:27-32) and fail withError.NotFoundwhen missing (:34-38). Enforce BR-235's self-upvote ban by comparingquestion.UserIdwith the caller, returningError.InvariantcodeSessionQuestionUpvote.OwnQuestion(:40-48). Take the upvote repository (:50) and run the resurrection read for(question, user)with tracking so a reactivation actually saves (:55-58), then pick the first of each partition (:59-60). Branch on intent (:62-64):ApplyUpvoteAsyncforUpvote == true,RemoveUpvoteotherwise, propagating any failure (:65-66).ApplyUpvoteAsync(:127-159) returns a no-op success when an active row already exists (:135-138), otherwise checks BR-237 through the aggregate'squestion.CanAcceptUpvote(nowUtc)(:140-142, which rejects a non-Approved question or one past its snapshottedLiveWindowEndUtc,MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:201-222), then eitherReactivate()s the soft-deleted row (:144-150) or creates and adds a new one (:152-156).RemoveUpvote(:165-176) soft-deletes the active row viaDelete()or no-ops (:167-170). Only when something actually changed (:68-69) does the upvote-on path re-check eligibility (:73-78) and thenSaveChangesAsyncand log (:80-82); removing an upvote deliberately skips the re-check so a voter can still withdraw after a dismissal or after the window closes (:71-72). Finally the handler recomputes the count withCountAsyncfiltered onSessionQuestionIdalone (:85-87), relying on the global soft-delete query filter to exclude withdrawn votes, and returnsResult.Success(upvoteCount)(:93). - Why it's built this way:
RecheckQuestionAcceptsUpvoteAsync(:106-120) exists because of a race a rowversion cannot catch, and the doc comment says so precisely (:96-105): the upvote only inserts or updates aSessionQuestionUpvoterow and never touches the question row, so the question's concurrency token is never part of this unit of work. A moderator's dismissal committing between the first eligibility check and the save would otherwise slip through, so the handler re-reads the question fresh immediately before saving. A millisecond residue remains and is explicitly accepted (:101-104): such an upvote is indistinguishable from one cast just before the dismissal. Publishing was moved out of this handler for two reasons recorded at:89-92: it awaited a gRPC call on the request path, and it could announce an upvote that a later rollback discards.TimeProviderinjection makes the live-window check deterministic under test. See ADR-039 for the live-channel transport the downstream event uses. - Where it's used: dispatched by SessionQuestionsController when an attendee taps upvote; the returned
intupdates the caller's own UI immediately, while the post-commit SessionQuestionUpvoteChanged domain event carries the count-only broadcast to everyone else. - Caveats / not-in-source: the filtered unique index that makes the reactivate path necessary is declared in the EF configuration, not in this handler.
GetModerationQueueHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.GetModerationQueue·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetModerationQueue/GetModerationQueueHandler.cs:19· Level 9 · class
- What it is: the query handler behind the moderation queue. It returns a session's questions in every status, Pending first, to organizers, admins, and the session's assigned speakers (BR-236). A
sealed classimplementing IQueryHandler<in TQuery, TResult> asIQueryHandler<GetModerationQueueQuery, Result<IReadOnlyList<SessionQuestionDTO>>>(GetModerationQueueHandler.cs:19-23). - Depends on: four primary-constructor parameters (
GetModerationQueueHandler.cs:19-23): IUnitOfWork, IQueryableExecutor (the abstraction that keeps the EF CoreToListAsyncextension out of the Application layer), IEventLiveValidationService (the Conference cross-module contract), and SessionQuestionViewBuilder. It also uses the module-internal LivePollAuthorization helper and projects to SessionQuestionDTO. - Concept introduced: authorization that depends on data another service owns. Engagement cannot answer "is this caller a speaker on this session" by itself: the speaker assignment lives in Conference. So the handler first fetches SessionLiveInfo over the cross-module contract (
GetModerationQueueHandler.cs:33) and then hands the caller's claims plus that remote fact toLivePollAuthorization.EnsureCanManage(:37-38), which passes organizers and admins outright and otherwise requires the caller's speaker id to appear insessionInfo.SpeakerIds(MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:28-38), failing withError.ForbiddencodeLivePoll.NotAuthorized(:40-43). Sharing that helper with the live-poll use cases means one BR-236 rule, one implementation.[Rubric §7, Microservices Readiness]assesses whether modules respect ownership across a process boundary; the speaker list is fetched, never joined (see ADR-007).[Rubric §11, Security]assesses authorization placement; the check runs in the application layer before any data is read, so a forbidden caller never sees a row. - Walkthrough: fetch the session's live facts and short-circuit on failure (
GetModerationQueueHandler.cs:33-35), which also enforces the Conference-owned session eligibility rules. Run the rights check and short-circuit on failure (:37-40). Take the question repository (:42) and compose an untracked query filtered to the session, ordered byStatusthenId, capped atMaxReturnedQuestions(:43-49); the cap is a publicconst intof 200 (:26), a server-side bound so a flooded session cannot return an unbounded payload. Build DTOs through the shared view builder withcallerUserId: null(:51), becauseMyUpvoteandIsMineare not meaningful in a moderation view (:16-17), and the builder skips the caller-scoped upvote query entirely when the caller is null (MMCA.ADC.Engagement.Application/SessionQuestions/Services/SessionQuestionViewBuilder.cs:51-58). Re-sort the DTOs byStatusthenId(:53-54) so the queue reads Pending, Approved, Dismissed in QuestionStatus declaration order, and return them (:56). - Why it's built this way: the ordering rides the enum's numeric values rather than a hand-written comparator, which is why the enum's member order is load-bearing and documented as such (
MMCA.ADC.Engagement.Shared/SessionQuestions/QuestionStatus.cs:8-18). The re-sort after building is what makes the final order stable against the order the builder returns, since the builder preserves input order rather than imposing one. - Where it's used: dispatched from
GET /SessionQuestions/moderationin SessionQuestionsController for GetModerationQueueQuery; the moderator UI turns the result into the approve, dismiss, and answered actions handled by ModerateQuestionHandler. - Caveats / not-in-source: the query orders and caps in the database but does not paginate; a session with more than 200 questions silently returns the first 200 by status and id.
GetSessionQuestionsHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.GetSessionQuestions·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetSessionQuestions/GetSessionQuestionsHandler.cs:26· Level 9 · class
- What it is: the query handler for the attendee view of a session's questions: every Approved question, most upvoted first, followed by the caller's own non-approved ones. A
sealed classimplementing IQueryHandler<in TQuery, TResult> asIQueryHandler<GetSessionQuestionsQuery, Result<IReadOnlyList<SessionQuestionDTO>>>(GetSessionQuestionsHandler.cs:26-29). - Depends on: IUnitOfWork, IQueryableExecutor, and SessionQuestionViewBuilder (
GetSessionQuestionsHandler.cs:26-29). It reads the SessionQuestion and SessionQuestionUpvote tables and returns SessionQuestionDTO rows, filtering on QuestionStatus. - Concept introduced: two separate server-side budgets instead of one shared cap, and ranking in the database before the cap applies. Both are defended in the class summary (
GetSessionQuestionsHandler.cs:17-24). The read returns at mostMaxReturnedQuestionsApproved questions (aconst intof 200,:32) plus at mostMaxReturnedOwnQuestionsof the caller's own non-approved ones (aconst intof 25,:35). A single shared budget filled by oldest id would let a flood of low-value questions push both the most upvoted question and the caller's own newest submission out of the payload. The ranking is a correlatedCOUNTsubquery over the upvote table (:55) rather than a navigation, because SessionQuestion and SessionQuestionUpvote are deliberately separate aggregates with no navigation between them; the comment notes the subquery seeks theSessionQuestionIdindex and that the global soft-delete filter keeps withdrawn upvotes out of the count without an explicit predicate (:45-50).[Rubric §12, Performance and Scalability]assesses whether a hot read stays bounded and pushes work to the database; ordering and capping both happen in SQL, so a plenum session cannot materialize an unbounded set.[Rubric §11, Security]assesses data scoping; other users' Pending and Dismissed rows are excluded by the predicate itself. - Walkthrough: take a tracked-capable repository for questions and a read repository for upvotes (
GetSessionQuestionsHandler.cs:42-43). Captureupvotes = upvoteRepo.TableNoTracking(:51) and compose the Approved read: filter by session andQuestionStatus.Approved, order by the correlated upvote count descending withIdas the tiebreak, take 200 (:52-58). Compose the caller's own read separately: same session,UserId == query.UserId,Status != QuestionStatus.Approved, newest first by descendingId, take 25 (:64-69). Excluding Approved there is load-bearing and the comment says why (:60-63): the first read already returned the caller's approved questions, so without the filter they would come back twice. Flip the caller's slice back to ascending id (:73) because the panel renders a user's own submissions oldest first. Build the DTOs for both slices in one call, passing the caller id soMyUpvoteandIsMineresolve (:75). Compose the final list with a collection expression (:80-84): the leadingapproved.CountDTOs re-sorted byUpvoteCountthenId, then the caller's own slice unchanged. ReturnResult.Success(ordered)(:86). - Why it's built this way: the re-sort at
:82is not redundant with the database order. The view builder computes the counts it returns, and re-sorting on those computed values keeps the presented order tie-stable against the numbers the user actually sees (:77-79). Splitting the two reads is also what lets each carry its ownORDER BY: one by popularity, one by recency, which no single query could do. - Where it's used: dispatched from
GET /SessionQuestionsin SessionQuestionsController for GetSessionQuestionsQuery, behind the session live Q&A panel; the same DTO shape is refreshed live by the SessionQuestionChannel events.
SubmitQuestionHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Submit·MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:25· Level 9 · class
- What it is: the command handler that creates a question against a live session, honoring the event's moderation default, then enqueues the live broadcast best-effort. A
sealed partial classimplementing ICommandHandler<in TCommand, TResult> asICommandHandler<SubmitQuestionCommand, Result<SessionQuestionDTO>>(SubmitQuestionHandler.cs:25-31). - Depends on: six primary-constructor parameters (
SubmitQuestionHandler.cs:25-31): IUnitOfWork; IEventLiveValidationService, the Conference cross-module lookup; SessionQuestionViewBuilder; ILiveChannelPublishQueue (:29), the in-process queue a hosted drain later forwards to the publisher, deliberately not the publisher itself;TimeProvider(BCL); andILogger<SubmitQuestionHandler>. It creates SessionQuestion aggregates, reads QuestionModerationDefault and QuestionStatus, routes through BestEffort, and serializes either a SessionQuestionApprovedPayload or a SessionQuestionPendingCountChangedPayload into a LiveChannelPublishWorkItem. - Concept introduced: the cross-service validation boundary in front of a write, plus the content versus count privacy split on the live channel. Engagement owns neither sessions nor events, so it calls
IEventLiveValidationService.GetSessionLiveInfoAsync(...)(SubmitQuestionHandler.cs:41) to learn the published flag, the live window, and the event'sQuestionModerationDefault; that one call also enforces the Conference-owned eligibility rules BR-49 and BR-91 (:17-18). On the broadcast side, an auto-approved question puts its text on the channel while a pending one puts only a count, because unmoderated content must never be fanned out (BR-238,:22-23and:147).[Rubric §7, Microservices Readiness]assesses ownership across a boundary; Conference facts arrive as a remote query rather than a join (see ADR-007).[Rubric §13, Observability and Operability]assesses whether silent failures are visible; the broadcast runs through the shared BestEffort helper, so a failure becomes one Warning plus abesteffort.dispatch.failedcounter increment tagged with the operation name (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:65-71) instead of only a log line. - Walkthrough: fetch the session's live facts and short-circuit on failure (
SubmitQuestionHandler.cs:41-43), then unwrap it (:45). Reject an unpublished event withError.InvariantcodeSessionQuestion.EventNotPublished(:47-54). SnapshotnowUtcfromtimeProvider(:56) and reject a submission outside the half-open window[LiveWindowStartUtc, LiveWindowEndUtc)with codeSessionQuestion.OutsideLiveWindow(:57-64). Enforce the anti-spam cap: count the caller's non-Dismissed questions for this session (:73-75) and reject atSessionQuestionInvariants.MaxOpenQuestionsPerUserPerSession, which is 10 (:76-83, constant atMMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:22), with codeSessionQuestion.OpenQuestionLimitReached. The comment above it is candid that this is a soft cap (:66-71): the count and the insert are not one atomic step, so parallel submits from the same user can each read a count under the cap and briefly exceed it; that is accepted over holding a transaction across the cross-service live-window lookup, and moderation drains the overflow. Derive the initial status from the event's moderation default,ApprovedwhenQuestionModerationDefault.ApprovedandPendingotherwise (BR-233,:86-88). Create the aggregate throughSessionQuestion.Create(...), passing the session,sessionInfo.EventId, the author, the text, the initial status, and the snapshottedLiveWindowEndUtc(:90-96); snapshotting the window end onto the row is BR-237 and is what later lets ToggleUpvoteHandler check the window without another remote call. Add and save (:101-103), log through the source-generated message (:105), enqueue the broadcast (:107), then build and return the DTO for the author's own view (:109-111).EnqueueSubmittedAsync(:130-160) wraps everything inBestEffort.ExecuteAsyncwith the low-cardinality operation namesession-question-submit-broadcast(:34,:131), derives the channel key fromLivePollChannel.ForSession(...)so questions and polls share one session channel (:133), and branches: Approved serializes a SessionQuestionApprovedPayload with the question text ontoSessionQuestionChannel.QuestionApproved(:135-144), Pending re-reads the fresh Pending count and serializes a count-only SessionQuestionPendingCountChangedPayload ontoSessionQuestionChannel.QuestionPendingCountChanged(:146-159). Both callliveChannelPublishQueue.Enqueue(...)with a LiveChannelPublishWorkItem rather than awaiting a publish. - Why it's built this way: reading the moderation default from Conference at submit time keeps the auto-approve policy owned by the event instead of duplicated in Engagement. Enqueueing rather than awaiting the gRPC publish keeps a hung Notification peer off the submit's latency path (
:115-116), which is the queueing model ADR-039 describes. Two details in theBestEffortcall are deliberate and documented (:119-128): the caller's cancellation token is not passed, because the question is already committed and the broadcast must outlive an abandoned request rather than turn a saved question into a cancelled one; and the cost of using the generic helper is that the warning does not carry the question id, which sits one line above inLogQuestionSubmitted. Inside the guarded block,Enqueueis a synchronous call, so the only thing that can realistically fail is the Pending branch's fresh-count read, and that read must never fail a question that has already committed (:117-118). - Where it's used: dispatched from
POST /SessionQuestionsin SessionQuestionsController for SubmitQuestionCommand; the returned SessionQuestionDTO renders the author's own row immediately, while the queued channel event updates every other connected attendee or moderator. - Caveats / not-in-source: the eligibility rules behind
GetSessionLiveInfoAsync(BR-49 and BR-91) are implemented in the Conference service and its gRPC adapter, not here; and the drain that turns a queued work item into an actual channel push lives in the Engagement module composition, not in this handler.
CastVoteRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/CastVoteRequest.cs:8· Level 0 · record
- What it is: the request body for casting (or changing) a vote on an open live poll. It carries exactly one field, the chosen option.
- Depends on: the
LivePollOptionIdentifierTypealias (= int,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:5); no first-party types. - Concept, identity-from-token, not from body.
[Rubric §11, Security](assesses that a caller cannot act as another principal). The most important thing about this DTO is what it deliberately omits: there is noUserId. The doc comment (CastVoteRequest.cs:3-6) states the rule theCastVoteHandlerenforces, the voting user is taken from the caller's token server-side, so a request can never cast a vote on behalf of another user. This is the same "bind identity fromICurrentUserService, never from the request" convention the whole live layer follows (see the overview).[Rubric §9, API & Contract Design](small, intention-revealing contracts): the request models only the one decision the client actually owns. - Walkthrough: a single member,
required LivePollOptionIdentifierType OptionId { get; init; }(CastVoteRequest.cs:11).requiredforces the client to supply it;initmakes it immutable once bound. The doc note (CastVoteRequest.cs:10) records the server-side invariant thatOptionIdmust belong to the poll (BR-226), checked in the handler, not here. - Why it's built this way: keeping the request to one field means the vote endpoint cannot be spoofed with a foreign user id and cannot smuggle option text; the option is referenced by id so the poll's authored options are the only valid targets.
- Where it's used: the body of the cast-vote endpoint on
LivePollsController, mapped into the command handled byCastVoteHandler.
CreateLivePollRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/CreateLivePollRequest.cs:6· Level 0 · record
- What it is: the request body for authoring a new live poll, which is always created in the
Draftstate (BR-221/BR-222). - Depends on: the
EventIdentifierTypeandSessionIdentifierTypealiases (both= int, defined in the Conference module and linked solution-wide,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7,14); BCLIReadOnlyList<string>. - Concept, request DTO carrying only client-owned authoring data.
[Rubric §9, API & Contract Design](assesses request contracts that mirror the business operation and defer validation). Every field maps to a decision the poll author actually makes: which event, an optional session scope, the question, and the answer option texts. The field-level constraints are documented as business rules but are not enforced by the record itself, they are checked downstream by the FluentValidation validator and byLivePollInvariantsinsideLivePoll.Create, so an invalid request fails with Problem Details rather than being unconstructable at the DTO level. - Walkthrough: four members.
required EventIdentifierType EventId(CreateLivePollRequest.cs:9): the owning event, which must be published (BR-222).SessionIdentifierType? SessionId(CreateLivePollRequest.cs:12): optional session scope;nullmeans an event-wide poll, and the doc note records that Wave 1 is alwaysnull(BR-230).required string Question(CreateLivePollRequest.cs:15): the poll question (1 to 200 characters, BR-220).required IReadOnlyList<string> Options(CreateLivePollRequest.cs:18): the answer texts in display order (2 to 10 options, each 1 to 100 characters, unique, BR-220).
- Why it's built this way: options arrive as a plain string list (not pre-built option DTOs) because the poll owns option identity, the aggregate assigns ids and sort order when
LivePollmaterializes itsLivePollOptionchildren. Modelling the request as raw texts keeps the client from inventing ids. - Where it's used: the body of the create endpoint on
LivePollsController, mapped into the command handled byCreateLivePollHandler.
LivePollChannel
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:11· Level 0 · class (static)
- What it is: the shared contract for the live-poll push channel, the event-name string constants that ride the SignalR channel plus the helpers that build a channel key from an event or session id. It is the one vocabulary both the publisher (Engagement handlers) and the subscriber (the Blazor UI) agree on.
- Depends on: BCL only (
System.Globalization); it references the payload recordsLivePollOpenedPayload,LivePollClosedPayload, andLivePollResultsDTOin its doc comments as the shapes each event carries. - Concept introduced, the channel-key + event-name contract.
[Rubric §7, Microservices Readiness](assesses shared contracts that let independently deployed parts agree without shared code paths) and[Rubric §6, CQRS & Event-Driven](assesses a well-named event vocabulary). The ephemeral push mechanism itself is taught in this chapter's overview and framed by ADR-039; this class is where the names live. A publisher callsILiveChannelPublisher.PublishAsync(channelKey, eventName, payloadJson)and a subscriber matches on the sameeventName, so if the two ends disagree on a string the broadcast silently no-ops. Putting the strings in one shared type is the single source of truth that prevents that drift.[Rubric §15, Best Practices & Code Quality]: rename an event once, here, and both ends move together.[Rubric §27, i18n]: the key builders format withCultureInfo.InvariantCultureso a channel key is byte-identical regardless of the server's locale (a locale-formatted integer would break the key match). - Walkthrough
- Three event-name constants, all
public const string:PollOpened = "poll.opened"(LivePollChannel.cs:14),PollClosed = "poll.closed"(LivePollChannel.cs:17), andPollResultsChanged = "poll.results-changed"(LivePollChannel.cs:20). Each doc comment names the payload record it carries and, forPollResultsChanged, records the rule that itsLivePollResultsDTOpayload hasMyVoteOptionIdnull (no per-user data on a broadcast). ForEvent(EventIdentifierType eventId)(LivePollChannel.cs:24): builds the event-wide keyevent:{id}viastring.Create(CultureInfo.InvariantCulture, ...).ForSession(SessionIdentifierType sessionId)(LivePollChannel.cs:29): builds the session-scoped keysession:{id}the same way (Wave 2 scope).
- Three event-name constants, all
- Why it's built this way: the keys deliberately match MMCA.Common's default
PushNotificationSettings.ChannelKeyPattern(^(event|session):[0-9]+$, quoted in the class doc comment,LivePollChannel.cs:9), so the framework hub accepts these joins without ADC-specific configuration (seePushNotificationSettings). Astaticclass ofconststrings has no state and no DI cost, so any layer, transport edge, or the browser client can reference it freely. - Where it's used: the poll command handlers resolve a key with
ForEvent/ForSessionand enqueue a work item carrying it under these event names; the hosted drain (LiveChannelPublishProcessor) is what callsILiveChannelPublisher, off the request path. The Blazor live surfaces join the same key and switch on the same names to decide patch-in-place versus reload.
LivePollClosedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollClosedPayload.cs:8· Level 0 · record
- What it is: the broadcast payload for the
LivePollChannel.PollClosedchannel event, a minimal record naming the poll that closed and the event it belongs to. - Depends on: the
LivePollIdentifierTypeandEventIdentifierTypealiases; no first-party types. - Concept, ephemeral broadcast payload carrying only a hint.
[Rubric §6, CQRS & Event-Driven](assesses events that carry just enough context to act on). A close is a structural event, so the payload holds no tally, just the two ids a subscriber needs to reload the affected poll. This is the "channel event as a cache-invalidation hint over fetchable state" rule (ADR-039): the payload is a nudge, and the fresh closed state comes from the next fetch. - Walkthrough: a positional
sealed recordwith two members,LivePollIdentifierType PollIdandEventIdentifierType EventId(LivePollClosedPayload.cs:8-10). Positional records give compiler-generated construction, equality, and JSON round-trip for free. - Why it's built this way: a close needs no per-user framing and no counts, so the payload is the smallest thing that identifies which card to reload.
sealedkeeps the wire shape closed to subclassing. - Where it's used: serialized to JSON and published by the close-poll handler (
CloseLivePollHandler); consumed by the live surfaces to trigger a targeted reload of the poll.
LivePollOpenedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollOpenedPayload.cs:10· Level 0 · record
- What it is: the broadcast payload for the
LivePollChannel.PollOpenedchannel event. Like its closed sibling it is a small positional record, but it additionally carries the question text for an immediate preview. - Depends on: the
LivePollIdentifierTypeandEventIdentifierTypealiases; no first-party types. - Concept, universally-visible-only broadcast data.
[Rubric §11, Security](assesses that broadcasts leak no privileged or per-user data). The doc comment (LivePollOpenedPayload.cs:5) states the constraint directly: the payload carries only universally visible data (BR-229/ADR-039). The question is safe to broadcast because an open poll is public to everyone in the room; there is nothing per-user here to strip. - Walkthrough: a positional
sealed recordwith three members (LivePollOpenedPayload.cs:10-13),LivePollIdentifierType PollId,EventIdentifierType EventId, andstring Question. The question rides along (unlike the close payload) so a client can render a snackbar or preview card without a follow-up fetch, per the member doc (LivePollOpenedPayload.cs:9). - Why it's built this way: an open is worth surfacing instantly ("a new poll just went live"), so the one universally visible field that makes the notification useful, the question, travels with the event, while everything per-user (the caller's own vote) is deliberately absent.
- Where it's used: serialized and published by the open-poll handler (
OpenLivePollHandler); consumed by the live surfaces to announce and reload the newly opened poll.
LivePollOptionDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollOptionDTO.cs:6· Level 0 · record
- What it is: the read-side representation of a single answer option of a live poll: its id, display text, and sort order.
- Depends on: the
LivePollOptionIdentifierTypealias; no first-party types. - Concept, the read DTO (the query-side counterpart of the domain entity).
[Rubric §9, API & Contract Design](assesses stable read contracts distinct from the domain model). This is the wire shape of aLivePollOption, it exposes only what a client renders and hides domain internals like the poll back-reference or audit fields.required/initgive it immutability once mapped. - Walkthrough: three members,
required LivePollOptionIdentifierType Id(LivePollOptionDTO.cs:9),required string Text(LivePollOptionDTO.cs:12), andint Sort(LivePollOptionDTO.cs:15) for display order.Sortis a plain (non-required) value, defaulting to 0. - Why it's built this way: options are authored data (they carry no live tally), so this DTO stays purely descriptive; the running counts live in the separate
LivePollOptionResultDTO. Splitting "what the option is" from "how many votes it has" keeps the authoring view and the results view independent. - Where it's used: nested in
LivePollDTO.Options; produced by theLivePollDTOMapperand hydrated by theLivePollNavigationPopulator.
LivePollOptionResultDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollOptionResultDTO.cs:6· Level 0 · record
- What it is: the per-option vote tally that sits inside a
LivePollResultsDTO: the option, its text, and its active vote count. - Depends on: the
LivePollOptionIdentifierTypealias; no first-party types. - Concept, the results projection.
[Rubric §6, CQRS & Event-Driven](assesses shaped read models for a specific view). WhereLivePollOptionDTOdescribes the option, this record describes the outcome: it repeats the id and text (so a results card can render standalone) and addsVoteCount. It is a computed projection, not a stored row. - Walkthrough: three members,
required LivePollOptionIdentifierType OptionId(LivePollOptionResultDTO.cs:9),required string Text(LivePollOptionResultDTO.cs:12), andint VoteCount(LivePollOptionResultDTO.cs:15), the number of active votes (soft-deleted votes are excluded). - Why it's built this way: carrying the text inline means the
poll.results-changedbroadcast payload is self-contained, a late-joining client can draw the whole bar chart from the results payload alone without first fetching the option list. - Where it's used: the
Optionscollection ofLivePollResultsDTO; computed by theLivePollResultsBuilder.
LivePollStatus
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollStatus.cs:7· Level 0 · enum
- What it is: the lifecycle status of a
LivePoll:Draft,Open, orClosed. - Depends on: nothing first-party.
- Concept, the lifecycle enum as ubiquitous language.
[Rubric §4, DDD](assesses a model that mirrors the business, including named state). The doc comment (LivePollStatus.cs:4-5) pins the state machine: transitions are strictlyDrafttoOpentoClosed, with no reopen (BR-221). The enum is only the vocabulary; the transition guards live on theLivePollaggregate'sOpen/Closemethods, which is where an illegal move is actually rejected. Because this enum crosses the wire onLivePollDTOandLivePollResultsDTO, it is also a small[Rubric §9, API & Contract Design]contract. - Walkthrough: three explicitly numbered members,
Draft = 0(LivePollStatus.cs:10),Open = 1(LivePollStatus.cs:13),Closed = 2(LivePollStatus.cs:16). The member docs record the behavior tied to each:DraftandClosedreject votes,Openaccepts them only while inside the event's live window (BR-224). - Why it's built this way: explicit numeric values make the enum stable across JSON serialization (reordering the members will not silently change the wire meaning), and
Draft = 0makes the default value the safe, non-visible state. - Where it's used: the
Statusfield ofLivePollDTOandLivePollResultsDTO; set and guarded by theLivePollaggregate.
LivePollDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollDTO.cs:8· Level 1 · record
- What it is: the read-side representation of a whole live poll, including its answer options. It is what the authoring and management views render.
- Depends on:
IBaseDTO<TIdentifierType>(implemented,LivePollDTO.cs:8, viaMMCA.Common.Shared.DTOs),LivePollStatus,LivePollOptionDTO, and theLivePollIdentifierType/EventIdentifierType/SessionIdentifierTypealiases. - Concept, the identified DTO.
[Rubric §9, API & Contract Design](assesses read contracts with a stable identity). By implementingIBaseDTO<LivePollIdentifierType>(the DTO counterpart of the entity's identity contract) the record slots into the generic entity-query and mapping machinery that keys results byId. Unlike the results DTO, this shape is descriptive (the authored poll) rather than computed (the tally). - Walkthrough: seven members.
required LivePollIdentifierType Id(LivePollDTO.cs:11, theIBaseDTOkey);required EventIdentifierType EventId(LivePollDTO.cs:14);SessionIdentifierType? SessionId(LivePollDTO.cs:17, null for an event-wide poll);required string Question(LivePollDTO.cs:20);LivePollStatus Status(LivePollDTO.cs:23);DateTime CreatedOn(LivePollDTO.cs:26); andIReadOnlyCollection<LivePollOptionDTO> Options(LivePollDTO.cs:29), defaulted to an empty collection[]so the property is never null before the populator fills it. - Why it's built this way:
Optionsdefaults to[]because the generic query-service path materializes the poll without EF.Include(), and theLivePollNavigationPopulatorloads the children afterward (ADR-002); an empty-collection default keeps a not-yet-populated poll safe to render. Mapping from theLivePollentity is a compile-time Mapperly mapper (ADR-001). - Where it's used: returned by the poll read endpoints on
LivePollsController; produced byLivePollDTOMapperand hydrated byLivePollNavigationPopulator.
LivePollResultsDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollResultsDTO.cs:8· Level 1 · record
- What it is: the live tally for a poll, the question, status, total and per-option vote counts, and (for the requesting user only) which option they voted for. It does double duty as both a query response and the
poll.results-changedbroadcast payload. - Depends on:
LivePollStatus,LivePollOptionResultDTO, and theLivePollIdentifierType/LivePollOptionIdentifierTypealiases; no external NuGet types. - Concept, one shape, two audiences, one security rule.
[Rubric §11, Security](assesses that per-user data never leaks to a broadcast) and[Rubric §12, Performance & Scalability](assesses reusing a self-contained payload to avoid refetches). The doc comment (LivePollResultsDTO.cs:3-7) records the dual role: when this DTO is returned to one caller it includes theirMyVoteOptionId; when it is broadcast on the channel that field is forced tonull, because broadcast payloads must never contain per-user data (BR-229/ADR-039). The nulling is done by the handler (CastVoteHandlerpublishesresults with { MyVoteOptionId = null }), not by this record, but the contract is documented here so both ends honor it. Because the payload is self-contained (question, status, all counts), a client can patch its tally in place from the broadcast alone, no follow-up fetch, which is the overview's patch-in-place performance win. - Walkthrough: six members.
required LivePollIdentifierType PollId(LivePollResultsDTO.cs:11);required string Question(LivePollResultsDTO.cs:14, repeated so a card can render from results alone);LivePollStatus Status(LivePollResultsDTO.cs:17);int TotalVotes(LivePollResultsDTO.cs:20, the sum of active votes);IReadOnlyCollection<LivePollOptionResultDTO> Options(LivePollResultsDTO.cs:23, defaulted to[]); and the nullableLivePollOptionIdentifierType? MyVoteOptionId(LivePollResultsDTO.cs:29), which is null when the caller has not voted or when the DTO is a broadcast payload. - Why it's built this way: making
MyVoteOptionIdnullable lets the exact same type serve both the personalized query response and the anonymized broadcast, so there is only one results shape to build and one to consume; the difference is a single nulled field rather than a second DTO. RepeatingQuestionand each option'sTextinline is what makes the broadcast self-sufficient for a late joiner. - Where it's used: returned by the results query endpoint on
LivePollsControllerand published (withMyVoteOptionIdnulled) as theLivePollChannel.PollResultsChangedpayload; computed byLivePollResultsBuilder.
ISessionLiveUIService
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/ISessionLiveUIService.cs:10· Level 0 · interface
- What it is: the cross-module UI extension point the Conference session-detail page uses to link to a session's Live page (session polls plus Q&A) without depending on the Engagement module at all.
- Depends on: the
SessionIdentifierTypealias (= int, defined in the Conference module and linked solution-wide); no first-party types in its own surface. - Concept introduced, the optional cross-module UI service.
[Rubric §7, Microservices Readiness](assesses whether one module can render a link into another without a hard reference) and[Rubric §18, UI Architecture](assesses feature-flagged composition of module UIs). The rule is stated in the doc comment (ISessionLiveUIService.cs:3-9): when the Engagement module is enabled its UI registers an implementation and the Conference session-detail page's Live button lights up; when Engagement is disabled the service is absent and the button simply does not render. Neither module references the other's UI project, the contract lives here inShared, so the two can be deployed together or apart. The doc comment names theISessionBookmarkUIServiceprecedent, the same optional-service idiom Engagement already uses for the bookmark button.[Rubric §1, SOLID]: a single-method boundary means a consumer depends only on "give me the Live path", not on how Engagement routes. - Walkthrough: one method,
string GetSessionLivePath(SessionIdentifierType sessionId)(ISessionLiveUIService.cs:14), which builds the route path of the session's Live page from a session id. It returns a plain route string, so the Conference page can render an anchor without knowing Engagement's route table. - Why it's built this way: routing a link into another module's page through an optional service (rather than a shared route constant) keeps the modular monolith honest. The button and its target live entirely inside Engagement, and Conference stays ignorant of whether the live layer is present.
- Where it's used: resolved by the Conference public session-detail page, which holds it as a nullable property and fills it in
OnInitializedwithServiceProvider.GetService<ISessionLiveUIService>()(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionDetail.razor.cs:43,59). The implementation isSessionLiveUIService, registered by the Engagement UI's DI module (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/DependencyInjection.cs:65), and it just delegates toEngagementRoutePaths.SessionLive(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionLiveUIService.cs:13-14). - Caveats / not-in-source: the interface doc comment says the Conference UI "injects this interface as NULLABLE" (
ISessionLiveUIService.cs:5), but the consumer does not use[Inject]. The comment atPublicSessionDetail.razor.cs:38-39records why: Blazor's[Inject]has no optional mode (an unregistered service throws at render), so the page resolves it throughIServiceProvider.GetServiceinstead. The effect is what the doc describes; the mechanism isGetService, not a nullable inject.
ModerationAction
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/ModerationAction.cs:7· Level 0 · enum
- What it is: the moderation action a moderator applies to a session question (BR-234): approve, dismiss, or mark answered. Each value maps to exactly one domain transition on
SessionQuestion. - Depends on: nothing first-party.
- Concept, the action enum as an intent contract.
[Rubric §9, API & Contract Design](assesses a small, closed vocabulary crossing the wire) and[Rubric §4, DDD](assesses naming that mirrors the business). This enum is the request-side counterpart toQuestionStatus: whereQuestionStatusnames where the question is,ModerationActionnames what the moderator asks for. The mapping from action to transition is enforced on theSessionQuestionaggregate, not here; the enum only carries the intent. - Walkthrough: three explicitly numbered members.
Approve = 0(ModerationAction.cs:10), valid from Pending or Dismissed;Dismiss = 1(ModerationAction.cs:13), valid from Pending or Approved;MarkAnswered = 2(ModerationAction.cs:16), which marks an approved question answered once. The member docs pin the allowed source states for each. - Why it's built this way: explicit numeric values keep the enum stable across JSON serialization (reordering members will not silently change wire meaning), and a single action enum lets one moderation endpoint accept every moderator move rather than one endpoint per transition.
- Where it's used: bound on the moderation request accepted by
SessionQuestionsControllerand dispatched into the moderation command handler, which calls the matching transition method onSessionQuestion.
OptionState
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/HappeningNow/PollManagementPanel.razor.cs:193· Level 0 · class
- What it is: a tiny private, mutable holder for one poll option's text, used purely as a two-way binding target while an organizer types the options of a new poll on the Manage tab.
- Depends on: BCL only (a nullable
string); no first-party types. - Concept, the mutable view-model row for two-way binding.
[Rubric §19, State Management](assesses how transient form state is held in a component) and[Rubric §24, Forms/Validation/UX Safety](assesses editable-collection form modelling). Blazor's@bindneeds a stable reference-typed target it can write back into. AList<string>cannot be bound element-by-element the same way, because a list slot is not an object the binder can hold onto across re-renders.OptionStategives each option row its own object, so growing, shrinking, and editing_newOptions(PollManagementPanel.razor.cs:54) stays stable: the markup binds@bind-Value="_newOptions[index].Text"(PollManagementPanel.razor:14) to that per-row object. It is deliberatelyprivate sealedand nested inside the panel, it is not a domain concept, only a UI scratch buffer. - Walkthrough: a single member,
public string? Text { get; set; }(PollManagementPanel.razor.cs:195), a mutable auto-property. The class isprivate sealed(PollManagementPanel.razor.cs:193), so nothing outside the panel can see or reuse it. - Why it's built this way: modelling the option rows as objects (not raw strings) is what lets
AddOption/RemoveOption(PollManagementPanel.razor.cs:58,66) add and remove rows betweenLivePollDTO.MinOptionsandLivePollDTO.MaxOptionswhile eachMudTextFieldkeeps binding to its own row. On submit the panel projects_newOptionsback to a trimmed, non-emptyList<string>(PollManagementPanel.razor.cs:82-86) for theCreateLivePollRequest. - Where it's used: only inside
PollManagementPanel, as the element type of the_newOptionslist backing the create-poll form.
QuestionStatus
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/QuestionStatus.cs:8· Level 0 · enum
- What it is: the moderation status of a
SessionQuestion:Pending,Approved, orDismissed. It is the small state vocabulary the whole Q&A feature is built around. - Depends on: nothing first-party.
- Concept, the moderation state machine as ubiquitous language.
[Rubric §4, DDD](assesses a model that names its states) and[Rubric §11, Security](assesses visibility rules encoded in the model). The doc comments pin both the starting state and the visibility rule per state: a new question starts at the event's moderation default, Pending or Approved (BR-233,QuestionStatus.cs:4-6); a Pending question is visible only to its author and moderators (QuestionStatus.cs:10); an Approved one is visible to all attendees and open to upvotes (QuestionStatus.cs:13); a Dismissed one is hidden from attendees, and a re-approve brings it back (QuestionStatus.cs:16, BR-234). The enum is only the vocabulary; the legal transitions are guarded on theSessionQuestionaggregate and requested viaModerationAction. - Walkthrough: three explicitly numbered members,
Pending = 0(QuestionStatus.cs:11),Approved = 1(QuestionStatus.cs:14),Dismissed = 2(QuestionStatus.cs:17).Pending = 0makes the default value the safe, non-public state. - Why it's built this way: explicit values keep the enum stable across the wire (it rides on
SessionQuestionDTO), and pinning visibility to the status in one place means every reader (query filter, DTO, UI) agrees on who may see a question. - Where it's used: the
Statusfield ofSessionQuestionDTO; set and guarded by theSessionQuestionaggregate andSessionQuestionInvariants; read by the moderation and read paths and by the live Q&A surfaces.
SessionQuestionAnsweredPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionAnsweredPayload.cs:8· Level 0 · record
- What it is: the broadcast payload for the
SessionQuestionChannelQuestionAnsweredchannel event, a minimal record naming the question that was marked answered and the session it belongs to. - Depends on: the
SessionQuestionIdentifierTypealias (= int,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:11) and the Conference-ownedSessionIdentifierTypealias; no first-party types. - Concept, the ephemeral broadcast payload as a reload hint.
[Rubric §6, CQRS & Event-Driven](assesses events that carry just enough to act on). Marking-answered is a structural change, so the payload holds no question body, only the two ids a subscriber needs to locate and refresh the affected question card. This is the same "channel event as a cache-invalidation hint over fetchable state" rule the poll payloads follow (seeLivePollClosedPayloadand ADR-039): the push is a nudge, the fresh state comes from the next fetch. - Walkthrough: a positional
sealed recordwith two members,SessionQuestionIdentifierType QuestionIdandSessionIdentifierType SessionId(SessionQuestionAnsweredPayload.cs:8-10). The positional form gives compiler-generated construction, equality, and JSON round-trip for free. - Why it's built this way: an answered mark needs no per-user framing and no content, so the payload is the smallest thing that identifies which card to update;
sealedcloses the wire shape to subclassing. - Where it's used: serialized to JSON and published under
SessionQuestionChannel.QuestionAnsweredby the mark-answered moderation path; consumed by the session Live and presenter surfaces to refresh the question.
SessionQuestionApprovedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionApprovedPayload.cs:10· Level 0 · record
- What it is: the broadcast payload for the
SessionQuestionChannelQuestionApprovedchannel event. Unlike its answered and dismissed siblings it additionally carries the question text, so a client can render the newly visible question immediately. - Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases; BCLstring. - Concept, universally-visible-only broadcast data.
[Rubric §11, Security](assesses that broadcasts leak no privileged or per-user data). The doc comment (SessionQuestionApprovedPayload.cs:4-5) states the constraint directly: the payload carries only universally visible data (BR-238), the approved question's content and no author identity. Approval is exactly the moment a question becomes public to the room, so its text is safe to broadcast; the author is deliberately absent because questions display anonymously (the same rule enforced onSessionQuestionDTO). - Walkthrough: a positional
sealed recordwith three members (SessionQuestionApprovedPayload.cs:10-13),SessionQuestionIdentifierType QuestionId,SessionIdentifierType SessionId, andstring Text. The text rides along (unlike the answered and dismissed payloads) so an attendee's list can insert the question without a follow-up fetch (SessionQuestionApprovedPayload.cs:9). - Why it's built this way: an approve is worth surfacing instantly, so the one universally visible field that makes the update useful, the text, travels with the event, while everything author-scoped stays off the wire.
- Where it's used: serialized and published under
SessionQuestionChannel.QuestionApprovedon submit under an Approved default or on moderation; consumed by the live Q&A surfaces to add the approved question.
SessionQuestionChannel
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionChannel.cs:12· Level 0 · class (static)
- What it is: the shared contract for the session Q&A push channel, the event-name string constants that ride the SignalR channel. It is the one vocabulary both the publisher (Engagement handlers) and the subscriber (the Blazor UI) agree on for questions, and it shares the session channel key with polls.
- Depends on: BCL only; it references the payload records
SessionQuestionApprovedPayload,SessionQuestionAnsweredPayload,SessionQuestionDismissedPayload,SessionQuestionUpvoteChangedPayload, andSessionQuestionPendingCountChangedPayloadin its doc comments as the shape each event carries. The class doc points atLivePollChannel.ForSessionas the source of the channel key. - Concept, the event-name contract that mirrors the poll channel.
[Rubric §7, Microservices Readiness](assesses shared contracts that let independently deployed parts agree without shared code paths) and[Rubric §6, CQRS & Event-Driven](assesses a well-named event vocabulary). The ephemeral push mechanism itself is taught in this chapter's overview and framed by ADR-039; this class is where the names live. A publisher pushes a(channelKey, eventName, payloadJson)triple and a subscriber matches on the sameeventName, so if the two ends disagree on a string the broadcast silently no-ops. The doc comment (SessionQuestionChannel.cs:6-8) records that channel keys come from the existingLivePollChannel.ForSessionhelper, so questions and polls ride one session channel rather than two, and pins the security rule: broadcast payloads carry only universally visible data (BR-238), approved question content and counts, while pending question content never rides the channel because moderators get a count-only event instead (SessionQuestionChannel.cs:8-10).[Rubric §11, Security]: encoding "counts, not content" into the channel's contract is what keeps unmoderated text off the wire. - Walkthrough: five
public const stringevent names.QuestionApproved = "question.approved"(SessionQuestionChannel.cs:15), raised when a question becomes Approved on submit under an Approved default or on moderation;QuestionAnswered = "question.answered"(SessionQuestionChannel.cs:18);QuestionDismissed = "question.dismissed"(SessionQuestionChannel.cs:21);QuestionUpvoteChanged = "question.upvote-changed"(SessionQuestionChannel.cs:24), raised after an upvote toggle commits; andQuestionPendingCountChanged = "question.pending-count-changed"(SessionQuestionChannel.cs:27), a count-only moderator signal (BR-238). Each doc comment names the payload record it carries. - Why it's built this way: reusing the poll channel key (rather than minting a second session channel) means an attendee on a session's Live page receives both poll and question events from one join, halving the SignalR group membership. A
staticclass ofconststrings has no state and no DI cost, so any layer, transport edge, or the browser client can reference it freely; renaming an event once here moves both ends together ([Rubric §15, Best Practices & Code Quality]). - Where it's used: the session-question command and domain-event handlers enqueue under these names onto
ILiveChannelPublishQueue, and the hosted drainLiveChannelPublishProcessoris what callsILiveChannelPublisheroff the request path; the live Q&A surfaces join the shared session key and switch on these names to decide add versus reload versus count-only refresh.
SessionQuestionDismissedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDismissedPayload.cs:8· Level 0 · record
- What it is: the broadcast payload for the
SessionQuestionChannelQuestionDismissedchannel event, structurally identical toSessionQuestionAnsweredPayload, naming the dismissed question and its session. - Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases; no first-party types. - Concept: the ephemeral reload-hint payload introduced by
SessionQuestionAnsweredPayload. A dismiss removes a question from attendees' view, so the payload carries no content, only the two ids a subscriber uses to drop the card.[Rubric §11, Security]: pushing no text on a dismiss means a moderator's removal never re-broadcasts the (now hidden) question body. - Walkthrough: a positional
sealed recordwith two members,SessionQuestionIdentifierType QuestionIdandSessionIdentifierType SessionId(SessionQuestionDismissedPayload.cs:8-10). - Why it's built this way: a dismiss is a structural event, so the smallest id-only payload is enough to tell a client which card to hide; an identical shape to the answered payload keeps the channel's payload family uniform.
- Where it's used: published under
SessionQuestionChannel.QuestionDismissedby the moderation path; consumed by the live Q&A surfaces to remove the question.
SessionQuestionPendingCountChangedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionPendingCountChangedPayload.cs:10· Level 0 · record
- What it is: the broadcast payload for the
SessionQuestionChannelQuestionPendingCountChangedchannel event, a count-only signal telling moderators how many Pending questions a session now has. - Depends on: the
SessionIdentifierTypealias; BCLint. - Concept, the count-only moderator broadcast.
[Rubric §11, Security](assesses that unmoderated content never leaves the server). The doc comment (SessionQuestionPendingCountChangedPayload.cs:3-6) states the rule that shapes this record: pending question content never rides the channel (BR-238), so instead of broadcasting a new pending question's text, the server broadcasts only the fresh count. A moderator's badge updates while the actual text stays gated behind an authenticated moderator fetch. This is the deliberate asymmetry that separates it fromSessionQuestionApprovedPayload, which does carry text because approval makes the content public. - Walkthrough: a positional
sealed recordwith two members (SessionQuestionPendingCountChangedPayload.cs:10-12),SessionIdentifierType SessionIdandint PendingCount, the fresh number of Pending questions for the session. Note there is noQuestionId: the signal is about the queue, not a single question. - Why it's built this way: broadcasting a count rather than a question keeps unmoderated (possibly abusive) text off the wire while still giving moderators a live queue badge, so the moderation UI needs no polling to know work has arrived.
- Where it's used: published under
SessionQuestionChannel.QuestionPendingCountChangedwhenever the Pending set changes (a new submission under a Pending default, or a moderation move); consumed bySessionLiveModerationPaneland the presenter surface to update the pending badge.
SessionQuestionUpvoteChangedPayload
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionUpvoteChangedPayload.cs:10· Level 0 · record
- What it is: the broadcast payload for the
SessionQuestionChannelQuestionUpvoteChangedchannel event, carrying the question, its session, and the fresh active-upvote count. - Depends on: the
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases; BCLint. - Concept, the counter broadcast that strips voter identity.
[Rubric §11, Security](assesses that broadcasts never reveal who acted) and[Rubric §12, Performance & Scalability](assesses patch-in-place over refetch). The doc comment (SessionQuestionUpvoteChangedPayload.cs:4-5) records the rule: the payload carries only the fresh count, never who voted (BR-238). Sending the newUpvoteCountinline lets each subscribed circuit patch the vote number in place without a refetch, the same burst-safe patch-in-place win the poll tallies use (see the overview andLivePollResultsDTO); each circuit keeps its own "did I upvote" marker locally because that per-user bit never rides the broadcast. - Walkthrough: a positional
sealed recordwith three members (SessionQuestionUpvoteChangedPayload.cs:10-13),SessionQuestionIdentifierType QuestionId,SessionIdentifierType SessionId, andint UpvoteCount, the fresh active-upvote count. - Why it's built this way: broadcasting the count rather than the delta means a late joiner and an existing viewer converge on the same number without ordering assumptions, and omitting the voter id both protects privacy and keeps the payload tiny under burst voting.
- Where it's used: published under
SessionQuestionChannel.QuestionUpvoteChangedbySessionQuestionUpvoteChangedHandlerafter an upvote toggle commits; consumed by the live Q&A surfaces throughLiveBroadcastPatchto patch the upvote count in place.
SubmitQuestionRequest
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SubmitQuestionRequest.cs:8· Level 0 · record
- What it is: the request body for submitting a question to a live session (BR-231/BR-233), carrying the target session and the question text.
- Depends on: the
SessionIdentifierTypealias; BCLstring. - Concept, identity-from-token, not from body.
[Rubric §11, Security](assesses that a caller cannot act as another principal) and[Rubric §9, API & Contract Design](assesses request contracts that model only client-owned data). LikeCreateLivePollRequestand the pollCastVoteRequest, the most important thing about this DTO is what it deliberately omits: there is noUserId. The doc comment (SubmitQuestionRequest.cs:3-6) states the rule the handler enforces, the submitting user is taken from the caller's token server-side viaICurrentUserService, never from the request body, so a question cannot be submitted on behalf of another user. The field-length limit (1 to 500 characters, BR-231) is documented here but validated downstream, so an invalid request fails with Problem Details rather than being unconstructable at the DTO level. - Walkthrough: two members,
required SessionIdentifierType SessionId { get; init; }(SubmitQuestionRequest.cs:11), the target session, which must be live-eligible (BR-49/BR-91); andrequired string Text { get; init; }(SubmitQuestionRequest.cs:14), the question text.requiredforces the client to supply both;initmakes them immutable once bound. - Why it's built this way: keeping the request to the session id plus the text means the submit endpoint cannot be spoofed with a foreign user id and cannot smuggle a status: the moderation default is decided server-side from the event (BR-233), not by the client.
- Where it's used: the body of the submit endpoint on
SessionQuestionsController; mapped into the submit command handled in the Application layer, which stamps the caller as author and creates aSessionQuestion.
SessionQuestionDTO
MMCA.ADC.Engagement.Shared ·
MMCA.ADC.Engagement.Shared.SessionQuestions·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDTO.cs:10· Level 1 · record
- What it is: the read-side representation of a session question: its text, moderation status, answered flag, upvote count, two per-caller flags (did I upvote, is this mine), and a concurrency token. It is what every Q&A list renders.
- Depends on:
IBaseDTO<TIdentifierType>andIConcurrencyAware(both implemented,SessionQuestionDTO.cs:10, fromMMCA.Common.Shared.DTOs),QuestionStatus, and theSessionQuestionIdentifierType/SessionIdentifierType/EventIdentifierTypealiases. - Concept, the anonymized identified read DTO.
[Rubric §9, API & Contract Design](assesses stable read contracts distinct from the domain model) and[Rubric §11, Security](assesses deliberate omission of identity). By implementingIBaseDTO<TIdentifierType>(the DTO counterpart of the entity identity contract) the record slots into the generic mapping and list machinery that keys results byId. The doc comment (SessionQuestionDTO.cs:5-9) records the deliberate design: the DTO carries no user-identity fields at all, because questions display anonymously; the caller is related to the question only through the two per-caller flagsMyUpvoteandIsMine(BR-238). Those flags are computed per request against the calling user, they are not stored on the entity. - Concept, the shared-layer constant as the single source of a number.
[Rubric §15, Best Practices & Code Quality].TextMaxLength = 500(SessionQuestionDTO.cs:18) lives on the DTO, and the doc comment explains why (SessionQuestionDTO.cs:12-17):Sharedis the lowest layer every consumer can reach, so the UI caps its input from here andSessionQuestionInvariants.TextMaxLengthreads the same constant. The 500 is written once, and the client-side cap and the server-side invariant cannot drift apart. - Walkthrough: one constant plus ten members.
public const int TextMaxLength = 500(SessionQuestionDTO.cs:18), the BR-231 text cap shared withSessionQuestionInvariants.required SessionQuestionIdentifierType Id(SessionQuestionDTO.cs:21), theIBaseDTOkey.required SessionIdentifierType SessionId(SessionQuestionDTO.cs:24) andrequired EventIdentifierType EventId(SessionQuestionDTO.cs:27), the event id denormalized at submission so the read model needs no join back to Conference.required string Text(SessionQuestionDTO.cs:30), the question body.QuestionStatus Status(SessionQuestionDTO.cs:33) andbool IsAnswered(SessionQuestionDTO.cs:36), the moderation state and the answered mark.int UpvoteCount(SessionQuestionDTO.cs:39), the number of active upvotes.bool MyUpvote(SessionQuestionDTO.cs:42) andbool IsMine(SessionQuestionDTO.cs:45), the per-caller flags: whether the calling user has an active upvote, and whether the calling user authored the question.DateTime CreatedOn(SessionQuestionDTO.cs:48), when the question was submitted.byte[] RowVersion { get; init; } = [](SessionQuestionDTO.cs:56), the optimistic-concurrency token that satisfiesIConcurrencyAware. The doc comment (SessionQuestionDTO.cs:50-55) pins the contract: it is always present, and the client echoes it in theIf-Matchheader of a moderation transition, so two moderators racing approve-versus-dismiss surface as a 412 Precondition Failed instead of the second decision silently applying (ADR-035).[Rubric §8, Data Architecture]: the lost-update guard is carried by the read contract, not bolted on at the controller.
- Why it's built this way: keeping author identity off the DTO entirely (rather than sending it and hoping the UI hides it) means an anonymous-by-design feature cannot leak an author through the wire; the only caller-relative facts,
MyUpvote/IsMine, are booleans computed for the one requester, so no other attendee's relationship to the question is ever exposed. DenormalizingEventIdkeeps the read model self-contained for filtering. - Where it's used: returned by the Q&A endpoints on
SessionQuestionsController. It is built bySessionQuestionViewBuilder(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/Services/SessionQuestionViewBuilder.cs:12), which is shared by the submit, list, and moderation use cases so every surface computes the counts and per-caller flags the same way; the moderation view passes anullcaller id, whereMyUpvote/IsMineare not meaningful (SessionQuestionViewBuilder.cs:18). It is rendered bySessionLive,PresenterView, andSessionLiveModerationPanel, and patched in place byLiveBroadcastPatch.
PollManagementPanel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/HappeningNow/PollManagementPanel.razor.cs:20· Level 8 · class (Blazor component)
- What it is: the presentational panel behind the
HappeningNowpage's organizer-only Manage tab: the create-poll form plus the event's poll lifecycle rows (open, close, delete). It performs the poll calls itself and tells the page when to reload. - Depends on: injected
ILivePollUIServiceandIToastService(PollManagementPanel.razor.cs:22-23); the DTOsLivePollDTO,LivePollStatus,LivePollOptionDTO, andCreateLivePollRequest;ResultandErrorTypefromMMCA.Common.Shared.Abstractions; the sharedDeleteConfirmationcomponent fromMMCA.Common.UI.Components; and its own nestedOptionState. It implementsIAsyncDisposable. - Concept introduced, the container/presentational split in Blazor.
[Rubric §18, UI Architecture](assesses component decomposition and responsibility boundaries) and[Rubric §19, State Management](assesses who owns which piece of state). The class doc (PollManagementPanel.razor.cs:11-18) states the division precisely: the panel owns the form state (the typed question and option rows) and performs the poll calls, while the page owns the lists and reloads them through the change callbacks, and the page keeps handling the live channel events. That is the container/presentational pattern: the panel is a leaf that renders and acts, the page is the container that holds the data and decides when to refetch. Parent-owned data arrives as[Parameter]s and the panel never mutates them, it raisesEventCallbacks instead.[Rubric §15, Best Practices & Code Quality]: pulling the authoring UI out of the page leaves the page focused on load, channel, and voting. - Concept, two-way parameter binding for a shared busy flag. The
IsSaving/IsSavingChangedpair (PollManagementPanel.razor.cs:37,41) is Blazor's@bind-IsSavingconvention: the page binds its ownIsSaving(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/HappeningNow/HappeningNow.razor:145), so when the panel raisesIsSavingChangedthe page re-renders and every section (not just this panel) disables its actions while an action runs. - Walkthrough, in teaching order:
- Injected services and parameters (
PollManagementPanel.razor.cs:22-49):PollServiceandToast; thenEventId(:28,[EditorRequired], the event new polls are created against),ManagePolls(:33, the event's manage list in every status, owned and refreshed by the page), theIsSaving/IsSavingChangedpair (:37,41), and the two post-action callbacksOnPollCreated(:45) andOnPollLifecycleChanged(:49). - Local state (
PollManagementPanel.razor.cs:51-56): aCancellationTokenSource _ctsfor disposal-safe async, the_newQuestionbuffer,_newOptionsseeded with two emptyOptionStaterows (:54, matchingLivePollDTO.MinOptions), and a_deleteConfirmreference to the shared confirmation dialog wired atPollManagementPanel.razor:85. - Option row editing (
PollManagementPanel.razor.cs:58-72):AddOptionappends a row only while the count is belowLivePollDTO.MaxOptions(:60);RemoveOptionremoves one only while the count is aboveLivePollDTO.MinOptionsand the index is in range (:68). The markup mirrors the same two constants to disable the add and remove controls (PollManagementPanel.razor:19,25), so the client cannot even attempt a shape the server would reject (BR-220).[Rubric §24, Forms/Validation/UX Safety]. - Create (
PollManagementPanel.razor.cs:74-126): guards a non-blank question and at least two non-empty options with a warning toast (:76-91), raises the shared saving flag, trims the option texts into aList<string>, builds aCreateLivePollRequest(:96-101), and on success clears the form back to two empty rows, toasts, and raisesOnPollCreatedso the page reloads the manage rows.OperationCanceledExceptionis swallowed as expected-during-disposal (:118-121) and the saving flag is always lowered infinally(:124). - Lifecycle actions (
PollManagementPanel.razor.cs:128-144):OpenPollAsyncandClosePollAsyncare one-liners passing the poll'sRowVersionthrough to the service (theIf-Matchtoken, ADR-035);DeletePollAsyncfirst awaits the sharedDeleteConfirmationdialog and returns unless the answer istrue(:137-141), the same confirm-first pattern the list pages use. - The shared action runner (
PollManagementPanel.razor.cs:146-170):RunManageActionAsyncis one place for the raise-saving, call, report-or-toast, raise-OnPollLifecycleChanged, lower-saving sequence, so the three lifecycle actions cannot drift in their error and busy handling.[Rubric §1, SOLID]. - Error surfacing (
PollManagementPanel.razor.cs:172-181):ShowActionErrordistinguishes a stated refusal from an unexpected fault.result.HasErrorType(ErrorType.Unexpected)shows the generic localized fallback, anything else shows the server's own localized Problem Details message viaresult.LocalizedErrorMessage(L)(ADR-027 Decision 9).[Rubric §11, Security]: a 500, a transport failure, or a timeout never leaks raw diagnostic text into the UI. - Disposal (
PollManagementPanel.razor.cs:183-190): cancels and disposes the_cts, then suppresses finalization.
- Injected services and parameters (
- Why it's built this way: the panel does the calls but not the reloads because the page is the one holding both poll lists; routing the refresh back through
OnPollCreated/OnPollLifecycleChanged(HappeningNow.razor:146-147) keeps one owner per piece of state and avoids two components fetching the same list. Localization is deliberately not given its own resource file: the markup injectsIStringLocalizer<HappeningNow>(PollManagementPanel.razor:2), following theSessionLiveModerationPanelprecedent, so the Manage tab keeps one resource set (PollManagementPanel.razor.cs:16-18).[Rubric §27, i18n]. - Where it's used: rendered inside the organizer-only Manage tab of
HappeningNow(HappeningNow.razor:143-148), which is itself gated on the page's_isOrganizerflag. - Caveats / not-in-source: the panel has no
@pagedirective and no route, it is a child component only. Its rendered layout, the MudBlazor controls, and the localization keys live in the pairedPollManagementPanel.razormarkup file, not in the code-behind.
HappeningNow
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.HappeningNow·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/HappeningNow/HappeningNow.razor.cs:24· Level 10 · class (Blazor page)
- What it is: the conference-day home page at
/happening-now. It shows now-and-next sessions, the event's open live polls with live tallies, and (for organizers only) a poll-manage tab, and it joins the event's live channel while the event is live so poll events refresh the tallies without polling. - Depends on: injected
ILiveEventUIService,ILivePollUIService,INowNextService,NotificationState,NotificationHubService,IToastService, andIHapticFeedbackService(HappeningNow.razor.cs:26-32); the DTOsLiveEventContext,LivePollResultsDTO,LivePollDTO, andNowNextSessionInfo; theLivePollChannelkey and event vocabulary; the UI helpersLiveChannelSubscriptionandLiveBroadcastPatch;RoleNamesfrom the Common auth contracts; and the child componentPollManagementPanel. It implementsIAsyncDisposable. - Concept introduced, the live Blazor surface: prerender-safe load, then interactive channel join.
[Rubric §18, UI Architecture](assesses component lifecycle and separation of load from live wiring),[Rubric §19, State Management], and[Rubric §23, Front-End Performance]. The page splits its lifecycle in two.OnInitializedAsync(HappeningNow.razor.cs:54) does the data load: it reads the organizer flag from the cascadingAuthenticationStateviaIsInRole(RoleNames.Organizer)(:72), fetches the currentLiveEventContextand returns early if there is none, then loads sessions, open polls, and (only for organizers) the manage list. The live wiring waits forOnAfterRenderAsync(:107-128). Note what that method is not: it is notfirstRender-gated, and the comment at:109-112explains why. First render fires at the firstawaitinsideOnInitializedAsync, while_liveEventis still null, so afirstRender-only join would never attach (BR-229). Instead the join happens on the first render after the load, using the subscription's ownIsJoinedas the already-joined guard andRendererInfo.IsInteractiveto keep the prerender pass and the bUnit suite from dialling the hub. It also refuses to join unless the event is live right now (_liveEvent.IsLiveAt(DateTime.UtcNow),:118).[Rubric §28, Front-End Testing]: the shape of this method is driven by what the component test can exercise, and the comment at:65-66records the related trade-off, unlike the sibling Live and Presenter pages this page keeps its loads on the prerender pass and accepts the double fetch, because adding the guard needs a hub-service test extension point (deferred). - Walkthrough, in teaching order:
- Injected state and fields (
HappeningNow.razor.cs:26-52): the seven injected services, the cascadingAuthState, aCancellationTokenSource _ctsfor disposal-safe async, the breadcrumb list, theIsLoading/IsSavingflags,_loadError,_isOrganizer, the loaded_liveEvent, the two poll lists (_pollsfor open polls with tallies,_managePollsfor every status), the now and next session lists, and aLiveChannelSubscription_channel(:51) that encapsulates join and leave. - Load (
HappeningNow.razor.cs:54-106): sets breadcrumbs, subscribes toNotificationState.OnChange(:63) so the header announcements badge stays in sync with the shared unread count, reads the organizer role, fetches the current event, then chains the loads so a failure short-circuits the rest. Every failure funnels into a single localized_loadError(:94),OperationCanceledExceptionis swallowed as expected-during-disposal, andIsLoadingis always cleared infinally. - Channel join (
HappeningNow.razor.cs:108-129): joinsLivePollChannel.ForEventfor the loaded event id and registersHandleChannelEventAsyncas the handler. - Channel handling (
HappeningNow.razor.cs:134-147): this is the performance heart. For aLivePollChannel.PollResultsChangedevent it callsLiveBroadcastPatch.TryApplyPollResults(_polls, payloadJson, preserveMyVote: true)(:139) to patch the matching poll's tallies in place from the broadcast payload, keeping this circuit's own vote marker; only then does it re-render. Everything else (structural events such as opened and closed) falls through toReloadPollsAsync. The comment at:135-137records why reload-on-broadcast was abandoned: one hot poll turned V votes times C viewers into V*C authenticated refetches under burst voting.[Rubric §12, Performance & Scalability]. - Loads and refresh (
HappeningNow.razor.cs:149-195):LoadSessionsAsynccalls the public now-next endpoint throughINowNextService, and the doc comment (:148-153) pins the division of labour, the server owns the eligibility filter, the event-local wall clock, and the "next = the batch sharing the earliest future start" rule, so the page only renders what comes back; a not-found answer (an unpublished or deleted event) is normalized to success so the page shows an empty state rather than a load failure (:162).LoadPollsAsyncandLoadManagePollsAsync(:165-169) eachTaptheir result into a list.ReloadPollsAsync(:171-194) is the background-refresh path: a failure toasts and returns rather than crashing the page, because the manual refresh button and the next channel event are the retry paths (:182-183).[Rubric §29, Resilience]. - Voting (
HappeningNow.razor.cs:197-226):VoteAsyncfires a haptic click, a no-op off native heads (ADR-042,:198-199), casts the vote throughILivePollUIService, and on success replaces the matching entry in_pollswith the returnedLivePollResultsDTO(:211-215), so the voter sees their own result immediately without waiting for the broadcast. - Post-action reload callbacks (
HappeningNow.razor.cs:228-253):ReloadManagePollsAsyncandReloadPollListsAsyncare the two handlers bound toPollManagementPanel's change callbacks. The comment at:227-230states the contract: the panel performs its own poll call, then the page (which owns the lists) reloads what the action affected, and reports a failed reload here while still inside the panel'stryblock, so a cancellation during disposal stays expected. - Error surfacing and formatting (
HappeningNow.razor.cs:255-267): the sameShowActionErrorsplit as the panel (a stated refusal shows the server's localized Problem Details, an unexpected fault shows the generic fallback, ADR-027 Decision 9), plusFormatSessionTime, which formats the event-local start and end withCultureInfo.CurrentCulture(:265-266).[Rubric §27, i18n]: a displayed time formats with the current culture, unlike a channel key, which formats invariant. - Disposal (
HappeningNow.razor.cs:269-280): unsubscribes fromNotificationState.OnChange, cancels and disposes the_cts, and disposes the channel subscription (which leaves the SignalR group).
- Injected state and fields (
- Why it's built this way: patch-in-place from the self-contained
LivePollResultsDTObroadcast (rather than a refetch on every event) is what keeps a hot poll from stampeding the API under burst voting, and gating the channel join onRendererInfo.IsInteractiveplus a live-window check means a prerender pass or an already-ended event never opens a SignalR connection (ADR-039). Announcements are deliberately not duplicated on this page: they live in the shared notification inbox and are reached from the header link with a live unread badge (HappeningNow.razor.cs:17-22), so there is one inbox, not two. - Where it's used: routed as the conference-day landing page,
@page "/happening-now"with[Authorize](MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/HappeningNow/HappeningNow.razor:1-2). It rendersPollManagementPanelin its organizer-only Manage tab and links out to the per-session live surfaceSessionLivefrom every now-and-next row;PresenterViewis the speaker-facing sibling. - Caveats / not-in-source: the
.razormarkup owns the tab layout, the poll cards, the empty and error states, and the localization keys, so the rendered structure is not determinable fromHappeningNow.razor.csalone.
OptionState
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLiveModerationPanel.razor.cs:312· Level 0 · class
- What it is: a one-field mutable holder for a single poll option's text,
private sealedand nested inside SessionLiveModerationPanel. It exists only to give the create-poll form's dynamic option rows a stable reference-type target for two-way Blazor binding. - Depends on: nothing first-party. One nullable
stringauto-property,Text(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLiveModerationPanel.razor.cs:314). - Concept introduced: reference-type binding cells for a growable form list. Blazor's
@bindneeds a settable member on an object whose identity survives a re-render. Binding straight to the elements of aList<string>does not give each text field its own writable backing store, because astringelement has no addressable setter and the slot is replaced on every keystroke. Wrapping each option in a small mutable class gives theMudTextFielda fixed object to writeTextinto (SessionLiveModerationPanel.razor:67), and letsAddOption/RemoveOptiongrow and shrink the list without disturbing the other rows' bindings.[Rubric §24, Forms/Validation/UX Safety]assesses how the UI models editable form state safely: this wrapper is the minimal mechanism that keeps a variable-length option list editable without index churn. The sibling OptionState nested in HappeningNow is the same idiom applied to the event-wide poll builder. - Walkthrough: declared at
SessionLiveModerationPanel.razor.cs:312with a single auto-propertypublic string? Text { get; set; }(:314). The panel seeds two cells at construction (_newPollOptions = [new(), new()],:69), enforces the DTO's option range throughAddOption(:161, capped atLivePollDTO.MaxOptions,:163) andRemoveOption(:169, floored atLivePollDTO.MinOptions,:171), and on submit projects the trimmed, non-empty texts into the request'sOptionslist (:185-189). After a successful create the panel clears the list and re-seeds exactly two blank cells (:214-216). - Why it's built this way: a
private sealednested type keeps this a pure implementation detail of the moderation panel. It never crosses a boundary (the wire shape is the plainList<string>on CreateLivePollRequest), so it does not belong in the Shared project. - Where it's used: only within SessionLiveModerationPanel, as the element type of
_newPollOptionsbacking the create-poll option rows.
CreateLivePollCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Create·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommand.cs:14· Level 1 · record
- What it is: the CQRS command that requests creation of a live poll (as Draft). It wraps the transport CreateLivePollRequest together with two facts about the caller: their
speaker_idclaim (if any) and whether they hold an organizer/admin role. - Depends on: CreateLivePollRequest (the request body shape,
MMCA.ADC.Engagement.Shared.LivePolls) andSpeakerIdentifierType?(Conference alias). It is dispatched to CreateLivePollHandler. - Concept introduced: identity travels beside the request, never inside it. The doc comment (
MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommand.cs:7) states the two caller fields are "bound from the token at the API edge (never from the request)". This is the standard guard against a client claiming to be a speaker or organizer by putting it in the JSON body: the controller readsCallerSpeakerIdandCallerIsOrganizerfrom the validated JWT and stamps them onto the command.[Rubric §11, Security]assesses exactly this boundary between attacker-controlled input and trusted claims; splittingRequestfrom the caller fields makes the trust boundary a compile-time shape.[Rubric §6, CQRS & Event-Driven]applies because this is the command half of the pattern taught in Group 05. - Walkthrough: positional
sealed record (CreateLivePollRequest Request, SpeakerIdentifierType? CallerSpeakerId, bool CallerIsOrganizer)(MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommand.cs:14). The nullableCallerSpeakerIdencodes "the caller is not a speaker";CallerIsOrganizeris the role bypass. The doc comment records the split rule the handler enforces: event-wide polls require an organizer/admin, session polls also allow the session's assigned speakers (BR-236 shape,MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommand.cs:8-9). - Why it's built this way: keeping authorization inputs on the command (rather than re-reading the HTTP context deep in the handler) keeps the Application layer host-agnostic and unit-testable: a test constructs the command with arbitrary claims and asserts the rights outcome.
- Where it's used: validated by CreateLivePollCommandValidator, handled by CreateLivePollHandler, constructed by LivePollsController.
ModerateQuestionCommand
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionCommand.cs:15· Level 1 · record
- What it is: the CQRS command that carries one moderation action (approve, dismiss, mark-answered) against a single session question, together with the caller's identity as resolved at the API edge and the concurrency token the caller stated in
If-Match. - Depends on:
ModerationAction(the action enum, same group) and the module identifier aliasesSessionQuestionIdentifierType(EngagementShared) /SpeakerIdentifierType(ConferenceShared); handled through theMutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>workflow, which is itself anICommandHandler<in TCommand, TResult>. - Concept introduced, identity-from-token commands.
[Rubric §11, Security]assesses whether authorization inputs come from a trusted source rather than the request body. Here the command recordsCallerSpeakerIdandCallerIsOrganizer(ModerateQuestionCommand.cs:18-19), which the controller binds from JWT claims, never from client-supplied JSON, so an attacker cannot claim organizer rights by editing the payload. The doc comment states the rule the pair encodes (BR-236: organizers and admins moderate everything, a session's assigned speakers moderate their own session's questions,ModerateQuestionCommand.cs:6-8).[Rubric §6, CQRS & Event-Driven]is the plain command-as-record shape. - Walkthrough: a
sealed recordwith five positional members (ModerateQuestionCommand.cs:15-20):QuestionId(which question),Action(theModerationActionto apply),CallerSpeakerId(nullableSpeakerIdentifierType?, present only for speakers),CallerIsOrganizer(aboolset when the caller holds the Organizer or Admin role), andRowVersion. That last member is a non-nullablebyte[](:20): the doc comment says it is read from the request'sIf-Matchheader (:14), so the optimistic-concurrency check of ADR-035 is not opt-in on this command. There is no unconditional call path that constructs it without a token. - Why it's built this way: keeping caller identity in the command (rather than reaching into
HttpContextfrom the handler) keeps the Application layer host-agnostic and unit-testable, and makes the trust boundary explicit, since the API edge is the only place that reads claims. Making the token a required member rather than an optional trailing parameter means the endpoint contract (conditional-only) and the command shape cannot drift apart. - Where it's used: constructed by
SessionQuestionsController's privateModerateAsync(SessionQuestionsController.cs:224-230), which the three moderation verbs delegate to with a fixedModerationActionand the token thatSupportsIfMatchAttribute.RequiredTokenpulled off the header (SessionQuestionsController.cs:145,171,197); handled byModerateQuestionHandler.
LivePollChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollChanged.cs:17· Level 2 · record
- What it is: the single domain event a
LivePollraises for its whole lifecycle: created, opened, closed, or soft-deleted. - Depends on:
BaseDomainEvent(base),DomainEntityState(the change classifier),LivePollStatus(the lifecycle status), and theLivePollIdentifierType/EventIdentifierTypealiases. - Concept introduced, one event carrying a state discriminator (BR-60).
[Rubric §6, CQRS & Event-Driven]assesses whether events carry enough context to be acted on without a re-read. Rather than four separateCreated/Opened/Closed/Deletedevents, this codebase raises one event whoseDomainEntityStatesays what kind of change happened and whoseLivePollStatussays the resulting lifecycle state (doc comment,LivePollChanged.cs:7-11). A consumer switches on those two fields. This BR-60 convention is shared by all four live-layer events below, so learn it once here. - Walkthrough: a
sealed record classderiving fromBaseDomainEventwith four positional members (LivePollChanged.cs:17-22):State(:18),PollId(:19),EventId(:20),Status(:21). There is no behavior; an event is an immutable fact. - Why it's built this way: the base carries the event identity and timestamp, and collapsing the transition matrix into one typed record keeps the outbox schema and the handler set small while still letting a handler distinguish an open from a close (ADR-003 for the outbox that drains domain events; ADR-039 for the live-channel transport).
- Where it's used: raised inside
LivePoll'sCreate/Open/Close/Delete(LivePoll.cs:94,131,154,228). - Caveats / not-in-source: unlike its three siblings,
LivePollChangedhas noIDomainEventHandler<LivePollChanged>implementation anywhere in the ADC source today. Poll lifecycle broadcasts are enqueued directly by the poll command handlers; the event is raised and dispatched, but nothing in-repo subscribes to it.
LivePollVoteChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:21· Level 2 · record
- What it is: the single domain event a
LivePollVoteraises when a vote is cast, changed to another option, or soft-deleted. - Depends on:
BaseDomainEvent,DomainEntityState, and theLivePollVoteIdentifierType/LivePollIdentifierType/LivePollOptionIdentifierType/UserIdentifierTypealiases. - Concept introduced, the zero-id trap on
Addedevents.[Rubric §6, CQRS & Event-Driven]also covers whether a consumer can actually correlate an event back to its row. This entity's identity is database-generated ([IdValueGenerated], seeLivePollVote), and the event is constructed before the INSERT runs and captured by value, soVoteIdis zero for a brand-new vote and is never re-stamped afterwards (LivePollVoteChanged.cs:11-17). A reactivated vote does carry a real id, because that row already exists. The documented contract is therefore: correlate onPollIdandUserId, which are both set before the event is raised, and never onVoteId. The same trap and the same workaround appear onSessionQuestionChangedandSessionQuestionUpvoteChanged. - Concept reinforced, BR-60 single-event pattern (introduced at
LivePollChanged; restated atLivePollVoteChanged.cs:8). Here the payload additionally carries theOptionIdchosen after the change, so a downstream tally recomputation knows which option moved. - Walkthrough: a
sealed record class : BaseDomainEventwith five positional members (LivePollVoteChanged.cs:21-27):State(:22),VoteId(:23),PollId(:24),OptionId(:25),UserId(:26). - Why it's built this way: votes are high-frequency, so the event stays a thin id-only fact with no denormalized counts; consumers that need tallies recompute them through
LivePollResultsBuilder. - Where it's used: raised inside
LivePollVote'sCreate/ChangeOption/Reactivate/Delete(LivePollVote.cs:67,86,109,125); consumed byLivePollVoteChangedHandler, which re-reads the poll with its options, rebuilds the tallies, and enqueues apoll.results-changedbroadcast (LivePollVoteChangedHandler.cs:41,57-62,73,79-82).
SessionQuestionChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:30· Level 2 · record
- What it is: the single domain event a
SessionQuestionraises when it is submitted, moderated, or soft-deleted. - Depends on:
BaseDomainEvent,DomainEntityState,QuestionStatus, and theSessionQuestionIdentifierType/SessionIdentifierType/UserIdentifierTypealiases. - Concept reinforced, BR-60 single-event pattern (see
LivePollChanged).[Rubric §6, CQRS & Event-Driven]. The Q&A analogue ofLivePollChanged:QuestionStatusrides along so a handler can tell a Pending question from an Approved, Dismissed, or Answered one (doc comment,SessionQuestionChanged.cs:8-11). - Concept reinforced, the zero-id trap (see
LivePollVoteChanged).QuestionIdis zero on theAddedpath because the identity is generated by the INSERT and the event is captured while the aggregate is still new (SessionQuestionChanged.cs:16-22). This is exactly whyUserIdis on the event at all: it is carried rather than read back from the row precisely becauseQuestionIdis unusable on that path (:24-28).[Rubric §30, Compliance/Privacy/Data Governance]is worth noting here: the same doc comment statesUserIdis never surfaced on a DTO, because questions display anonymously (BR-238). The event is an internal correlation channel, not a projection source. - Walkthrough: a
sealed record class : BaseDomainEventwith five positional members (SessionQuestionChanged.cs:30-36):State(:31),QuestionId(:32),SessionId(:33),UserId(:34),Status(:35). - Why it's built this way: identical rationale to
LivePollChanged, a compact lifecycle fact instead of five per-transition event types, with the submitter id added as the only reliable correlation key on the create path. - Where it's used: raised inside
SessionQuestionon create, on each of the three moderation transitions, and on delete (SessionQuestion.cs:109,134,158,190,234); consumed bySessionQuestionSubmittedPointsHandler, which must filter to the submission case because the same event also fires for moderation and deletion (SessionQuestionSubmittedPointsHandler.cs:53,60-63).
SessionQuestionUpvoteChanged
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:20· Level 2 · record
- What it is: the single domain event a
SessionQuestionUpvoteraises when an upvote is cast, reactivated, or removed (soft-deleted). - Depends on:
BaseDomainEvent,DomainEntityState, and theSessionQuestionUpvoteIdentifierType/SessionQuestionIdentifierType/UserIdentifierTypealiases. - Concept reinforced, BR-60 single-event pattern (see
LivePollChanged) plus the zero-id trap (seeLivePollVoteChanged).[Rubric §6, CQRS & Event-Driven]. This is the thinnest of the four: an upvote has only two meaningful states, so the doc comment notesAddedcovers both cast and reactivated whileDeletedcovers un-upvoted (SessionQuestionUpvoteChanged.cs:10), andUpvoteIdcarries the same "zero on a brand-new row, real on a reactivation" caveat withQuestionIdandUserIdas the correlation keys (:11-17). - Walkthrough: a
sealed record class : BaseDomainEventwith four positional members (SessionQuestionUpvoteChanged.cs:20-25):State(:21),UpvoteId(:22),QuestionId(:23),UserId(:24). No status field: upvotes have no lifecycle beyond active and removed. - Why it's built this way: same BR-60 economy as its siblings, and because there is no status enum the
DomainEntityStatealone fully describes the change. - Where it's used: raised inside
SessionQuestionUpvoteon create, reactivate, and delete (SessionQuestionUpvote.cs:60,76,91); consumed bySessionQuestionUpvoteChangedHandler(SessionQuestionUpvoteChangedHandler.cs:42).
SessionLivePollPanel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLivePollPanel.razor.cs:18· Level 4 · class
- What it is: the presentational child component that renders a session's open polls with their live tallies and casts the attendee's vote. It is the "polls" third of the container/presentational split of the SessionLive page.
- Depends on: ILivePollUIService (the vote call), LivePollResultsDTO (the per-poll tally model), LivePollStatus (the can-vote test in the markup), IToastService (error toasts), IHapticFeedbackService (native tactile confirmation), the ResultUiExtensions helpers
HasErrorType/LocalizedErrorMessage, and ErrorType. The markup renders the sharedLivePollCardcomponent from the HappeningNow page folder. - Concept introduced: container/presentational split with parent-owned state. The page (SessionLive) is the container: it owns the poll list, the channel subscription, and the shared saving flag. This panel is presentational: it receives
Pollsas an[EditorRequired][Parameter](SessionLivePollPanel.razor.cs:27) and never loads them itself. The only state it mutates is an in-place patch of the passed-in list after a vote, so the container sees the fresh tally without a reload.[Rubric §19, State Management]assesses where state lives and who owns it: ownership stays with the page, the panel only renders and emits.[Rubric §18, UI Architecture](component decomposition) is embodied by splitting one large live page into three focused panels that each own their own actions. - Walkthrough: three injected services,
PollService,Toast,Haptics(SessionLivePollPanel.razor.cs:20-22).Pollsis the[EditorRequired]List<LivePollResultsDTO>parameter (:27);IsSaving/IsSavingChanged(:31,:35) are the page-wide saving flag flowing in and back out so every section disables together.VoteAsync(pollId, optionId)(:39) firesHaptics.Click()first (a no-op off native heads, ADR-042,:42), raises the saving flag through the callback (:44), callsPollService.CastVoteAsync(:47), and on success finds the poll byPollIdand replaces it in the container-owned list in place (:54-58).OperationCanceledExceptionfrom disposal is swallowed (:60), and the flag is always lowered in thefinally(:66).ShowActionError(:76) is the shared failure surface: an ErrorType.Unexpectedresult (a 500, a transport failure, a timeout) shows the generic localized fallback, while a refusal the API stated shows the server's own localized Problem Details message (ADR-027 Decision 9 carve-out).DisposeAsync(:83) cancels and disposes the component'sCancellationTokenSource. The markup loops the polls intoLivePollCard, enabling the vote only whilepoll.Status == LivePollStatus.Open(SessionLivePollPanel.razor:12-17) and rendering an info alert when the list is empty (:8). - Why it's built this way: patching the returned tally into the shared list (rather than reloading) keeps the panel cheap and avoids a redundant round-trip, since the cast already returned the new counts. The saving flag is lifted to the page so a vote here also disables the Q&A submit and the moderation buttons, preventing overlapping mutations.
[Rubric §27, i18n]: every user-visible string is a resource key resolved through theIStringLocalizer<SessionLive>the markup injects, so all three panels share one resource file. - Where it's used: instantiated by SessionLive's markup as the open-polls section (
SessionLive.razor:41), which passes_pollsand two-way-binds the shared saving flag. - Caveats / not-in-source: the tally bars and vote buttons are rendered by
LivePollCard, invoked from the siblingSessionLivePollPanel.razorfile, not from this code-behind.
PresenterView
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/PresenterView.razor.cs:20· Level 6 · class
- What it is: the chrome-less, large-type projector page for a session's live layer: the session title, the open polls as big result bars, and the top approved questions by upvotes. It takes no input; it is meant to be thrown on the room screen and left to refresh itself from the live channel.
- Depends on: ILivePollUIService and ISessionQuestionUIService (the data loads), ISessionLookupService plus SessionInfo (the single-session label), NotificationHubService (the SignalR channel subscription), the channel key and event constants LivePollChannel and SessionQuestionChannel, the patch helper LiveBroadcastPatch, the models LivePollResultsDTO and SessionQuestionDTO, QuestionStatus, IToastService, and the ResultUiExtensions helper
IsNotFound. - Concept introduced: patch-on-broadcast versus reload-on-broadcast for a hot channel. The projector is typically the most-connected client during a live poll, so reloading on every broadcast multiplies backend reads at exactly the wrong moment.
HandleChannelEventAsync(PresenterView.razor.cs:114) therefore patches the two high-frequency tally events in place from the broadcast payload, which already carries the fresh counts (BR-229/BR-238), and only falls back to a full reload for structural events.[Rubric §12, Performance & Scalability]assesses how a design behaves under load: the patch path is a deliberate fan-out mitigation.[Rubric §23, Front-End Performance]covers client render and network cost: replacing one list element and callingStateHasChangedis far cheaper than a full refetch and rebind.[Rubric §13, Observability & Operability]is under-used here: a patch that cannot be applied degrades silently into a reload with no counter or log. - Walkthrough:
TopQuestionCount = 5(PresenterView.razor.cs:22); five injected services (:23-27); theIdroute parameter (:31). State is the pageCancellationTokenSource(:33),IsLoading(:35),_loadError,_session,_polls,_questions(:37-40), and the channel subscription plus its key (:42-43).TopQuestions(:46) projects the approved questions ordered byUpvoteCountdescending thenCreatedOn, taking the top five.OnInitializedAsync(:53) short-circuits during the SSR prerender pass viaRendererInfo.IsInteractive(:57), so the loads do not run twice per visit and the prerender renders the loading skeleton; it then point-reads the session withSessionLookup.GetByIdAsync(:66), treating a not-found result as the view's own "not found" alert and anything else as a load failure (:71-74), and finally callsLoadAsync(:81).OnAfterRenderAsync(:96) is deliberately notfirstRender-gated: the first render fires at the firstawaitinOnInitializedAsyncwhile_sessionis still null, so afirstRender-only join never attached (BR-229/BR-238). Instead_channelKeydoubles as the already-joined guard (:102), and the join runs on the first render after the session load completes: subscribe withHubService.OnChannelEvent, thenJoinChannelAsynconLivePollChannel.ForSession(Id)(:108-110).HandleChannelEventAsync(:113) matchesLivePollChannel.PollResultsChangedtoLiveBroadcastPatch.TryApplyPollResults(..., preserveMyVote: false)(:119-120) andSessionQuestionChannel.QuestionUpvoteChangedtoTryApplyUpvoteCount(:126-127), re-rendering on a successful patch; every other event, and any payload that could not be applied, falls through to a fullLoadAsyncwhose transient failure is toasted rather than crashing the projector (:135-141).LoadAsync(:151) refetches the open session polls and the questions, returning a failed Result on either refusal.FormatSessionTime(:170) renders the start-end range under the current culture.DisposeAsync(:176) cancels the token source, disposes the subscription, and leaves the channel (:184). - Why it's built this way: the projector shows no per-user data, so it passes
preserveMyVote: falseand takes the broadcast tally wholesale, unlike the attendee page, which must carry its own vote marker across the patch (ADR-039 is the transport this rests on). The SSR-prerender skip and the point read (rather than fetching the whole session catalog to label one row) are the same load-shedding instincts applied to first paint. - Where it's used: a routed page at
/conference/sessions/{Id:int}/presentunderPresenterLayoutand[Authorize](PresenterView.razor:1-3), reached from the Present button that SessionLive renders for moderators. - Caveats / not-in-source: the route template, the layout choice, and the big result bars (percentage widths computed inline at
PresenterView.razor:38-44) live inPresenterView.razor, not in this code-behind.
SessionLive
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLive.razor.cs:27· Level 6 · class
- What it is: the routed session Live page and the container for the whole per-session live experience: the session's open polls with live tallies, the attendee Q&A surface, and a moderation panel rendered for organizers, admins, and speaker-claim holders. It owns the lists, the channel subscription, and the shared saving flag, and renders the three sections through presentational child panels.
- Depends on: ILivePollUIService, ISessionQuestionUIService, ISessionLookupService plus SessionInfo, NotificationHubService, IToastService; the join-once handle LiveChannelSubscription and the patch helper LiveBroadcastPatch; the child panels SessionLivePollPanel, SessionLiveQuestionPanel, SessionLiveModerationPanel; the channel constants LivePollChannel and SessionQuestionChannel; the models LivePollResultsDTO, SessionQuestionDTO, LivePollDTO; RoleNames and EngagementRoutePaths; plus
AuthenticationStateand MudBlazor'sBreadcrumbItem. - Concept introduced: container-owns-state, panels-own-actions, with post-action reload callbacks. Each presentational panel performs its own service call and then invokes an
EventCallback, so the page (the single owner of every list) reloads exactly what the action affected:ReloadManagePollsAsync(SessionLive.razor.cs:304),ReloadQuestionListsAsync(:305),ReloadModerationListsAsync(:310),ReloadPollListsAsync(:313). All four are one-liners over the sameRefreshAsyncbody, and two of them widen the reload when_canModerateis set.[Rubric §19, State Management]is embodied cleanly: one source of truth per list, and a narrow patch-or-reload contract between page and panels.[Rubric §11, Security]: the page computes_canModeratefrom the Organizer or Admin role plus the presence of aspeaker_idclaim (:80-83), but the code is explicit that this is a UI affordance only. The server is the authority on per-session rights (BR-236), and a speaker whose claim does not match this session gets a refusal on the moderation-queue read, at which point the page sets_canModerate = falseand degrades to the attendee view (:274-279). - Walkthrough: five injected services (
SessionLive.razor.cs:29-33), the cascadingAuthState(:34-35), and theIdroute parameter (:39). State: the pageCancellationTokenSource(:41), breadcrumbs (:43),IsLoading/IsSaving(:45-46),_loadError,_canModerate,_session,_polls,_questions,_moderationQueue,_managePolls(:48-54), and the LiveChannelSubscription field (:56).OnInitializedAsync(:58) builds the breadcrumb trail, returns early during the SSR prerender pass (:70), computes_canModerate(:80), point-reads the session (:88, with a not-found result rendering the page's own info alert rather than an error,:93), then loads polls and questions in order (:103-107) and, when allowed, the moderation data (:115-117);IsLoadingis cleared in thefinally(:126).OnAfterRenderAsync(:130) uses the same not-firstRender-gated join as the projector view, with_channel.IsJoinedas the already-joined guard (:137), and joinsLivePollChannel.ForSession(Id)(:143).HandleChannelEventAsync(:146) tries the tally fast path first (:153), then routes structural events: anypoll.prefix reloads the poll lists (:158),SessionQuestionChannel.QuestionPendingCountChangedreloads only the moderation queue and only for moderators (:164-171), and anyquestion.prefix reloads the question lists (:174).TryHandleTallyEventAsync(:185) is a three-state switch:nullfor a non-tally event so the caller reloads (:199),truefor an applied patch that only needs a re-render (:201), andfalsefor an unapplicable payload, which falls back to the targeted reload of just that list (:204-206). The comment at:148-152records the concrete reason for the patch path: reload-on-broadcast turned V voters times C viewers into V*C authenticated refetches per hot poll, colliding with the per-user rate limiter under burst voting.RefreshAsync(params Func<Task<Result>>[])(:220) is the page's one reload: it runs the named loads in order, stops at the first failure with a generic toast (:228), re-renders on success, and swallows the disposal cancellation.LoadListAsync<T>(:249) is the page's one list load, parameterized by the fetch and the field assignment, which is what makesLoadPollsAsync,LoadQuestionsAsync, andLoadModerationQueueAsyncone-liners (:263,:266,:269).LoadManagePollsAsync(:292) calls the session-scopedGetSessionManagePollsAsync(:294), which carries the BR-236 rights rather than the organizer-only LiveManage capability, so a speaker moderating their own session gets the real list (every status, each row carrying its concurrency token) with the server doing the filtering; it is best-effort, so a refusal simply yields no rows and never fails a reload chain (:296-297).DisposeAsync(:324) cancels the token source and disposes the channel handle, which leaves the channel. - Why it's built this way: the container/presentational split keeps one page from ballooning while preserving a single owner for each list and for the saving flag. The tally patch path is a measured response to a real rate-limit collision, not a premature optimization: the code comments name the failure mode. Pushing the manage-poll read to a session-scoped endpoint removes a client-side session filter and an organizer-only refusal that a speaker moderator would otherwise have had to work around.
[Rubric §18, UI Architecture]: three panels plus one container is the decomposition;[Rubric §9, API & Contract Design]shows up in the read shapes, where each list has its own endpoint scoped to what the caller is allowed to see rather than one over-broad read filtered on the client. - Where it's used: a routed
[Authorize]page at/conference/sessions/{Id:int}/live(SessionLive.razor:1-2), reached from the Happening Now surface via EngagementRoutePaths; it renders the Present button to PresenterView for moderators (SessionLive.razor:32-36) and instantiates the three panels at:41,:44, and:52. - Caveats / not-in-source: the route template, breadcrumbs, loading and error states, and the three panel instantiations with their parameter wiring live in
SessionLive.razor.
SessionLiveQuestionPanel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLiveQuestionPanel.razor.cs:19· Level 6 · class
- What it is: the presentational child component for the attendee Q&A surface: the submit box with dictation, the approved questions sorted by upvotes, the caller's own not-yet-approved questions, and the upvote toggle. It is the "questions" third of the SessionLive split.
- Depends on: ISessionQuestionUIService (submit and upvote calls), SessionQuestionDTO and QuestionStatus (the question model and its states), SubmitQuestionRequest (the submit payload), ISpeechToTextService (dictation), IToastService, and the ResultUiExtensions helpers with ErrorType.
- Concept introduced: linked-token dictation as a toggle. Voice input (ADR-042 Wave 4) uses a second
CancellationTokenSourcelinked to the component's own_cts(SessionLiveQuestionPanel.razor.cs:82), so the same button both starts a dictation and cancels one already in flight (:69-79), and component disposal tears down the in-flight listen along with everything else (:187). This is the first place in the live layer where a device-capability service is toggled inline in a form.[Rubric §24, Forms/Validation/UX Safety]assesses input UX and guard rails: the submit path warns on empty text before making any call (:106-110) and trims on send (:115), and the markup caps the box atSessionQuestionDTO.TextMaxLength, 500 characters (SessionLiveQuestionPanel.razor:9).[Rubric §21, Accessibility]: the dictation button carries both anaria-labeland atitlethat flip with the dictation state (SessionLiveQuestionPanel.razor:21-22), and the upvote button is labelled the same way (:50).[Rubric §19, State Management]applies again: the panel patches the container-ownedQuestionslist in place after an upvote, so the page stays the single owner. - Walkthrough: injected
QuestionService,Toast,SpeechToText(SessionLiveQuestionPanel.razor.cs:21-23). Parameters:SessionIdand the[EditorRequired]Questionslist (:28,:33), the sharedIsSaving/IsSavingChanged(:37,:41), and theOnQuestionSubmittedreload callback (:45). Two computed projections drive the markup:ApprovedQuestions(:52) filters toQuestionStatus.Approvedordered byUpvoteCountdescending thenCreatedOn, andMyModeratedQuestions(:59) surfaces the caller's own not-yet-approved questions viaIsMine, rendered with a status chip.ToggleDictationAsync(:69) cancels an in-flight dictation when one is running, otherwise creates the linked source, awaitsSpeechToText.ListenAsyncunder the current UI culture (:85-88), and appends the recognized text to whatever is already in the box (:91-93), always clearing the dictation state in thefinally(:96-101).SubmitQuestionAsync(:104) validates, raises the saving flag, builds a SubmitQuestionRequest with the trimmed text (:115), submits (:116), clears the box, toasts success (:123-124), and invokesOnQuestionSubmittedso the page reloads its lists (:126).ToggleUpvoteAsync(:138) calls remove-or-add based onMyUpvote(:143-145), then patches the returned count and the flipped marker into the list in place with awithexpression (:154-158), which re-sortsApprovedQuestionson the next render.ShowActionError(:176) is the same refusal-versus-fault split the sibling panels use.DisposeAsync(:183) cancels and disposes the page token source and disposes any dictation source. - Why it's built this way: patching the upvote count locally rather than reloading keeps the sort responsive under rapid toggling, and the channel's
question.upvote-changedbroadcast reconciles every other client. Lifting the saving flag to the page disables the poll and moderation sections during a submit, so a moderator cannot act on a half-submitted question. - Where it's used: instantiated by SessionLive as the Q&A section (
SessionLive.razor:44-47), which passes_questions, the session id, and the reload callback. - Caveats / not-in-source: the submit box, the dictation button (rendered only when
SpeechToText.IsSupported,SessionLiveQuestionPanel.razor:17), and the question cards live inSessionLiveQuestionPanel.razor.
CreateLivePollRequestValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Create·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollRequestValidator.cs:10· Level 7 · class
- What it is: the FluentValidation validator for the CreateLivePollRequest body: it checks the event id, question text, and the option list before the handler runs (BR-220).
- Depends on:
AbstractValidator<CreateLivePollRequest>(FluentValidation), LivePollInvariants (the domain's shared limits, imported atMMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollRequestValidator.cs:2), and theEventIdentifierTypealias. - Concept introduced: input validation reusing domain constants, not magic numbers. Every length and count bound comes from
LivePollInvariants(QuestionMaxLength,MinOptions,MaxOptions,OptionTextMaxLength), so the edge validator and the aggregate's ownCreateguard agree by construction rather than by two copies of the same number.[Rubric §24, Forms/Validation/UX Safety]assesses layered validation with actionable messages; each rule carries both a humanWithMessageand a machineWithErrorCode(for example"LivePoll.Question.Required",MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollRequestValidator.cs:22) so a client can branch or localize on the code.[Rubric §14, Testability]applies because a pure validator with no dependencies is trivially unit-tested. - Walkthrough:
EventIdmust not equaldefault(EventIdentifierType)(MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollRequestValidator.cs:14-17).QuestionisNotEmptythenMaximumLength(LivePollInvariants.QuestionMaxLength)(:19-25).OptionsisNotNullthen constrained by aMustlist-pattern predicateoptions is { Count: >= LivePollInvariants.MinOptions and <= LivePollInvariants.MaxOptions }(:31), which also handles the null case in the same expression.RuleForEach(x => x.Options)(:35) then appliesNotEmptyplusMaximumLength(LivePollInvariants.OptionTextMaxLength)to each option string (:36-41). Every rule pairs a message with an error code. - Why it's built this way: validating shape at the edge lets the handler assume a well-formed request and spend its logic on authorization and cross-service checks; sourcing bounds from LivePollInvariants keeps edge and domain in lockstep even when a limit changes.
- Where it's used: invoked by CreateLivePollCommandValidator via
SetValidator, which is how it reaches the Validating decorator of the CQRS pipeline (see Group 05).
SessionLiveModerationPanel
MMCA.ADC.Engagement.UI ·
MMCA.ADC.Engagement.UI.Pages.SessionLive·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLiveModerationPanel.razor.cs:20· Level 8 · class
- What it is: the presentational child component for the moderation section, rendered for organizers, admins, and speaker-claim holders: the question moderation queue (approve, dismiss, mark answered), the create-poll form, and the poll lifecycle rows (open, close). It owns the create-poll form state and performs the moderation and poll calls; the page reloads the affected lists through three separate change callbacks.
- Depends on: ILivePollUIService and ISessionQuestionUIService (the moderation and poll calls), the models SessionQuestionDTO and LivePollDTO, the enums LivePollStatus and QuestionStatus, CreateLivePollRequest (the new-poll payload), the nested OptionState binding cell, IToastService, and the ResultUiExtensions helpers with ErrorType.
- Concept introduced: conditional writes from a moderation UI. Every state transition this panel issues carries the
RowVersionof the row the moderator was actually looking at, which travels as the request'sIf-Matchheader (ADR-035), so two moderators racing approve against dismiss surface as a conflict instead of one silently overwriting the other.QuestionRowVersion(SessionLiveModerationPanel.razor.cs:82) andPollRowVersion(:87) look the token up from the list the row came from, andRunConditionalModerationActionAsync(:122) refuses outright when no token is held, toasting the generic failure rather than sending an unconditional write (:128-131). The remark at:117-121names the case: an item that arrived through a SignalR payload carries no token, and reloading the queue is what supplies one.[Rubric §9, API & Contract Design]assesses precondition semantics on the wire: the client states the version it read, and the server decides.[Rubric §11, Security]assesses defense in depth: the panel renders actions, but the server enforces the real per-session rights (BR-236, stated in the class doc at:11-19); the page-side_canModerategate is an affordance, not the trust boundary.[Rubric §24, Forms/Validation/UX Safety]covers the create-poll form, which holds the option count betweenLivePollDTO.MinOptions(2) andLivePollDTO.MaxOptions(10) (:163,:171) and requires a question plus at least two non-empty options before calling the service (:179,:190). - Walkthrough: injected
PollService,QuestionService,Toast(SessionLiveModerationPanel.razor.cs:22-24). Parameters:SessionId(:29),EventId(:34, the event new polls are created against), the[EditorRequired]ModerationQueueandManagePollslists (:39,:44), the sharedIsSaving/IsSavingChanged(:48,:52), and the three reload callbacksOnModerationChanged,OnPollCreated,OnPollLifecycleChanged(:56,:60,:64). Form state is_newPollQuestion(:68) and_newPollOptions, seeded with two OptionState cells (:69).ManagePollRows(:76-77) projects the manage list into id, question, and status tuples for the lifecycle rows; because the session-scoped manage endpoint serves every moderator this panel renders for, there is no client-side fallback list. The three question transitions,ApproveQuestionAsync(:90),DismissQuestionAsync(:96), andMarkQuestionAnsweredAsync(:102), all pass their looked-up token intoRunConditionalModerationActionAsync(:122), which delegates toRunModerationActionAsync(:136): raise the saving flag, call, toast the success key, invokeOnModerationChanged, swallow the disposal cancellation, always lower the flag.AddOption(:161) andRemoveOption(:169) grow and shrink the option list within the DTO's bounds.CreateSessionPollAsync(:177) warns on a missing question (:179-183), projects the trimmed non-empty option texts and warns again if fewer than two survive (:185-194), builds the CreateLivePollRequest againstEventIdandSessionId(:199-205), creates, resets the form to two blank cells, toasts, and invokesOnPollCreated(:213-219).OpenPollAsync(:231) andClosePollAsync(:237) mirror the question path throughRunConditionalPollActionAsync(:250) andRunPollActionAsync(:265), which invokesOnPollLifecycleChangedso the page reloads both poll lists.ShowActionError(:296) applies the same refusal-versus-fault split as the sibling panels.DisposeAsync(:303) cancels and disposes the token source, and the nested OptionState type closes the file (:312). - Why it's built this way: routing every transition through a conditional-write wrapper means the concurrency contract cannot be forgotten on a new action: the only way to call a transition is to hand it a token. Splitting the change notification into three callbacks lets the container reload only the lists an action touched, which is why an approve does not refetch the poll tallies. The moderation queue and the manage rows are parameters rather than panel-owned state, so the page remains the single owner and the SignalR handler and the panel actions converge on the same reload paths.
- Where it's used: instantiated by SessionLive as the moderation section, guarded by the page's
_canModerateflag (SessionLive.razor:50-59). - Caveats / not-in-source: the moderation-queue cards, the create-poll form (whose field lengths bind to
LivePollDTO.QuestionMaxLength, 200, andLivePollOptionDTO.TextMaxLength, 100, atSessionLiveModerationPanel.razor:62and:69), and the lifecycle rows are laid out inSessionLiveModerationPanel.razor.
CreateLivePollCommandValidator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Create·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommandValidator.cs:9· Level 8 · class
- What it is: the validator the pipeline actually resolves for CreateLivePollCommand: it asserts the command carries a non-null
Requestand delegates the request's field rules to CreateLivePollRequestValidator (BR-220). - Depends on:
AbstractValidator<CreateLivePollCommand>(FluentValidation) and CreateLivePollRequestValidator. - Concept introduced: composed validators via
SetValidator. The command validator does not restate the body rules; it validates the wrapper concern (aRequestmust be present) and thenSetValidator(new CreateLivePollRequestValidator())(MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommandValidator.cs:16) reuses the request validator for the nested shape. This is the FluentValidation composition idiom, and it is why the pipeline (which resolves a validator for the command type) still enforces the body rules.[Rubric §24, Forms/Validation/UX Safety]and[Rubric §15, Best Practices & Code Quality]apply: one source of truth for body rules, composed rather than duplicated. - Walkthrough: the constructor is a single expression-bodied
RuleFor(x => x.Request)chain (MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollCommandValidator.cs:11-16):NotNullwith message "Request body is required." and code"LivePoll.Request.Required"(:13-15), thenSetValidatordelegating to the request validator (:16). The caller-identity members of the command (CallerSpeakerId,CallerIsOrganizer) carry no rules here, deliberately: they are token-derived, so there is nothing a client could get wrong, and the authorization decision belongs to CreateLivePollHandler. - Why it's built this way: the Validating decorator resolves a validator for the command type, so a thin command validator is needed to bridge to the reusable request validator without copying its rules.
- Where it's used: auto-discovered by assembly scanning and applied by the Validating decorator before CreateLivePollHandler runs.
CreateLivePollHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Create·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollHandler.cs:20· Level 10 · class
- What it is: the command handler that creates a live poll as Draft, enforcing the poll business rules (BR-220 shape, BR-221 created as Draft, BR-222 published event, BR-236 authoring rights) before persisting a LivePoll aggregate and returning its LivePollDTO.
- Depends on: IUnitOfWork (repository plus save), IEventLiveValidationService (the Conference cross-module lookup returning SessionLiveInfo/EventLiveInfo), LivePoll and its LivePollAuthorization helper, LivePollDTOMapper, Result/Error,
ILogger<CreateLivePollHandler>, and ICommandHandler<in TCommand, TResult>. It is asealed partial classso the source generator can emit its logger method. - Concept introduced: branching authorization across a service boundary before touching the aggregate. The handler splits on whether the request is session-scoped (
request.SessionId is { } sessionId,MMCA.ADC.Engagement.Application/LivePolls/UseCases/Create/CreateLivePollHandler.cs:34). A session poll fetchesGetSessionLiveInfoAsyncacross the Conference boundary (:38), guards that the session belongs to the given event (:45), then callsLivePollAuthorization.EnsureCanManagewith the session info so assigned speakers are allowed (:54-55). An event-wide poll instead requires organizer/admin by passingsessionInfo: nullto the same helper (:64-65) and only then checks publication viaGetEventLiveInfoAsync(:70). Only after rights and publish state pass does it callLivePoll.Create(:86).[Rubric §6, CQRS & Event-Driven]assesses the command-handler shape;[Rubric §7, Microservices Readiness]applies because authorization facts are pulled from Conference through a service interface rather than a database join, with an explicit disabled-stub fallback;[Rubric §11, Security]applies because rights are enforced server-side from the token-derived claims carried on CreateLivePollCommand. - Walkthrough: the primary constructor injects the four collaborators (
:20-24).HandleAsync(:27) resolves a localisPublisheddown either branch. In the session branch, a failed lookup short-circuits with the upstream errors (:39-40), and the event-match check is skipped when the info carries a default event id, because "the disabled-stub fallback reports a default event id" (:44-45): a mismatch otherwise fails withError.Invariant("LivePoll.SessionNotInEvent", ...)targetingSessionId(:47-52).isPublishedthen comes fromsessionInfo.IsPublished(:59) or frominfoResult.Value!.IsPublished(:74). An unpublished target is rejected withError.Invariant("LivePoll.EventNotPublished", "Polls can only be created for a published event.", ...)(:79-84). The aggregate is built withLivePoll.Create(request.EventId, request.SessionId, request.Question, request.Options)(:86), whose failure is propagated as-is (:87-88). On success the handler takes the typed repositoryunitOfWork.GetRepository<LivePoll, LivePollIdentifierType>()(:91),AddAsynces the poll (:92), commits withSaveChangesAsync(cancellationToken).ConfigureAwait(false)(:94), emits the source-generatedLogLivePollCreated(logger, poll.Id, request.EventId)(:96, declared[LoggerMessage(Level = LogLevel.Information, ...)]at:101-102), and returnsResult.Success(dtoMapper.MapToDTO(poll))(:98). Every early guard returnsResult.Failure<LivePollDTO>carrying the upstream errors, so no exception is used for control flow. - Why it's built this way: keeping authorization and publish-state checks in the handler (not the aggregate) lets LivePoll
.Createstay purely about poll shape, while cross-service facts come from the Conference boundary. The class doc records that the same boundary "also enforces the session eligibility rules BR-49/BR-91" (:17-18), so Engagement does not re-implement Conference's rules. The source-generated[LoggerMessage]gives allocation-free structured logging ([Rubric §13, Observability & Operability]). - Where it's used: dispatched for CreateLivePollCommand through the CQRS decorator pipeline; reached from the
/livepollsPOST on LivePollsController that LivePollUIService'sCreateAsynccalls.
ModerateQuestionHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:23· Level 14 · class (sealed partial)
- What it is: the command handler that applies a moderation transition to a
SessionQuestion(BR-234), enforcing the BR-236 rights, then best-effort enqueues the matching live-channel event (BR-238) for the off-request-path drain worker. - Depends on:
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>(base),IUnitOfWork,IEventLiveValidationService(the Conference gRPC boundary for session info),ILiveChannelPublishQueue(ModerateQuestionHandler.cs:26),LiveChannelPublishWorkItem,BestEffort,LivePollAuthorization, theSessionQuestionChannelevent names,LivePollChannelfor the channel key, the channel payload records (SessionQuestionApprovedPayload,SessionQuestionDismissedPayload,SessionQuestionAnsweredPayload,SessionQuestionPendingCountChangedPayload), plusSystem.Text.JsonandILogger. - Concept reinforced, the load-mutate-save template method (introduced with
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>in Group 05).[Rubric §1, SOLID]and[Rubric §15, Best Practices & Code Quality]. This handler writes noHandleAsyncof its own. It declares: MutateEntityHandlerBase<ModerateQuestionCommand, SessionQuestion, SessionQuestionIdentifierType>(unitOfWork)(:28) and fills in four hooks. The base owns the shared sequence inMutateCoreAsync(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271): resolve the repository and load the tracked aggregate, failNotFoundwhen it is gone (:282), stamp the caller's token (:290-291), run the mutation,SaveChangesAsync(:302), thenLogMutated(:304) andOnMutatedAsync(:305). Reading this handler means reading only what is different about moderating a question. - Concept introduced, the best-effort side channel that can never fail the command (BR-238).
[Rubric §29, Resilience & Business Continuity]and[Rubric §7, Microservices Readiness]: a downstream service being unreachable must not fail the local write. The broadcast runs from the post-commitOnMutatedAsynchook, so the mutation is already durably saved, and the work runs insideBestEffort.ExecuteAsync(:138) rather than a hand-rolledtry/catch. Read the guard precisely:Enqueueis avoidcall that never rejects, so what the guard actually covers is the Pending-count follow-up's database read (:145-148, rationale at:91-104).[Rubric §13, Observability & Operability]: using the shared helper means a broadcast that has quietly stopped working incrementsbesteffort.dispatch.failedon a meter, tagged with the low-cardinality operation constant"session-question-moderation-broadcast"(:31), instead of only producing a log line (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:18-23). The caller'sCancellationTokenis deliberately not passed (:89, rationale:100-103), so the helper's token parameter falls back to its default (BestEffort.cs:45-49) and the broadcast outlives an abandoned request instead of turning a saved moderation into a cancelled one. - Walkthrough
- The primary constructor injects the unit of work, the Conference validation service, the publish queue, and a logger (
:23-27);BroadcastOperationis the metric-tag constant (:31). _wasPending(:38) is the one piece of instance state: a value captured during the mutation and read back in the post-commit hook. The doc comment justifies it (:33-37): a command handler is resolved per DI scope and handles exactly one command, so instance state cannot leak between requests.EntityId(:41) tells the base which aggregate to load:command.QuestionId.RowVersion(:48) returnscommand.RowVersion, which is what turns on the base's concurrency stamping. The comment above it (:43-45) states the effect: two moderators racing approve against dismiss surface as 412 Precondition Failed rather than the second decision silently applying (ADR-035).MutateAsync(:51-78) is the interesting override, and it isasyncfor a reason: it fetches the session's live info across the Conference boundary throughIEventLiveValidationService(:56-58) and runsLivePollAuthorization.EnsureCanManage(:60-63) with the aggregate already loaded and its token already stamped. A rights failure short-circuits before any state change. It then captures_wasPendingbefore the transition (:65) and dispatches the action through aswitchexpression to the domain methodsApprove()/Dismiss()/MarkAnswered()(:67-77); an unknown action becomes anError.Invariantfailure rather than a silent no-op (:72-76).LogMutated(:81-82) emits the source-generated moderation log (declared at:160-161), andOnMutatedAsync(:85-89) forwards the question, the action, and the captured_wasPendingto the privateEnqueueModeratedAsync.EnqueueModeratedAsync(:106-158): resolves the session channel key withLivePollChannel.ForSession(:111), then builds the(eventName, payload)pair per action (:118-136). Only universally visible data rides the channel, and the Approve arm is the single place question content is broadcast (:120-124). Thisswitchis built outside the best-effort guard on purpose (:113-117): its discard arm throwsArgumentOutOfRangeException(:135) because an unknown action is a programming error that must fault loudly, not a transient publish failure to be swallowed.- Inside the guard (
:138-157) it enqueues the work item (:140), and when a Pending question left the queue on Approve or Dismiss it issues a fresh Pending-count read offUnitOfWork(:145-148) and enqueues aSessionQuestionPendingCountChangedPayloadso moderators' badges update (:150-155).
- The primary constructor injects the unit of work, the Conference validation service, the publish queue, and a logger (
- Why it's built this way: the base commits before the hook runs, and the swallow-and-count guard covers the follow-up read, giving the live layer at-most-once broadcast semantics layered over a durably committed write, which is the correct trade for ephemeral UI signals that must never block a moderation. Queueing rather than awaiting the publish also keeps a hung Notification peer off the moderator's request path (ADR-039 for the channel transport).
- Where it's used: registered for
ModerateQuestionCommandand invoked bySessionQuestionsController's approve, dismiss, and mark-answered verbs, all three routed through one privateModerateAsync(SessionQuestionsController.cs:145,171,197,224-230). - Caveats / not-in-source: the
switchdiscard arm at:135is unreachable in practice, sinceMutateAsyncalready applied a known action before the post-commit hook runs (the comment at:116-117says as much).
LivePollAuthorization
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:12· Level 3 · class (static, internal)
- What it is: the one shared rights check for the whole live layer. It decides whether a caller may manage (author, open, close, moderate) content in a given scope.
- Depends on:
SessionLiveInfo(the Conference-owned session snapshot it inspects),ResultandError. - Concept introduced, the BR-236 rights shape as one authorization gate.
[Rubric §11, Security]assesses whether authorization is centralized and consistent rather than re-implemented per endpoint. Every live-layer mutation and every moderator-only read routes its rights decision through this single method, so the rule "organizers and admins manage everything; a speaker manages only content scoped to a session they are assigned to" lives in exactly one place (doc comment,LivePollAuthorization.cs:7-10).[Rubric §1, SOLID]: authorization is one responsibility, not smeared across six handlers.[Rubric §7, Microservices Readiness]: the speaker-assignment fact arrives asSessionLiveInfo.SpeakerIdsfrom the Conference service, so this check consumes a cross-service snapshot rather than reaching into another module's tables. - Walkthrough: one static method,
EnsureCanManage(bool callerIsOrganizer, SpeakerIdentifierType? callerSpeakerId, SessionLiveInfo? sessionInfo, string source)(LivePollAuthorization.cs:22-44). Order matters. An organizer or admin short-circuits toResult.Success()(:28-31). Otherwise, if a session scope is supplied and the caller has a speaker id and that id is insessionInfo.SpeakerIds(:33-35), success. Anything else returnsError.Forbidden("LivePoll.NotAuthorized", ...)carrying the caller-suppliedsource(:40-43). PassingsessionInfoasnull(event-wide scope) means only organizers and admins pass, which is exactly the intent for event-wide polls (:15-16). - Why it's built this way: a pure static helper keeps the rule dependency-free and trivially unit-testable, and the explicit
sourceparameter threads the calling handler name into the error, which is this codebase's convention for stack-free tracing. - Where it's used: eight call sites across six handlers in both live-layer verticals:
CreateLivePollHandler(CreateLivePollHandler.cs:54,64),OpenLivePollHandler(OpenLivePollHandler.cs:52,62),CloseLivePollHandler(CloseLivePollHandler.cs:47,54),GetSessionManagePollsHandler(GetSessionManagePollsHandler.cs:42),GetModerationQueueHandler(GetModerationQueueHandler.cs:37), andModerateQuestionHandler(ModerateQuestionHandler.cs:60). Two of those, the moderation queue and the organizer poll list, are reads that still run the check, which is the point of centralizing it: moderator-only reads and writes cannot drift apart.
LivePollInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:10· Level 6 · class (static)
- What it is: the invariant helper for
LivePollandLivePollOption. It re-exports the poll's field-length and option-count constants into the domain and owns theResult-returning checks that guard them (BR-220). - Depends on:
CommonInvariants(every check delegates to it),LivePollDTO/LivePollOptionDTO(where the numbers actually live),Result. - Concept introduced, the constant declared once on the contract and re-exported into the domain.
[Rubric §9, API & Contract Design]and[Rubric §15, Best Practices & Code Quality]. The fourpublic const intmembers here are not literals: each is defined as the matching constant on the shared DTO,QuestionMaxLength = LivePollDTO.QuestionMaxLength(LivePollInvariants.cs:13),OptionTextMaxLength = LivePollOptionDTO.TextMaxLength(:16),MinOptions = LivePollDTO.MinOptions(:19), andMaxOptions = LivePollDTO.MaxOptions(:22). The numbers themselves are 200, 100, 2, and 10, declared on theSharedDTOs (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollDTO.cs:16,22,28and.../LivePollOptionDTO.cs:14). That direction matters:Sharedis the assembly the UI and the API contract both reference, so the organizer's poll-builder form, the validator, and the domain guard cannot disagree about the limit. Because they areconst, the re-export costs nothing at runtime. - Concept reinforced, the shared invariant class (introduced in Group 02).
[Rubric §4, DDD]: the guards are owned by the domain, not by a controller filter. - Walkthrough: five static check methods, each a one-expression delegation to
CommonInvariantswith a stable error code, a message, asource, and atargetfor tracing.EnsureEventIdIsValid(:24-25) callsCommonInvariants.EnsureIdIsNotDefault(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:63) with the code"LivePoll.EventId.Invalid".EnsureQuestionIsValid(:27-35) callsCommonInvariants.EnsureStringLengthIsWithin(CommonInvariants.cs:219) with the range1toQuestionMaxLengthand the code"LivePoll.Question.Invalid". The message interpolates the constant (:33), so it can never disagree with the number it enforces.EnsureOptionTextIsValid(:37-45) does the same per option againstOptionTextMaxLength, code"LivePoll.Option.Invalid".EnsureOptionCountIsValid(:47-55) callsCommonInvariants.EnsureCountIsWithin(CommonInvariants.cs:310) withMinOptionstoMaxOptions, code"LivePoll.Options.CountInvalid".EnsureOptionTextsAreUnique(:57-64) callsCommonInvariants.EnsureValuesAreUnique(CommonInvariants.cs:348) passingStringComparer.OrdinalIgnoreCase(:60), code"LivePoll.Options.Duplicate", so "Yes" and "yes" cannot both be options on the same poll.
- Why it's built this way: pushing the comparison logic down into
CommonInvariantsmeans this file contains only policy (which rule, which limit, which code) and no mechanism, which is why every method fits on one expression. Keeping the class separate from the entity also lets EF configuration and validators referenceLivePollInvariants.QuestionMaxLengthwithout depending on theLivePolltype itself. - Where it's used: combined through
Result.CombineinsideLivePoll.Create(LivePoll.cs:72-76) and singly insideLivePollOption.Create(LivePollOption.cs:45).
LivePollVoteInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVoteInvariants.cs:9· Level 6 · class (static)
- What it is: the invariant helper for
LivePollVote: three id-presence checks. - Depends on:
CommonInvariants,Result. - Concept reinforced, the shared invariant class (see
LivePollInvariants).[Rubric §4, DDD]. This is the compact sibling: a vote has no free-text fields and no counts, so it declares no constants and all three methods just delegate toCommonInvariants.EnsureIdIsNotDefaultwith a vote-specific error code. - Walkthrough: three one-expression static methods returning
Result:EnsurePollIdIsValid(LivePollVoteInvariants.cs:11-12, code"LivePollVote.PollId.Invalid"),EnsureOptionIdIsValid(:14-15, code"LivePollVote.OptionId.Invalid"), andEnsureUserIdIsValid(:17-18, code"LivePollVote.UserId.Invalid"). Each rejects a default (zero or empty) identifier and passesnameof(...)as the error target. - Why it's built this way: even a trivial guard is expressed as a named invariant so the factory reads as a
Result.Combineof intent rather than a stack ofifs, and every id-presence failure produces a consistent, traceable error code. - Where it's used: combined inside
LivePollVote.Create(LivePollVote.cs:53-56);EnsureOptionIdIsValidis also called on its own byChangeOptionandReactivate(LivePollVote.cs:80,100).
SessionQuestionInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions·MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:10· Level 6 · class (static)
- What it is: the static rule holder for the
SessionQuestionaggregate: three validation checks its factory runs, plus the two numeric limits the Q&A layer treats as single sources of truth (BR-231). - Depends on:
CommonInvariants(the reusable lower-layer guard toolbox),Result/ErrorfromMMCA.Common.Shared.Abstractions,SessionQuestionDTO(for the length constant), and the module identifier aliasesSessionIdentifierType/UserIdentifierType. - Concept reinforced, the invariants class as the aggregate's rule sheet. The idiom is taught in
Group 02: domain rules live in a dedicated
static class returning
Result, never as ad-hocifblocks inside the entity, so the factory reads as a checklist and each rule is independently testable. What this class adds is twopublic constlimits, and the first of them shows a deliberate layering choice.TextMaxLength(SessionQuestionInvariants.cs:13) does not declare the number: it readsSessionQuestionDTO.TextMaxLength(MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDTO.cs:18, the literal500), because Shared is the lowest layer every consumer can reach and the Blazor input caps itself from there (MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLiveQuestionPanel.razor:9) without taking a reference on Domain. The doc comment at:12and the DTO's own comment (SessionQuestionDTO.cs:12-17) state that contract in both directions.MaxOpenQuestionsPerUserPerSession = 10(:22) is an anti-spam cap on how many Pending-or-Approved questions one user may hold per session; its comment (:15-21) explains both the motive (an event whose moderation default auto-approves would otherwise let one attendee flood the session, and dismissed questions deliberately do not count) and its limit: it is an explicitly soft cap, because the submit handler counts and then inserts without holding a lock, so concurrent submits from the same user can briefly push the total past it and moderation drains the overflow.[Rubric §4, DDD]assesses whether business rules are expressed in the domain's own vocabulary rather than scattered at the edges; both constants and all three checks live beside the aggregate they guard.[Rubric §15, Best Practices & Code Quality]assesses single-source-of-truth for repeated values: the literal500exists in exactly one file, and the cap is a named constant rather than a magic number in a handler. - Walkthrough
TextMaxLength(:13) andMaxOpenQuestionsPerUserPerSession(:22): the two shared constants, consumed by domain, persistence, validation, the submit use case, and the UI alike (see Where it's used).EnsureSessionIdIsValid(:24) andEnsureUserIdIsValid(:27): both delegate toCommonInvariants.EnsureIdIsNotDefault, failing with a stable code (SessionQuestion.SessionId.Invalid/SessionQuestion.UserId.Invalid) when the identifier is still its type default.EnsureTextIsValid(:30-38): also a delegation, toCommonInvariants.EnsureStringLengthIsWithin(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:219), passing a minimum of1andTextMaxLengthas the maximum (:33-34) under the codeSessionQuestion.Text.Invalid. The message is templated from the same constant (:36), so tightening the number rewords the error automatically. Null and whitespace fail through the shared guard rather than through a local check.
- Why it's built this way: stable machine-readable
codes plus one shared constant mean the API layer, the FluentValidation validator, the EF configuration, and the Blazor input all agree on one rule without duplicating a literal. Every method takes asourceargument (callers passnameof(Create)), which threads the origin of the failure into theErrorfor diagnostics. - Where it's used: the three checks are combined inside
SessionQuestion.Create(MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:85-88).TextMaxLengthis reused bySessionQuestionConfigurationfor the column length (group 22,MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestions/SessionQuestionConfiguration.cs:32) and bySubmitQuestionCommandValidator'sMaximumLengthrule and message (MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionCommandValidator.cs:22-23).MaxOpenQuestionsPerUserPerSessionis enforced bySubmitQuestionHandler(MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:76,80), not by the entity: the cap is a cross-row rule that needs a query, so it cannot live in a factory that only sees one instance.
SessionQuestionUpvoteInvariants
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions·MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionUpvoteInvariants.cs:9· Level 6 · class (static)
- What it is: the sibling invariants class for
SessionQuestionUpvote: the two identifier checks its factory needs. - Depends on:
CommonInvariants,Result, and theSessionQuestionIdentifierType/UserIdentifierTypealiases. - Concept reinforced: nothing new. This is the compact twin of
SessionQuestionInvariants, with no length constant because an upvote has no free-text field, only two foreign keys.[Rubric §1, SOLID]assesses whether a unit has one reason to change: even a two-line rule set gets its own type, so the aggregate factory stays a flatResult.Combineof named intents. - Walkthrough
EnsureQuestionIdIsValid(:11-12):EnsureIdIsNotDefaulton the upvoted question, codeSessionQuestionUpvote.QuestionId.Invalid.EnsureUserIdIsValid(:14-15): the same guard on the upvoting user, codeSessionQuestionUpvote.UserId.Invalid.
- Why it's built this way: see
SessionQuestionInvariants; one guard unit per aggregate keeps each factory readable and each rule unit-testable in isolation. - Where it's used: combined by
SessionQuestionUpvote.Create(MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionUpvote.cs:47-49).
LivePollVote
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19· Level 7 · class (sealed aggregate root)
- What it is: the aggregate root for one user's vote on a live poll. Deliberately a separate aggregate from
LivePoll, not a child of it. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(base),LivePollVoteChanged,LivePollVoteInvariants,DomainEntityState,IdValueGeneratedAttribute,Result. - Concept introduced, splitting a high-frequency child into its own aggregate for write scalability.
[Rubric §12, Performance & Scalability](which assesses contention and change-tracker load) and[Rubric §4, DDD](aggregate boundaries chosen for consistency, not convenience). The doc comment states the reasoning explicitly (LivePollVote.cs:9-17): votes are high-frequency attendee writes, so folding them into theLivePollaggregate would bloat the change tracker and make every vote contend on the poll row. Instead each vote is its own root, and "one active vote per (poll, user)" is enforced by a filtered unique index at the database (BR-225), not by loading sibling votes into memory.[Rubric §8, Data Architecture]: the reactivation-over-reinsert pattern below is what keeps that filtered index from tripping over soft-deleted duplicates (ADR-005 for the soft-delete model). - Walkthrough
- Marked
[IdValueGenerated](:18), so the database assigns the identity; the factory setsId = defaultand lets SQL Server fill it in. - Three FK properties with private setters:
LivePollId(:22),OptionId(:25),UserId(:28), plus the EF parameterless constructor (:31) and a private field constructor (:33-38). Create(livePollId, optionId, userId)(:48): combines the threeLivePollVoteInvariantsid checks (:53-56), constructs the vote withId = default(:60-63), and raisesLivePollVoteChangedwithDomainEntityState.Added(:67). The comment immediately above that line (:65-66) is the source of the zero-id contract described atLivePollVoteChanged.ChangeOption(optionId)(:78): the re-vote path while a poll is open (BR-225). It validates the new option (:80), reassignsOptionId(:84), and raises the event withDomainEntityState.Updated(:86). Note there is no lifecycle guard here: whether the poll is still open is checked byLivePoll.CanAcceptVotebefore this is called.Reactivate(optionId)(:98): the BR-135 pattern. It validates the option (:100), calls the baseUndelete()(:104), and only on success reassigns the option and raisesAdded(:106-110), so a user who un-votes and then re-votes reuses the same soft-deleted row instead of inserting a new one that would collide with the filtered unique index.Delete()(:120): overrides the base soft-delete, callsbase.Delete()first (:122), and raisesLivePollVoteChangedwithDomainEntityState.Deletedonly when that succeeded (:124-125). The row stays;IsDeletedflips.
- Marked
- Why it's built this way: separating the write-hot vote from the read-hot poll is the central scalability decision of the poll subsystem. Combined with the filtered unique index and reactivation, a poll can absorb a burst of conference-day votes without serializing them on one row.
- Where it's used: created, re-pointed, and reactivated by
CastVoteHandler(CastVoteHandler.cs:61,67,73); tallied on the read side byLivePollResultsBuilderthrough a groupedCOUNT.
SessionQuestion
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions·MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:19· Level 7 · class (sealed aggregate root)
- What it is: the aggregate root for an attendee-submitted, moderated session question in the conference-day live layer. It carries the text, a moderation status, an answered flag, and a snapshot of the event's live-window end so upvote timing can be checked without a cross-service call.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>(identity, soft-delete, audit fields,RowVersion, the domain-event list),SessionQuestionInvariants,QuestionStatus,SessionQuestionChanged,DomainEntityState,IdValueGeneratedAttribute, andResult/Error. Externals: the BCLDateTimeonly (the caller suppliesnowUtcfrom an injectedTimeProvider). - Concept introduced, the snapshotted cross-service fact.
[Rubric §7, Microservices Readiness]assesses whether a boundary avoids chatty synchronous dependence on a peer. Engagement and Conference are separate services with separate databases (ADR-006), so the event's live-window end is fetched once from Conference at submission time and stored on the row asLiveWindowEndUtc(:39-43); every later upvote-timing decision is then a local field read instead of a gRPC round trip (BR-237, the same trickLivePollplays atOpen). The class also shows the single-event-plus-state convention (BR-60): instead ofQuestionApproved/QuestionDismissed/QuestionDeletedevent types, oneSessionQuestionChangedcarries aDomainEntityStatediscriminator (:15-16).[Rubric §4, DDD]assesses invariant enforcement and explicit transitions inside the model: every mutator here is a guarded method returningResult, and an illegal transition fails rather than throws.[Rubric §6, CQRS and Event-Driven]applies because each state change announces itself as a domain event for downstream handlers. - Walkthrough
- Properties (
:22-43), all withprivate setso state changes only through the methods below:SessionId(:22),EventId(:25, denormalized from the session and deliberately not validated because the disabled-stub extension point can report a default, per the remark at:24),UserId(:28, never copied onto a DTO because questions display anonymously, BR-238),Text(:31),Status(:34),IsAnswered(:37), andLiveWindowEndUtc(:43). - Constructors (
:46-62): a private parameterless one for EF materialization that initializesTextto empty (:46), and a private all-args one the factory uses (:48-62). Create(:77-83): combines the threeSessionQuestionInvariantschecks (:85-88), then separately rejects anyinitialStatusother thanPendingorApprovedwith codeSessionQuestion.InvalidInitialStatus(:92-99), because a question must start at the event's moderation default (BR-233). It constructs withId = default(:101-104; theIdValueGeneratedAttributeat:18marks the key database-generated) and raisesSessionQuestionChangedwithAdded(:109-110). The comment at:106-108is the load-bearing detail: the id is still0at this point, so the event carries the session and the asker by value and consumers correlate on those rather than reading a row back by an id that does not exist yet. Every raise in this class passes the same five values (state,Id,SessionId,UserId,Status).Approve(:121) andDismiss(:145): the moderation transitions (BR-234). Each rejects the no-op case (approving an already-approved question at:123-130, dismissing an already-dismissed one at:147-154) with anError.InvariantcodedSessionQuestion.InvalidTransition, setsStatus, and raisesUpdated. Note there is no one-way door: a dismissed question can be approved again.MarkAnswered(:168): valid only whileApproved(codeSessionQuestion.NotApproved,:170-177) and only once (codeSessionQuestion.AlreadyAnswered,:179-186); setsIsAnswered(:188) and raisesUpdated(:190).CanAcceptUpvote(:201): the guard the upvote use case calls before writing. It fails when the question is notApproved(:203-210) or whennowUtc >= LiveWindowEndUtc(SessionQuestion.OutsideLiveWindow,:212-219), enforcing the live window purely from the snapshot, with no call into Conference.Delete(:229): overrides the base soft-delete and, on success, raisesSessionQuestionChangedwithDeleted(:233-234).
- Properties (
- Why it's built this way: snapshotting the window end rather than calling Conference per upvote
trades a small staleness window for removing a synchronous peer dependency from the conference-day hot
path (ADR-006 for the
database-per-service split,
ADR-007 for the gRPC boundary this
avoids). The single-event-plus-state shape (BR-60) keeps the event catalog small and lets one handler
branch on
DomainEntityState. - Where it's used: created by
SubmitQuestionHandler, transitioned byModerateQuestionHandler, read byGetSessionQuestionsHandler/GetModerationQueueHandlerand projected bySessionQuestionViewBuilder; re-read for the fresh count bySessionQuestionUpvoteChangedHandler; mapped to SQL Server bySessionQuestionConfiguration(group 22).
SessionQuestionUpvote
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.SessionQuestions·MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionUpvote.cs:19· Level 7 · class (sealed aggregate root)
- What it is: a standalone aggregate root recording one user's upvote on one
SessionQuestion. Deliberately not modeled as a child of the question. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>,SessionQuestionUpvoteInvariants,SessionQuestionUpvoteChanged,DomainEntityState,IdValueGeneratedAttribute, andResult. - Concept reinforced, splitting a high-frequency satellite into its own aggregate. The same decision
LivePollVotemakes, stated again in the class comment (:9-17): upvotes are frequent attendee writes, and pulling every upvote into the question aggregate would bloat the change tracker and make each upvote contend on the question row, so an upvote is its own root with no navigation back.[Rubric §4, DDD]assesses aggregate boundaries chosen for consistency needs rather than convenience.[Rubric §8, Data Architecture]assesses where uniqueness and concurrency are enforced: "one active upvote per (question, user)" (BR-235) is a filtered unique index over non-deleted rows inSessionQuestionUpvoteConfiguration(group 22), not an in-memory sibling scan, and toggling an upvote off then on is soft-delete followed byReactivateso those rows never accumulate duplicates (the BR-135 reactivation pattern). - Walkthrough
- Properties (
:22,:25):SessionQuestionIdandUserId, bothprivate setscalar foreign keys, with no navigation to the question, which is what keeps the two aggregates independent. - Constructors (
:28,:30-34): private parameterless for EF, private two-arg for the factory. Create(:43-45): combines the twoSessionQuestionUpvoteInvariantschecks (:47-49), constructs withId = default(:53-56), and raisesSessionQuestionUpvoteChangedwithAdded(:60). As on the question, the comment at:58-59records that the id is still0, so consumers correlate on the question and the voter, never on the upvote's own id.Reactivate(:71): calls the inheritedUndelete()(:73) and, on success, raisesAddedagain (:75-76), so a re-upvote looks exactly like a fresh upvote to every downstream consumer, including the broadcast handler.Delete(:86): overrides the base soft-delete (an un-upvote) and raisesDeleted(:90-91).
- Properties (
- Why it's built this way: separating the upvote root and driving on/off through soft-delete plus reactivation is what lets the filtered unique index be a durable one-active-per-user guarantee while keeping the write traffic off the question row.
- Where it's used: written by
ToggleUpvoteHandler; counted with a grouped SQLCOUNTbySessionQuestionViewBuilderand with a plainCOUNTbySessionQuestionUpvoteChangedHandler.
LivePoll
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18· Level 8 · class (sealed aggregate root)
- What it is: the aggregate root for a live poll: a question with 2 to 10 authored options and a strict
Draft -> Open -> Closedlifecycle, scoped either to a whole event or to a single session. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>,LivePollOption(its child),LivePollChanged,LivePollInvariants,LivePollStatus,DomainEntityState,IdValueGeneratedAttribute, the[Navigation]marker (NavigationAttribute),ResultandError. - Concept introduced, a lifecycle state machine with a snapshotted cross-service fact.
[Rubric §4, DDD](a root that guards its own transitions) and[Rubric §7, Microservices Readiness](avoiding a synchronous cross-service call on the hot vote path). The lifecycle is enforced as explicit guarded transitions, andOpensnapshots the event's live-window end onto the poll (LiveWindowEndUtc,LivePoll.cs:32-36) so later vote checks never call the Conference service again (BR-223/BR-224, doc comment:10-15).[Rubric §8, Data Architecture]: the child options are held in an encapsulatedList<T>exposed only as a read-only view. - Walkthrough
[IdValueGenerated](:17); the propertiesEventId(:21),SessionId?(:24, null means event-wide, BR-230),Question(:27),Status(:30), andLiveWindowEndUtc?(:36) all have private setters. Options live in a privateList<LivePollOption> _options(:38) exposed as[Navigation(IsCollection = true)] IReadOnlyCollection<LivePollOption> Options => _options.AsReadOnly()(:41-42).Create(eventId, sessionId, question, optionTexts)(:64): null-checks the texts (:70), combines fourLivePollInvariantschecks, event id, question, option count, and option uniqueness (:72-76), constructs the poll withId = defaultandStatus = Draft(private constructor at:47-53, object initializer at:80-83), then builds eachLivePollOptionin display order using the loop index asSortand short-circuiting on the first option failure (:85-92), and raisesLivePollChangedAdded(:94). Per-option text validation is not in the combined list: it happens insideLivePollOption.Create.Open(nowUtc, liveWindowStartUtc, liveWindowEndUtc)(:108): rejects any non-Draftpoll with"LivePoll.InvalidTransition"(:110-117) and any attempt outside the live window with"LivePoll.OutsideLiveWindow"(:119-126; note the end bound is exclusive,nowUtc >= liveWindowEndUtcfails), then flipsStatustoOpenand snapshotsLiveWindowEndUtc(:128-129) before raisingUpdated(:131).Close()(:141):Openonly, and no reopen path exists (:143-150); flips toClosed(:152) and raisesUpdated(:154).CanAcceptVote(nowUtc, optionId)(:167): the guard the vote handler calls. It requiresOpenstatus (:169-176), requiresnowUtcto be strictly before a snapshotted window end that is actually set (:178-185), and requires the option to exist, be non-deleted, and belong to this poll (_options.Exists(...),:187-194). Each failure returns its ownErrorcode. This runs entirely against in-memory state, with no cross-service call.SetOptions(options)(:201-202): aninternalhook that routes through the baseSetItems, used only byLivePollNavigationPopulatorto rehydrate the collection.Delete()(:210): refuses to delete anOpenpoll (BR-228,"LivePoll.DeleteWhileOpen",:212-219), then cascade soft-deletes through the base helper,Result.Combine(DeleteChildren<LivePollOption, LivePollOptionIdentifierType>(_options), base.Delete())(:223-225), and raisesLivePollChangedDeletedonly when the combined result succeeded (:227-228). The comment above it explains the ordering: children first, root last, soResult.Combineaggregates every child failure with the root's own and a failing option cannot leave a half-applied delete behind (:221-222).DeleteChildrenis the shared base method atMMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:273.
- Why it's built this way: snapshotting the live-window end at
Opentrades a small amount of staleness for removing a synchronous Conference call from every single vote (ADR-007 describes the gRPC boundary this sidesteps), and the explicit transition guards make an invalid lifecycle move impossible regardless of which handler calls in. Refusing to delete an open poll rather than silently closing it means a delete can never end a running vote behind the audience's back. - Where it's used: created, opened, closed, and deleted by
CreateLivePollHandler,OpenLivePollHandler, andCloseLivePollHandlerbehindLivePollsController; its options rehydrated byLivePollNavigationPopulator; tallied byLivePollResultsBuilder.
LivePollOption
MMCA.ADC.Engagement.Domain ·
MMCA.ADC.Engagement.Domain.LivePolls·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollOption.cs:13· Level 8 · class (sealed child entity)
- What it is: a single answer option belonging to a
LivePoll: display text plus a sort order, authored with the poll and immutable afterwards. - Depends on:
AuditableBaseEntity<TIdentifierType>(note: a plain auditable child, not an aggregate root),LivePollInvariants,IdValueGeneratedAttribute, the[Navigation]marker (NavigationAttribute),Result. - Concept reinforced, the child entity inside an aggregate boundary.
[Rubric §4, DDD]. UnlikeLivePollVote, an option is a genuine child of the poll: it derives fromAuditableBaseEntity<TIdentifierType>, so it has no domain-event list of its own, and it is only ever created and soft-deleted through its parentLivePoll. Its changes are announced by the parent'sLivePollChanged, which is the practical meaning of "inside the boundary". - Walkthrough:
[IdValueGenerated](:12);Text(:16) andSort(:19) have private setters. The back-reference[Navigation] public LivePoll? LivePoll { get; private set; }(:22-23) is not publicly settable: the writer is the explicitSetLivePoll(LivePoll?)method at:59, which the populator calls. The FKLivePollId(:26) is get-only and is written by EF Core. The EF parameterless constructor seedsTexttostring.Emptyto satisfy nullability (:29), and a private field constructor takes the two real values (:31-35). The only factory,Create(text, sort)(:43), validates throughLivePollInvariants.EnsureOptionTextIsValid(:45) and constructs the option withId = default(:49-52). There is no mutation method forTextorSort: immutability is enforced by omission, and the doc comment says to re-author the Draft poll instead (:8-10). - Why it's built this way: modeling the option as an immutable child keeps the poll's consistency boundary simple. Tally math only ever gains new options through re-authoring, so an existing option's meaning can never change under a live vote count. Exposing the back-reference through a named
SetLivePollrather than a public setter keeps the one legitimate writer (the populator) explicit at the call site. - Where it's used: built inside
LivePoll.Create(LivePoll.cs:87) and cascade-deleted byLivePoll.Delete(LivePoll.cs:224); rehydrated as a collection byLivePollNavigationPopulator; its own back-reference filled byLivePollOptionNavigationPopulator; read byLivePollResultsBuilderto label and order each tally.
SessionQuestionUpvoteChangedHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.DomainEventHandlers·MMCA.ADC.Engagement.Application/SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:39· Level 8 · class (sealed)
- What it is: the domain-event handler that broadcasts a question's fresh upvote count whenever an
upvote is cast or withdrawn (the
question.upvote-changedchannel event, BR-238). - Depends on:
IDomainEventHandler<in TDomainEvent>(implemented forSessionQuestionUpvoteChanged),BestEffort(the framework's swallow-and-count helper),ILiveChannelPublishQueueandLiveChannelPublishWorkItem,IUnitOfWork(resolved per event from a fresh scope),SessionQuestion/SessionQuestionUpvote,LivePollChannelandSessionQuestionChannelfor the channel key and event name, andSessionQuestionUpvoteChangedPayload. Externals:IServiceScopeFactory,ILogger,System.Text.Json. - Concept introduced, moving a broadcast from the command handler onto the domain event. The class
comment (
:18-25) states both defects this fixed, and they are worth internalizing because they generalize to any side effect attached to a write. First, the publish used to be awaited inline on the request path, so a slow or hung Notification peer added its latency to every single upvote. Second, and worse, it ran before the command's transaction committed, so a later rollback left clients told about an upvote that never persisted. Domain-event dispatch inside a transactional command is deferred until after the commit succeeds and dropped on rollback, so relocating the publish here makes it post-commit by construction.[Rubric §29, Resilience and Business Continuity]assesses whether a dependency outage can corrupt or block the primary write; here it can do neither, since the enqueue is off the request path and the whole body runs insideBestEffort.ExecuteAsync.[Rubric §6, CQRS and Event-Driven]assesses using domain events as the extension point for downstream reactions. - Concept introduced, a swallowed failure that is still counted.
[Rubric §13, Observability and Operability]assesses whether an operator can tell that something stopped working. A hand-rolledcatch { log; }produces a Warning line in a log nobody reads; this handler instead hands its whole body toBestEffort.ExecuteAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:45), which does three things a local catch does not. It emits exactly one Warning naming the operation (BestEffort.cs:81-84), it increments thebesteffort.dispatch.failedcounter on theMMCA.Common.BestEffortmeter tagged byoperation(BestEffort.cs:107-115), so a broadcast path that has quietly died shows up on a dashboard, and it rethrows anOperationCanceledExceptioncaused by the caller's own token instead of recording it as a failure (BestEffort.cs:59-64), so a host shutdown still unwinds promptly. The operation name is aprivate const(SessionQuestionUpvoteChangedHandler.cs:45,"session-question-upvote-broadcast") precisely because it becomes a low-cardinality metric tag. The class comment records the change at:26-31. - Walkthrough
- The primary constructor (
:39-42) takesIServiceScopeFactory,ILiveChannelPublishQueue, and anILogger. The handler is a singleton per the framework convention (:32-34), which is exactly why it must open its own scope to reach scoped services. HandleAsync(:48): null-guards the event (:50), then returns the task produced byBestEffort.ExecuteAsync(BroadcastOperation, logger, …, cancellationToken)(:52, closing at:84). Everything below runs inside that lambda, under thebroadcastTokenthe helper hands it.- Scope (
:54-55):scopeFactory.CreateAsyncScope()andIUnitOfWorkresolved from it. - Question re-read (
:57-62): a no-trackingGetByIdAsyncwithincludes: []. Anullresult means the question was removed between the upvote committing and this dispatch, and the handler simply returns (:64-68): nothing meaningful is left to broadcast. - Count (
:70-73):CountAsyncoverSessionQuestionUpvotefiltered to this question. The count is computed in SQL, and the global soft-delete query filter means withdrawn upvotes are excluded automatically rather than by an explicitIsDeletedpredicate. - Payload and enqueue (
:76-83): serializes aSessionQuestionUpvoteChangedPayloadof(questionId, sessionId, upvoteCount)withJsonSerializerOptions.Web, thenEnqueues aLiveChannelPublishWorkItemaddressed toLivePollChannel.ForSession(question.SessionId)with the event nameSessionQuestionChannel.QuestionUpvoteChanged. The comment at:75is the privacy rule: the payload carries the count only, never who voted (BR-238).
- The primary constructor (
- Why it's built this way: this is the at-most-once ephemeral broadcast layered over a durably
committed write that
ADR-039 prescribes. The queue keeps
the gRPC publish off the request path, the domain-event timing keeps it post-commit, and
BestEffortkeeps it best-effort and measurable; the authoritative upvote count is always re-readable from the API, so a dropped broadcast degrades freshness, never correctness. - Where it's used: not called directly. It is discovered by convention-based scanning
(
ScanModuleApplicationServices<ClassReference>(),MMCA.ADC.Engagement.Application/DependencyInjection.cs:88) and invoked by the framework's domain-event dispatcher wheneverToggleUpvoteHandlercommits aSessionQuestionUpvotechange, which that handler's comment points at (MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:89-90). The work item it enqueues is drained by the hosted processor behindILiveChannelPublishQueue(MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57).
SessionQuestionViewBuilder
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.SessionQuestions.Services·MMCA.ADC.Engagement.Application/SessionQuestions/Services/SessionQuestionViewBuilder.cs:12· Level 8 · class (sealed)
- What it is: the shared read-side service that projects a set of
SessionQuestionentities intoSessionQuestionDTOviews, computing each question's active upvote count and the calling user's own upvote and authorship flags. - Depends on:
IUnitOfWork(for the read repository),IQueryableExecutor(async materialization of a rawIQueryablewithout an EF dependency in the Application layer),SessionQuestionUpvote, andSessionQuestionDTO. Externals: LINQ and BCL collections. - Concept introduced, computing tallies with a grouped SQL COUNT in a shared builder.
[Rubric §12, Performance and Scalability]assesses whether hot read paths avoid materializing whole tables. The comment at:36-38is the record of why this shape exists: counts come from aGroupBy(SessionQuestionId).Select(Count())that returns one row per question instead of one row per upvote, so a hot plenum session no longer re-materializes its entire upvote set on every read, and the change mirrorsLivePollResultsBuilderon the poll side.[Rubric §1, SOLID]applies to the sharing: submit, list, and moderation all need the same per-question counts and per-caller flags, so the projection lives in one reusable unit rather than being re-derived (and re-optimized) in three handlers. - Walkthrough
- The primary constructor (
:12) takesIUnitOfWorkandIQueryableExecutor. BuildAsync(questions, callerUserId, cancellationToken)(:21-24): null-guards the input (:26) and short-circuits to an empty list when there is nothing to project (:28-31), which keeps the empty-session case free of any query.- Grouped count (
:33-46): collects the question ids (:33), takes a read repository forSessionQuestionUpvote(:34), and runs the grouped projection overTableNoTrackingthroughqueryableExecutor.ToListAsync(:39-44). Soft-deleted (withdrawn) upvotes are excluded by the global query filter, so "active" needs no explicit predicate. The result folds into acountsByQuestiondictionary (:46). - The caller's own upvotes (
:50-58): a separate, narrower query issued only whencallerUserIdis non-null, usingGetProjectedAsyncto fetch justSessionQuestionIdfor rows owned by that user (:53-56) into aHashSet(:57). The moderation view passesnulland skips the query entirely (comment at:48-49), becauseMyUpvoteandIsMineare not meaningful there. - Projection (
:60-73): maps each question in its original order to aSessionQuestionDTO, takingUpvoteCountfrom the dictionary viaGetValueOrDefault(:68),MyUpvotefrom the set (:69),IsMinefrom the caller comparison (:70), and carryingRowVersionthrough for the ADR-035 round trip (:72).UserIdis never copied onto the DTO: questions display anonymously (BR-238), and this single projection is the one place that rule has to hold.
- The primary constructor (
- Why it's built this way: centralizing the projection guarantees every surface computes views identically, with the same number of queries, and honors the anonymity rule in exactly one place; splitting the "my upvotes" read out of the grouped count keeps the fan-out and moderation paths from paying for data they will not use.
- Where it's used: registered scoped at
MMCA.ADC.Engagement.Application/DependencyInjection.cs:74and injected intoSubmitQuestionHandler(MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:28),GetSessionQuestionsHandler(MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetSessionQuestions/GetSessionQuestionsHandler.cs:29), andGetModerationQueueHandler(MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/GetModerationQueue/GetModerationQueueHandler.cs:23), the last being thecallerUserId is nullpath.
LivePollResultsBuilder
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12· Level 9 · class (sealed)
- What it is: the shared read-side service that computes poll result tallies: per-option active-vote counts, the total, the caller's own vote when there is a caller, and the poll's concurrency token. It computes one poll or a whole set with the same code path.
- Depends on:
IUnitOfWork(for the read repository),IQueryableExecutor(async materialization without an EF dependency in the Application layer),LivePoll/LivePollVote, and the result DTOsLivePollResultsDTO/LivePollOptionResultDTO. - Concept introduced, computing tallies with a grouped SQL COUNT instead of materializing votes.
[Rubric §12, Performance & Scalability]assesses whether hot read paths avoid loading whole tables. The comment atLivePollResultsBuilder.cs:59-60states the intent: tallies come from aGroupBy(new { LivePollId, OptionId }).Select(Count())that returns one row per (poll, option) rather than one row per vote, on a path that runs on every vote, every results read, and every open-polls listing. Centralizing this in one builder means all three surfaces compute results identically (:8-10). - Concept introduced, batching a per-item query into a set-wide one.
[Rubric §12, Performance & Scalability]again, on round trips rather than row counts.BuildManyAsynctakes the whole poll set and issues a fixed number of queries: one groupedCOUNTover every poll (:61-66) and, only when a caller is present, one read of that caller's votes across the same set (:75-79). The doc comment names the cost this replaced: a per-poll loop issued two queries per poll, so a session with a dozen open polls cost two dozen round trips (:33-38). Single-poll callers are not a separate code path;BuildAsyncsimply wraps its argument in a one-element list and takesresults[0](:29-30), so there is only one implementation to keep correct. - Concept introduced, making the parts add up to the whole.
[Rubric §9, API & Contract Design]covers whether a payload is internally consistent. The projection keeps only the options a poll still presents (.Where(o => !o.IsDeleted),:99), so votes cast on an option that was later removed are excluded from the breakdown, andTotalVotesis summed from that same projected list rather than counted independently (:116). A client computing percentages from the parts therefore always reconciles with the total. Both comments say so in place (:95-97,:114-115). - Walkthrough: a primary constructor takes
IUnitOfWorkandIQueryableExecutor(:12); the class has three members.BuildAsync(poll, userId?, cancellationToken)(:22-31): null-checks the poll (:27), delegates toBuildManyAsync([poll], ...)(:29), and returns the single element (:30).BuildManyAsync(polls, userId?, cancellationToken)(:44-88): null-checks (:49), returns an empty list for an empty input before touching the database (:51-54), takes a no-tracking read repository forLivePollVote(:56) and the distinct poll ids (:57). It runs the grouped count overvoteRepo.TableNoTrackingfiltered bypollIds.Contains(v.LivePollId)(:61-66) and folds the rows into a dictionary keyed by the(LivePollId, OptionId)tuple (:68). The caller's own votes are a separate set-wide read issued only whenuserIdis non-null: broadcast payloads passnulland skip it entirely (BR-229,:70-85). Finally it maps every poll throughAssemblein the order supplied (:87).Assemble(poll, countsByPollOption, myVoteByPoll)(:90-123), a private static: filters to non-deleted options, orders bySort, and projects each into aLivePollOptionResultDTOwhoseVoteCountcomes from the dictionary viaGetValueOrDefault, so an option with zero votes still appears (:98-107). It then assembles theLivePollResultsDTO(:109-122) with poll id, question, status, the summedTotalVotes(:116), the options,MyVoteOptionId(:118, null when no caller or no vote), andRowVersion(:121). That last line is deliberate: the concurrency token travels with the results so a surface fed only by results holds the token it puts in theIf-Matchheader of an open or close (:119-120).
- Why it's built this way: the grouped count keeps the tally cost proportional to option count rather than vote count, and the set-wide shape keeps the round-trip count constant rather than proportional to the number of polls on screen. Skipping the "my vote" read for broadcast payloads (which have no single caller) avoids a pointless query on the fan-out path.
- Where it's used: registered as scoped in the module's DI (
DependencyInjection.cs:68) and injected intoCastVoteHandler(CastVoteHandler.cs:21, called at:91),GetPollResultsHandler(GetPollResultsHandler.cs:15), andGetOpenPollsHandler(GetOpenPollsHandler.cs:17, the one caller ofBuildManyAsyncat:47), and resolved out of a fresh scope byLivePollVoteChangedHandlerfor the results broadcast (LivePollVoteChangedHandler.cs:55,73). - Caveats / not-in-source:
Optionsmust already be loaded on every passedLivePoll(viaLivePollNavigationPopulatoror an explicit include, asLivePollVoteChangedHandlerdoes atLivePollVoteChangedHandler.cs:60).Assemblereadspoll.Optionsdirectly and does not load it; the XML docs say so at:15-16and:38.
LivePollNavigationPopulator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollNavigationPopulator.cs:11· Level 10 · class (sealed)
- What it is: the declarative navigation populator that loads a
LivePoll'sOptionscollection on query-service paths where EF Core.Include()is not applied. - Depends on:
DeclarativeNavigationPopulator<TEntity>(base),ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>(the descriptor),IUnitOfWork,LivePoll/LivePollOption. - Concept reinforced, declarative navigation population (ADR-002).
[Rubric §2, Design Patterns]. The framework's entity-query path returns entities without EF includes; a populator declares, in data, which child collections to rehydrate and how. That is the whole class: it subclassesDeclarativeNavigationPopulator<TEntity>closed overLivePolland passes exactly oneChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>(LivePollNavigationPopulator.cs:11-22). - Walkthrough: a primary constructor takes
IUnitOfWorkand forwards a single-element descriptor array to the base (:11-22). The descriptor (:15) wiresPropertyName = nameof(LivePoll.Options)(:17),ParentKeySelector = p => p.Id(:18),ChildForeignKeySelector = child => child.LivePollId(:19), andAssignAction = (p, options) => p.SetOptions(options)(:20). That last line calls the aggregate'sinternalSetOptions, so the collection is rehydrated through the root's ownSetItemspath rather than by writing the backing field directly. The class body is empty (:23-24); all behavior lives in the base. - Why it's built this way: expressing the load as a descriptor rather than hand-written query code keeps every populator uniform and lets the base own batching and assignment. Routing the assignment through
SetOptionspreserves the aggregate boundary even during rehydration. - Where it's used: registered as
INavigationPopulator<LivePoll>in the module's DI (DependencyInjection.cs:59), so the query pipeline runs it beforeLivePollResultsBuilderreadspoll.Options. Note the sibling registration one line on:LivePollVotegets aNullNavigationPopulator<TEntity>(DependencyInjection.cs:60), because a vote has nothing to rehydrate.
LivePollOptionNavigationPopulator
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.Services·MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollOptionNavigationPopulator.cs:11· Level 10 · class (sealed)
- What it is: the mirror-image populator for
LivePollOption: it fills the option's back-reference to its parentLivePollwhen an option is queried on its own. - Depends on:
DeclarativeNavigationPopulator<TEntity>(base),FKNavigationDescriptor<TEntity, TChild, TChildId>(the descriptor),IUnitOfWork,LivePoll/LivePollOption. - Concept reinforced, the two descriptor flavors (see
LivePollNavigationPopulatorand ADR-002).[Rubric §2, Design Patterns]. This pair is the clearest illustration of the difference anywhere in the module. AChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>walks down from a parent key to many children, while anFKNavigationDescriptor<TEntity, TChild, TChildId>walks up an FK to a single parent, which is why itsAssignActionends inFirstOrDefault(). - Walkthrough: a primary constructor takes
IUnitOfWorkand forwards oneFKNavigationDescriptor<LivePollOption, LivePoll, LivePollIdentifierType>to the base (LivePollOptionNavigationPopulator.cs:11-22). The descriptor (:15) setsPropertyName = nameof(LivePollOption.LivePoll)(:17),ParentKeySelector = e => e.LivePollId(:18, the FK on the option, which is the inversion relative to the child descriptor),ChildForeignKeySelector = child => child.Id(:19, the poll's own primary key), andAssignAction = (e, livePolls) => e.SetLivePoll(livePolls.FirstOrDefault())(:20), calling the option's explicit setter method rather than assigning a public property. The class body is empty (:23-24). - Why it's built this way: the base loads parents in one batched query for a whole page of options rather than one query per option, so declaring the relationship in data is what removes the N+1 a naive lazy-loaded back-reference would create. Going through
SetLivePollis whyLivePollOption.LivePollcan keep aprivate setand still be assignable here: the writer is named, not open to anyone. - Where it's used: registered as
INavigationPopulator<LivePollOption>in the module's DI (DependencyInjection.cs:64).
DeleteLivePollHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.UseCases.Delete·MMCA.ADC.Engagement.Application/LivePolls/UseCases/Delete/DeleteLivePollHandler.cs:14· Level 9 · class (sealed)
- What it is: the poll delete use case: an eight-line subclass of the framework's generic
DeleteEntityHandler<TEntity, TIdentifierType>that adds nothing but the child collection the aggregate's own delete cascade has to see. - Depends on:
DeleteEntityHandler<TEntity, TIdentifierType>(base, closed overLivePollandLivePollIdentifierType),IUnitOfWork(forwarded to the base), andDeleteEntityCommand<TEntity, TIdentifierType>as the message it handles. - Concept introduced, extending a generic handler by overriding a structural hook, not behavior.
[Rubric §2, Design Patterns]assesses the template-method shape: the base owns the workflow (load by id, refuse or proceed, call the aggregate'sDelete(), save) and exposes exactly the two things a real delete outgrows, both structural. The framework spells this out atMMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:13-22: the child collections the cascade must see (Includes,:57) and a cross-aggregate refusal (OnDeletingAsync). A subclass overriding neither behaves identically to the base, down to the query it issues. This handler overrides onlyIncludes.[Rubric §8, Data Architecture]assesses whether a soft-delete leaves the store consistent, and the class comment (:7-13) names the exact failure mode avoided:LivePoll.Deletecascades soft-delete to its owned options (MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:223-225,DeleteChildrenover the_optionsbacking field), but only over the children actually loaded, so an unloaded collection would leave the option rows active under a soft-deleted poll, still reachable by the results builder and by exports. Declaring the include is what closes that gap.[Rubric §15, Best Practices]applies to the size of the result: the whole use case is two property overrides, because everything else is already correct in the framework. - Walkthrough
- The class (
:14-15): a sealed primary-constructor type takingIUnitOfWorkand forwarding it toDeleteEntityHandler<LivePoll, LivePollIdentifierType>(unitOfWork). NoHandleAsyncoverride exists; the inherited one atDeleteEntityHandler.cs:67-89runs. HandlerName(:18): overrides the base default so aNotFoundfailure reportsDeleteLivePollHandleras itsSourcerather than the open generic name (base default atDeleteEntityHandler.cs:50, used at:73).Includes(:21):[nameof(LivePoll.Options)], the one substantive line. The base feeds this into its by-id load, so the aggregate arrives with its options materialized and tracked (the base loads with tracking by default,DeleteEntityHandler.cs:64, because a no-tracking load would make the delete a silent no-op).- What the base then does (
DeleteEntityHandler.cs:71-88): resolve the write repository, load, returnError.NotFoundwhen missing (:73), runOnDeletingAsync(:75, not overridden here), callentity.Delete()(:79), andSaveChangesAsynconly on success (:82). No domain event is raised by the handler: that belongs toLivePoll.Delete, which is also where the BR-228 "close an open poll before deleting it" refusal lives (MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:212-219).
- The class (
- Why it's built this way: the delete rules that matter for a poll are all domain rules and already live on the aggregate, so re-implementing a handler would only duplicate the load-refuse-save workflow and risk drifting from it. Subclassing keeps the module's delete path indistinguishable from every other module's while still loading what this particular cascade needs.
- Where it's used: registered as the
ICommandHandler<DeleteEntityCommand<LivePoll, LivePollIdentifierType>, Result>implementation atMMCA.ADC.Engagement.Application/DependencyInjection.cs:68, and dispatched byLivePollsController.DeleteAsync(MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:158-160), which is the one endpoint behind[HasPermission(EngagementPermissions.LiveManage)].
LivePollDTOMapper
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.DTOs·MMCA.ADC.Engagement.Application/LivePolls/DTOs/LivePollDTOMapper.cs:13· Level 9 · class (sealed partial)
- What it is: the Mapperly-generated mapper that turns a
LivePollentity, including its options, into aLivePollDTOfor read responses. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>(the framework mapper contract it satisfies,:14),LivePoll,LivePollDTO, and theRiok.Mapperly.Abstractionssource generator ([Mapper],:12). - Concept reinforced, compile-time DTO mapping
(ADR-001). Taught with the other
…DTOMappertypes: the[Mapper]attribute on apartialclass makes Mapperly emit the property-copy code at build time, so there is no runtime reflection, no hand-written assignments to drift, and a missing member is a build error rather than a silently null field.[Rubric §9, API and Contract Design]assesses whether the wire contract is decoupled from the domain model; mapping the aggregate to a dedicated record (LivePollDTO, which also carries the ADR-035RowVersiontoken) is that decoupling.[Rubric §15, Best Practices]applies because generated mapping is allocation-light and analyzer-clean under this repo's warnings-as-errors setting. - Walkthrough
MapToDTO(LivePoll entity)(:17): declaredpartialwith no body; Mapperly generates the entity-to-DTO copy, including the nestedLivePollOptioncollection, from the two shapes.MapToDTOs(IReadOnlyCollection<LivePoll>)(:20-24): the hand-written collection overload, null-guarded withArgumentNullException.ThrowIfNull(:22) and projecting each entity throughMapToDTOinto a collection-expression result (:23).
- Why it's built this way: implementing the framework's mapper interface with generated code keeps the poll read path fast and drift-free, which is exactly the trade ADR-001 records against reflection-based mapping.
- Where it's used: resolved by the generic entity-query-service wiring and by the poll read handlers
such as
GetEventPollsHandlerandGetSessionManagePollsHandler. Registration is convention-based:ScanModuleApplicationServices<ClassReference>()picks up every mapper in the assembly (MMCA.ADC.Engagement.Application/DependencyInjection.cs:88).
LivePollsController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:45· Level 9 · class (sealed)
- What it is: the REST controller for the live poll layer: create, open, close, and delete a poll, list polls for the organizer and for the session moderation panel, read tallies, and cast a vote.
- Depends on: nine handlers injected through
ICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>(including the genericDeleteEntityCommand<TEntity, TIdentifierType>for delete, served byDeleteLivePollHandler),ICurrentUserServicefor claims,ApiControllerBasefor theHandleFailureResult-to-HTTP bridge,RoleNames,HasPermissionAttribute,SupportsIfMatchAttribute,IdempotentAttribute,EngagementPermissions, andEngagementFeatures. Externals: ASP.NET Core MVC,Asp.Versioning, andMicrosoft.FeatureManagement.Mvc([FeatureGate]). - Concept introduced, three stacked authorization tiers on one controller.
[Rubric §11, Security]assesses where the trust boundary sits and whether identity is derived from a trusted source.[Rubric §9, API and Contract Design]assesses whether controllers stay thin transport adapters over the handler pipeline. The class attributes (:39-43) set two of the tiers:[FeatureGate(EngagementFeatures.LivePolls)]makes the entire surface dark when the flag is off, and a bare[Authorize]requires a token at all. The third tier is per-endpoint: only the organizer-facing delete (:149) and the event-wide manage list (:168) carry[HasPermission(EngagementPermissions.LiveManage)], while the finer "this speaker owns this session" rule (BR-236) is evaluated inside the handlers viaLivePollAuthorization, because it needs data the transport layer does not have. The session moderation list at:192is the instructive case: its doc comment (:181-187) states that it is deliberately not behindLiveManage, so a session's assigned speakers get the real list from the handler's BR-236 check instead of an organizer-only 403 they would have to work around. Complementing all three, caller identity is bound from the token and never from the request (:284-293). - Concept introduced, conditional writes over the
If-Matchheader (ADR-035). The lifecycle verbs carry no body at all.[SupportsIfMatch](:92,:126) makes the precondition mandatory: the action filter reads the caller's entity tag fromIf-Match, answers a request that states none with428 Precondition Requiredbefore the action ever runs, answers an undecodable tag with400, and rewrites a concurrency conflict from the handler into412 Precondition Failed(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:49,:126-153,:161-190). The action itself just callsSupportsIfMatchAttribute.RequiredToken(HttpContext)(:104,:138) to pull the already-validated token out and pass it into the command. Alongside it,[Idempotent](ADR-017) marks the four writes where replaying a retried request is what the caller meant (:63,:91,:125,:260), which on a conference-day mobile network is not a theoretical concern. TheProducesResponseTypelists on those actions (:93-99,:127-133) are the documented contract for all of it. - Walkthrough
- The primary constructor (
:44-54) injects five command handlers, four query handlers, andICurrentUserService. Every action follows the same three steps: build a message, await the handler, map theResultto HTTP. CreateAsync(:67): buildsCreateLivePollCommandfrom the body plusGetCallerSpeakerId()/IsCallerOrganizer()(:71) and returns201 Createdwith a relative location built underCultureInfo.InvariantCulture(:76).OpenAsync(:100) andCloseAsync(:134): the lifecycle verbs, returning204 No Content. Both take the row version fromRequiredToken(:104,:138) and forward it intoOpenLivePollCommand/CloseLivePollCommand(:105,:139).DeleteAsync(:153):[HasPermission(LiveManage)]-gated (:149), dispatching the genericDeleteEntityCommand<LivePoll, LivePollIdentifierType>(:157-159); the BR-228 "close an open poll before deleting it" rule lives deeper, inLivePoll.Delete.GetEventPollsAsync(:170): the organizer manage list, also[HasPermission(LiveManage)], with[FromQuery, Required] EventIdentifierType eventId(:171).GetSessionManagePollsAsync(:192): the session moderation panel, ungated at the transport layer, passingsessionIdplus the two claim-derived flags intoGetSessionManagePollsQuery(:196).GetOpenPollsAsync(:211): the attendee and presenter view, taking optionaleventIdorsessionId(:212-213). LikeGetResultsAsync(:235) andCastVoteAsync(:264), it first readscurrentUserService.UserIdand returns anError.Forbiddenwhen the token carries no subject (:216-220), then stamps the id onto the query or command.CastVoteAsync(:264): buildsCastVoteCommandfrom the route id, the body'sOptionId, and the token subject (:276), and returns the freshLivePollResultsDTOas200 OK(:281).- The two claim helpers are the load-bearing security detail:
GetCallerSpeakerId()(:285) reads thespeaker_idclaim and maps a default value tonull(:287-288), andIsCallerOrganizer()(:292) isIsInRole(Organizer) || IsInRole(Admin)(:293).
- The primary constructor (
- Why it's built this way: a declarative capability gate keeps the two organizer-only endpoints
locked without any code, while delegating the data-scoped speaker decision to a shared handler check
avoids duplicating BR-236 at the transport layer and keeps the same rule in force for any future
transport. Pushing the concurrency token into a header rather than a request body means the lifecycle
verbs need no body type at all and the missing-precondition case is answered by the filter instead of
by every handler. The
[FeatureGate]lets the whole live-poll surface ship dark and be enabled per environment. - Where it's used: mounted by the Engagement service host and reached by the Blazor and MAUI clients through the YARP Gateway (ADR-008).
SessionQuestionsController
MMCA.ADC.Engagement.API ·
MMCA.ADC.Engagement.API.Controllers·MMCA.ADC.Engagement.API/Controllers/SessionQuestionsController.cs:37· Level 9 · class (sealed)
- What it is: the REST controller for the conference-day session Q&A layer: submit a question, read the attendee and moderation views, run the three moderation transitions, and set or withdraw an upvote.
- Depends on: five handlers via
ICommandHandler<in TCommand, TResult>/IQueryHandler<in TQuery, TResult>,ICurrentUserService,ApiControllerBase,SupportsIfMatchAttribute,IdempotentAttribute,ModerationAction,SubmitQuestionRequest,EngagementFeatures, andRoleNames. - Concept reinforced, identity bound at the edge, rights enforced in the handler. The same shape as
LivePollsController, with one instructive difference: this controller has no[HasPermission]endpoint at all. Its class attributes (:32-36) apply[FeatureGate(EngagementFeatures.SessionQA)]and a bare[Authorize], and every moderation decision (BR-236) is made inside the handler from thespeaker_idclaim and the organizer role, because "may this speaker moderate this session" is a data question. That is why the moderation-queue action documents both403and404(:106-107): the handler, not the pipeline, decides which one applies.[Rubric §11, Security]and[Rubric §9, API and Contract Design]both apply for the reasons given underLivePollsController. - Walkthrough
- The primary constructor (
:37-43) injects three command handlers, two query handlers, andICurrentUserService. SubmitAsync(:56):[Idempotent](:52) so a timed-out submit replayed with the sameIdempotency-Keydoes not post the question twice. It reads the token subject, refuses withError.Forbiddenwhen absent (:60-64), buildsSubmitQuestionCommand(:66), and returns201 Createdat/sessionquestions/{id}(:71).GetSessionQuestionsAsync(:81): the attendee view (approved questions plus the caller's own pending or dismissed ones), keyed on[FromQuery, Required] sessionId(:82) and the caller id (:92).GetModerationQueueAsync(:108): the all-statuses moderator view; it passesGetCallerSpeakerId()andIsCallerOrganizer()intoGetModerationQueueQuery(:113) so the rights check happens in the handler.ApproveAsync(:142),DismissAsync(:168),MarkAnsweredAsync(:194): three expression-bodied verbs that differ only by theirModerationActionand all funnel into the privateModerateAsync(:224), which buildsModerateQuestionCommand(:230) and returns204 No Content(:235). Each carries no body and the same[Idempotent]+[SupportsIfMatch]pair as the poll lifecycle verbs (:133-134,:159-160,:185-186), reading its row version fromSupportsIfMatchAttribute.RequiredToken(HttpContext)at the call site (:145,:171,:197).UpvoteAsync(:209) andRemoveUpvoteAsync(:219): the POST/DELETE pair on{id}/upvotes, both delegating to the privateToggleUpvoteAsync(:238) withupvote: true|false. Only the POST is marked[Idempotent](:205), and its comment (:200-203) explains why that is safe: the route sets the upvote rather than toggling it, so a replay is the same assertion, while the DELETE is the separate withdraw path. The helper binds the caller id from the token (:243-247), dispatchesToggleUpvoteCommand(:250), and returns the fresh count as200 OK(:255), so the clicking client updates immediately without waiting for the broadcast.GetCallerSpeakerId()(:259) andIsCallerOrganizer()(:266): identical in shape to the poll controller's helpers, reading the token only.
- The primary constructor (
- Why it's built this way: keeping the controller a pure transport adapter means the moderation rule is written once, in the handler, and cannot be bypassed by a second caller path; returning the fresh upvote count synchronously gives the acting user immediate feedback while the ADR-039 broadcast fans the same number out to everyone else.
- Where it's used: mounted by the Engagement service host; reached through the Gateway (ADR-008).
LivePollVoteChangedHandler
MMCA.ADC.Engagement.Application ·
MMCA.ADC.Engagement.Application.LivePolls.DomainEventHandlers·MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:38· Level 10 · class (sealed)
- What it is: the poll-side twin of
SessionQuestionUpvoteChangedHandler: it broadcasts fresh poll tallies whenever a vote is cast, changed, or withdrawn (thepoll.results-changedchannel event, BR-229 / ADR-039). - Depends on:
IDomainEventHandler<in TDomainEvent>(implemented forLivePollVoteChanged),BestEffort,ILiveChannelPublishQueue/LiveChannelPublishWorkItem,IUnitOfWork,LivePollResultsBuilder,LivePoll, andLivePollChannel. Externals:IServiceScopeFactory,ILogger,System.Text.Json. - Concept reinforced: the post-commit, off-request-path, best-effort broadcast is taught under
SessionQuestionUpvoteChangedHandler; the comment here (:18-24) records the same rollback defect on the vote path, where the command handler enqueued while its transaction was still open, and:25-30records the same move toBestEffortwith its own operation name (:44,"livepoll-results-broadcast"). Two details are specific to polls. The tally is not a singleCOUNTbut a fullLivePollResultsDTObuilt byLivePollResultsBuilder, and the channel key is conditional, because a poll may be session-scoped or event-wide.[Rubric §29, Resilience and Business Continuity]and[Rubric §13, Observability and Operability]apply for the same reasons as the sibling handler. - Walkthrough
- The primary constructor (
:38-41) mirrors the sibling's, and the class is likewise a singleton that opens its own scope (:31-33). HandleAsync(:47): null-guards the event (:49), then returnsBestEffort.ExecuteAsync(BroadcastOperation, logger, …, cancellationToken)(:51, closing at:83); the lambda opens an async scope and resolves bothIUnitOfWorkandLivePollResultsBuilderfrom it (:53-55).- Poll re-read (
:57-62): a no-trackingGetByIdAsyncthat explicitly passesincludes: [nameof(LivePoll.Options)](:60), because the results builder readspoll.Optionsand does not load them itself. Anullpoll means it was removed between the vote committing and this dispatch, and the handler returns (:64-69). - Tally (
:73):resultsBuilder.BuildAsync(poll, userId: null, broadcastToken). Passingnullis deliberate and documented at:71-72: a broadcast has no single caller, soMyVoteOptionIdstays null and the builder skips its per-user point read entirely. No per-user data ever rides the channel (BR-229 / ADR-039). - Channel key (
:75-77):poll.SessionId is { } sessionId ? LivePollChannel.ForSession(sessionId) : LivePollChannel.ForEvent(poll.EventId), the one place the event-wide versus session-scoped distinction (BR-230) turns into a transport address. - Enqueue (
:79-82): aLiveChannelPublishWorkItemwith that key, theLivePollChannel.PollResultsChangedevent name (MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:20), and the serialized results.
- The primary constructor (
- Why it's built this way: recomputing the tally here rather than shipping a delta means every subscriber receives the same authoritative snapshot regardless of how many votes raced, and enqueuing after commit means no client ever sees a tally that a rollback erased (ADR-039).
- Where it's used: discovered by convention-based scanning
(
MMCA.ADC.Engagement.Application/DependencyInjection.cs:88) and invoked by the domain-event dispatcher afterCastVoteHandlercommits; its comment atMMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHandler.cs:88-89points back here to explain why the handler itself no longer publishes.
⬅ ADC Engagement Module (Session Bookmarks) • Index • ADC Identity Module (Users, Profiles, GDPR Export/Erasure) ➡