Onboarding guide
10. Notifications (Push + In-App Inbox + Email)
What this group covers. This is the notification subsystem, the machinery that turns "an
organizer wants to tell every attendee something" into messages that actually reach people across
several transports at once. It spans all five layers and both repositories: the framework
(MMCA.Common) owns the aggregates, the transport abstractions, the SignalR hub, the REST
controllers, and the CQRS handlers, while MMCA.ADC supplies the thin
NotificationModule host, the app-specific recipient rule, and the gRPC
edges that let an extracted service still deliver over the one WebSocket. The design principle is
the same one that runs through the whole codebase: application and domain code talk to
abstractions (IPushNotificationSender,
ILiveChannelPublisher,
INotificationRecipientProvider,
IEmailSender); the concrete transport (SignalR, SMTP, a native push hub, a gRPC
forwarder) is chosen at the composition root, and a no-op default is always registered so nothing
has to be configured for DI to resolve.
There are really four delivery channels here, and it is worth separating them up front because they have different durability guarantees:
- The durable in-app inbox. Every send writes one
UserNotificationrow per recipient, so a user who was offline at send time still sees the message when they next open their inbox (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:58-66). This is the persistent half of the two-channel model (ADR-024). - The transient SignalR push.
IPushNotificationSenderfans the same message out to any connections the recipient has open right now via theNotificationHub; clients not connected at send time simply never see this copy (the inbox is their catch-up). This is the real-time half of ADR-024. - The OS-level native push (ADR-044). A separate best-effort channel reaches devices the
SignalR hub cannot (app backgrounded or killed), through
INativePushSenderand the device registrations managed byDevicesController. The sender itself and its Azure Notification Hubs implementation live in Group 07; this chapter covers only the request record and the registration endpoint. - Ephemeral live-channel events (ADR-039). A distinct, never-persisted fan-out to a group
of subscribed connections (for example
event:1orsession:123), used by the ADC Engagement live layer for poll and question updates. This rides the same hub but throughILiveChannelPublisherand the hub'sJoinChannel/LeaveChannelgroup membership rather than per-user targeting (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hubs/NotificationHub.cs:43-59).
A fifth transport, plain email, is present as IEmailSender /
SmtpEmailSender (MailDev locally, real SMTP in production) but is a
lower-traffic, fire-one-message helper rather than part of the broadcast pipeline: it builds and
disposes an SmtpClient per call and offers a "send to the configured default recipient" overload
for system mail (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SmtpEmailSender.cs:30-54).
The layering, and why the pieces sit where they do
The dependency flow of the group mirrors the framework's Clean Architecture story
([Rubric §3, Clean Architecture]). The Domain layer holds the two aggregates,
PushNotification (the audit record of a broadcast: title, body, sender,
recipient count, and a PushNotificationStatus of Pending/Sent/Failed)
and UserNotification (one per-recipient inbox row with read/unread state),
plus the PushNotificationCreated domain event and the
PushNotificationInvariants guards (title max 200 chars, body max
2000, both non-empty:
MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:12-25).
Both aggregates are [IdValueGenerated], so the database assigns their ids
(MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:13,
MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:11).
The Application layer defines the four transport contracts and the CQRS use cases; the
Shared layer carries the wire types (PushNotificationDTO,
UserNotificationDTO,
SendPushNotificationRequest,
DeviceInstallationRequest) and the
NotificationFeatures flag constant
(Notification.PushNotifications,
MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationFeatures.cs:9). The
Infrastructure layer supplies the SignalR-backed implementations
(SignalRPushNotificationSender,
SignalRLiveChannelPublisher), the SMTP email sender, and the
NotificationHub itself, plus the no-op fallbacks
(NullPushNotificationSender,
NullLiveChannelPublisher,
NullNotificationRecipientProvider). The API layer exposes
three controllers (NotificationsController,
InboxController, DevicesController).
The critical placement decision is that the four transport interfaces live in Application
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPushNotificationSender.cs:7,
ILiveChannelPublisher.cs:9, INotificationRecipientProvider.cs:8, IEmailSender.cs:6), not
Infrastructure. That keeps the send handler and the Engagement live layer depending on an
abstraction the way IMessageBus and the gRPC service interfaces do (the microservices-extraction
discipline in ADRs 007/008): the same application code runs unchanged whether the concrete sender is
an in-process SignalR call or a gRPC forward to another service.
The broadcast send flow, end to end
Sending a notification is a command-side vertical slice ([Rubric §5, Vertical Slice],
[Rubric §6, CQRS & Event-Driven]). An organizer POSTs to
NotificationsController, which is gated three ways:
[FeatureGate(NotificationFeatures.PushNotifications)], [Authorize(Policy = RequireOrganizer)],
and API versioning
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:25-27).
The controller reads the authenticated id from ICurrentUserService, refuses the call with an
Error.Unauthorized when there is none, wraps the request in a
SendPushNotificationCommand and hands it to
SendPushNotificationHandler
(NotificationsController.cs:42-53).
The handler runs a deliberate ordering
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:31-109):
resolve the recipient user ids through
INotificationRecipientProvider (fail early with a
PushNotification.NoRecipients validation error if the set is empty, lines 34-40); create the
PushNotification audit aggregate and save it (lines 43-56); then create one
UserNotification inbox row per recipient and save again, so a user can
retrieve a missed notification even if every real-time channel fails (lines 59-66); then attempt
SignalR delivery via IPushNotificationSender, catching any exception
and recording MarkAsSent() or MarkAsFailed() accordingly (delivery failure is non-fatal, lines
69-86); then a best-effort native-push leg through INativePushSender (ADR-044) whose failures are
logged at Warning but never change the audit status (lines 92-105); and finally a third save plus a
map of the aggregate to a PushNotificationDTO via
PushNotificationDTOMapper (lines 107-109). The durable inbox write
happening before the transient channels is the load-bearing choice: the record of who should
have been reached survives even when nobody is connected.
Who counts as a recipient is deliberately left to the consuming app.
INotificationRecipientProvider is the extension point; the
framework registers NullNotificationRecipientProvider, which
returns an empty list, as a safe default
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:67), and ADC
supplies AttendeeNotificationRecipientProvider, which
bridges the Identity module's
IAttendeeQueryService (over gRPC across
service boundaries) so a broadcast targets every conference attendee. The override works by
ordering, not by configuration: ADC registers its provider with AddScoped before calling
AddNotificationApplicationServices(), whose TryAddScoped default then finds the slot already
taken
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:24-31).
The inbox side
Reading and acknowledging notifications is the query/command counterpart, served by
InboxController under the same feature gate and
[Authorize(RequireAuthenticated)], so any user reaches only their own inbox
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:27-28);
every action re-reads ICurrentUserService.UserId and builds the message from it rather than
trusting a client-supplied id. It exposes four use cases: the paged inbox
(GetMyNotificationsQuery /
GetMyNotificationsHandler, which joins the per-user
UserNotification rows to their PushNotification
content newest-first and clamps the page size to 500,
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetInbox/GetMyNotificationsHandler.cs:24-50),
an unread count (GetUnreadNotificationCountQuery, served
[ResponseCache(NoStore = true)] so the bell badge is never stale,
NotificationInboxController.cs:59), a single mark-read
(MarkNotificationReadCommand), and a mark-all-read
(MarkAllNotificationsReadCommand).
UserNotification.MarkAsRead is idempotent and takes the read timestamp
as a parameter (from an injected TimeProvider) so the domain stays free of ambient clock access
and the transition is deterministically testable
(MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:58-67).
The SignalR transport, and how it survives extraction
NotificationHub is intentionally thin
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hubs/NotificationHub.cs:17): it is
[Authorize]d, and beyond ASP.NET's built-in per-user connection mapping it only manages channel
(SignalR group) membership through JoinChannel/LeaveChannel, validating each channel key against
a configured regex with a cached, one-second-timeout Regex so a bad key throws HubException
rather than opening an injection or ReDoS hole (NotificationHub.cs:31-34,61-71)
([Rubric §11, Security]). Actual delivery does not run inside the hub:
SignalRPushNotificationSender and
SignalRLiveChannelPublisher both use IHubContext<NotificationHub>
so they can be called from any handler without a live connection. The push sender targets
Clients.User / Clients.Users / Clients.All and batches large user lists in chunks of 100
to avoid overwhelming the connection manager
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SignalRPushNotificationSender.cs:15,42-59)
([Rubric §12, Performance & Scalability]); the live publisher does a single Clients.Group send
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SignalRLiveChannelPublisher.cs:15-19).
Both are wired by AddPushNotifications(configuration), which also attaches a Redis backplane when
a redis connection string is present, so the fan-out crosses replicas
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:250-272).
The live channel is where the extracted-service topology shows through
([Rubric §7, Microservices Readiness]). In a monolith the default
NullLiveChannelPublisher is registered, and a host that maps the hub
swaps in the real SignalRLiveChannelPublisher. In extracted ADC,
Engagement is a different process from the one that owns the WebSocket, so Engagement's live layer
depends on ILiveChannelPublisher as usual but the composition root
Replaces the registration with
LiveChannelPublisherGrpcAdapter
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:42-51). That
adapter forwards each event over gRPC with a tight 2-second deadline to the Notification service's
LiveChannelGrpcService, which then delegates to the local
SignalRLiveChannelPublisher, the only host whose IHubContext can
reach connected clients
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:19-35).
Both the adapter and the whole live path are best-effort by contract (ADR-039): every transport,
resolution, or broken-circuit failure is logged and swallowed, never thrown, so a publishing command
can never fail because Notification is slow or down
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/LiveChannelPublisherGrpcAdapter.cs:26,43-48)
([Rubric §29, Resilience & Business Continuity]). Serving both a WebSocket and an h2c gRPC ingress
from one host is the mixed-endpoint profile of ADR-012, and it is why the Notification host keeps its
default endpoint on Http1AndHttp2 (the WebSocket Upgrade needs HTTP/1.1) while the gRPC ingress
sits on a dedicated Http2-only named endpoint that peers resolve as _grpc.notification
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/KestrelConfiguration.cs:23-35). That gRPC
surface carries no [Authorize]: it is reachable only on the internal service network, never routed
by the Gateway (LiveChannelGrpcService.cs:13-17).
The module host, native-device registration, and the privacy export
On the ADC side the whole capability is packaged by NotificationModule
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15), an
IModule that declares a hard dependency on
Identity (RequiresDependencies => true, since it needs attendee data) and whose Register calls
AddNotificationModule to wire the application handlers, the EF configurations, the SignalR push
registration, the native-push channel, and the Common controllers
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:21-34). It
is a deliberately thin module (API + Application only, no Infrastructure project of its own). The
framework's own registration (DependencyInjection in
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:26) uses
TryAddScoped throughout so a consuming app can override any handler or the recipient provider, and
the API-layer DependencyInjection
(MMCA.Common/Source/Presentation/MMCA.Common.API/Notifications/DependencyInjection.cs:19-23) adds
the Common controllers as an MVC application part, because they ship in a NuGet assembly that
ASP.NET does not scan by default.
Native-device management is the third channel's control plane.
DevicesController (ADR-044) lets an authenticated user upsert
(PUT, after login and on token rotation) or delete (DELETE, before logout) a device
installation, described by DeviceInstallationRequest. The two verbs
scope ownership differently, and only one actually stamps it: UpsertAsync reads
currentUserService.UserId and passes it to registrar.UpsertAsync, so a PUT can only ever register
the caller's own installation
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:33-45).
DeleteAsync, by contrast, takes no ownership check at all: it calls
registrar.DeleteAsync(installationId, ...) with only the route-supplied id
(DevicesController.cs:50-56), so any authenticated caller who knows an installation id can remove
it. The only thing standing between that and cross-user deletion is that installation ids are
client-generated GUIDs and therefore not enumerable, not an ownership assertion. The class XML-doc
comment (DevicesController.cs:13-19) claims ownership "is stamped server-side from the
authenticated user" without qualification; that holds for PUT but overstates DELETE, so the code, not
the comment, is authoritative here ([Rubric §11, Security]). The registrar it delegates to lives in
Group 07.
Finally, the module carries the Notification half of the cross-service data-subject export
(PRIVACY.md §7), UserNotificationExportService, published across
modules as IUserNotificationExportService and reachable from
the Identity aggregator over gRPC via
UserNotificationExportGrpcService and its client-side
UserNotificationExportServiceGrpcAdapter, producing
UserNotificationExportItemDTO rows scoped strictly to the
requesting user through a where un.UserId == userId filter on an unpaged, newest-first join
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/UserNotificationExportService.cs:27-41).
When the module is disabled, NotificationModule registers
DisabledUserNotificationExportService so the cross-module
interface still resolves (NotificationModule.cs:34-35)
([Rubric §30, Compliance, Privacy & Data Governance]).
Where this group sits
Upstream, this group depends on the domain building blocks of
Group 02 (both aggregates derive from
AuditableAggregateRootEntity<TIdentifierType>),
the Result pattern of
Group 01, the CQRS pipeline and
IUnitOfWork of persistence
(Group 07), and the module system of
Group 14. Downstream, the Blazor UI of
Group 15 consumes it: the notification bell, inbox, and send
pages call these REST endpoints, and the UI's SignalR client listens on the hub's
ReceiveNotification / ReceiveChannelEvent methods (NotificationHub.cs:20-23). The ADC
Engagement live layer is the busiest producer of live-channel events, resolving attendees through the
Identity module. Read this chapter as the
answer to one question: how does a single "notify everyone" intent become a durable inbox row, a
real-time toast, an OS push, and (for the live layer) an ephemeral group event, without any of the
four ever taking the others down.
GetMyNotificationsQuery
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetInbox·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetInbox/GetMyNotificationsQuery.cs:7· Level 0 · record
- What it is: the read request that backs a user's in-app notification inbox: "give me page N of my notifications." A
sealed recordcarrying the caller'sUserIdplus paging arguments. - Depends on: the solution-wide
UserIdentifierTypealias (see primer §2); BCL only otherwise. Consumed byGetMyNotificationsHandlerthrough the query side of the CQRS pipeline (IQueryHandler). - Concept introduced: the paged-query record shape. This is the first notification query, so note the convention it shares with every read in the codebase: an immutable positional
recordwith defaulted paging (PageNumber = 1,PageSize = 20, lines 9-10) is the message, a matching handler is the behavior, and the two are joined by a closed generic registration rather than a direct call (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:58).[Rubric §6, CQRS & Event-Driven]assesses whether reads and writes are modeled as distinct, single-purpose messages, and this record is a pure read with no side effects.[Rubric §12, Performance & Scalability]assesses guarding against unbounded work: the XML doc pinsPageSizeat "max 500" (line 6) andGetMyNotificationsHandlerenforces that ceiling, so a client cannot request an unbounded page. - Walkthrough: three positional members:
UserId(the authenticated user, line 8),PageNumberdefaulting to 1 (line 9),PageSizedefaulting to 20 (line 10). No factory, no validation here: it is a plain carrier, and the page ceiling is applied downstream in the handler. - Why it's built this way: a positional record gives value equality and immutability for free, which is exactly what a query message wants (it is data in flight, never mutated). The default page size keeps the common "just show my inbox" call one-argument-simple.
- Where it's used: constructed by
InboxControllerfrom the resolved caller id and the query-string paging values (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:50), then handled byGetMyNotificationsHandler.
GetNotificationHistoryQuery
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.GetHistory·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/GetHistory/GetNotificationHistoryQuery.cs:6· Level 0 · record
- What it is: the read request for the push-notification sent history (the admin-facing "what did we broadcast" list), as opposed to a single user's inbox. A
sealed recordof paging arguments only. - Depends on: BCL only. Handled by
GetNotificationHistoryHandler. - Concept introduced: none new; this is the same paged-query shape
GetMyNotificationsQueryintroduced, minus a user filter. History is global (it listsPushNotificationrows, the sent artifacts), so there is noUserIdmember.[Rubric §6, CQRS & Event-Driven]: a second read model (sent history) distinct from the inbox read model, each with its own query and handler. - Walkthrough: two positional members,
PageNumber = 1(line 7) andPageSize = 10(line 8). Note the default page size is 10 here versus 20 for the inbox; both are capped at 500 by their handlers, and both XML docs state the ceiling (line 5). - Where it's used: constructed by
NotificationsController(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:64), handled byGetNotificationHistoryHandler.
GetUnreadNotificationCountQuery
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetUnreadCount·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetUnreadCount/GetUnreadNotificationCountQuery.cs:5· Level 0 · record
- What it is: the tiniest read in the group: "how many unread notifications does this user have?" A single-line
sealed recordwrappingUserId. Its XML doc (line 3) names the unread badge as the reason it exists. - Depends on: the
UserIdentifierTypealias; BCL only otherwise. Handled byGetUnreadNotificationCountHandler. - Concept introduced: none new; a purpose-built count query rather than fetching a page and counting client-side.
[Rubric §12, Performance & Scalability]: a dedicatedCOUNTquery avoids materializing rows just to size a badge, so the bell can poll cheaply. - Walkthrough: one positional member,
UserId(line 5). No paging: the answer is a single integer. - Where it's used: constructed by
InboxController's unread-count action (NotificationInboxController.cs:69) behind theNotificationBellbadge; handled byGetUnreadNotificationCountHandler.
IEmailSender
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IEmailSender.cs:6· Level 0 · interface
- What it is: the Application-layer port for sending email. Two
SendAsyncoverloads: one to an explicit recipient, one to a default/system recipient (admin notifications). Infrastructure supplies the concrete transport. - Depends on: BCL only (
Task,CancellationToken). Implemented bySmtpEmailSender(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SmtpEmailSender.cs:12). - Concept introduced: the port/adapter split for outbound side effects. Application code that needs to send mail depends on this interface, never on an SMTP client; the XML doc names SMTP or SendGrid as interchangeable backings (line 4).
[Rubric §3, Clean Architecture]assesses whether the core depends inward on abstractions rather than outward on I/O libraries, and this interface is a textbook outbound port: the dependency arrow points from Infrastructure'sSmtpEmailSenderinto Application, so the transport is swappable without touching a handler.[Rubric §1, SOLID](Dependency Inversion): the high-level policy owns the contract, the low-level detail implements it. - Walkthrough:
SendAsync(string to, string subject, string body, bool isHtml = false, CancellationToken cancellationToken = default)(line 15): explicit recipient, HTML flag defaulting to plain text.SendAsync(string subject, string body, bool isHtml = false, CancellationToken cancellationToken = default)(line 23): the same message routed to the implementation's configured default/system recipient (organizer or admin alerts), so callers that always mail the operators do not repeat the address. - Why it's built this way: keeping the interface in Application (and the SMTP dependency in Infrastructure) is what lets a test host register a no-op sender and production register
SmtpEmailSender. Registration isTryAddTransient<IEmailSender, SmtpEmailSender>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:194), so theTryAddlets a host pre-register its own sender and win. - Where it's used: registered by the Infrastructure DI extension so any consuming app can inject it. As of this reading there are no first-party injection sites in MMCA.Common or MMCA.ADC beyond that registration and the
SmtpEmailSenderimplementation itself: this is a capability the framework offers rather than one the ADC feature set currently calls. - Caveats / not-in-source: the "default/system recipient" of the second overload is not defined by this interface; it is whatever the implementation's SMTP settings configure.
ILiveChannelPublisher
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ILiveChannelPublisher.cs:9· Level 0 · interface
- What it is: the port for publishing ephemeral live events to a channel of currently-connected clients (for example
event:1orsession:123). Its defining property, stated in the XML doc (lines 4-6): channel events are not persisted, so a client that is not connected and subscribed at publish time never sees them. - Depends on: BCL only. Contrast with
IPushNotificationSender(which persists aPushNotificationplus per-userUserNotificationinbox rows), a contrast the XML doc draws explicitly via<see cref="IPushNotificationSender"/>(line 5). Implemented bySignalRLiveChannelPublisherand theNullLiveChannelPublisherno-op. - Concept introduced: ephemeral fan-out versus durable notification. This is the distinction that splits the whole group in two: live channel events (poll-results-changed, a new session question) are fire-and-forget to whoever is watching right now, while push notifications are durable and land in an inbox. The interface deliberately speaks in strings (a
channelKey, an application-definedeventName, apayloadJsonstring) so it stays transport-agnostic; the XML doc names SignalR groups or a message fan-out service as candidate backings (line 7).[Rubric §7, Microservices Readiness]assesses whether cross-boundary calls go through abstractions that can be re-homed onto a network transport: in MMCA.ADC this exact interface is served over gRPC by the Notification host'sLiveChannelGrpcService(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:19), so the boundary already survives extraction (ADR-007, ADR-008). - Walkthrough: one method,
PublishAsync(string channelKey, string eventName, string payloadJson, CancellationToken cancellationToken = default)(line 17): publish an event to every client currently subscribed tochannelKey. No return value beyond theTask, because there is no delivery guarantee to report. - Why it's built this way: a JSON-string payload plus a free-form event name keeps the framework out of the business of knowing each live event's schema; the presentation and UI layers agree on the contract. Non-delivery to absent clients is the intended semantics, not a gap. The default registration is the inert
NullLiveChannelPublisher(MMCA.Common.Infrastructure/DependencyInjection.cs:196), replaced bySignalRLiveChannelPublisherwhen a host opts into the SignalR wiring (line 269). - Where it's used: ADC's conference-day live layer injects it into the Engagement command handlers that change live state,
OpenLivePollHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:22),SubmitQuestionHandler(SubmitQuestionHandler.cs:27), plus the moderate and upvote handlers, and forwards them in FIFO order throughLiveChannelPublishProcessor. - Caveats / not-in-source: the interface itself makes no delivery or ordering guarantee; those are properties of the concrete SignalR and gRPC wiring, not visible here.
INotificationRecipientProvider
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/INotificationRecipientProvider.cs:8· Level 0 · interface
- What it is: a single-method port returning the set of user IDs that should receive a broadcast push notification. The framework knows how to send; the consuming app implements this to answer who (the XML doc's examples, lines 5-6: all attendees, users in a role, subscribers to a topic).
- Depends on: the
UserIdentifierTypealias; BCL only otherwise. Works alongsideIPushNotificationSender. The default framework registration isNullNotificationRecipientProvider; MMCA.ADC suppliesAttendeeNotificationRecipientProvider. - Concept introduced: separating recipient policy from delivery mechanism. "Who to notify" is app-specific domain knowledge; "how to notify" is framework infrastructure. Splitting them means the push pipeline never needs to understand ADC's attendee model.
[Rubric §7, Microservices Readiness]: an inversion point that lets the shared framework host an app-defined audience query.[Rubric §1, SOLID](Interface Segregation and Dependency Inversion): one focused method, and the sender depends on the abstraction rather than on a concrete audience source. - Walkthrough:
Task<IReadOnlyList<UserIdentifierType>> GetRecipientUserIdsAsync(CancellationToken cancellationToken = default)(lines 15-16): return the eligible recipient IDs. A read-only list, so callers cannot mutate the returned audience. - Why it's built this way: registration is
TryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:67), which is the mechanical expression of "framework default, app override": whichever provider the app registers first wins, and the null default only fills the gap. - Where it's used:
SendPushNotificationHandlertakes it as a constructor dependency (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:19), resolves the audience, then hands it toIPushNotificationSender. Until an app registers its own provider,NullNotificationRecipientProviderreturns an empty audience.
IPushNotificationSender
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPushNotificationSender.cs:7· Level 0 · interface
- What it is: the Application-layer port for real-time push delivery, with three targeting shapes: one user, a set of users, or a broadcast to everyone connected. The XML doc names SignalR or Firebase Cloud Messaging as interchangeable backings (lines 4-5).
- Depends on: the
UserIdentifierTypealias; BCLDictionaryfor the optional metadata. Implemented bySignalRPushNotificationSender(real delivery) andNullPushNotificationSender(the inert default). Paired withINotificationRecipientProviderfor audience resolution. - Concept introduced: the metadata dictionary as an open payload. All three methods share
title,body, and an optionalDictionary<string, string>? metadata(lines 16, 25, 33). That dictionary carries typed extras (a deep-link URL, a notification type, per the XML doc on line 13) without a bespoke strongly-typed payload per notification kind, so a new notification variety needs no interface change.[Rubric §10, Cross-Cutting Concerns]assesses whether a capability like push is factored once and reused broadly, and this single port serves every push-emitting feature. This is the durable counterpart toILiveChannelPublisher's ephemeral fan-out. - Walkthrough:
SendToUserAsync(UserIdentifierType userId, string title, string body, Dictionary<string,string>? metadata = null, CancellationToken cancellationToken = default)(line 16): one user.SendToUsersAsync(IEnumerable<UserIdentifierType> userIds, ...)(line 25): an explicit set.BroadcastAsync(string title, string body, ...)(line 33): all connected clients. The three methods differ only in targeting; body and metadata are identical. - Why it's built this way: three targeting methods rather than one "audience" parameter keeps each call site's intent explicit and lets the SignalR implementation map user-targeting to hub groups directly. The default registration is the no-op (
MMCA.Common.Infrastructure/DependencyInjection.cs:195) so a host with no real-time transport still resolves the port; the SignalR wiring replaces it withAddTransient(line 268), a deliberate override rather than aTryAdd. - Where it's used:
SendPushNotificationHandlerfans a message out through this port after persisting thePushNotificationrecord and its per-userUserNotificationrows.
KestrelConfiguration
MMCA.ADC.Notification.Service ·
MMCA.ADC.Notification.Service·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/KestrelConfiguration.cs:10· Level 0 · class
- What it is: the Notification host's Kestrel endpoint wiring, lifted out of
Program.csinto aninternal statichelper. It applies the mixed-protocol endpoint defaults the SignalR-plus-gRPC host needs and, when configured, adds a dedicated HTTP/1.1 health-probe listener. - Depends on: ASP.NET Core hosting types only (
WebApplicationBuilder,HttpProtocolsfromMicrosoft.AspNetCore.Server.Kestrel.Core, line 1) and the host'sIConfiguration. No first-party types. Three sibling classes of the same name exist in the Conference, Engagement, and Identity service hosts; this is the Notification one. - Concept introduced: per-endpoint protocol selection, not per-host. This is the ADR-012 story in one method. The Notification service hosts the SignalR hub (
NotificationHub), whose WebSocket transport needs the HTTP/1.1Upgradehandshake, and an inbound cleartext gRPC server (LiveChannelGrpcService) that needs h2c prior-knowledge HTTP/2. No single whole-host protocol profile satisfies both, so the protocols are split across two endpoints in one process:appsettings.jsondeclareshttpon port 8080 asHttp1AndHttp2andgrpcon port 8081 asHttp2(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.json:9-20), and this class supplies the defaults plus the probe listener around them.[Rubric §7, Microservices Readiness]assesses whether a service's transport story survives being split out: here it does, at the cost of an explicit per-endpoint protocol map.[Rubric §13, Observability & Operability]: the separate probe listener exists so platform health probes never depend on the protocol choices the application traffic needs.[Rubric §17, DevOps]: the probe port is injected by infrastructure (HealthProbe__Port) rather than hardcoded, so local runs and Azure Container Apps share one code path. - Walkthrough: one public method,
ConfigureMixedEndpointsWithHealthProbe(WebApplicationBuilder builder)(line 23). It null-guards the builder (line 25), then callsbuilder.WebHost.ConfigureKestrel(line 27). Inside:ConfigureEndpointDefaultssetsHttpProtocols.Http1AndHttp2(line 29), which is the default applied to endpoints that do not state their own protocol (the config-declaredhttpandgrpcendpoints keep their explicit values). Thenbuilder.Configuration.GetValue<int?>("HealthProbe:Port")is pattern-matched withis int probePort(line 31), and only when the value is present does itListenAnyIP(probePort, o => o.Protocols = HttpProtocols.Http1)(line 33). The XML doc records that the value arrives asHealthProbe__Port=8082 frominfra/main.bicepand is absent locally (lines 15-16), so locally the third listener simply does not exist. - Why it's built this way: two reasons are stated in source. First, config-declared Kestrel endpoints and explicit
Listencalls coexist, so adding the probe listener is strictly additive rather than a replacement (XML doc, lines 17-18). Second, the class exists at all soProgram.csstays inside the S1541 cyclomatic-complexity budget the analyzers enforce (lines 7-8): a small structural concession to analyzers-as-errors. See ADR-012 for the transport convention this implements. - Where it's used: called once from the Notification host's startup (
MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:70).
UserNotificationExportItemDTO
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.UserNotifications·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/UserNotificationExportItemDTO.cs:7· Level 0 · record
- What it is: one notification-inbox row inside a user's personal-data export (the data-subject access artifact the XML doc ties to PRIVACY.md §7, line 4): the notification id, title, and sent/read timestamps. This is an MMCA.ADC-specific contract, not a framework type.
- Depends on: the ADC
UserNotificationIdentifierTypealias; BCLstringandDateTime. Returned byIUserNotificationExportService; consumed by the Identity module'sExportUserDataHandlerand folded intoUserDataExportNotificationDTO. - Concept introduced: export DTOs deliberately omit content. The XML doc (line 5) records that the notification body is left out of the summary by design; the export carries the metadata a data subject is owed (that they were notified, when, whether they read it) without duplicating message bodies.
[Rubric §30, Compliance/Privacy/Data Governance]assesses whether the codebase has concrete data-subject access and portability paths, and this DTO is the Notification module's contribution to that per-user export. - Walkthrough: a
sealed record classwithrequired NotificationId(line 10),required Title(line 13),required SentOn(line 16, UTC), plusIsRead(line 19) and nullableReadOn(line 22, null when unread). Therequiredmembers force every export row to be fully populated at construction; the two optional members model the "never read" case without a sentinel date. - Why it's built this way: a flat immutable record is the right shape for a serialized export line: value semantics, no behavior, self-describing timestamps in UTC. Being
init-only means an export row cannot be edited after assembly. - Where it's used: assembled by the in-process
UserNotificationExportServiceand returned across the export boundary to Identity; the disabled-module path substitutesDisabledUserNotificationExportServiceand the out-of-process pathUserNotificationExportServiceGrpcAdapter.
IUserNotificationExportService
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.UserNotifications·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/IUserNotificationExportService.cs:11· Level 1 · interface
- What it is: the cross-module service contract for exporting the personal data the Notification module holds for one user: their inbox rows (ids, titles, sent and read dates). It is how the Identity module reaches into Notification-owned data to build a complete cross-service export.
- Depends on:
UserNotificationExportItemDTO(its return element) and the ADCUserIdentifierTypealias. Implemented in-process byUserNotificationExportServiceinside the Notification module and, per the XML doc (lines 8-9), by a gRPC adapter inMMCA.ADC.Notification.Contractseverywhere else; the disabled-module stub isDisabledUserNotificationExportService. - Concept introduced: the "one interface, in-process or gRPC" extraction pattern. The XML doc calls out that this mirrors Engagement's
IUserEngagementExportService: a single interface the caller depends on, satisfied by an in-process implementation when the module is co-hosted (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:28) and by a gRPC adapter thatReplaces that registration when it is not (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:84).[Rubric §7, Microservices Readiness]assesses exactly this: whether a module boundary is expressed as an interface that can be re-homed onto a network transport without changing the caller (ADR-007 for gRPC extraction, ADR-008 for topology).[Rubric §30, Compliance/Privacy]: it is one leg of the data-subject-access aggregation.[Rubric §9, API & Contract Design]: the contract is a plain async method over DTOs, so the same shape serves both the in-process and the wire binding. - Walkthrough: one method,
GetUserNotificationExportAsync(UserIdentifierType userId, CancellationToken cancellationToken)(line 20), returningIReadOnlyList<UserNotificationExportItemDTO>newest-first. Note the token has no default value here, so every caller must pass one explicitly. The XML doc (lines 14-15) states the implementation joins the frameworkUserNotificationrows with theirPushNotificationcontent, which is the same join the inbox read performs. - Where it's used: the Identity module's
ExportUserDataHandlertakes it as a constructor dependency (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:29) and merges the result into the full export, unaware of which of the three implementations it received.
NullNotificationRecipientProvider
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/NullNotificationRecipientProvider.cs:8· Level 1 · class
- What it is: the framework's default
INotificationRecipientProvider: a Null Object that resolves an empty recipient list. - Depends on:
INotificationRecipientProvider(the interface it fulfills); BCL only. - Concept introduced: the Null Object pattern.
[Rubric §2, Design Patterns]assesses idiomatic pattern use; here a benign default lets the push pipeline resolve and run in a host that has not (yet) declared an audience, so there is no null check inSendPushNotificationHandlerand no "provider not registered" failure at startup. MMCA.Common ships this as the default; a consuming app overrides it (in MMCA.ADC,AttendeeNotificationRecipientProvider). - Walkthrough: a
sealed classwith one method.GetRecipientUserIdsAsync(lines 11-13) returnsTask.FromResult<IReadOnlyList<UserIdentifierType>>([]), an empty collection expression: no I/O, no allocation of a populated list, and the explicit type argument keeps the returned task typed as the interface's read-only list. - Why it's built this way: the XML doc (lines 5-6) states the expectation directly, that consuming apps should register their own provider. Pairing that with the
TryAddScopeddefault registration means "safe by default, overridable without ceremony." - Where it's used: registered by the notification DI extension (
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:67); a broadcast that resolves recipients through this default simply reaches nobody until an app-specific provider replaces it.
DisabledUserNotificationExportService
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.UserNotifications·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/DisabledUserNotificationExportService.cs:7· Level 2 · class
- What it is: the stub
IUserNotificationExportServiceregistered when the Notification module is disabled in a given host. It returns an empty inbox export. - Depends on:
IUserNotificationExportServiceandUserNotificationExportItemDTO; BCL only. - Concept introduced: none new; it is the same Null Object idea as
NullNotificationRecipientProvider, applied to the module-disabled path.[Rubric §7, Microservices Readiness]: the module system keeps cross-module interfaces resolvable even when a module is switched off, becauseIModulehas a dedicatedRegisterDisabledStubshook thatNotificationModuleimplements with exactly this registration (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:35). Identity's export handler still binds its dependency and simply gets no notification rows rather than a resolution failure. - Walkthrough:
GetUserNotificationExportAsync(lines 10-11) returnsTask.FromResult<IReadOnlyList<UserNotificationExportItemDTO>>([]), ignoring both theuserIdand the token. Registered as a singleton, which is safe precisely because it holds no state. - Why it's built this way: a disabled module must not turn an unrelated feature (the data-subject export) into a 500. Returning an empty section is the honest answer: the module holds no data in this host.
- Where it's used: the disabled-stub path of
NotificationModule, so a host running without the Notification module still produces a valid (empty) notification section in a user data export.
GetMyNotificationsHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetInbox·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetInbox/GetMyNotificationsHandler.cs:15· Level 8 · class
- What it is: the query handler that materializes a user's inbox page. It joins the per-user
UserNotificationrows with their sharedPushNotificationcontent and projects the pair into a single flatUserNotificationDTO. - Depends on:
IUnitOfWork(typed repositories) andIQueryableExecutor(EF terminal operations kept out of Application), injected via primary constructor (lines 15-17). ImplementsIQueryHandler<GetMyNotificationsQuery, Result<PagedCollectionResult<UserNotificationDTO>>>. ReturnsResult,PagedCollectionResult<T>, andPaginationMetadata. - Concept introduced: the two-table inbox join and why the model is split. A push notification is stored once (the
PushNotification: title, body, created time) and fanned out into one lightweightUserNotificationper recipient (which carries only per-user state:IsRead,ReadOn). The read side rejoins them.[Rubric §8, Data Architecture]assesses normalization and read/write model fit: the shared content is not duplicated per recipient, and the handler pays a join at read time to reassemble the inbox view.[Rubric §3, Clean Architecture]: the handler expresses the join as a LINQIQueryablebut never calls EF'sToListAsyncorCountAsyncdirectly, delegating those toIQueryableExecutorso Application stays EF-free. - Walkthrough: clamp the page size to a 500 ceiling with
Math.Min(query.PageSize, 500)(line 24); grab the two repositories from the unit of work, each typed by entity and identifier alias (lines 25-26); build the LINQ query-syntax join ofUserNotificationtoPushNotificationonun.PushNotificationId equals pn.Id, filtered toquery.UserId, ordered bypn.CreatedOndescending, projected intoUserNotificationDTO(lines 28-41, mapping id, push id, title, body,IsRead,ReadOn, andSentOn = pn.CreatedOn); count the joined set (line 43); page it withSkip((query.PageNumber - 1) * pageSize).Take(pageSize)and materialize (lines 45-47); wrap total, page size, and page number intoPaginationMetadataand return a successfulPagedCollectionResult<T>(lines 49-50). - Why it's built this way: both repositories are read through
TableNoTracking, which is correct for a read: no change-tracking overhead since nothing is saved. Server-side projection into the DTO means only the needed columns cross the wire, and the count runs against the same joined expression so the total is consistent with the page. Note the count and the page are two round trips against one composedIQueryable, the standard cost of a paged read. - Where it's used: injected as a closed
IQueryHandlerintoInboxController(NotificationInboxController.cs:30), which is how it reaches the CQRS decorator pipeline described in Group 05.
GetNotificationHistoryHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.GetHistory·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/GetHistory/GetNotificationHistoryHandler.cs:14· Level 8 · class
- What it is: the query handler for the push sent history: a reverse-chronological page of
PushNotificationrows (no per-user join), mapped toPushNotificationDTO. - Depends on:
IUnitOfWork,IQueryableExecutor, andPushNotificationDTOMapper(primary constructor, lines 14-17). ImplementsIQueryHandler<GetNotificationHistoryQuery, Result<PagedCollectionResult<PushNotificationDTO>>>. - Concept introduced: none new; note the contrast with the inbox handler. History reads a single table (the sent artifacts) with no
UserIdfilter, and maps entities to DTOs with an explicitPushNotificationDTOMapperrather than an inline LINQ projection, which means it materializes whole entities before mapping.[Rubric §6, CQRS & Event-Driven]: a separate read model and handler for the admin history view, sharing nothing with the inbox read but the underlying table. - Walkthrough: clamp page size to 500 (line 24); get the
PushNotificationrepository (line 25); ask the repository itself for the total viarepository.CountAsync(line 27, note this one goes through the repository, not the queryable executor, because there is no composed predicate to count); pageTableNoTrackingordered byCreatedOndescending withSkip/Takeand materialize the entities through the executor (lines 29-34); run them throughdtoMapper.MapToDTOs(line 36); buildPaginationMetadata(line 37) and return a successfulPagedCollectionResult<T>(line 39). - Why it's built this way: history has no per-user state, so there is nothing to join; a dedicated mapper (versus an inline projection) is used because
PushNotificationDTOis a richer, reused contract mapped in several places, and centralizing that mapping keeps the shape consistent (see ADR-001 on manual/Mapperly mapping). - Where it's used: injected into
NotificationsController's history endpoint (NotificationsController.cs:30).
GetUnreadNotificationCountHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetUnreadCount·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetUnreadCount/GetUnreadNotificationCountHandler.cs:12· Level 8 · class
- What it is: the query handler behind the unread badge: it counts a user's unread
UserNotificationrows and returns the integer. - Depends on:
IUnitOfWorkandIQueryableExecutor(primary constructor, lines 12-14). ImplementsIQueryHandler<GetUnreadNotificationCountQuery, Result<int>>. - Concept introduced: none new; the smallest possible read, and a good illustration of why
IQueryableExecutorexists: the handler composes aWherein Application and hands the still-unexecutedIQueryableto Infrastructure to run.[Rubric §12, Performance & Scalability]: it issues a server-sideCOUNToverun.UserId == query.UserId && !un.IsReadrather than fetching rows, so the bell can poll without materializing the inbox. - Walkthrough: get the
UserNotificationrepository from the unit of work (line 21);queryableExecutor.CountAsyncoverTableNoTrackingfiltered to the user's unread rows (lines 23-25); returnResult.Success(count)(line 27). No paging, no mapping, no failure branch: the count either comes back or the call throws through the pipeline. - Where it's used: injected into
InboxController(NotificationInboxController.cs:31), which serves theNotificationBellbadge.
DeviceInstallationRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications.PushNotifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/DeviceInstallationRequest.cs:12· Level 0 · record (sealed)
- What it is: the client request a native app (MAUI) sends to register or refresh this device
for platform push delivery. It carries three strings: a client-generated stable
InstallationId, aPlatformdiscriminator, and thePushChannelplatform handle (the FCM registration token or APNs device token). - Depends on: nothing first-party. It uses
System.ComponentModel.DataAnnotations([Required],[MaxLength]) from the BCL to bound the wire shape. - Concept introduced, the annotated request record. This is the first push-side inbound contract,
and it shows the framework's convention for a client-supplied DTO: a
sealed recordwithrequired initmembers plus DataAnnotations that ASP.NET model-binding validates before a handler ever runs. Twoconst stringplatform values are published on the type itself,FcmV1Platform = "fcmv1"(DeviceInstallationRequest.cs:15) andApnsPlatform = "apns"(DeviceInstallationRequest.cs:18), so the accepted platform tokens have one source of truth rather than being sprinkled as magic strings.[Rubric §9, API & Contract Design]assesses whether request contracts are explicit, bounded, and self-describing: the[MaxLength(128/16/1024)]caps (DeviceInstallationRequest.cs:22,27,32) pin the column and payload sizes right on the contract.[Rubric §11, Security]is visible in the doc comment's design rule (DeviceInstallationRequest.cs:5-11): ownership is stamped server-side from the authenticated user and is deliberately not a field on this request, so a client cannot register a device against someone else's account. - Walkthrough: two platform constants (lines 15, 18), then three
required stringinit members,InstallationId(line 23),Platform(line 28), andPushChannel(line 33), each with[Required]and a[MaxLength]cap. TheInstallationIdis client-stable by design so that re-registering after a token rotation updates the same installation rather than creating a duplicate. - Why it's built this way: the doc comment attributes the shape to ADR-044 (native push registration). A stable client id plus a rotating platform channel is the standard installation model both FCM v1 and APNs expect, and keeping ownership server-stamped keeps the trust boundary at the authenticated request.
- Where it's used: the device-registration endpoint on the push pipeline in
MMCA.Common.Infrastructure(the SignalR/native push senders, see the group overview).
MarkAllNotificationsReadCommand
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkAllRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkAllRead/MarkAllNotificationsReadCommand.cs:5· Level 0 · record (sealed)
- What it is: the CQRS command to mark every unread inbox notification as read for one user. A
single-parameter positional record carrying only the
UserIdthat scopes the bulk update (MarkAllNotificationsReadCommand.cs:5). - Depends on: nothing first-party. It uses the
UserIdentifierTypealias (a solution-wideglobal using ... = int;linked viaDirectory.Build.props), so there is no first-party type edge. - Concept: the "mark all read" bulk sibling of
MarkNotificationReadCommand. Where the single-item command carries
both a
NotificationIdand aUserId, this one carries only the owner: the target set is "all of the user's unread rows," so the row identity is implicit in the scope rather than passed explicitly.[Rubric §6, CQRS & Event-Driven]assesses whether writes flow through explicit command messages; this is the minimal shape of a scope-only command. - Walkthrough: one positional parameter,
UserId(MarkAllNotificationsReadCommand.cs:5). No marker interfaces are declared on the type, so it rides the default command pipeline unadorned. - Where it's used: handled by MarkAllNotificationsReadHandler;
the authenticated
UserIdis supplied by the controller from the token claim, never by the client body.
MarkNotificationReadCommand
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkRead/MarkNotificationReadCommand.cs:6· Level 0 · record (sealed)
- What it is: the CQRS command to mark a single inbox notification as read for the current user. A
two-parameter positional record: the
NotificationIdto mark and theUserIdthat must own it (MarkNotificationReadCommand.cs:6-8). - Depends on: nothing first-party. It uses the
UserNotificationIdentifierTypeandUserIdentifierTypealiases (MarkNotificationReadCommand.cs:7-8), which are solution-wideglobal using ... = int;aliases linked viaDirectory.Build.props, so there is no first-party type edge. - Concept, a command carrying its own authorization key. Unlike a request DTO, a command is the
input to a handler in the CQRS pipeline (see
primer §2). The load-bearing
detail is that
UserIdis part of the command, not looked up loosely later: the handler filters on it to enforce ownership.[Rubric §6, CQRS & Event-Driven]assesses whether writes flow through explicit command messages; this is the minimal shape of one. - Walkthrough: two positional parameters (
MarkNotificationReadCommand.cs:6-8). No decorators are declared on the type (noITransactional/ICacheInvalidatingmarker interfaces), so it rides the default command pipeline unadorned. - Where it's used: handled by MarkNotificationReadHandler; the
authenticated
UserIdis supplied by the controller from the token claim, never by the client body.
NotificationFeatures
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationFeatures.cs:6· Level 0 · class (static)
- What it is: the feature-flag key constants for the Notification module. Today it holds exactly
one:
PushNotifications = "Notification.PushNotifications"(NotificationFeatures.cs:9). - Depends on: nothing first-party.
- Concept, feature flags as named constants.
[Rubric §10, Cross-Cutting Concerns]assesses whether cross-cutting config lives in one place rather than copy-pasted string literals. Defining the flag key once as aconst stringkeeps every gate that references it typo-free; the value is resolved at runtime by the feature-management layer that theFeatureGatecommand/query decorators consult (the outermost decorator in the CQRS pipeline, see primer §2). - Walkthrough: one
public const stringon astaticclass (NotificationFeatures.cs:6-10). - Where it's used: wherever push behavior is conditionally enabled, both the Notification handlers gated by the feature and the UI that hides push affordances when the flag is off.
PushNotificationStatus
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.PushNotifications·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotificationStatus.cs:6· Level 0 · enum
- What it is: the delivery lifecycle status of a push notification:
Pending,Sent,Failed. - Depends on: nothing first-party.
- Concept: a domain lifecycle enum owned by the PushNotification aggregate.
Its members are unnumbered (
PushNotificationStatus.cs:9-15), so the ordinal is implicit (Pending= 0); the value is persisted and read within a single store, so a pinned numeric contract is not required here.[Rubric §4, DDD]assesses whether state is modeled explicitly rather than as loose booleans; a three-state enum captures the send outcome precisely. - Walkthrough: three members with the obvious transition,
Pendingmoves to eitherSentorFailed, driven by the aggregate'sMarkAsSent/MarkAsFailedmethods. - Where it's used: the private-set
Statusproperty on PushNotification; surfaced (as its string name) on PushNotificationDTO.
SendPushNotificationRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications.PushNotifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/SendPushNotificationRequest.cs:6· Level 0 · record (sealed)
- What it is: the broadcast request to push a notification to every recipient. A two-parameter
positional record:
sealed record SendPushNotificationRequest(string Title, string Body)(SendPushNotificationRequest.cs:6). - Depends on: nothing first-party.
- Concept: a
sealed recordmessage DTO. A class-based record is the natural choice for a body that is not on a perf-sensitive hot path and reads as a message (contrast thereadonly record structpayloads used on the auth hot path).[Rubric §9, API & Contract Design]: the minimal explicit contract for the send endpoint. - Walkthrough: two positional parameters (line 6); no validation attributes here, the content
invariants (title/body length) are enforced downstream by the
PushNotification aggregate's
Createfactory. - Where it's used: the send endpoint on the push pipeline; the handler resolves recipients via INotificationRecipientProvider, builds a PushNotification, and fans out to the SignalR sender in Infrastructure.
UserNotificationDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications.UserNotifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/UserNotifications/UserNotificationDTO.cs:7· Level 0 · record (sealed)
- What it is: the read DTO for one item in a user's notification inbox. It merges the user's
read-tracking (
IsRead,ReadOn) with the underlying push content (Title,Body,SentOn) into the single shape the inbox UI renders. - Depends on: nothing first-party. It uses the
UserNotificationIdentifierTypeandPushNotificationIdentifierTypealiases (UserNotificationDTO.cs:10,13), which areglobal using ... = int;aliases, so there is no first-party edge. - Concept, strongly-typed identifier aliases in a DTO.
[Rubric §4, DDD]assesses avoiding primitive obsession: theIdis typedUserNotificationIdentifierTypeand the foreign keyPushNotificationIdis typedPushNotificationIdentifierType, so the names carry intent even though both currently resolve toint. Change the alias in one file and every usage updates (see primer §2). - Walkthrough: a
sealed record classwithrequired initvalues that must always be present,Id,PushNotificationId,Title,Body,IsRead(UserNotificationDTO.cs:10-22), plus plaininitmembers for the nullableReadOn?(line 25) and the always-presentSentOn(line 28).required+initgives set-once, non-null-where-it-matters immutability without a hand-written constructor. - Why it's built this way: the DTO flattens two persistence concepts (the per-user read row and
the shared push content) into the one row the inbox needs
[Rubric §9], mirroring the two-table join the read handler performs. - Where it's used: returned by the inbox query GetMyNotificationsHandler;
note the ADC export path uses a separate
UserNotificationExportItemDTOshape instead.
AttendeeNotificationRecipientProvider
MMCA.ADC.Notification.Application ·
MMCA.ADC.Notification.Application·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/AttendeeNotificationRecipientProvider.cs:10· Level 1 · class (sealed)
- What it is: the ADC-specific implementation of the framework's INotificationRecipientProvider boundary. It answers the question "who are the recipients of a broadcast push?" with "every attendee," by delegating to the Identity module's attendee query.
- Depends on: INotificationRecipientProvider (implements it) and IAttendeeQueryService (constructor-injected).
- Concept introduced, the app-supplied recipient strategy. The framework defines what a recipient
provider must return but deliberately does not decide who recipients are, that is an application
policy each host plugs in. This is the
[Rubric §1, SOLID]dependency-inversion story in miniature:MMCA.Commonowns the INotificationRecipientProvider abstraction, and ADC provides the concrete "all attendees" rule.[Rubric §3, Clean Architecture]is why the class lives in ADC's module rather than in Common: recipient policy is business-specific and must not leak into the reusable framework. - Walkthrough: a primary-constructor class taking
IAttendeeQueryService(AttendeeNotificationRecipientProvider.cs:10-11). Its single methodGetRecipientUserIdsAsync(lines 14-16) is a one-lineawaitforwarding toattendeeQueryService.GetAttendeeUserIdsAsync, an expression-bodied delegation with no added logic. - Why it's built this way: keeping the provider a thin bridge means the "who is a recipient" decision has exactly one place to change, and the Identity module stays the owner of the attendee roster.
- Where it's used: registered in the Notification module's
DependencyInjection as the
INotificationRecipientProviderimplementation; consumed by the send handler when it fans a broadcast out to per-user rows.
PushNotificationDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications.PushNotifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/PushNotificationDTO.cs:8· Level 1 · record class
- What it is: the read DTO for a persisted PushNotification: id, title, body, the sender, the recipient count, a delivery-status string, and the creation timestamp.
- Depends on: IBaseDTO<TIdentifierType>
(implements
IBaseDTO<PushNotificationIdentifierType>,PushNotificationDTO.cs:8). - Concept: the standard
IBaseDTOread-model shape (see IBaseDTO<TIdentifierType>). Note thatStatusis astring(PushNotificationDTO.cs:26) even though the domain uses the PushNotificationStatus enum: the DTO carries the serialized form so the API surface can evolve independently of the domain enum and stays readable in responses without a separate enum-to-string step.[Rubric §9, API & Contract Design]. - Walkthrough:
required initmembers for the id, title, body,SentByUserId,RecipientCount, andStatus(PushNotificationDTO.cs:11-26), plus a plaininitCreatedOn(line 29). - Where it's used: returned by the notification-history query and rendered on the organizer's push-notification admin view.
PushNotification
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.PushNotifications·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:14· Level 5 · class (sealed)
- What it is: the framework-level aggregate root for a push-notification broadcast. It records the title, body, sender, recipient count, and delivery status, and it raises a domain event on creation so the send/fan-out machinery can react.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>
(base class),
PushNotificationInvariants(validation), PushNotificationStatus (status), thePushNotificationCreateddomain event, and Result (factory return). It carries the IdValueGeneratedAttribute (PushNotification.cs:13). - Concept: the aggregate-root factory idiom applied to a framework-owned entity (the canonical
entity-chain teaching is Group 02). Two design choices are worth
naming. First,
[IdValueGenerated](PushNotification.cs:13) tells the persistence layer the database generates the id, soCreatesetsId = default(line 72) and lets SQL Server fill theIDENTITY. Second, onlyCreateraises a domain event, the state mutators do not, which is the[Rubric §6, CQRS & Event-Driven]and[Rubric §4, DDD]distinction between a business-observable fact (a notification was created) and an internal delivery bookkeeping flip. - Walkthrough: five
private setproperties,Title,Body,SentByUserId,RecipientCount,Status(PushNotification.cs:17-29). A private parameterless constructor (lines 32-36) seeds non-null strings for EF materialization; a private all-args constructor (lines 38-45) setsStatus = Pending(line 44). The staticCreatefactory (lines 56-78) combines the title and body invariants viaResult.Combine(lines 62-64), returns a Result.Failure on any broken invariant (lines 65-68), constructs the entity withId = default(lines 70-73), and raisesPushNotificationCreated(line 75) before returning success.MarkAsSentandMarkAsFailed(lines 83, 88) are one-line expression-bodiedvoidtransitions with no event, they record the delivery outcome for audit. - Why it's built this way: living in
MMCA.Common.Domain, this aggregate is reused by both ADC and Store without coupling to any app's module. ThePendingtoSent/Failedbookkeeping is intentionally event-free because it is an infrastructure callback, not a business decision. - Where it's used: created by the send handler (one per broadcast), which then fans out a UserNotification per recipient; read back through PushNotificationDTO.
MarkAllNotificationsReadHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkAllRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkAllRead/MarkAllNotificationsReadHandler.cs:11· Level 8 · class (sealed)
- What it is: the command handler that marks all of one user's unread inbox notifications as read in a single pass.
- Depends on: IUnitOfWork (repository + save),
IQueryableExecutor (async materialization),
TimeProvider(BCL, testable clock), MarkAllNotificationsReadCommand (input), and Result (outcome). - Concept, the owner-scoped bulk update. This is the fan-in counterpart to the single-item
MarkNotificationReadHandler.
[Rubric §11, Security]assesses whether authorization is enforced where the data is touched: the read filters onun.UserId == command.UserId(MarkAllNotificationsReadHandler.cs:24), so only the requesting user's rows are ever loaded and mutated.[Rubric §14, Testability]shows in the injectedTimeProvider: the read timestamp comes fromtimeProvider.GetUtcNow().UtcDateTime(line 27), notDateTime.UtcNow, so a test can pin the clock. - Walkthrough:
HandleAsync(lines 17-39) gets theUserNotificationrepository from the unit of work (line 21), materializes every unread row for the user with aWhere(UserId == && !IsRead)query through the queryable executor (lines 23-25), captures one sharedreadOnUtc(line 27), then loops and calls the aggregate's idempotentMarkAsRead(readOnUtc)on each (lines 28-31). It saves through the unit of work only when at least one row changed (unread.Count > 0, lines 33-36) and always returnsResult.Success()(line 38). There is noNotFoundpath: an empty inbox is a successful no-op. Every await usesConfigureAwait(false). - Why it's built this way: filtering to
!IsReadup front keeps the write set minimal, and gating the save onCount > 0avoids opening a transaction (and stamping audit fields) when nothing changed. Keeping idempotency inside the aggregate'sMarkAsReadmeans a re-run is harmless. - Where it's used: dispatched by the inbox controller's mark-all-read action, with the authenticated user id supplied server-side.
MarkNotificationReadHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkRead/MarkNotificationReadHandler.cs:12· Level 8 · class (sealed)
- What it is: the command handler that marks one inbox notification as read, but only if it belongs to the requesting user.
- Depends on: IUnitOfWork (repository + save),
IQueryableExecutor (async materialization),
TimeProvider(BCL, testable clock), MarkNotificationReadCommand (input), and Result / Error (outcome). - Concept, ownership enforced in the query, not after it.
[Rubric §11, Security]assesses whether authorization is enforced where the data is touched. This handler never loads a row by id and then checks the owner in memory; it filters on bothIdandUserIdin the same predicate (MarkNotificationReadHandler.cs:26), so a mismatched owner simply returns zero rows and the handler answersNotFound(lines 30-36) rather than leaking that the notification exists for someone else.[Rubric §14, Testability]shows in the injectedTimeProvider: the read timestamp comes fromtimeProvider.GetUtcNow().UtcDateTime(line 38), notDateTime.UtcNow, so a test can pin the clock. - Walkthrough:
HandleAsync(lines 18-42) gets theUserNotificationrepository from the unit of work (line 22), builds aWhere(Id && UserId).Take(1)query and materializes it through the queryable executor (lines 24-28), returns aNotFoundfailure ("UserNotification.NotFound") when there is no match (lines 30-36), otherwise calls the aggregate's idempotentMarkAsRead(...)with the injected UTC time (line 38), saves through the unit of work (line 39), and returns success (line 41). Every await usesConfigureAwait(false). - Why it's built this way: the
Take(1)+ owner-scoped filter is the cheapest safe read; the aggregate keeps the idempotency (a repeated mark is a no-op), so the handler stays a thin orchestrator. - Where it's used: dispatched by the inbox controller's mark-read action, with the authenticated user id supplied server-side.
UserNotificationExportService
MMCA.ADC.Notification.Application ·
MMCA.ADC.Notification.Application·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/UserNotificationExportService.cs:15· Level 8 · class (sealed)
- What it is: the Notification half of ADC's cross-service data-subject export (PRIVACY.md §7). It returns every notification row for one user, joined to its push content, unpaged and newest-first, so Identity's export aggregator can include a person's inbox in their downloadable data.
- Depends on:
IUserNotificationExportService(implements it, the interface lives inMMCA.ADC.Notification.Shared), IUnitOfWork, IQueryableExecutor, the framework UserNotification and PushNotification entities, and theUserNotificationExportItemDTOprojection. - Concept, the privacy/GDPR export read.
[Rubric §30, Compliance/Privacy/Data Governance]assesses whether a person can obtain the data held about them; this is one section of that document. The service performs the same join as the inbox query but strips paging and always constrains to the requested user's rows (UserNotificationExportService.cs:29), so it can never return another subject's data.[Rubric §12, Performance & Scalability]shows in the twoTableNoTrackingreads (lines 27-28): the export is read-only, so change tracking is disabled. - Walkthrough:
GetUserNotificationExportAsync(lines 20-42) gets both repositories from the unit of work (lines 24-25), builds a LINQ join ofUserNotificationtoPushNotificationonPushNotificationId(lines 27-28), filterswhere un.UserId == userIdand orders bypn.CreatedOn descending(lines 29-30), projects intoUserNotificationExportItemDTO(lines 31-38), materializes through the queryable executor (line 40), and returns a fresh list via a collection expression (line 41). - Why it's built this way: reusing the inbox join keeps the export consistent with what the user sees in-app, while the no-tracking, unpaged read matches a one-shot export rather than an interactive page.
- Where it's used: registered by the Notification module's
DependencyInjection as the in-process
IUserNotificationExportService; consumed by Identity's export aggregation over the cross-module interface (in-process when co-hosted, cross-process via the Notification service's gRPC ingress when extracted).
DependencyInjection
MMCA.ADC.Notification.Application ·
MMCA.ADC.Notification.Application·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:12· Level 9 · class (static)
- What it is: the Notification module's application-layer DI composition. Its single extension
method
AddModuleNotificationApplicationwires ADC's recipient policy and export service, then pulls in the framework's shared notification handlers. - Depends on: ApplicationSettings
(parameter), INotificationRecipientProvider +
AttendeeNotificationRecipientProvider,
IUserNotificationExportService+ UserNotificationExportService, and the framework'sAddNotificationApplicationServices()registration. - Concept, the
extension(IServiceCollection)registration block. DI wiring here uses the C# preview extension-member syntax (DependencyInjection.cs:14), which lets the framework add methods directly toIServiceCollection(taught in the primer).[Rubric §3, Clean Architecture]is visible in the split of responsibility: the module registers its app-specific choices (attendees as recipients, the export service) and then calls the framework'sAddNotificationApplicationServices()(line 31) for the reusable handlers, mapper, validator, and entity query service, so shared and app-specific wiring stay separate. - Walkthrough:
AddModuleNotificationApplication(lines 19-34) discards the unusedapplicationSettings(line 21, reserved for future use), registers AttendeeNotificationRecipientProvider as the scopedINotificationRecipientProvider(line 24), registers UserNotificationExportService as the scopedIUserNotificationExportServicefor the privacy export (line 28), calls the framework'sAddNotificationApplicationServices()(line 31), and returns the collection for chaining (line 33). - Where it's used: called from the Notification service host's composition root during module registration.
LiveChannelGrpcService
MMCA.ADC.Notification.Service ·
MMCA.ADC.Notification.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:19· Level 1 · class (sealed)
- What it is: the gRPC server endpoint that other services call to fan an ephemeral "live"
event out to connected clients. It implements the generated
LiveChannelPushService.LiveChannelPushServiceBaseand delegates each call to the framework's ILiveChannelPublisher. - Depends on: ILiveChannelPublisher (injected via the primary
constructor, line 19; in this host it resolves to SignalRLiveChannelPublisher,
registered by
AddPushNotificationsbecause Notification is the host that maps the SignalR NotificationHub), the generatedLiveChannelPushServicebase (compiled from the.Contracts.proto, line 20), andGrpc.Core.ServerCallContext. - Concept introduced, the live-channel ingress (ADR-039).
[Rubric §6, CQRS & Event-Driven]assesses whether state changes travel as events;[Rubric §7, Microservices Readiness]assesses whether cross-process collaboration rides typed transports. The conference-day live layer (LivePolls, SessionQuestions) lives in the Engagement service, but only the Notification host owns the SignalRIHubContextthat can reach browsers. So Engagement calls THIS gRPC endpoint post-commit to hand off an ephemeral event, which the service passes to the local publisher that fans it out over NotificationHub. This is the server half; its client half is LiveChannelPublisherGrpcAdapter. - Walkthrough: the single
PushToChanneloverride (line 23) null-guardsrequestandcontext(lines 27-28), then awaitspublisher.PublishAsync(request.ChannelKey, request.EventName, request.PayloadJson, context.CancellationToken)(lines 30-32) and returns an emptyPushToChannelResponse(line 34). The channel key, event name, and payload are opaque strings: the transport relays the event rather than modeling it. - Why it's built this way (security posture): there is deliberately no
[Authorize](doc comment lines 13-17).[Rubric §11, Security]: this surface is reachable only on the internal service network (a dedicated internal port in Azure Container Apps, never routed by the Gateway), the same posture as the other internal gRPC services (for exampleAttendeesGrpcService). The trust boundary is the network, not a bearer token. Transport-wise it rides the ADR-012 mixed-endpoint profile: Notification keeps its default endpointHttp1AndHttp2for SignalR WebSockets and serves this h2c gRPC ingress on a dedicatedHttp2-only endpoint. - Where it's used: mapped by the Notification service's
Program.cs; invoked by LiveChannelPublisherGrpcAdapter running inside Engagement.
LiveChannelPublisherGrpcAdapter
MMCA.ADC.Notification.Contracts ·
MMCA.ADC.Notification.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/LiveChannelPublisherGrpcAdapter.cs:20· Level 1 · class (sealed partial)
- What it is: the client half of the live-channel ingress. A hand-written adapter that
implements the framework's ILiveChannelPublisher port on top of the
generated gRPC
LiveChannelPushService.LiveChannelPushServiceClient, so a publishing service (the Engagement live layer) keeps depending on the abstraction while its calls actually cross the wire to the Notification host. It is the counterpart to LiveChannelGrpcService. - Depends on: ILiveChannelPublisher (Level 0, the implemented port); the
generated
LiveChannelPushService.LiveChannelPushServiceClient(constructor param, line 21);ILogger<LiveChannelPublisherGrpcAdapter>(line 22). Externals:Grpc.Core(per-call deadline), the source-generated[LoggerMessage]logging. - Concept introduced, the best-effort cross-service adapter with a tight deadline.
[Rubric §7, Microservices Readiness]assesses whether a boundary crossing degrades gracefully;[Rubric §29, Resilience & Business Continuity]assesses whether a peer outage can take a caller down. This adapter is fire-and-forget by contract (ADR-039): a privatestatic readonly TimeSpan PushDeadline = TimeSpan.FromSeconds(2)(line 26) is deliberately tighter than the shared resilience pipeline's 30s attempt timeout, because a live event that takes longer than that is already stale, and every failure (transport, resolution, broken circuit) is logged and swallowed, never thrown, so a publishing command can never fail because Notification is down or slow. - Walkthrough
PushDeadline(line 26): the 2-second per-call ceiling.PublishAsync(channelKey, eventName, payloadJson, cancellationToken)(line 29): inside atry, callsclient.PushToChannelAsync(...)with aPushToChannelRequestcarryingChannelKey,EventName, andPayloadJson(lines 33-39), passingdeadline: DateTime.UtcNow.Add(PushDeadline)(line 40) and the caller's token. The whole call is wrapped in acatch (Exception ex)guarded by a justified#pragma warning disable CA1031(lines 43-45): a failure callsLogPushFailed(ex, channelKey, eventName)(line 47) and returns, it never propagates.LogPushFailed(lines 51-52): a source-generated[LoggerMessage]atWarninglevel.
- Why it's built this way: the deadline keeps a publishing request from being held hostage by a slow peer, and swallowing the exception keeps the best-effort contract (ADR-039) honest: the live event is a courtesy fan-out, not a guaranteed delivery, so its failure must not roll back the business command that produced it.
- Where it's used: registered by
AddNotificationLiveChannelClientin the Notification.Contracts DependencyInjection, whichservices.Replace(...)s it over the framework's NullLiveChannelPublisher default in ADC's Engagement composition root. The server half it dials is LiveChannelGrpcService; inside the Notification host that in turn delegates to SignalRLiveChannelPublisher.
NullLiveChannelPublisher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NullLiveChannelPublisher.cs:11· Level 1 · class (sealed)
- What it is: a no-op implementation of the ILiveChannelPublisher port. It is the default the container resolves when a host has not wired a real transport, so the live-channel path always has something to call.
- Depends on: ILiveChannelPublisher (Level 0). No externals beyond
the BCL
Task. - Concept introduced, the Null Object pattern for optional infrastructure.
[Rubric §1, SOLID]assesses the Dependency Inversion Principle: application handlers depend only on the abstraction, and the concrete adapter is chosen at the composition root.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation: rather than leave the port unregistered (which would make DI throw when a handler asks for it), the framework registers a member that does nothing, so a host that never configures push simply publishes into the void without failing. This is the same idea behind NullNotificationRecipientProvider and NullNavigationPopulator<TEntity>. - Walkthrough: one method,
PublishAsync(channelKey, eventName, payloadJson, cancellationToken)(line 14), whose whole body is=> Task.CompletedTask(line 15). No exception, no logging, no work. - Why it's built this way: the class doc comment (lines 5-10) states the contract: downstream apps
override this with SignalRLiveChannelPublisher via
AddPushNotifications(), or with their own transport (in ADC, a gRPC adapter that forwards to the host that maps the hub). Because the default resolves cleanly, no host is forced to configure a real-time transport (ADR-039, live channels are best-effort by design). - Where it's used: registered by the framework so
ILiveChannelPublisheris always resolvable;AddPushNotificationsswaps it for the SignalR implementation, and in ADC Engagement's composition rootservices.Replace(...)overwrites it with the LiveChannelPublisherGrpcAdapter (see the Notification.Contracts DI in DependencyInjection).
NullPushNotificationSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NullPushNotificationSender.cs:10· Level 1 · class (sealed)
- What it is: the no-op default for the IPushNotificationSender port, the delivery-side counterpart of NullLiveChannelPublisher. It lets a host resolve and run the send pipeline even with no real-time transport configured.
- Depends on: IPushNotificationSender (Level 0). Uses the
solution-wide
UserIdentifierTypealias (see primer §2). - Concept: cross-reference the Null Object pattern taught on
NullLiveChannelPublisher.
[Rubric §29, Resilience]: because push delivery is best-effort (the durable inbox is the source of truth), a missing transport must not break sending, it must simply deliver nothing. - Walkthrough: three methods, each
=> Task.CompletedTask:SendToUserAsync(line 13),SendToUsersAsync(line 17), andBroadcastAsync(line 21). Together they mirror the fullIPushNotificationSendersurface (single user, batch, broadcast) so the interface is satisfied without behavior. - Why it's built this way: the doc comment (lines 5-8) notes downstream apps override this with
SignalRPushNotificationSender via
AddPushNotifications(). The send handler always calls the port; whether anything reaches a browser is a composition-root decision. - Where it's used: registered as the default
IPushNotificationSender; replaced by the SignalR sender in any host that callsAddPushNotifications.
SendPushNotificationCommand
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:11· Level 1 · record (sealed)
- What it is: the CQRS command that triggers a push-notification broadcast. It wraps a
SendPushNotificationRequest plus the sender's
UserIdentifierType. - Depends on: ICommandWithRequest<out TRequest> (Level 0), SendPushNotificationRequest (Level 0).
- Concept: the command-wraps-request idiom (see
ICommandWithRequest<out TRequest>, G05). The public
HTTP request is the small
Title/Bodyrecord; the command additionally carries server-derived context (SentByUserId, taken from the caller's token, not the body) so the client cannot spoof the sender. ExposingRequestsatisfies theICommandWithRequest<SendPushNotificationRequest>contract, which lets the generic validating command decorator run SendPushNotificationRequestValidator automatically in the pipeline.[Rubric §6, CQRS & Event-Driven],[Rubric §11, Security]. - Walkthrough: a two-property positional record implementing
ICommandWithRequest<SendPushNotificationRequest>(lines 11-13).Requestis the validated DTO;SentByUserIdis the audit/authorization context. - Where it's used: dispatched by the push-notification API endpoint (the organizer-only notification controller); handled by SendPushNotificationHandler.
SmtpEmailSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SmtpEmailSender.cs:12· Level 1 · class (sealed)
- What it is: the SMTP adapter for the IEmailSender port. It sends mail through a
System.Net.Mail.SmtpClientconfigured from ISmtpSettings. This is the entire "email channel" of the notification subsystem, independent of the push/inbox flow. - Depends on: IEmailSender (Level 0);
ISmtpSettings (Level 0, injected via the
primary constructor). Externals:
System.Net.Mail(SmtpClient,MailMessage) andSystem.Net(NetworkCredential). - Concept introduced, the settings-bound infrastructure adapter.
[Rubric §3, Clean Architecture]assesses whether transport detail stays at the edge: the portIEmailSenderlives in Application, and this SMTP concretion lives in Infrastructure, so nothing above it knows what "email" is made of.[Rubric §10, Cross-Cutting Concerns]: host/port/credentials come from bound configuration (ISmtpSettings), never hard-coded, so the same code targets a real relay in production and the Aspire MailDev container locally (SMTPlocalhost:1025, web inboxhttp://localhost:1080). - Walkthrough: the primary constructor copies seven settings into readonly fields (
_host,_port,_username,_password,_fromAddress,_toAddress,_enableSsl, lines 14-20).SendAsync(to, subject, body, isHtml, cancellationToken)(line 23): guards each string withArgumentException.ThrowIfNullOrEmpty(lines 25-27), then constructs a freshSmtpClientwithNetworkCredentialandEnableSslinside ausing(lines 30-34, wrapped in a justified#pragma warning disable S5332at lines 29/35 becauseEnableSslis config-driven and local dev targets MailDev, which does not offer TLS), builds aMailMessage(alsousing, lines 37-40), and awaitsSendMailAsync(message, cancellationToken)(line 42). The doc comment (lines 9-10) is explicit that a new client is created and disposed per send, no pooled long-lived connection.SendAsync(subject, body, isHtml, cancellationToken)(line 53): a convenience overload that sends to the default_toAddressfrom settings (line 54), for admin/system mail with no explicit recipient.
- Why it's built this way: a per-send client keeps the sender stateless and thread-safe with no
connection lifecycle to manage, acceptable for the low volume of system/admin mail this channel
carries. There is deliberately no
NullEmailSenderanalogue: email is registered only where a host opts in, unlike push where a null default keeps the pipeline resolvable. - Where it's used: registered as
IEmailSenderwhere a host wires SMTP; callers inject the port, not this class. It is not called from the push/inbox send flow.
DependencyInjection
MMCA.ADC.Notification.API ·
MMCA.ADC.Notification.API·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:13· Level 2 · class (static,extension(IServiceCollection))
- What it is: the ADC Notification module's composition root. Its single
AddNotificationModulemethod assembles the full module registration by chaining the Application, Infrastructure, push, native-push, and controller wiring in one call. (This is distinct from the Notification.ContractsDependencyInjectioncovered at the end of this chapter, which wires the consumer-side gRPC clients.) - Depends on: ApplicationSettings
(Level 1); the Common API notification-controllers extension (
AddNotificationControllers); the Common Application module wiring (AddModuleNotificationApplication),AddPushNotifications, andAddNativePushNotifications. Externals:Microsoft.Extensions.DependencyInjection,Microsoft.Extensions.Configuration. - Concept, layered DI composition with
extension(IServiceCollection).[Rubric §5, Vertical Slice]assesses whether a feature owns its own end-to-end wiring: this one method registers the module's Application handlers, Infrastructure EF configs, real-time transport, OS-level native push, and REST controllers together, so a host adds the whole slice with a single call.[Rubric §7, Microservices Readiness]: this class is the boundary where the shared push framework becomes ADC's concrete behavior, which is what lets the module boot standalone inMMCA.ADC.Notification.Service. - Walkthrough:
AddNotificationModule(ApplicationSettings applicationSettings, IConfiguration configuration)(line 21) runs five registrations in order (lines 23-32):AddModuleNotificationApplication(applicationSettings)(line 23), the ADC Application facade that registers AttendeeNotificationRecipientProvider before Common's notification Application services, so theTryAdd*defaults leave the ADC recipient source in place.AddNotificationInfrastructure()(line 24), the Common Infrastructure extension registering the EF configs for the two notification aggregates.AddPushNotifications(configuration)(line 25), which swaps in the SignalR adapters (and the optional Redis backplane) over the null defaults.AddNativePushNotifications(configuration)(line 29), the ADR-044 third channel: a no-op unless theNativePushconfig section is enabled and complete (doc comment lines 27-28), so it is safe to call in every environment.services.AddControllers().AddNotificationControllers()(line 32), splicing the Common notification controllers in as MVC application parts so ASP.NET Core routing can discover them.
- Why it's built this way: keeping the ordering (Application before its
TryAdd*defaults; controllers registered as an application part because they ship in a NuGet assembly; native push added defensively as a safe no-op) inside one named method means the hostProgram.csstays clean and the sequence cannot drift per service. - Where it's used: called by NotificationModule.Register, which the ModuleLoader invokes at startup.
PushNotificationCreated
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.PushNotifications.DomainEvents·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/DomainEvents/PushNotificationCreated.cs:11· Level 2 · record (sealed)
- What it is: the domain event raised when a PushNotification is created. It records the notification's title and recipient count as a published fact.
- Depends on: BaseDomainEvent (Level 1, its base
record). Uses the
PushNotificationIdentifierTypealias. - Concept: cross-reference the domain-event / outbox story in
Group 04.
[Rubric §6, CQRS & Event-Driven]assesses whether state changes are announced as events carrying enough context to act on: this event carriesNotificationId,Title, andRecipientCount(lines 12-14). Note the doc comment onNotificationId(line 8), "default until persisted": the aggregate id is database-generated, so atCreatetime it is stilldefault; the event captures intent, not the assigned key. - Walkthrough: a positional
sealed record classwith three parameters,NotificationId,Title,RecipientCount(lines 11-14), deriving fromBaseDomainEvent. No body, no logic; the record is a pure payload. - Why it's built this way: raising a domain event inside
PushNotification.Create(withdefaultfor the not-yet-assigned id) makes creation an announceable fact that flows through the outbox like any other domain event (ADR-003), giving a persistable record and a future extension point. - Where it's used: added to the aggregate's event list by
PushNotification.Createand captured by the outbox onSaveChangesAsync. There is noIDomainEventHandler<PushNotificationCreated>in the codebase today: delivery happens synchronously inside the send handler after the inbox rows are written, so this event is currently a published, persistable record with no consumer wired to it.
NotificationModule
MMCA.ADC.Notification.API ·
MMCA.ADC.Notification.API·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15· Level 3 · class (sealed)
- What it is: the ADC Notification bounded context's IModule entry point. It is the discovery hook that lets the framework register the whole Notification slice in dependency order, and it declares the one cross-module service the context publishes.
- Depends on: IModule (Level 2);
ApplicationSettings (Level 1); the ADC
DependencyInjection
(
AddNotificationModule); IUserNotificationExportService and its disabled stub DisabledUserNotificationExportService. Externals:Microsoft.Extensions.DependencyInjection/Microsoft.Extensions.Configuration. - Concept: cross-reference the module system in
Group 14.
[Rubric §7, Microservices Readiness]assesses whether modules are independently composable: this module declaresDependencies => ["Identity"]withRequiresDependencies => true(lines 21, 24) because it needs the Identity attendee query to resolve recipients, and the ModuleLoader uses that to register it after Identity (Kahn topological order). It does publish a cross-module service (the doc comment lines 12-13), so it implementsRegisterDisabledStubsto keep that interface resolvable when the module is turned off. - Walkthrough: three declarative members plus two actions.
Name => "Notification"(line 18);Dependencies => ["Identity"](line 21);RequiresDependencies => true(line 24).Register(services, configuration, applicationSettings)(line 27), whose whole body delegates toservices.AddNotificationModule(applicationSettings, (IConfiguration)configuration)(line 31). Note the deliberate(IConfiguration)configurationcast:IModule.Registerhands over anIConfigurationBuilder, which the module treats as the concrete configuration to read from.RegisterDisabledStubs(services)(line 34) registersAddSingleton<IUserNotificationExportService, DisabledUserNotificationExportService>()(line 35) so a host that disables Notification can still satisfy the export interface the Identity service calls for the PRIVACY.md data-subject export.
- Why it's built this way: keeping the module thin (a name, its dependencies, a one-line delegation
to the composition-root extension, and one stub registration) is the framework's convention: policy
about what to register lives in the
DependencyInjectionclass, and theIModuleonly declares ordering, identity, and its cross-module contract's disabled fallback. - Where it's used: discovered by reflection and registered by the
ModuleLoader at startup; in the extracted
topology it is the only enabled module in
MMCA.ADC.Notification.Service.
SignalRLiveChannelPublisher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SignalRLiveChannelPublisher.cs:12· Level 3 · class (sealed)
- What it is: the real (non-null) adapter for ILiveChannelPublisher. It fans an ephemeral channel event out to every connection subscribed to a channel by doing a SignalR group send through NotificationHub.
- Depends on: ILiveChannelPublisher (Level 0);
NotificationHub (Level 2, referenced for its group send and method-name constant).
External:
Microsoft.AspNetCore.SignalR(IHubContext<THub>). - Concept introduced, out-of-band delivery via
IHubContext<THub>.[Rubric §12, Performance & Scalability]assesses horizontal scale-out: because the publisher addresses the hub throughIHubContext<NotificationHub>(injected, line 12) rather than holding a hub connection, it works from any host that maps the hub, and when a Redis backplane is configured the group send fans out across replicas (doc comment lines 8-10).[Rubric §1, SOLID]: the sameILiveChannelPublisherabstraction is implemented by the null default, this SignalR adapter, and (in ADC) a gRPC adapter, port-and-adapter taken to its conclusion. - Walkthrough: one method,
PublishAsync(channelKey, eventName, payloadJson, cancellationToken)(line 15):hubContext.Clients.Group(channelKey).SendAsync(NotificationHub.ReceiveChannelEventMethod, channelKey, eventName, payloadJson, cancellationToken)(lines 16-18). It invokes the hub'sReceiveChannelEventMethodconstant so the client and server agree on the method name, and passes the channel key, event name, and opaque JSON payload straight through. - Why it's built this way: live channels are transient by contract (ADR-039), so this adapter just addresses the group and sends, with no persistence and no per-recipient bookkeeping. A connection that is not subscribed at publish time simply never receives the event.
- Where it's used: swapped in over NullLiveChannelPublisher by
AddPushNotificationsin any host that maps the hub; in ADC it is the local implementation the Notification service's gRPC ingress (LiveChannelGrpcService) delegates to. The browser side lives in group 15.
SignalRPushNotificationSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Services·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/SignalRPushNotificationSender.cs:13· Level 3 · class (sealed)
- What it is: the real adapter for IPushNotificationSender. It delivers a notification to specific users, a batch of users, or everyone, through NotificationHub, and chunks large audiences so one send does not overwhelm the SignalR connection manager.
- Depends on: IPushNotificationSender (Level 0);
NotificationHub (Level 2). Externals:
Microsoft.AspNetCore.SignalR(IHubContext<THub>),System.Globalization(invariantint.ToString). - Concept: cross-reference out-of-band delivery via
IHubContexttaught on SignalRLiveChannelPublisher.[Rubric §12, Performance & Scalability]here is about batching: a privateconst int BatchSize = 100(line 15) caps how many user ids ride a singleClients.Users(batch)call, so broadcasting to a large attendee list is split into bounded sends rather than one giant fan-out.[Rubric §27, i18n]in miniature: user ids are stringified withCultureInfo.InvariantCulture(lines 20, 47) so the SignalR user-id keys are locale-stable. - Walkthrough
SendToUserAsync(userId, title, body, metadata, cancellationToken)(line 18): addressesClients.User(userId.ToString(InvariantCulture))and invokesNotificationHub.ReceiveNotificationMethod(lines 19-22).SendToUsersAsync(userIds, ...)(line 25): iteratesBatchUserIds(userIds)and sends each batch toClients.Users(batch)(lines 27-33).BroadcastAsync(...)(line 37): addressesClients.All(lines 38-40).BatchUserIds(userIds)(line 42): a private iterator that accumulates invariant-culture id strings into aListandyield returns each time it reachesBatchSize, flushing the remainder at the end (lines 44-58).
- Why it's built this way: all three delivery shapes route through the one hub context and the one shared method-name constant, so the client listens on a single event regardless of how it was targeted. Batching keeps a broadcast to thousands of recipients from constructing one oversized argument list for the connection manager.
- Where it's used: swapped in over NullPushNotificationSender by
AddPushNotifications; called by SendPushNotificationHandler after the inbox rows are persisted, inside a swallow-everythingtry/catchso a transport hiccup never loses the durable inbox.
SendPushNotificationRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationRequestValidator.cs:10· Level 5 · class (sealed)
- What it is: the FluentValidation validator for SendPushNotificationRequest: it enforces that title and body are present and within the length limits declared by PushNotificationInvariants.
- Depends on: SendPushNotificationRequest (Level 0, the validated
type), PushNotificationInvariants (the domain invariant constants,
Level 4), and
FluentValidation.AbstractValidator<T>(NuGet base class). - Concept: request validation as a pipeline concern, not handler code.
[Rubric §24, Forms, Validation & UX Safety](server-side validation with actionable messages) and[Rubric §16, Maintainability]. Reusing the samePushNotificationInvariantsconstants here and in the PushNotification domain factory keeps the API limit and the entity limit from drifting apart. - Walkthrough: the constructor (line 12) defines two rules.
Title(lines 14-17) getsNotEmpty()plusMaximumLength(PushNotificationInvariants.TitleMaxLength)(200);Body(lines 19-22) getsNotEmpty()plusMaximumLength(PushNotificationInvariants.BodyMaxLength)(2000). Each rule supplies a human-readableWithMessage, with the limit interpolated into the over-length message. - Where it's used: auto-discovered by the notification Application DI's validator scan; invoked by
the validating command decorator in the CQRS pipeline (against the embedded
Request, via ICommandWithRequest<out TRequest>) before SendPushNotificationHandler runs.
SendPushNotificationHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:17· Level 8 · class (sealed partial)
- What it is: the command handler for the push-notification broadcast. It resolves recipients, persists a sender-side audit aggregate plus one inbox row per recipient, attempts real-time delivery over SignalR and OS-level native push, and records the resulting status, returning a PushNotificationDTO.
- Depends on: IUnitOfWork (repositories plus save),
INotificationRecipientProvider (the audience),
IPushNotificationSender (the SignalR transport),
INativePushSender (the OS-level transport, added
by ADR-044), PushNotificationDTOMapper (the success-payload mapper), and
ILogger<>; the persisted aggregates are PushNotification and UserNotification. Implements ICommandHandler<in TCommand, TResult> and returns Result<PushNotificationDTO>. - Concept introduced, one durable path plus two ephemeral push channels.
[Rubric §6, CQRS & Event-Driven],[Rubric §8, Data Architecture],[Rubric §29, Resilience & Business Continuity]. The handler writes to storage (the audit aggregate, then N inbox rows) and separately performs two best-effort real-time pushes (SignalR, then native OS push). These are different reliability tiers on purpose: the aggregate and inbox rows are the durable record a recipient can retrieve later, while the SignalR and native pushes are the immediate deliveries that an offline user may miss. - Walkthrough (teaching order)
- resolve recipients (lines 31-32) via the app-specific
INotificationRecipientProvider (in ADC,
AttendeeNotificationRecipientProvider). An empty set
short-circuits to a
Result.Failurewith an ErrorValidationcodePushNotification.NoRecipients(lines 34-40), before any rows are written. - create the audit aggregate (lines 43-47) via
PushNotification.Create(...)withrecipientIds.Count; propagate errors on failure (lines 48-51), then add plus save (lines 53-56). This is where the aggregate's PushNotificationCreated domain event is captured to the outbox (ADR-003). - durable inbox (lines 58-66): one
UserNotification.Create(recipientId, notification.Id)row per recipient, added and saved. This is what lets a user retrieve a notification they missed while offline. - best-effort SignalR delivery (lines 69-86):
pushNotificationSender.SendToUsersAsync(...)inside atry/catchwith a justified#pragma warning disable CA1031(line 80). A delivery failure is non-fatal: success callsnotification.MarkAsSent()plus an info log (lines 77-78), failure callsnotification.MarkAsFailed()plus an error log (lines 84-85); the failure becomes recorded status, not a thrown exception. - best-effort native push (lines 88-105, ADR-044):
nativePushSender.SendToUsersAsync(...)in a secondtry/catchwith its own justifiedCA1031suppression (line 100). This is the OS-level channel that can reach devices whose app is backgrounded or killed (doc comment lines 88-91). It is purely additive: the SignalR leg above already decided the audit status, so a native-push failure only logs a warning (LogNativePushFailed, line 104) and never touchesStatus. The default NullNativePushSender keeps this a no-op until a notification hub is configured. - persist final status (line 107) and return the mapped DTO (line 109).
All three log paths use source-generated
[LoggerMessage]methods (lines 112-119).[Rubric §13, Observability].
- resolve recipients (lines 31-32) via the app-specific
INotificationRecipientProvider (in ADC,
AttendeeNotificationRecipientProvider). An empty set
short-circuits to a
- Why it's built this way: shipping the whole feature in the framework means both ADC and Store
get push for free, with the audience and both transports as injected abstractions (
[Rubric §10, Cross-Cutting Concerns]). Treating each delivery failure as a recorded status or a logged warning rather than an exception keeps the audit trail honest while the audit plus inbox writes stay durable even when a live push does not land. Note the real-time sends are not the outbox: they are synchronous best-effort calls inside the handler; only thePushNotificationCreateddomain event flows through the outbox. - Where it's used: dispatched from the Common organizer-only notification controller (mounted into ADC by the Notification DI facade); the real-time legs land on connected clients through NotificationHub and, for native push, through the configured OS notification hub.
- Caveats / not-in-source:
SendPushNotificationCommandis not markedITransactional, so the Transactional decorator opens no ambient transaction; the threeSaveChangesAsynccalls (lines 56, 66, 107) are independent. A crash between the inbox writes and the final status save can therefore leave inbox rows persisted while the aggregateStatusstaysPending. There is no automatic redelivery of a failed push: the outcome is recorded, not re-attempted.
UserNotificationExportGrpcService
MMCA.ADC.Notification.Service ·
MMCA.ADC.Notification.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/UserNotificationExportGrpcService.cs:20· Level 9 · class (sealed)
- What it is: the gRPC server endpoint that exposes the in-process IUserNotificationExportService over the wire. The Identity service calls it to pull a user's notification inbox rows (ids, titles, sent/read dates) so it can assemble the data-subject export document (PRIVACY.md §7, the GDPR right of access).
- Depends on: IUserNotificationExportService (injected via the
primary constructor, line 20), the generated
UserNotificationExportService.UserNotificationExportServiceBase(compiled from the.Contracts.proto, line 21), andGrpc.Core.ServerCallContext. UsesSystem.Globalization.CultureInfofor invariant-culture date formatting. - Concept introduced, cross-service data-subject export over internal gRPC.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether the system can satisfy a subject-access request across service boundaries: each service owns its own database, so the user's inbox rows live inADC_Notification, not in Identity; this RPC lets the Identity export aggregator gather that slice without a cross-database query.[Rubric §7, Microservices Readiness]and[Rubric §9, API & Contract Design]: the export contract is a versioned.protoshared through the.Contractspackage, the same extraction pattern as the live-channel ingress.[Rubric §27, i18n]: timestamps are serialized with the round-trip"O"format andCultureInfo.InvariantCulture(lines 40, 42) so the export is locale-stable. - Walkthrough: the single
GetUserNotificationExportoverride (line 24) null-guardsrequestandcontext(lines 28-29), then awaitsinner.GetUserNotificationExportAsync(request.UserId, context.CancellationToken)(lines 31-33) to get the in-process items. It builds aGetUserNotificationExportResponse(line 35) andAddRanges a projection of each item into aUserNotificationExportItem(lines 36-43):NotificationId,Title,SentOnas an ISO-8601 invariant string (line 40),IsRead, andReadOnas an invariant string orstring.Emptywhen the notification is unread (line 42). It returns the assembled response (line 45). - Why it's built this way (security posture): there is deliberately no
[Authorize](doc comment lines 14-18).[Rubric §11, Security]: like LiveChannelGrpcService and the other internal gRPC surfaces, this endpoint is reachable only on the internal service network (a dedicated internal port in Azure Container Apps, never routed by the Gateway), so the trust boundary is the network. It is served on the same dedicatedHttp2-only "grpc" Kestrel endpoint as the live-channel ingress (ADR-012 mixed-endpoint profile). - Where it's used: mapped by the Notification service's
Program.cs; the wire is dialed by its client half UserNotificationExportServiceGrpcAdapter, which runs inside the Identity service's export aggregator and stitches this Notification slice into the full data-subject export. When the Notification module is disabled, DisabledUserNotificationExportService (registered by NotificationModule.RegisterDisabledStubs) stands in for the in-process interface this service wraps.
UserNotificationExportServiceGrpcAdapter
MMCA.ADC.Notification.Contracts ·
MMCA.ADC.Notification.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/UserNotificationExportServiceGrpcAdapter.cs:17· Level 9 · class (sealed)
- What it is: the client half of the data-subject export RPC. A hand-written adapter that
implements IUserNotificationExportService on top of the generated
UserNotificationExportService.UserNotificationExportServiceClient, so Identity'sExportUserDataHandlerkeeps depending on the C# interface while its call crosses the wire to the Notification service (where the inbox rows actually live). It is the counterpart to UserNotificationExportGrpcService. - Depends on: IUserNotificationExportService (Level 1, the
implemented port); the generated
UserNotificationExportService.UserNotificationExportServiceClient(constructor param, line 18); UserNotificationExportItemDTO (the mapped result rows). Externals:Grpc.Core(per-call deadline),System.Globalization(DateTime.ParsewithInvariantCulture+RoundtripKind). - Concept, failure that propagates rather than being swallowed.
[Rubric §30, Compliance, Privacy & Data Governance],[Rubric §7, Microservices Readiness],[Rubric §29, Resilience]. Unlike LiveChannelPublisherGrpcAdapter (fire-and-forget), this adapter's transport failures do propagate to the caller (doc comment lines 13-15), because the Identity export aggregator wants to know a section is missing so it can degrade that section toAvailable = falserather than silently returning an incomplete export. A privatestatic readonly TimeSpan CallDeadline = TimeSpan.FromSeconds(5)(line 23), tighter than the shared 30s attempt / 90s total budget, ensures a HUNG (not refused) peer degrades its section quickly instead of stalling the whole export request. - Walkthrough
CallDeadline(line 23): the 5-second per-call ceiling.GetUserNotificationExportAsync(userId, cancellationToken)(line 26): awaitsclient.GetUserNotificationExportAsync(...)with aGetUserNotificationExportRequestcarryingUserId(lines 31-34) anddeadline: DateTime.UtcNow.Add(CallDeadline)(line 35), then materializes a collection expression (lines 38-45) mapping each responseNotificationto aUserNotificationExportItemDTO:NotificationId,Title,SentOnviaParseRoundtripUtc(line 42),IsRead, andReadOnasnullwhen the wire value is empty else parsed (line 44).ParseRoundtripUtc(value)(line 48):DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)(line 49), the parse-side mirror of the server's"O"invariant format so the timestamp round-trips exactly across the boundary.
- Why it's built this way: propagating the transport failure (rather than swallowing it as the live-channel adapter does) is the right call for an export: a compliance document must not quietly omit a section, so the aggregator needs the exception to mark the section unavailable. The invariant round-trip parse pairs with the server's invariant round-trip format so no locale can corrupt the timestamps.
- Where it's used: registered by
AddNotificationUserExportClientin the Notification.Contracts DependencyInjection, whichservices.Replace(...)s it over the DisabledUserNotificationExportService stub in Identity's composition root. The server half it dials is UserNotificationExportGrpcService.
DependencyInjection
MMCA.ADC.Notification.Contracts ·
MMCA.ADC.Notification.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:16· Level 10 · class (static,extension(IServiceCollection))
- What it is: the consumer-side DI helpers other services call to wire the two gRPC-backed
adapters pointing at the Notification service's dedicated h2c ingress. This is a different
DependencyInjectionfrom the Notification.API module composition root covered earlier in this chapter: that one assembles the module inside the Notification host, while this one lets other hosts (Engagement, Identity) dial into it. - Depends on:
AddTypedGrpcClient<TClient>(MMCA.Common.Grpc); ILiveChannelPublisher and its adapter LiveChannelPublisherGrpcAdapter; IUserNotificationExportService and its adapter UserNotificationExportServiceGrpcAdapter;ServiceCollectionDescriptorExtensions.Replace. Externals:Microsoft.Extensions.DependencyInjection(.Extensions). - Concept introduced, the
Replace-not-TryAddclient swap plus named-endpoint discovery.[Rubric §7, Microservices Readiness]assesses whether a module can be dialed as a remote service without changing application code;[Rubric §17, DevOps]covers configuration-driven service discovery. Two extension methods each register a typed gRPC client and thenservices.Replace(...)the corresponding abstraction, deliberately overwriting whatever default sits in the container (NullLiveChannelPublisheror the disabled export stub) rather thanTryAdd-ing, so the gRPC adapter always wins. Both target the named Aspire endpoint_grpc.notification: Notification's default endpoint staysHttp1AndHttp2for SignalR WebSockets, so its cleartext gRPC (h2c) lives on a dedicatedHttp2-only Kestrel endpoint namedgrpc, and discovery resolveshttp://_grpc.notificationfrom theservices__notification__grpc__0config entry (injected by the AppHost'sWithReferencelocally and byinfra/main.bicepin production). ADR-012. - Walkthrough: one
extension(IServiceCollection services)block (line 18) with two methods.AddNotificationLiveChannelClient(serviceName = "_grpc.notification")(line 42): registers the typedLiveChannelPushServiceClient(line 44), thenservices.Replace(ServiceDescriptor.Scoped<ILiveChannelPublisher, LiveChannelPublisherGrpcAdapter>())(line 48). The doc comment (lines 46-47) is explicit that it isReplace, notTryAdd, because the framework already registeredNullLiveChannelPublisher; call it fromProgram.csafterAddInfrastructure(...).AddNotificationUserExportClient(serviceName = "_grpc.notification")(line 78): registers the typedUserNotificationExportServiceClient(line 80), thenservices.Replace(ServiceDescriptor.Scoped<IUserNotificationExportService, UserNotificationExportServiceGrpcAdapter>())(line 84), overwriting theDisabledUserNotificationExportServicestub a disabled module may have left; call it fromProgram.csafterModuleLoader.DiscoverAndRegister(...).
- Why it's built this way: keeping the client wiring (typed client with its JWT-forwarding
interceptor and Polly resilience handler from
MMCA.Common.Grpc, plus theReplaceswap and the named-endpoint default) behind one named method per consumer keeps each host'sProgram.csdeclarative, and centralizing the_grpc.notificationdefault means the h2c endpoint name cannot drift between the two consumers. - Where it's used:
AddNotificationLiveChannelClientis called by the Engagement service host's composition root (its live layer publishes ephemeral events);AddNotificationUserExportClientis called by the Identity service host's composition root (itsExportUserDataHandleraggregates the data-subject export).
NotificationHub
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Hubs·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hubs/NotificationHub.cs:17· Level 2 · class (sealed)
- What it is: the SignalR hub that anchors the group's real-time transport. It is deliberately thin: it maps authenticated connections and manages channel (SignalR group) membership. It does not itself construct or fan out messages, that work lives in
SignalRPushNotificationSenderandSignalRLiveChannelPublisher, both of which push throughIHubContext<NotificationHub>(documentedNotificationHub.cs:10-15). - Depends on:
PushNotificationSettings(read throughIOptions<T>for the channel-key pattern); externalsMicrosoft.AspNetCore.SignalR(theHubbase class,Groups,Context,HubException,[HubMethodName]),Microsoft.AspNetCore.Authorization([Authorize]),Microsoft.Extensions.Options(IOptions<T>), and the BCLSystem.Text.RegularExpressions.RegexplusSystem.Collections.Concurrent.ConcurrentDictionary. - Concept introduced, the SignalR hub as a real-time transport endpoint. A SignalR
Hubis a server-side endpoint over a persistent connection (WebSockets where available). Clients invoke named hub methods on it, and the server pushes messages back to individual connections or to named groups (here called channels). This hub keeps only the membership half of that contract:JoinChannelAsync/LeaveChannelAsyncadd or remove the calling connection to a SignalR group, and delivery is done elsewhere throughIHubContext. Keeping the hub free of message construction means the sender/publisher services can be tested and scaled independently of the connection surface.[Rubric §11, Security]assesses whether the boundary authenticates and constrains input. The class carries a class-level[Authorize](NotificationHub.cs:16), so only authenticated connections can open the hub, and every channel key is validated against a configured allow-pattern before a connection may join a group, so a client cannot subscribe to an arbitrary group name.[Rubric §12, Performance & Scalability]assesses whether hot paths avoid repeated work. The compiledRegexis cached in a staticConcurrentDictionarykeyed by the pattern string (NotificationHub.cs:33-34) so join/leave calls never recompile it, and each match runs under a 1-second timeout (NotificationHub.cs:31) to bound worst-case matching (a defense against catastrophic-backtracking, ReDoS-style input).
- Walkthrough
- Primary-constructor DI of
IOptions<PushNotificationSettings>, over theHubbase (NotificationHub.cs:17). - The shared method-name constants:
ReceiveNotificationMethod="ReceiveNotification"(NotificationHub.cs:20) andReceiveChannelEventMethod="ReceiveChannelEvent"(NotificationHub.cs:23) are the client-listen method names the sender/publisher target;JoinChannelMethod="JoinChannel"(NotificationHub.cs:26) andLeaveChannelMethod="LeaveChannel"(NotificationHub.cs:29) are the server methods clients invoke. Exposing them as constants keeps both ends of the wire from drifting on a magic string. ChannelKeyMatchTimeout(1 second,NotificationHub.cs:31) and theChannelKeyRegexCache(NotificationHub.cs:34,StringComparer.Ordinal) back the validation helper.JoinChannelAsync(string channelKey)(NotificationHub.cs:44): attributed[HubMethodName(JoinChannelMethod)](NotificationHub.cs:43), it validates the key then callsGroups.AddToGroupAsync(Context.ConnectionId, channelKey)(NotificationHub.cs:47).LeaveChannelAsync(string channelKey)(NotificationHub.cs:55):[HubMethodName(LeaveChannelMethod)](NotificationHub.cs:54), validates thenGroups.RemoveFromGroupAsync(...)(NotificationHub.cs:58).EnsureValidChannelKey(NotificationHub.cs:61):GetOrAdds the cachedRegexforsettings.Value.ChannelKeyPattern(NotificationHub.cs:63-65); an empty or non-matching key throwsHubException("Invalid channel key.")(NotificationHub.cs:67-70), which SignalR surfaces to the caller rather than tearing down the connection.
- Primary-constructor DI of
- Why it's built this way: routing delivery through
IHubContextinstead of hub instance methods lets the framework construct and send messages from anywhere (background senders, the outbox path) without holding a live hub instance, and it is the shape SignalR scale-out (a Redis backplane) expects. The thin hub plus a configured channel-key pattern is the framework default; the pattern lives inPushNotificationSettingsso a host tunes which channels exist without touching code. - Where it's used: mapped by a consuming host as a SignalR endpoint; driven by
SignalRPushNotificationSender(per-user notification delivery) andSignalRLiveChannelPublisher(ephemeral channel events), both viaIHubContext<NotificationHub>. - Caveats / not-in-source: the concrete hub route path and the SignalR scale-out backplane (if any) are set in host composition, not in this file. Not determinable from source here.
DevicesController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:25· Level 4 · class (sealed)
- What it is: the REST controller that lets any authenticated user manage THEIR own native push-device installations (ADR-044):
PUT /Notifications/Devicesupserts the installation (called after login and on token rotation),DELETE /Notifications/Devices/{installationId}removes it (called before logout). - Depends on:
ApiControllerBase;IPushDeviceRegistrar(the registry facade injected into the primary constructor,DevicesController.cs:26);ICurrentUserService(DevicesController.cs:27);ErrorandResult;DeviceInstallationRequest;NotificationFeatures. Externals: ASP.NET Core MVC ([ApiController],[Route],[HttpPut],[HttpDelete]),Asp.Versioning([ApiVersion("1.0")]), andMicrosoft.FeatureManagement.Mvc([FeatureGate]). - Concept introduced, the ownership-stamped device controller. This is the first controller in the group whose resources are addressed by client-generated GUID installation ids rather than server sequence ids (documented
DevicesController.cs:13-18). Because the id is unguessable and ownership is stamped server-side from the authenticated user, the endpoint never trusts a caller-supplied owner.[Rubric §11, Security]assesses whether authorization and ownership are enforced at the boundary: the class-level[Authorize](DevicesController.cs:24) requires authentication, andUpsertAsyncre-derivesuserIdfromICurrentUserService.UserId(DevicesController.cs:37) rather than the request body, so a user can only register a device against their own identity.[Rubric §9, API & Contract Design]assesses REST-shape consistency: both actions declare typed[ProducesResponseType]results (204 on success, 400ProblemDetailson failure) and follow the sameHandleFailure-or-success return shape as the rest of the group. - Walkthrough
- Primary-constructor DI of the registrar and the current-user service (
DevicesController.cs:25-27). UpsertAsync(DevicesController.cs:33): readscurrentUserService.UserId; a null user short-circuits toError.Unauthorized("PushDevice.Unauthorized", ...)viaHandleFailure(DevicesController.cs:37-41). Otherwise it callsregistrar.UpsertAsync(userId.Value, request, ...)and returnsNoContent()(204) orHandleFailure(result.Errors)(DevicesController.cs:43-44).DeleteAsync(DevicesController.cs:50): takes theinstallationIdfrom the route ([HttpDelete("{installationId}")],DevicesController.cs:48) and callsregistrar.DeleteAsync; the operation is idempotent (unknown ids succeed, perIPushDeviceRegistrar), so a repeated logout-time delete still returns 204 (DevicesController.cs:54-55).
- Primary-constructor DI of the registrar and the current-user service (
- Why it's built this way: the whole controller is wrapped in
[FeatureGate(NotificationFeatures.PushNotifications)](DevicesController.cs:23), so when theNotification.PushNotificationsflag is off the routes return 404 rather than surfacing dead endpoints. The registry indirection throughIPushDeviceRegistrarkeeps the controller free of any push-provider detail; the default implementation is a no-op until a hub is configured (ADR-044). - Where it's used: registered into the MVC application parts by the API-layer
DependencyInjection'sAddNotificationControllers; called by the native-client login/logout flow.
InboxController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:29· Level 4 · class (sealed)
- What it is: the REST controller for a user's notification inbox: get the inbox (paged), get the unread count, mark one notification read, and mark all read. Any authenticated user reaches only their own inbox.
- Depends on:
ApiControllerBase;AuthorizationPolicies;Error;Result;PagedCollectionResult<T>;UserNotificationDTO;ICurrentUserService; the CQRS handler contractsICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>; and the four use casesGetMyNotificationsQuery,GetUnreadNotificationCountQuery,MarkNotificationReadCommand,MarkAllNotificationsReadCommand. - Concept introduced, the feature-gated, handler-injecting controller.
[Rubric §6, CQRS & Event-Driven]assesses whether presentation delegates to command/query handlers rather than to service wrappers: this controller injects four handlers directly and only translates HTTP to a query/command and back (NotificationInboxController.cs:29-34).[Rubric §9, API & Contract Design](consistent structure:[ApiVersion("1.0")],[FeatureGate], typed[ProducesResponseType]).[Rubric §11, Security](the class-level[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]atNotificationInboxController.cs:28enforces authentication; every action re-scopes tocurrentUserService.UserIdso a user reads only their own inbox). The class-level[FeatureGate(NotificationFeatures.PushNotifications)](NotificationInboxController.cs:27) makes the whole controller 404 when the flag is off, not stubbed. - Walkthrough
- Primary-constructor DI of two query handlers, two command handlers, and the current-user service (
NotificationInboxController.cs:29-34). GetInboxAsync(NotificationInboxController.cs:39): readspageNumber/pageSizefrom[FromQuery]with[Range(1, int.MaxValue)]validation, guards a null user withError.Unauthorized(NotificationInboxController.cs:44-48), builds aGetMyNotificationsQuery(userId.Value, pageNumber, pageSize)(NotificationInboxController.cs:50), and returns 200 with the paged result orHandleFailure(NotificationInboxController.cs:54). DefaultpageSize = 20(NotificationInboxController.cs:41).GetUnreadCountAsync(NotificationInboxController.cs:61): marked[ResponseCache(NoStore = true)](NotificationInboxController.cs:59) so the badge count is never cached; returns the rawintfromGetUnreadNotificationCountQuery(NotificationInboxController.cs:69-72).MarkReadAsync(NotificationInboxController.cs:79): route{id:int}/read(NotificationInboxController.cs:76), builds aMarkNotificationReadCommand(id, userId.Value)(NotificationInboxController.cs:89), returns 204 or a 404ProblemDetails.MarkAllReadAsync(NotificationInboxController.cs:98): routeread-all(NotificationInboxController.cs:96), no body; issuesMarkAllNotificationsReadCommand(userId.Value)(NotificationInboxController.cs:106) and returns 204.
- Primary-constructor DI of two query handlers, two command handlers, and the current-user service (
- Why it's built this way: inbox reads and writes go through the standard CQRS decorator pipeline (logging, caching, validation, transaction) that wraps every handler, so the controller carries no cross-cutting logic. Passing
userIdinto every query/command keeps authorization data-scoped, not just route-scoped. - Where it's used: registered into MVC via
AddNotificationControllers; consumed by the in-app inbox UI (badge count plus the notifications list/detail).
NotificationsController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:28· Level 4 · class (sealed)
- What it is: the organizer-only REST controller for push notifications: send a notification to all recipients (
POST-> 201) and read the send history (GET, paged). - Depends on: the same controller family as
InboxController:ApiControllerBase,AuthorizationPolicies,ICurrentUserService,Error,Result,PagedCollectionResult<T>. It addsSendPushNotificationCommand,GetNotificationHistoryQuery,PushNotificationDTO, andSendPushNotificationRequest. - Concept: cross-reference
InboxControllerfor the feature-gated, handler-injecting controller shape. What differs is the authorization boundary:[Authorize(Policy = AuthorizationPolicies.RequireOrganizer)](NotificationsController.cs:27) restricts both actions to organizers.[Rubric §11, Security](a distinct, stricter policy for the write side: only organizers may broadcast).[Rubric §9, API & Contract Design](theSendAsyncaction returnsCreated(...)with aLocationheader pointing at the new resource, the REST-correct 201 shape). - Walkthrough
- Primary-constructor DI of the send command handler, the history query handler, and the current-user service (
NotificationsController.cs:28-31). SendAsync(NotificationsController.cs:38): guards a null user withError.Unauthorized(NotificationsController.cs:42-46), wraps the[FromBody] SendPushNotificationRequestplus the caller'suserIdinto aSendPushNotificationCommand(NotificationsController.cs:48), and on success returnsCreated(new Uri(string.Create(CultureInfo.InvariantCulture, $"/notifications/{result.Value!.Id}"), UriKind.Relative), result.Value)(NotificationsController.cs:53).GetHistoryAsync(NotificationsController.cs:59): reads[FromQuery]paging (defaultpageSize = 10,NotificationsController.cs:61), runsGetNotificationHistoryQuery(NotificationsController.cs:64), and returns 200 with the pagedPushNotificationDTOcollection.
- Primary-constructor DI of the send command handler, the history query handler, and the current-user service (
- Why it's built this way: the send path (a broadcast) is an organizer-privileged operation, so its policy is separated from the per-user inbox; keeping the two controllers apart lets each carry exactly its own authorization surface. Both stay behind
[FeatureGate(NotificationFeatures.PushNotifications)](NotificationsController.cs:26). - Where it's used: registered via
AddNotificationControllers; consumed by the organizer push-notification UI.
PushNotificationInvariants
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.PushNotifications.Invariants·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:9· Level 4 · class (static)
- What it is: the static invariants helper that validates a push notification's title and body: non-empty plus a maximum length, delegating the actual checks to
CommonInvariants. - Depends on:
CommonInvariants;Result. - Concept: this is the same "static invariants class +
MaxLengthconstants as a single source of truth" pattern first taught for value objects in Group 02, here applied to a domain entity.TitleMaxLength = 200andBodyMaxLength = 2000arepublic const(PushNotificationInvariants.cs:12-15), so the EF entity configuration can reuse the exact same numbers for column constraints.[Rubric §8, Data Architecture]assesses whether validation and schema stay consistent: because both the domain check and the DB column length derive from one constant, they cannot drift. - Walkthrough:
EnsureTitleIsValid(PushNotificationInvariants.cs:17) andEnsureBodyIsValid(PushNotificationInvariants.cs:22) each callResult.CombineoverCommonInvariants.EnsureStringIsNotEmptyandCommonInvariants.EnsureStringMaxLength, passing the matchingMaxLengthconstant and asource/field name so any failure carries a preciseErrorcode (e.g.PushNotification.Title.TooLong,PushNotificationInvariants.cs:20). - Why it's built this way: factoring the rules out of the entity keeps the
PushNotificationfactory readable and lets the constants be shared with persistence and validators, the same one-source-of-truth rationale as the rest of the invariants family. - Where it's used: called by the
PushNotificationentity factory when a notification is created.
DependencyInjection
MMCA.Common.API ·
MMCA.Common.API.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Notifications/DependencyInjection.cs:9· Level 5 · class (static)
(API-layer notification DI. There is a second DependencyInjection in this unit, the Application-layer one at Level 9 below; both keep the raw type name as their heading.)
- What it is: the API-layer DI helper for the Notification module. Its one method,
AddNotificationControllers, registers the group's controllers into the MVC application so ASP.NET Core routing can discover them. - Depends on: the
NotificationsControllertype (used only as an assembly anchor); externalsMicrosoft.Extensions.DependencyInjection(IMvcBuilder) and ASP.NET Core MVC application parts. - Concept introduced, the application-part registration for controllers shipped in a NuGet assembly. ASP.NET Core scans only the host's own assembly for controllers by default; the notification controllers live in the
MMCA.Common.APIpackage, so they are invisible until their assembly is added as an application part.AddNotificationControllersis written as anextension(IMvcBuilder builder)member (DependencyInjection.cs:11) that callsbuilder.AddApplicationPart(typeof(NotificationsController).Assembly)(DependencyInjection.cs:21) to make all three controllers discoverable.[Rubric §7, Microservices Readiness]assesses whether a capability packages cleanly for reuse across hosts: shipping the controllers plus their own registration helper means any host adds the whole notification surface with one call, whichever service it is extracted into. - Walkthrough: inside the
extension(IMvcBuilder builder)block (DependencyInjection.cs:11),AddNotificationControllers()(DependencyInjection.cs:19) adds the application part (DependencyInjection.cs:21) and returns the builder for fluent chaining (DependencyInjection.cs:22). - Why it's built this way: controllers cannot be auto-discovered from a referenced package without an explicit application part; exposing that as a named
extension(IMvcBuilder)member keeps host composition roots declarative and reads as a first-classAddControllers().AddNotificationControllers()call (see primer §4). This is the API counterpart to the Application-layerDependencyInjection(AddNotificationApplicationServices) below. - Where it's used: called from a consuming host's MVC setup (
AddControllers().AddNotificationControllers()), alongside the Application-layer registration.
UserNotification
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.UserNotifications·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:12· Level 5 · class (sealed)
- What it is: the framework-level aggregate root that tracks delivery of a single
PushNotificationto one user, giving each recipient a per-user inbox row withIsRead/ReadOnstate. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(its base, closed overUserNotificationIdentifierType);IdValueGeneratedAttribute(the[IdValueGenerated]marker,UserNotification.cs:11);Result; the identifier aliasesUserNotificationIdentifierType,UserIdentifierType, andPushNotificationIdentifierType; andTimeProvider(BCL, the injectable clock the caller reads from). - Concept: the fan-out-on-send inbox row. One
PushNotificationproduces NUserNotificationrows, one per recipient, so read state is tracked per user.[Rubric §4, Domain-Driven Design]assesses whether behavior and invariants live inside the aggregate: state changes flow only through the factory andMarkAsRead, never through public setters (every property has aprivate set,UserNotification.cs:15-24).[Rubric §14, Testability]assesses whether time is injectable:MarkAsReadtakes the read instant as a parameter rather than reading an ambient clock, so behavior is deterministic under test (see below). - Walkthrough
- State:
UserId(UserNotification.cs:15),PushNotificationId(UserNotification.cs:18),IsRead(UserNotification.cs:21), and the nullableReadOntimestamp (UserNotification.cs:24), allprivate set. Create(userId, pushNotificationId)(UserNotification.cs:44-47): a directResult.Success(new UserNotification(...) { Id = default }). There is no validation, the only inputs are two foreign keys, andId = defaultbecause[IdValueGenerated](UserNotification.cs:11) hands ID generation to the database.MarkAsRead(DateTime readOnUtc)(UserNotification.cs:58): idempotent, an earlyif (IsRead) return(UserNotification.cs:60-63) preserves the original read time on repeat calls; otherwise it setsIsRead = trueandReadOn = readOnUtc(UserNotification.cs:65-66). The instant is supplied by the caller (from an injectedTimeProvider, per the method's own doc atUserNotification.cs:53-57), so the domain stays free of ambient clock access. No domain event is raised on read.
- State:
- Why it's built this way: idempotent
MarkAsReadshrugs off duplicate UI calls without corrupting the first read time, and passing the clock in keeps the entity pure. Database-generated ids ([IdValueGenerated]) suit a high-volume fan-out table where a factory-assigned sequence would be extra coordination. - Where it's used: created by
SendPushNotificationHandlerfor each recipient; read byGetMyNotificationsQuery's handler for the inbox; flipped by the mark-read command handlers behindInboxController.
PushNotificationDTOMapper
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.DTOs·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOMapper.cs:12· Level 6 · class (sealed, partial)
- What it is: the Mapperly-generated mapper from the
PushNotificationdomain entity to itsPushNotificationDTOresponse shape, implementingIEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>overPushNotification/PushNotificationDTO/PushNotificationIdentifierType(PushNotificationDTOMapper.cs:13). - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>,PushNotification,PushNotificationDTO,PushNotificationStatus; externalRiok.Mapperly.Abstractions([Mapper],[MapProperty]). - Concept: compile-time DTO mapping via Mapperly (ADR-001). Rather than hand-writing property copies, the
[Mapper]attribute (PushNotificationDTOMapper.cs:11) makes Mapperly generate the body of thepartialMapToDTOat build time, so there is no reflection cost at runtime.[Rubric §9, API & Contract Design]assesses whether the domain type is kept off the wire: the mapper is the single place the entity is projected to its contract shape. - Walkthrough
MapToDTO(PushNotification entity)(PushNotificationDTOMapper.cs:17): the generated one-to-one map, with one override,[MapProperty(nameof(PushNotification.Status), nameof(PushNotificationDTO.Status), Use = nameof(MapStatusToString))](PushNotificationDTOMapper.cs:16) routes the enum through a custom converter.MapToDTOs(IReadOnlyCollection<PushNotification>)(PushNotificationDTOMapper.cs:20): null-guards withArgumentNullException.ThrowIfNull(PushNotificationDTOMapper.cs:22) then projects the collection withMapToDTO(PushNotificationDTOMapper.cs:23).MapStatusToString(PushNotificationStatus status)(PushNotificationDTOMapper.cs:26): the private converter that renders thePushNotificationStatusenum as itsToString()name, so clients see a readable status string rather than a numeric code.
- Why it's built this way: Mapperly keeps mapping fast and analyzer-checked while still allowing per-property overrides (the enum-to-string case) where a plain copy is wrong (ADR-001).
- Where it's used: registered by the Application-layer
DependencyInjectionbelow (both as itself and as theIEntityDTOMapper<...>interface); consumed by the notification query/history use cases to shape responses forNotificationsController.
DependencyInjection
MMCA.Common.Application ·
MMCA.Common.Application.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:26· Level 9 · class (static, extension)
(Application-layer notification DI. Distinct from the API-layer DependencyInjection at Level 5 above; both keep the raw type name as their heading.)
- What it is: the Application-layer composition helper for the Notification module. Its single
extension(IServiceCollection)method,AddNotificationApplicationServices, registers every notification command/query handler, the DTO mapper, the query service, the validator, and the default recipient provider. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>andPushNotificationDTOMapper;IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>/EntityQueryService<TEntity, TEntityDTO, TIdentifierType>;NullNavigationPopulator<TEntity>;INotificationRecipientProvider/NullNotificationRecipientProvider; the handler contractsICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>;PushNotification,PagedCollectionResult<T>,Result, and the concrete use-case handlers plusSendPushNotificationRequestValidator. Externals:Microsoft.Extensions.DependencyInjection(.Extensions)(TryAddScoped),FluentValidation(AddValidatorsFromAssemblyContaining). - Concept introduced, explicit
TryAddregistration for a module that ships inside the framework assembly. Most modules are auto-scanned byScanModuleApplicationServices<TAssemblyMarker>(), but the Notification types live inMMCA.Common.Applicationitself, not a separate module assembly, so they are wired by hand here instead (DependencyInjection.cs:35). Every registration usesTryAddScoped(DependencyInjection.cs:38-67), theTryAddsemantic assessed under[Rubric §3, Clean Architecture]: a consuming app that registers its own handler or recipient provider before calling this helper keeps its override, becauseTryAddnever replaces an existing registration.[Rubric §1, SOLID](Dependency Inversion): the defaultINotificationRecipientProvideris the no-opNullNotificationRecipientProvider(DependencyInjection.cs:67), documented as the extension point a real app overrides to supply its own audience. - Walkthrough:
AddNotificationApplicationServices()(DependencyInjection.cs:35), insideextension(IServiceCollection services)(DependencyInjection.cs:28), registers in reading order:- The
PushNotificationaggregate's navigation populator (NullNavigationPopulator<PushNotification>) and itsEntityQueryService<...>(DependencyInjection.cs:38-40). - The
PushNotificationDTOMapper, both as itself and as theIEntityDTOMapper<...>interface (DependencyInjection.cs:43-45). - Three command handlers (send, mark-one-read, mark-all-read) at
DependencyInjection.cs:48-53and three query handlers (history, my-notifications, unread-count) atDependencyInjection.cs:56-61. - The FluentValidation validators from the
SendPushNotificationRequestValidatorassembly,includeInternalTypes: true(DependencyInjection.cs:64). - The default no-op recipient provider (
DependencyInjection.cs:67), then returns the collection for chaining (DependencyInjection.cs:69).
- The
- Why it's built this way: hand-registration keeps the notification sub-system self-contained inside the framework package while still honoring the framework-wide override contract (
TryAdd), and theextension(IServiceCollection)C# preview syntax lets it read as a first-classservices.AddNotificationApplicationServices()call (see primer §4). Its API counterpart is theAddNotificationControllershelper above. - Where it's used: called from every consuming host's Application composition root, before
AddApplicationDecorators()so the scanned handlers exist when the decorator pipeline wraps them.
⬅ Caching • Index • Navigation Metadata & Populators (EF-decoupled eager loading) ➡