Architecture Decision Record
ADR-039: Live channel push (ephemeral events over the notification hub)
Status
Accepted (2026-07-09).
Context
Conference-day features (live polls, session Q&A, live result counters) need sub-second fan-out of
small events to whoever is looking at a page right now. The existing notification pipeline
(ADR-024) is deliberately durable: one use case writes a per-user inbox row and fires a transient
SignalR push, with the inbox as the source of truth. Live updates are the opposite shape: high
frequency, broadcast to an interest group rather than a user list, worthless seconds later, and
wrong to persist. Routing them through the outbox/broker path adds seconds of latency
(Outbox:ProcessingDelaySeconds, ADR-003) and per-user rows nobody will read.
A second WebSocket/hub for live traffic would double the connection count per client, need its own auth, reconnect, and backplane story, and split the client-side connection management in two.
Decision
One realtime transport, two publisher boundaries:
NotificationHubstays the single hub and gains its first client-invokable methods:JoinChannel/LeaveChannelmap the calling connection into a SignalR group named by a channel key (event:1,session:123). Keys are validated againstPushNotificationSettings.ChannelKeyPattern(default^(event|session):[0-9]+$); invalid keys throwHubException, so clients cannot join arbitrary group names.- A new
ILiveChannelPublisherabstraction in Application (PublishAsync(channelKey, eventName, payloadJson)) sits besideIPushNotificationSender. Payloads are pre-serialized JSON strings: one serialization point at the publishing edge, and the same payload shape rides any transport hop unchanged. - Infrastructure ships
SignalRLiveChannelPublisher(group send ofReceiveChannelEventviaIHubContext<NotificationHub>) and a no-opNullLiveChannelPublisherdefault, swapped in byAddPushNotifications()exactly like the ADR-024 sender pair. A host that does not map the hub can replace the registration with its own transport (for example a gRPC adapter that forwards to the service that does map the hub). NotificationHubService(UI) carries both traffic kinds on its existing connection:JoinChannelAsync/LeaveChannelAsynctrack membership,OnChannelEventregisters multicast subscriptions (an invisible layout listener and a page can observe the same channel concurrently), and every tracked channel is re-joined onReconnected, because SignalR group membership does not survive an automatic reconnect.
The durable-vs-ephemeral split is decided at the publisher boundary, not the transport:
IPushNotificationSender for anything a user must be able to find later (inbox-backed),
ILiveChannelPublisher for anything that only matters while it is on screen (no persistence, no
delivery guarantee).
Rationale
- One WebSocket per client keeps connection management, token refresh, reconnect, and backplane behavior in one place; channel membership is a property of the existing connection.
- An Application-layer abstraction keeps application code transport-free (the same rule as
IMessageBus, enforced by the architecture tests), and the Null default preserves the "resolvable everywhere, active only where configured" convention. - String-JSON payloads avoid a serializer dependency in the contract and make cross-service ingress trivial (the payload is opaque to every hop).
- Group-name validation by configurable pattern closes the obvious abuse vector of client-supplied group names without hardcoding app-specific channel vocabularies into the framework.
Trade-offs
- Ephemeral means lossy: a client that connects after an event was published never sees it. Features must treat channel events as cache-invalidation hints over fetchable state, not as the state itself.
- Handlers receive raw JSON and deserialize themselves; type safety is by convention (shared payload records in the consuming app's Shared project).
- The single settable
NotificationCallbackfor durable notifications remains single-subscriber; only channel events are multicast. Unifying them is deliberate future work, not blocked by this decision. - If a hub-hosting service ever runs more than one replica, the already-detected Redis backplane (ADR-024) is required for group sends to reach connections on other replicas; single-replica deployments need nothing.
Revision (2026-07-24)
Two corrections from a code review; the best-effort, per-session-ordered decision is unchanged.
Broadcasts are enqueued after commit, not during the command.
CastVoteHandlerandToggleUpvoteHandlerboth emitted their broadcast inside the handler, while the ADR-014 transactional decorator still had the transaction open, so a rollback left clients already told about a vote or upvote that never persisted. Both moved to domain-event handlers onLivePollVoteChanged/SessionQuestionUpvoteChanged, events the aggregates already raised but nothing consumed. In-process dispatch inside a transactional command is deferred until after commit and dropped on rollback (ADR-003 Revision 2026-07-19), so post-commit delivery follows from where the work is attached rather than from extra sequencing code.ToggleUpvoteHandleradditionally awaited the gRPC publish on the request thread, so a slow Notification peer added its latency to every upvote: precisely what the queue exists to prevent. The upvote path now uses the same off-request-path queue as the poll path.Drops under backpressure are observable. The queue uses
BoundedChannelFullMode.DropOldest, under whichTryWritealways returns true because it evicts to make room. The caller's "if the enqueue failed, log it" branch was therefore unreachable and every drop was silent. Drops now go through the channel'sitemDroppedcallback: counted onDroppedCountand logged with a running total, so a drain falling behind is visible rather than inferred from missing client updates.