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 default is always registered so nothing has to
be configured for DI to resolve. NullPushNotificationSender and
NullLiveChannelPublisher are no-ops
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:326-327),
IEmailSender defaults to the real SmtpEmailSender
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:325), and
INotificationRecipientProvider gets its default in the Application layer
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:75).
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:105-113). 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/Notifications/NotificationHub.cs:127-152).
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/Mail/SmtpEmailSender.cs:61-86). Two safety
decisions ride on that per-call client: TLS is resolved once at construction by
SmtpTransportSecurity from the settings, the configuration, and the host environment, rather than
left to a caller (SmtpEmailSender.cs:49-58), and the client's timeout is set explicitly from
SmtpSettings.TimeoutSeconds instead of inheriting SmtpClient's 100-second default, which is
longer than the callers sitting in front of it (SmtpEmailSender.cs:97-108,
ADR-070).
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, an optional deduplication key, an optional scope key, and a
PushNotificationStatus of Pending/Sent/Failed,
MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:24-53)
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:13-26).
Both aggregates are [IdValueGenerated], so the database assigns their ids
(MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:15,
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 three constant holders:
NotificationFeatures (the Notification.PushNotifications flag, declared
[FeatureFlag(FeatureFlagLifetime.Permanent, Owner = "MMCA.Common")] so the flag inventory knows it
is a permanent capability switch and not a cleanup candidate,
MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationFeatures.cs:11-12),
NotificationPermissions (the single notifications:manage capability,
MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationPermissions.cs:10), and
NotificationScopeKey (the event:{id} / session:{id} formatter and its
guard pattern,
MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:23-32). 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/Notifications/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 ADR-007 and
ADR-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 at the class level
(API versioning, [FeatureGate(NotificationFeatures.PushNotifications)], and
[HasPermission(NotificationPermissions.Manage)]) and a
fourth on the POST action itself,
[Idempotent]
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:25-29,43-44).
The authorization is stated as a capability, never a role: the endpoint asks for
notifications:manage, and the consuming host decides who holds it. ADC declares that binding once,
in NotificationPermissionGrants, whose single Apply grants
NotificationPermissions.Manage to the Organizer role and to no other
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/Authorization/NotificationPermissionGrants.cs:34-39),
and the module registration applies it as AddPermissions(NotificationPermissionGrants.Apply)
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:44). The map
is a named type rather than a lambda inside that registration because two hosts apply it: the
Notification host gates the framework's endpoints with it, and the token-minting Identity host
applies the same map so the issued access token carries the permission claim, which is what the
framework's push-notifications navigation entry is gated on
(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Authorization/TokenPermissionGrants.cs:58,
NotificationPermissionGrants.cs:11-18). It sits in the module's Shared (contracts) project, and the
RoleNames members it names are const, so they leave no reference in the compiled contract
assembly and the Shared-layer fitness test stays green (NotificationPermissionGrants.cs:19-24).
That is what lets an attendee never broadcast and an administrator hold no notification capability it
was not explicitly given ([Rubric §11, Security]). The controller then reads the authenticated id from
ICurrentUserService and refuses the call with an Error.Unauthorized when there is none
(NotificationsController.cs:52-56), reads the raw Idempotency-Key header and carries it into the
domain as the command's DedupKey, treating a whitespace-only header as absent
(NotificationsController.cs:60-67). Header binding is done by hand rather than with [FromHeader]
precisely so the key stays protocol plumbing and does not leak into the generated OpenAPI contract
(NotificationsController.cs:61). The request is wrapped in a
SendPushNotificationCommand and handed to
SendPushNotificationHandler (NotificationsController.cs:69-74).
Those two idempotency mechanisms are deliberately stacked (ADR-017). The filter replays the
original HTTP response for a repeated key; the DedupKey protects delivery even when the
filter's cache is cold, evicted, or degraded (NotificationsController.cs:37-41). The command is
also ITransactional
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:23),
which is what keeps the dedup short-circuit honest: without a transaction, a fault between the audit
write and the recipient writes would leave a committed row carrying the key that nothing ever
delivered, and every retry of that key would then report success forever
(SendPushNotificationCommand.cs:11-19). Inside that transaction the handler runs a deliberate
ordering
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:52-159):
- Dedup lookup first (lines 39-50). When a key is present the handler requeries for an existing
PushNotificationwith that key and, on a hit, returns it mapped to a DTO without sending anything again. The lookup goes throughunitOfWork.GetReadRepository<...>()rather than an injected repository, so it reads the same data source the write below targets (SendPushNotificationHandler.cs:188-197). - Resolve recipients through
INotificationRecipientProvider, failing early with aPushNotification.NoRecipientsvalidation error when the set is empty (lines 52-62). - Create and save the audit aggregate (lines 64-83), stamping the caller's optional scope key
onto the aggregate along with the dedup key (line 71). Because the dedup lookup is a
check-then-act, two concurrent retries of the same send both pass it, and the loser only fails
here, on the insert, against the filtered unique index on
DedupKey. The catch block requeries by key and, when the winner now exists, returns it; anything else rethrows, so a genuine persistence fault still reaches the exception middleware (lines 85-112). The requery deliberately usesCancellationToken.None, otherwise a save aborted by the caller's token could never be classified (lines 99-103). The index itself is the arbiter: unique, filtered to[DedupKey] IS NOT NULLand to live rows, so the many keyless sends coexist and a soft-deleted row does not occupy a key slot forever (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:68-70). - Write the inbox rows, one
UserNotificationper recipient, then save again (lines 114-122). - Attempt SignalR delivery via
IPushNotificationSender, catching any exception and recordingMarkAsSent()orMarkAsFailed()accordingly, since delivery failure is non-fatal (lines 124-142). - Attempt the native-push leg through
INativePushSender(ADR-044), whose failures are logged at Warning and never change the audit status (lines 144-161). - Save a third time and map the aggregate to a
PushNotificationDTOviaPushNotificationDTOMapper(lines 163-165).
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, and the audit status records which of the two real-time legs succeeded ([Rubric §29, Resilience & Business Continuity]).
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:75), and ADC
supplies AttendeeNotificationRecipientProvider, which
bridges the Identity module's
IAttendeeQueryService (over gRPC across
service boundaries) so a broadcast targets every conference attendee
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/AttendeeNotificationRecipientProvider.cs:10-16).
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).
Scope keys: one string that narrows a broadcast, an inbox, and a live channel
NotificationScopeKey is the small type that ties three otherwise unrelated
paths together. It owns the format (event:{id} via ForEvent, session:{id} via ForSession,
both rendered under InvariantCulture so a culture with non-ASCII digits cannot produce a key the
guard rejects) and, in the same file, the regular expression that validates one:
^(event|session):[0-9]+$
(MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:32,44-51,58),
compiled once through a source-generated [GeneratedRegex] with a one-second match timeout
(NotificationScopeKey.cs:64). That constant is also the default of
PushNotificationSettings.ChannelKeyPattern
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:33), which
is what NotificationHub enforces before a client may join a group. Producer and
guard therefore cannot drift apart: keeping the format and its pattern in one type is the whole point
of the class ([Rubric §15, Best Practices & Code Quality]).
On the send side the key is optional and opaque. It arrives as an init property on
SendPushNotificationRequest rather than a positional parameter, so
every pre-existing caller keeps compiling and an omitted key means "unscoped"
(MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/SendPushNotificationRequest.cs:23-28);
SendPushNotificationRequestValidator caps its length at
128 and the domain re-checks the same bound in
PushNotification.Create
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationRequestValidator.cs:27-29,
MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:22,114-120).
On the read side the scope is a view filter, not a security boundary, and the code says so in as
many words: a read that supplies a scope sees the notifications carrying that scope plus every
unscoped one, and a read that supplies none still sees everything
(PushNotification.cs:47-53). Ownership is enforced separately, by the un.UserId == query.UserId
predicate that every inbox read carries. ADC's Engagement UI is the producer of the key in practice:
CurrentEventNotificationScopeProvider
resolves event:{EventId} for the conference currently in focus, caches it for five minutes, and
deliberately answers event:0 (a well-formed key no row carries, hence an empty inbox) rather than
null when it cannot resolve one, because on that contract null means unscoped and would widen an
attendee's inbox to every event
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Notifications/CurrentEventNotificationScopeProvider.cs:35-48,67).
The inbox side
Reading and acknowledging notifications is the query/command counterpart, served by
InboxController under the same feature gate and a plain [Authorize], so any
authenticated user reaches only their own inbox
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:24-28);
every action re-reads ICurrentUserService.UserId and builds the message from it rather than
trusting a client-supplied id ([Rubric §11, Security]). It exposes four use cases, three of which
accept the optional scope query parameter, length-checked against
PushNotification.ScopeKeyMaxLength at the boundary
(NotificationInboxController.cs:46,70,113):
- The paged inbox (
GetMyNotificationsQuery/GetMyNotificationsHandler), which joins the per-userUserNotificationrows to theirPushNotificationcontent newest-first, applies the scope predicate to thePushNotificationside only when one was supplied, and clamps paging throughPagingMathwith a 500-row page ceiling (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetInbox/GetMyNotificationsHandler.cs:21,32,39-61). - The unread count (
GetUnreadNotificationCountQuery/GetUnreadNotificationCountHandler), served[ResponseCache(NoStore = true)]so the bell badge is never stale (NotificationInboxController.cs:66-68). Its join toPushNotificationis introduced only for a scoped count, because an unconditional join would dragPushNotification's soft-delete global query filter into the unscoped count and change a number no caller asked to change (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetUnreadCount/GetUnreadNotificationCountHandler.cs:27-40). - A single mark-read (
MarkNotificationReadCommand/MarkNotificationReadHandler), which matches on both the notification id and the requesting user id, returningUserNotification.NotFoundrather than a forbidden when the row belongs to somebody else (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkRead/MarkNotificationReadHandler.cs:24-36). - A mark-all-read (
MarkAllNotificationsReadCommand/MarkAllNotificationsReadHandler), whose scoped variant keeps the write aligned with what the caller can see. Its conditional join deliberately uses the trackedTablerather thanTableNoTracking: anAsNoTrackingsource anywhere in a composed EF query switches the whole query to no-tracking, theUserNotificationrows would come back untracked, and theMarkAsReadmutations would silently never be saved (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkAllRead/MarkAllNotificationsReadHandler.cs:37-46) ([Rubric §8, Data Architecture]).
UserNotification.MarkAsRead is idempotent and takes the read timestamp
as a parameter (from an injected TimeProvider, MarkNotificationReadHandler.cs:15,38) 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 organizer-facing history read is the fifth use case and lives on the other controller:
GetNotificationHistoryQuery /
GetNotificationHistoryHandler pages the
PushNotification audit rows newest-first under the same 500-row clamp and maps
them through PushNotificationDTOMapper
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/GetHistory/GetNotificationHistoryHandler.cs:21,30-43).
List reads that go through the generic query service take a different path again:
PushNotificationDTOProjector wraps the Mapperly-generated
PushNotificationDTOProjection, and merely registering it switches
those reads onto server-side projection
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:48-53)
([Rubric §12, Performance & Scalability]).
The SignalR transport, and how it survives extraction
NotificationHub is intentionally thin
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:24-27): it is
[Authorize]d, and beyond ASP.NET's built-in per-user connection mapping it does three things.
It caps concurrent connections per user on the replica it runs on, counting them in a static
dictionary and refusing the surplus with a HubException, then handing the slot straight back so the
refused connections cannot lock the account out once aborted ones drain (the cap is
PushNotificationSettings.MaxConnectionsPerUser, 20 by default:
NotificationHub.cs:49-58,63-81,99-118,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:46).
It manages channel (SignalR group) membership through JoinChannel/LeaveChannel, validating
each channel key against the configured pattern with a cached, one-second-timeout Regex so a bad
key throws HubException rather than opening an injection or ReDoS hole
(NotificationHub.cs:44-47,175-185). And before it creates that group membership it consults an
entitlement check (NotificationHub.cs:130-131,154-173) ([Rubric §11, Security]).
That check is IChannelJoinAuthorizer
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IChannelJoinAuthorizer.cs:16),
an optional constructor dependency defaulted to null (NotificationHub.cs:18-27), and the reason
it exists is that shape is not entitlement: the pattern proves a key is well formed, not that the
caller may read what is published to it, and the keys are consecutive integers, so enumerating them
costs nothing. A host that publishes anything channel-scoped which is not public to every signed-in
user has to register one. ADC does, per connection in the Notification host
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:239), and the implementation is
LiveChannelJoinAuthorizer: it refuses an unauthenticated principal,
admits the privileged read audience without a lookup (ConferenceReadAudience.PrivilegedRoles, the
same Organizer / ContentEditor pair that reads the unfiltered catalog through REST), and otherwise
asks Conference's IEventLiveValidationService whether the event behind an event:{id} key is
published, or whether the session behind a session:{id} key is live-eligible and its owning event
published
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Live/LiveChannelJoinAuthorizer.cs:53-72,85-102).
Reusing Conference's answer rather than inventing a second rule is the point: the same service
already gates opening a poll and submitting a question. Every other outcome, a failed lookup
included, is a refusal logged at Information (LiveChannelJoinAuthorizer.cs:65-72,142-146), because
a join is not a request the user is waiting on data for, so failing closed costs a retry while
failing open would restore exactly the exposure the gate closes. It also parses the key with its own
TryParseChannelKey instead of trusting the hub's match, so the gate cannot be widened by a host
that widens ChannelKeyPattern in configuration (LiveChannelJoinAuthorizer.cs:108-140).
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/Notifications/Push/SignalRPushNotificationSender.cs:14,41-58)
([Rubric §12, Performance & Scalability]); the live publisher does a single Clients.Group send
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Live/SignalRLiveChannelPublisher.cs:14-18).
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.Notifications.cs:41-70).
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
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:327,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:66). 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:31-63).
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 endpoints on
Http1AndHttp2 (the WebSocket Upgrade needs HTTP/1.1) while a second, Http2-only Kestrel endpoint
named grpc (8081 in the container, 5996 locally) is declared in the Kestrel:Endpoints config
section and resolved by peers as _grpc.notification through the
services__notification__grpc__0 entry
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:57-72). The host maps the hub
itself at /hubs/notifications via MapNotificationHub() (Program.cs:280-284, the path coming
from PushNotificationSettings.HubPath,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:21) and both
gRPC services on that dedicated endpoint (Program.cs:293,301). The two gRPC surfaces are not
protected alike, and the difference is deliberate: the live-channel ingress is mapped
.AllowAnonymous() (Program.cs:293) because it is reachable only on the internal service network
and its caller (Engagement's
LiveChannelPublishProcessor background
drain) has no HttpContext and forwards no bearer
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:16-23),
while the export rpc is mapped with .RequireAuthorization() because its response carries personal
data keyed by a raw user id (Program.cs:297-301).
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 (Dependencies => ["Identity"], RequiresDependencies => true, since it needs attendee
data, NotificationModule.cs:21-24) and whose Register calls AddNotificationModule to wire the
application handlers, the EF configurations, the SignalR push registration, the native-push channel,
the one role-to-permission grant (NotificationPermissionGrants),
and the Common controllers
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:30-50). It
is a deliberately thin module (API + Application only, no Infrastructure project of its own). The
framework's own registration
(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:36-78) uses
TryAddScoped throughout so a consuming app can override any handler or the recipient provider, and
the API-layer registration
(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 ([Rubric §15, Best Practices & Code Quality]).
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 (a
client-generated stable installation id, a platform of fcmv1 or apns, and the platform push
handle,
MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/DeviceInstallationRequest.cs:14-33).
Both verbs scope ownership the same way: each reads currentUserService.UserId and passes it to the
registrar, so a PUT can only register the caller's own installation and a DELETE only removes one
the caller owns
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:38-45,62-69).
The ownership check itself lives in the registrar
(IPushDeviceRegistrar, Group 07): an id
that does not exist and an id belonging to another user both answer 204 without deleting anything,
so the response cannot be used as an existence oracle for other users' installation ids
(DevicesController.cs:48-54) ([Rubric §11, Security]).
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 (id, title, sent/read dates,
and the scope the notification was sent under, but never the body) 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-42,
MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/UserNotificationExportItemDTO.cs:8-29).
When the module is disabled, NotificationModule registers
DisabledUserNotificationExportService, which answers with
an empty list 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 of
Group 05 and
IUnitOfWork of persistence
(Group 07), the permission attribute of
Group 08, the idempotency filter of
Group 12, and the module system of
Group 14. Downstream, the Blazor UI of
Group 15 consumes it: the
NotificationBell, inbox, and send pages call
these REST endpoints, ask an
INotificationScopeProvider for the
scope to pass, and the UI's SignalR client listens on the hub's ReceiveNotification /
ReceiveChannelEvent methods (NotificationHub.cs:30-33). The ADC Engagement live layer
(Group 23) is the busiest producer of live-channel events, and
recipients are resolved 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, and without a retried request doing it twice.
GetMyNotificationsQuery
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetInbox·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetInbox/GetMyNotificationsQuery.cs:11· 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'sUserId, paging arguments, and an optional scope key. - Depends on: the solution-wide
UserIdentifierTypealias (see primer §2); BCL only otherwise. Handled by GetMyNotificationsHandler through the query side of the CQRS pipeline (IQueryHandler<in TQuery, TResult>). - 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,GetMyNotificationsQuery.cs:13-14) 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:66-67). It also introduces the scope key as an optional view filter:ScopeKeydefaults tonull, and the XML doc states what that default means (GetMyNotificationsQuery.cs:7-10), namely that a null scope is the legacy read returning every notification, while a supplied scope narrows the inbox to notifications carrying that scope plus the unscoped ones. Making the narrowing opt-in is what keeps an existing caller's result set unchanged (ADR-024 records the scope key on the sent artifact).[Rubric §6, CQRS & Event-Driven]assesses whether reads and writes are modeled as distinct, single-purpose messages; 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" (GetMyNotificationsQuery.cs:6) and GetMyNotificationsHandler enforces that ceiling as aconst, so a client cannot request an unbounded page. - Walkthrough: four positional members,
UserId(the authenticated user, line 12),PageNumberdefaulting to 1 (line 13),PageSizedefaulting to 20 (line 14), and the nullableScopeKey(line 15). No factory, no validation here: it is a plain carrier, and the page ceiling, the negative-offset guard and the scope join are all 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, and the defaulted
ScopeKeymeans adding scoping did not break a single existing construction site. - Where it's used: constructed by InboxController from the resolved caller id, the
query-string paging values and the optional
scopeargument (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:55), then handled by GetMyNotificationsHandler. The controller bounds the scope string with[StringLength(PushNotification.ScopeKeyMaxLength)](NotificationInboxController.cs:46), the same 128-character constant the entity publishes (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:22).
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
GetMyNotificationsQuery introduced, minus a user filter and minus the scope
key. History is global (it lists PushNotification rows, the sent artifacts), so
there is no
UserIdmember and no per-reader narrowing.[Rubric §6, CQRS & Event-Driven]: a second read model (sent history) distinct from the inbox read model, each with its own query, handler and DI entry (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:64-65). - 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 (GetNotificationHistoryQuery.cs:5). - Where it's used: constructed by NotificationsController
(
MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:85), handled by GetNotificationHistoryHandler.
GetUnreadNotificationCountQuery
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetUnreadCount·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetUnreadCount/GetUnreadNotificationCountQuery.cs:9· Level 0 · record
- What it is: the tiniest read in the group, "how many unread notifications does this user have?". A
two-member
sealed recordwrappingUserIdplus the same optionalScopeKeythe inbox read takes. Its XML doc (GetUnreadNotificationCountQuery.cs:3) names the unread badge as the reason it exists. - Depends on: the
UserIdentifierTypealias; BCL only otherwise. Handled by GetUnreadNotificationCountHandler. - Concept introduced: none new; a purpose-built count query rather than fetching a page and counting
client-side. The scope member exists for one reason, stated in its XML doc
(
GetUnreadNotificationCountQuery.cs:5-8): the badge must count exactly what the list will show, so both reads narrow by the same rule.[Rubric §12, Performance & Scalability]: a dedicatedCOUNTquery avoids materializing rows just to size a badge, so the bell can poll cheaply. - Walkthrough: two positional members,
UserId(line 10) and the nullableScopeKey(line 11). No paging: the answer is a single integer. - Where it's used: constructed by InboxController's unread-count action
(
NotificationInboxController.cs:79, scope bound atNotificationInboxController.cs:70) behind the NotificationBell badge; handled by GetUnreadNotificationCountHandler. That action is also marked[ResponseCache(NoStore = true)](NotificationInboxController.cs:67), so a badge poll is never served from a stale cache.
IEmailSender
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Mail·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Mail/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 by SmtpEmailSender (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/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 (
IEmailSender.cs: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's SmtpEmailSender into 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, so callers that always mail the operators do not repeat the address. In the SMTP implementation that address isSmtpSettings.To, reached through a settings field captured once fromIOptions(SmtpEmailSender.cs:14) and forwarded to the explicit-recipient overload (SmtpEmailSender.cs:86). - 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 is
TryAddTransient<IEmailSender, SmtpEmailSender>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:325), so theTryAddlets a host pre-register its own sender and win. - Where it's used: the framework's own consumer is the password-reset flow:
ForgotPasswordHandlerBase<TUser, TCommand>
takes it as a primary-constructor dependency
(
MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:39), and MMCA.ADC's concrete ForgotPasswordHandler passes its own sender through (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:24). MMCA.Store uses it twice more, resolving it from a scope insideOrderPaidHandlerandOrderPaymentFailedSagaHandler(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/DomainEventHandlers/OrderPaidHandler.cs:41,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/Saga/OrderPaymentFailedSagaHandler.cs:31), the arrangement ADR-024 describes. - Caveats / not-in-source: the "default/system recipient" of the second overload is not defined by this interface; it is whatever the implementation's settings configure.
ILiveChannelPublisher
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/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 (ILiveChannelPublisher.cs: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 a PushNotification plus per-user UserNotification
inbox rows), a contrast the XML doc draws explicitly via
<see cref="IPushNotificationSender"/>(line 5). Implemented by SignalRLiveChannelPublisher, the NullLiveChannelPublisher no-op, and in MMCA.ADC by the out-of-process LiveChannelPublisherGrpcAdapter (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/LiveChannelPublisherGrpcAdapter.cs:22). - 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 (ADR-039). 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's LiveChannelGrpcService (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:31) and consumed through a scoped adapter thatReplaces the local registration in publishing services (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:48), 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/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:327), replaced by SignalRLiveChannelPublisher when a host opts into the SignalR wiring (same file, line 632). - Where it's used: ADC's conference-day live layer does not inject it into command handlers.
Handlers enqueue a work item and a single-reader hosted drain,
LiveChannelPublishProcessor, resolves the
publisher from a fresh scope per work item and calls
PublishAsyncin FIFO order (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:130-136).[Rubric §29, Resilience & Business Continuity]assesses whether a degraded dependency stays contained: the drain wraps each publish in BestEffort (LiveChannelPublishProcessor.cs:45-58), which turns any non-cancellation failure into one Warning log plus one metric increment instead of an exception (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:65-71), and it returns quietly when host shutdown is the reason a publish stopped (LiveChannelPublishProcessor.cs:60-64, the rethrow-on-cancellation half ofBestEffortatBestEffort.cs:59-64). A down Notification peer can therefore never fail an Engagement command. The other consumer is the Notification host's gRPC ingress, which forwards a wire call onto the local SignalR publisher. - Caveats / not-in-source: the interface itself makes no delivery or ordering guarantee; those are properties of the concrete SignalR, queue and gRPC wiring, not visible here.
INotificationRecipientProvider
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/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,
INotificationRecipientProvider.cs:5-6: all attendees, users in a role, subscribers to a topic). - Depends on: the
UserIdentifierTypealias; BCL only otherwise. Works alongside IPushNotificationSender. The default framework registration is NullNotificationRecipientProvider; MMCA.ADC supplies AttendeeNotificationRecipientProvider (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:24). - 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]assesses whether a shared component can be re-homed without its consumers changing; this is 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:75), 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. ADC registers its own with a plainAddScopedbefore callingAddNotificationApplicationServices()(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:24,31), so theTryAddfinds the slot already taken. - Where it's used: SendPushNotificationHandler takes it as a primary
constructor dependency
(
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:45), resolves the audience, then hands it to IPushNotificationSender. Until an app registers its own provider, NullNotificationRecipientProvider returns an empty audience.
IPushNotificationSender
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/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 (
IPushNotificationSender.cs:4-5). - Depends on: the
UserIdentifierTypealias; BCLDictionaryfor the optional metadata. Implemented by SignalRPushNotificationSender (real delivery) and NullPushNotificationSender (the inert default). Paired with INotificationRecipientProvider for 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 §6, CQRS & Event-Driven Design]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 to ILiveChannelPublisher's ephemeral fan-out (ADR-024). - 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/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:326) so a host with no real-time transport still resolves the port; the SignalR wiring replaces it withAddTransient(same file, line 631), a deliberate override rather than aTryAdd. - Where it's used: SendPushNotificationHandler fans a message out
through this port (
SendPushNotificationHandler.cs:46) after persisting the PushNotification record and its per-user UserNotification rows. That handler also carries a second, separate delivery leg,INativePushSender(SendPushNotificationHandler.cs:47), which is the OS-level channel of ADR-044 rather than part of this port.
MarkAllNotificationsReadCommand
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkAllRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkAllRead/MarkAllNotificationsReadCommand.cs:10· Level 0 · record
- What it is: the only bulk write in the inbox, "clear my unread badge". A two-member
sealed recordwrapping the authenticatedUserIdand the same optionalScopeKeythe two inbox reads accept. - Depends on: the
UserIdentifierTypealias; BCL only otherwise. Handled by MarkAllNotificationsReadHandler via ICommandHandler<in TCommand, TResult>. Its single-row sibling is MarkNotificationReadCommand. - Concept introduced: commands opt into pipeline behavior through marker interfaces. This record
declares no base list at all (line 10), and neither does its single-row sibling
(
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkRead/MarkNotificationReadCommand.cs:6-8). That is a decision, not an omission: the Transactional decorator only opens a transaction for a command that implements ITransactional and otherwise passes it straight through (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:29-31), so marking notifications read runs on the handler's own singleSaveChangesAsyncrather than an ambient transaction.[Rubric §6, CQRS & Event-Driven]assesses whether the write path is modeled as explicit messages with explicit cross-cutting opt-ins; the decorator pipeline is described in Group 05. - Walkthrough: two positional members,
UserId(line 11) and the nullableScopeKey(line 12). The scope of the write is entirely implied by those two values; there is no id list to validate. The XML doc (MarkAllNotificationsReadCommand.cs:5-9) states the rule that motivates the second member: a scoped caller marks only the notifications it could see, "so a scoped client never silently clears rows it could not see".[Rubric §11, Security]: read and write narrow by the identical predicate, which is what keeps a scoped client from mutating rows outside its view. - Why it's built this way: taking the user id as data rather than reading an ambient identity keeps the handler pure and testable; the controller is the one place that resolves "who is calling".
- Where it's used: constructed by InboxController's
PUT read-allaction from the current user id and the optional scope (NotificationInboxController.cs:113,122), and dispatched through the injectedICommandHandler<MarkAllNotificationsReadCommand, Result>(NotificationInboxController.cs:33). Registered in DI atMMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:60-61.
UserNotificationExportItemDTO
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.UserNotifications·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/UserNotificationExportItemDTO.cs:8· 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,
UserNotificationExportItemDTO.cs:4): the notification id, title, sent/read timestamps, and the scope it was sent under. This is an MMCA.ADC-specific contract, not a framework type. - Depends on: the ADC
UserNotificationIdentifierTypealias; BCLstringandDateTime. Returned by IUserNotificationExportService; consumed by the Identity module's NotificationUserDataExportSection and projected into UserDataExportNotificationDTO. - Concept introduced: export DTOs deliberately omit content. The XML doc (lines 5-6) 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, under which scope) 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 11),required Title(line 14),required SentOn(line 17, UTC), then three optional members:IsRead(line 20), nullableReadOn(line 23, null when unread) and nullableScopeKey(line 29, null for an unscoped notification, documented with theevent:1example at lines 25-28). Therequiredmembers force every export row to carry an identity, a title and a send time; the optional members model "never read" and "unscoped" without sentinel values. - 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. CarryingScopeKeykeeps the export self-describing: two rows with the same title are distinguishable by the event they belonged to. - Where it's used: assembled by the in-process
UserNotificationExportService, which projects all six members
server-side out of the inbox join
(
MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/UserNotificationExportService.cs:31-39), and returned across the export boundary to Identity; the disabled-module path substitutes DisabledUserNotificationExportService and the out-of-process path UserNotificationExportServiceGrpcAdapter.
IUserNotificationExportService
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.UserNotifications·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/IUserNotificationExportService.cs:14· 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),
the ADC
UserIdentifierTypealias, and the[ServiceContract]marker fromMMCA.Common.Shared.Abstractions(applied atIUserNotificationExportService.cs:13, imported at line 1). Implemented in-process by UserNotificationExportService inside the Notification module (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/UserNotificationExportService.cs:15) and, per the XML doc (IUserNotificationExportService.cs:10-11), by a gRPC adapter inMMCA.ADC.Notification.Contractseverywhere else; the disabled-module stub is DisabledUserNotificationExportService. - 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). The[ServiceContract]attribute is the machine-readable half of the same idea: it marks the interface as a cross-module boundary that architecture fitness tests can find and police.[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/Data Governance]: 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 (the.protomirror lives atMMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/Protos/user_notification_export.proto:17-19, whose comments record that identifier aliases areinton the wire and dates travel as round-trip ISO 8601 strings,user_notification_export.proto:14-16). - Walkthrough: one method,
GetUserNotificationExportAsync(UserIdentifierType userId, CancellationToken cancellationToken)(line 23), returningIReadOnlyList<UserNotificationExportItemDTO>newest-first. Note the token has no default value here, so every caller must pass one explicitly. The XML doc (lines 17-18) states the implementation joins the framework UserNotification rows with their PushNotification content, which is the same join the inbox read performs. - Where it's used: the Identity module wraps it in a per-section adapter,
NotificationUserDataExportSection,
which takes it as its single constructor dependency
(
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:18), calls it (NotificationUserDataExportSection.cs:29-31) and projects the rows into the export document (NotificationUserDataExportSection.cs:33-44), unaware of which of the three implementations it received.[Rubric §29, Resilience & Business Continuity]: that section deliberately does not catch transport failures (NotificationUserDataExportSection.cs:11-15), because the export handler wraps every section and degrades an unreachable peer to one unavailable section rather than failing the whole export.
NullNotificationRecipientProvider
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Infrastructure.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/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 in SendPushNotificationHandler and 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, 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 4-6) states the expectation directly, that consuming
apps should register their own provider. Pairing that with the
TryAddScopeddefault registration (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:75) means "safe by default, overridable without ceremony", and the comment on the line above says so in as many words (DependencyInjection.cs:74). - Where it's used: registered by the notification DI extension; 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 IUserNotificationExportService registered when the Notification module is disabled in a given host. It returns an empty inbox export.
- Depends on: IUserNotificationExportService and UserNotificationExportItemDTO; 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, because IModule has a dedicatedRegisterDisabledStubshook that NotificationModule implements with exactly this registration (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:34-35). Identity's export section still binds its dependency and simply gets no notification rows rather than a resolution failure. - Walkthrough: an
internal sealed class(line 7) whose single methodGetUserNotificationExportAsync(lines 10-11) returnsTask.FromResult<IReadOnlyList<UserNotificationExportItemDTO>>([]), ignoring both theuserIdand the token. Registered as a singleton (NotificationModule.cs:35), which is safe precisely because it holds no state.internalis deliberate: the type is only ever named by the module that registers it, and every consumer sees it through the interface. - 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:16· Level 8 · class
- What it is: the query handler that materializes a user's inbox page. It joins the per-user UserNotification rows with their shared PushNotification content, optionally narrows that join by scope, and projects the pair into a single flat UserNotificationDTO.
- Depends on: IUnitOfWork, resolved as
IReadRepository<TEntity, TIdentifierType>
via
GetReadRepositoryfor both entities (lines 33-34), and IQueryableExecutor (EF terminal operations kept out of Application), injected via primary constructor (lines 16-18), plus PagingMath. Implements IQueryHandler<in TQuery, TResult><GetMyNotificationsQuery, Result<PagedCollectionResult<UserNotificationDTO>>>. Returns Result, PagedCollectionResult<T> and PaginationMetadata. - 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, scope key) and fanned
out into one lightweight UserNotification per 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 to IQueryableExecutor so Application stays EF-free.[Rubric §11, Security]: thewhere un.UserId == query.UserIdclause (line 50) is the entire tenancy boundary for an inbox, and the value comes from the resolved caller rather than from client input (NotificationInboxController.cs:49,55). The scope filter is explicitly not part of that boundary: the comment above it (lines 36-38) calls scope "a view filter, not a security boundary". - Walkthrough: a
const int MaxPageSize = 500states the ceiling on the type (line 21), matching the query's documented "max 500".PagingMath.Clamp(query.PageNumber, query.PageSize, MaxPageSize)(line 32) turns the requested page into a safe(skip, take)pair; the comment above it (lines 28-31) records why: the offset is computed in 64-bit because a 32-bit(PageNumber - 1) * PageSizewraps negative nearint.MaxValue, and SQL Server rejects a negativeOFFSEToutright (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:32-43). Then: grab the two read-only repositories from the unit of work viaGetReadRepository, each typed by entity and identifier alias (lines 33-34); build the push-notification source, which isTableNoTrackingunchanged for an unscoped read and gains apn.ScopeKey == null || pn.ScopeKey == scopeKeypredicate when a scope is supplied (lines 39-44, the localstring scopeKeyon line 42 giving the expression tree a non-nullable capture); build the LINQ query-syntax join ofUserNotificationto that source onun.PushNotificationId equals pn.Id, filtered toquery.UserId, ordered bypn.CreatedOndescending thenun.Iddescending (line 51), projected intoUserNotificationDTO(lines 48-61, mapping id, push id, title, body,IsRead,ReadOn, andSentOn = pn.CreatedOn); count the joined set (line 63); page it withSkip(skip).Take(take)and materialize (lines 65-67); wrap total, the clamped requested page size (Math.Clamp(query.PageSize, 1, MaxPageSize)) and floored page number into PaginationMetadata (line 73) and return a successful PagedCollectionResult<T> (line 74). - 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. The scope predicate is applied to the push-notification side before the join rather than after it, so an unscoped read composes exactly the query it always did. Theun.Idtie-break afterCreatedOnmakes the sort a total order: without it, two notifications created in the same instant have no defined relative order, so they could swap places between twoOFFSETpages and one would repeat while the other vanished (the comment on lines 46-47 states exactly that). The metadata reports the clamped requested page size and the floored page number (line 73) so the response describes the page actually served. It deliberately uses the clamped request rather thantake: a page past the 32-bit offset range reads nothing (take0), and a zero page size would report zero pages for a non-empty inbox (the comment on lines 69-72 says so). It matters because the[Range]attributes at the API boundary (NotificationInboxController.cs:44-45) do not protect a direct in-process caller. - Where it's used: injected as a closed
IQueryHandlerinto InboxController (NotificationInboxController.cs:30), which is how it reaches the CQRS decorator pipeline described in Group 05.
GetUnreadNotificationCountHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.GetUnreadCount·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/GetUnreadCount/GetUnreadNotificationCountHandler.cs:13· Level 8 · class
- What it is: the query handler behind the unread badge: it counts a user's unread UserNotification rows, optionally narrowed to a scope, and returns the integer.
- Depends on: IUnitOfWork, resolved as
IReadRepository<TEntity, TIdentifierType>
via
GetReadRepository(line 22, and again for the scoped path at line 34), and IQueryableExecutor (primary constructor, lines 13-15). Implements IQueryHandler<in TQuery, TResult><GetUnreadNotificationCountQuery, Result<int>>. - Concept introduced: a conditional join, and why the unscoped path must stay byte-for-byte the
same. The comment on lines 27-30 is the teaching moment: the join to
PushNotification is introduced only for a scoped count, because an
unconditional join would drag
PushNotification's soft-delete global query filter into the legacy no-scope count and silently change a number no caller asked to change. A scoped count accepts that narrowing deliberately, since a scoped reader could not see a deleted parent anyway.[Rubric §8, Data Architecture]assesses awareness of how global query filters and joins interact; this is that awareness written into the code.[Rubric §12, Performance & Scalability]: the handler issues a server-sideCOUNToverun.UserId == query.UserId && !un.IsReadrather than fetching rows, so the bell can poll without materializing the inbox. It is also a good illustration of why IQueryableExecutor exists: the handler composes the predicate in Application and hands the still-unexecutedIQueryableto Infrastructure to run. - Walkthrough: get the UserNotification read-only repository from the unit of
work via
GetReadRepository(line 22); compose the base filter overTableNoTrackingfor the user's unread rows (lines 24-25); when and only when a non-blankScopeKeyarrived, resolve the PushNotification read-only repository the same way and re-form the query as a join keeping rows whose parent is unscoped or matches the scope (lines 31-40, withselect unso the projection staysUserNotification); runqueryableExecutor.CountAsync(line 42); returnResult.Success(count)(line 44). No paging, no mapping, no failure branch: the count either comes back or the call throws through the pipeline. - Why it's built this way:
string.IsNullOrWhiteSpace(line 31) is the gate, so a blank query-string scope is treated as absent rather than as a scope nothing matches. Reassigningunreadrather than branching into two separate counts keeps a single execution path for the terminal operator. - Where it's used: injected into InboxController
(
NotificationInboxController.cs:31), which serves the NotificationBell badge.
MarkAllNotificationsReadHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.UserNotifications.UseCases.MarkAllRead·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/UserNotifications/UseCases/MarkAllRead/MarkAllNotificationsReadHandler.cs:18· Level 8 · class
- What it is: the command handler that clears a user's unread notifications (all of them, or just the
ones in a scope) with one set-based
UPDATE: it builds a predicate over the user's unread rows and hands it to the repository'sExecuteUpdateAsync, never loading a row. - Depends on: IUnitOfWork, resolved as
IRepository<TEntity, TIdentifierType>
via
GetRepository, and the BCLTimeProvider(primary constructor, lines 18-20). Implements ICommandHandler<in TCommand, TResult><MarkAllNotificationsReadCommand, Result>and writes the same two columns UserNotification'sMarkAsReadowns. - Concept introduced: a set-based write that deliberately bypasses the change tracker. The type's
<remarks>(lines 13-17) carry the argument: an inbox can hold an unbounded number of unread rows, andMarkAsReadonly setsIsReadandReadOnand raises no domain event (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:58-67), so issuingUPDATE ... SET IsRead = 1, ReadOn = @now WHERE ...loses nothing that loading and tracking each row would have kept. The repository contract spells out the cost of the bypass: no domain events, global query filters still apply to the predicate, and the update runs on the ambient transaction when one is active (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:460-483). Audit fields are not lost either: becauseExecuteUpdateskips the save pipeline's audit interceptor,EFRepositorystampsLastModifiedOnandLastModifiedByitself unless the caller set them (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:141-155).[Rubric §12, Performance & Scalability]: one statement regardless of inbox size, with no materialization.[Rubric §4, DDD]assesses whether state changes go through the aggregate rather than around it: here they go around it on purpose, and the invariantMarkAsReadprotects (an already-read row keeps its originalReadOn,UserNotification.cs:60-63) is preserved by the!un.IsReadterm in the predicate, so a read row never matches and its timestamp is never overwritten.[Rubric §14, Testability]: the read timestamp comes from an injectedTimeProvider(line 20, used at line 48) rather thanDateTime.UtcNow, which is what makes the clock substitutable in a unit test. - Walkthrough: get the UserNotification write repository via
GetRepository(line 27); form the base predicateun.UserId == command.UserId && !un.IsReadas anExpression<Func<UserNotification, bool>>(lines 29-30); when and only when a non-blankScopeKeyarrived (line 37), copy it into a local (line 39), take the PushNotificationTable(line 40) and replace the predicate with one that adds a correlatedpushNotifications.Any(pn => pn.Id == un.PushNotificationId && (pn.ScopeKey == null || pn.ScopeKey == scopeKey))test (lines 42-45), which EF translates to anEXISTSsubquery; take one UTC instant for the whole batch (line 48) so every row in one call reports the same read time; callrepository.ExecuteUpdateAsyncwith the predicate and setters forIsRead = trueandReadOn = readOnUtc(lines 50-55); returnResult.Success()(line 57). - Why it's built this way: the scope condition mirrors the unread count for the two reasons the
comment on lines 32-36 gives: a scoped client must not mark rows it cannot see as read, and a no-scope
command keeps the legacy predicate exactly as it was rather than inheriting
PushNotification's soft-delete global query filter. Expressing the scope as anEXISTSsubquery rather than a join keeps the update targeted atUserNotificationalone while the push-notification filter applies to the subquery exactly as it did to the former join (same comment). A repeated "mark all read" is a cheap no-op at the database: the predicate matches zero rows and nothing changes, which matters because the UI can fire it on every inbox open. The returned row count is discarded, since the command reports success either way. Since MarkAllNotificationsReadCommand does not implement ITransactional, the singleUPDATEstatement is the atomic unit; see Group 05 for the decorator order. - Where it's used: injected into InboxController as
ICommandHandler<MarkAllNotificationsReadCommand, Result>(NotificationInboxController.cs:33) and invoked by thePUT read-allaction (NotificationInboxController.cs:123), which returns 204 on success (NotificationInboxController.cs:125).
GetNotificationHistoryHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.GetHistory·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/GetHistory/GetNotificationHistoryHandler.cs:15· Level 9 · class
- What it is: the query handler for the push sent history: a reverse-chronological page of PushNotification rows (no per-user join), mapped to PushNotificationDTO.
- Depends on: IUnitOfWork, resolved as
IReadRepository<TEntity, TIdentifierType>
via
GetReadRepository(line 31), IQueryableExecutor and PushNotificationDTOMapper (primary constructor, lines 15-18), plus PagingMath. Implements IQueryHandler<in TQuery, TResult><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 no scope narrowing, and maps entities to DTOs with an explicit PushNotificationDTOMapper rather 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.[Rubric §15, Best Practices & Code Quality]: the paging arithmetic that both this handler and the inbox handler once open-coded now lives once in PagingMath, whose own remarks record that duplication as the reason it was extracted (PagingMath.cs:14-18). - Walkthrough:
const int MaxPageSize = 500(line 21) thenPagingMath.Clamp(query.PageNumber, query.PageSize, MaxPageSize)(line 30); get the PushNotification read-only repository viaGetReadRepository(line 31); ask the repository itself for the total viarepository.CountAsync(line 33, note this one goes through the repository, not the queryable executor, because there is no composed predicate to count); pageTableNoTrackingordered byCreatedOndescending thenIddescending withSkip/Takeand materialize the entities through the executor (lines 35-41); run them throughdtoMapper.MapToDTOs(line 43); build PaginationMetadata from the total, the clamped requested page size (Math.Clamp(query.PageSize, 1, MaxPageSize), nottake, so a page past the offset range that reads nothing still reports a non-zero size) and the floored page number (line 49); return a successful PagedCollectionResult<T> (line 51). - Why it's built this way: the
ThenByDescending(n => n.Id)tie-break (line 38) makes the order total, so two notifications sent in the same instant cannot repeat or skip acrossOFFSETpages, the same guarantee the inbox handler gets from itsun.Idtie-break. History has no per-user state, so there is nothing to join; a dedicated mapper (versus an inline projection) is used because PushNotificationDTO is a richer contract reused across the push endpoints, 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:32), which builds the query from its route arguments (NotificationsController.cs:85).
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 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 the 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 in
the chapter, 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 around as magic strings.[Rubric §9, API & Contract Design]assesses whether request contracts are explicit, bounded, and self-describing: the[MaxLength(128)],[MaxLength(16)], and[MaxLength(1024)]caps (DeviceInstallationRequest.cs:22,27,32) pin the payload and column sizes right on the contract.[Rubric §11, Security]shows in the design rule the doc comment states (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 (DeviceInstallationRequest.cs:6-9). - Why it's built this way: the doc comment attributes the shape to ADR-044 (native push delivery). A stable client id plus a rotating platform channel is the 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: bound as the
[FromBody]parameter of the registration action on DevicesController (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:35), which is routed atNotifications/Devices(.../DevicesController.cs:21) and gated by the NotificationFeatures.PushNotificationsflag (.../DevicesController.cs:23).
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 here. - 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 rather than 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 decorator marker interfaces are declared on the type (noITransactional, no cache-invalidation marker), so it rides the default command pipeline unadorned. - Where it's used: handled by MarkNotificationReadHandler; the
authenticated
UserIdis supplied by the inbox 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:8· Level 1 · class (static)
- What it is: the feature-flag key constants for the Notification module. It holds exactly one:
PushNotifications = "Notification.PushNotifications"(NotificationFeatures.cs:12), declared[FeatureFlag(FeatureFlagLifetime.Permanent, Owner = "MMCA.Common")](NotificationFeatures.cs:11). - Depends on:
MMCA.Common.Shared.FeatureFlags.FeatureFlagAttributeandFeatureFlagLifetime(Level 0types this one now sits above). - Concept, feature flags as named constants with a declared lifecycle.
[Rubric §6, CQRS & Event-Driven Design]assesses whether cross-cutting configuration lives in one place rather than as 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 the feature-gate decorators and the[FeatureGate]attribute consult (see primer §2). The[FeatureFlag]attribute is ADR-031's dead-toggle detector:Permanentmarks a flag with no end date (a capability a host chooses to run with or without), as opposed toTemporary, which must carry a removal date and fails the build once that date passes (MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagLifetime.cs:9-22). - Walkthrough: one
public const stringon astaticclass, carrying the[FeatureFlag]attribute (NotificationFeatures.cs:8-13). - Where it's used: the constant is applied as
[FeatureGate(NotificationFeatures.PushNotifications)]on all three notification controllers, NotificationsController (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:28),NotificationInboxController(.../NotificationInboxController.cs:27), and DevicesController (.../DevicesController.cs:23), so turning the flag off removes the whole push surface at once. The[FeatureFlag]declaration itself is asserted byFeatureFlagLifecycleTestsand inventoried byFeatureFlagRegistryTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/FeatureFlags/FeatureFlagRegistryTests.cs).
NotificationPermissions
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationPermissions.cs:7· Level 0 · class (static)
- What it is: the permission-key constants for the Notification module. It holds exactly one:
Manage = "notifications:manage"(NotificationPermissions.cs:10), covering both sending push notifications and reading the send history. - Depends on: nothing first-party.
- Concept, capability-named permissions instead of roles. The doc comment states the rule
(
NotificationPermissions.cs:3-6): a host grants these constants to whichever roles it wants to hold them throughAddPermissions(...), and the endpoints state the capability, never a role.[Rubric §11, Security]assesses whether authorization is expressed as fine-grained capabilities that can be re-granted without touching endpoint code: because the controller carries[HasPermission(NotificationPermissions.Manage)](MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:29) rather than a role name, moving the capability from one role to another is a single line in the host's DI. The permission machinery itself (HasPermissionAttribute, PermissionRegistry) is taught in Group 08.[Rubric §15, Best Practices & Code Quality]: aconst stringmeans the grant site and the check site cannot drift apart by a typo. - Walkthrough: one
public const stringon astaticclass (NotificationPermissions.cs:7-11). - Where it's used: granted to RoleNames
.Organizerand to no other role by ADC's Notification module wiring (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:38, see DependencyInjection); enforced on the framework's NotificationsController.
NotificationScopeKey
MMCA.Common.Shared ·
MMCA.Common.Shared.Notifications·MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:20· Level 0 · class (static partial)
- What it is: the canonical formatter and validator for a notification scope key (called a
channel key on the SignalR join path): the
event:{id}/session:{id}string that narrows a push notification, or a live channel, to one subject. - Depends on: nothing first-party. It uses
System.Globalization.CultureInfoand the source-generatedSystem.Text.RegularExpressions.Regexfrom the BCL. - Concept introduced, keeping a format and its guard in one type. A string format that is written in
one place and validated in another is a latent bug: change either half and the other silently
strands. This type collapses both halves onto one static class (
NotificationScopeKey.cs:11-19). ThePatternconstant (NotificationScopeKey.cs:32) is literally the default value ofPushNotificationSettings.ChannelKeyPattern(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:33), the regex NotificationHub enforces before a client may join a group (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:178), so every key the factories produce is a key the hub accepts by construction.[Rubric §15, Best Practices & Code Quality]assesses whether shared literals are centralized: the alternative here was a hand-written interpolation at each call site, which is what this replaced.[Rubric §12, Performance & Scalability]shows in[GeneratedRegex](NotificationScopeKey.cs:64): the regex is compiled at build time by the source generator rather than parsed at runtime, and it carries a 1000 ms match timeout.[Rubric §27, i18n]is the subtle one:ForEventandForSessionformat the identifier underCultureInfo.InvariantCulture(NotificationScopeKey.cs:45,51) so a thread culture with non-ASCII digits cannot produce a key that its ownPatternwould reject. - Walkthrough: four constants first,
EventPrefix = "event"(line 23),SessionPrefix = "session"(line 26),Pattern = "^(event|session):[0-9]+$"(line 32), andMetadataKey = "scopeKey"(line 39), the key under which a live (SignalR) notification's metadata carries its scope key so a client can leave a notification for another scope out of its toast and badge; the sender writes it (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:181) and an unscoped send carries no metadata at all. Then two factories,ForEvent(long)(lines 44-45) andForSession(long)(lines 50-51), both built withstring.Create(CultureInfo.InvariantCulture, $"..."), which formats into a stack buffer without an intermediate allocation.IsValid(string?)(lines 58-59) short-circuits on null or empty before running the generated matcher. The matcher itself is the private partial propertyScopeKeyRegex(lines 64-65), declared[GeneratedRegex(Pattern, RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)]; the comment above it (lines 61-63) records whyExplicitCaptureis safe, it only suppresses the unused capture group of the alternation and leaves the pattern text andIsMatchsemantics identical. - Why it's built this way: a scope key crosses three subsystems (the send request, the persisted aggregate, and the SignalR group name), so its shape is a contract. Putting the format, the prefixes, and the regex on one type makes a change to any of them a compile-time coordinated edit.
- Where it's used: ADC's Engagement module builds every live-channel and points-subject key through
it, LivePollChannel
(
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:25,30), PointsSubjectKeys (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:20,26), and CurrentEventNotificationScopeProvider, which even builds its unresolved-state sentinel fromEventPrefix(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Notifications/CurrentEventNotificationScopeProvider.cs:39) so the hub guard still accepts it. ThePatternconstant is consumed as a settings default by PushNotificationSettings.
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 written 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 (PushNotification.cs:139,144). - Where it's used: the
private setStatusproperty on PushNotification (PushNotification.cs:37); 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), plus an optionalScopeKeyinit property and the two length limits both sides of the wire share. - Depends on: nothing first-party.
- Concept, publishing an invariant's number on the contract.
TitleMaxLength = 200(SendPushNotificationRequest.cs:15) andBodyMaxLength = 2000(SendPushNotificationRequest.cs:21) mirror the domain invariantsPushNotificationInvariants.TitleMaxLength/.BodyMaxLength(MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:13,16), which the server enforces. The doc comment (SendPushNotificationRequest.cs:8-14) gives the reason: the contract is the one type both sides of the wire share, so a compose form can cap its input and draw its character counter from the same number the server rejects on, instead of restating it.[Rubric §9, API & Contract Design]assesses whether the contract is self-describing; publishing the bound is exactly that.[Rubric §24, Forms/Validation/UX Safety]: the UI validation and the server validation cannot disagree because they read the same constant, and the framework's send page test exercises the boundary through it (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Notifications/NotificationSendTests.cs:139). - Walkthrough: two positional parameters (line 6); two
public const intlimits (lines 15, 21); andScopeKeyas a nullableinitproperty (line 28) rather than a third positional parameter, which the comment (lines 23-27) notes was deliberate so every existing caller keeps compiling. Null, the default, sends an unscoped notification that every read sees. No validation attributes live on this record: the content invariants are enforced server-side by the request validator and by the PushNotification aggregate'sCreatefactory. - Why it's built this way: mirroring rather than importing the domain constant keeps
MMCA.Common.Sharedfree of a dependency onMMCA.Common.Domain, which is what lets a client project reference the contracts assembly alone[Rubric §3, Clean Architecture]. - Where it's used: bound as the
[FromBody]parameter of the send action on NotificationsController (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:49), which also reads theIdempotency-Keyheader and passes it on as the dedup key (.../NotificationsController.cs:62); consumed client-side by PushNotificationService.
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, identifier-type 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, andIsRead(UserNotificationDTO.cs:10-22), plus plaininitmembers for the nullableReadOn(line 25) and the always-presentSentOn(line 28).requiredplusinitgives 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 that the ADC privacy export uses a separate UserNotificationExportItemDTO shape instead, because it carries the scope key and omits the body.
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,
AttendeeNotificationRecipientProvider.cs:10-11). - 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 supplies the concrete "all attendees" rule, while a host that supplies nothing falls back to NullNotificationRecipientProvider.[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.[Rubric §7, Microservices Readiness]: the provider talks to Identity through an interface, so when Identity is extracted the same call becomes a gRPC hop with no change here. - Walkthrough: a primary-constructor class taking
IAttendeeQueryService(AttendeeNotificationRecipientProvider.cs:10-11). Its single methodGetRecipientUserIdsAsync(lines 14-16) is an expression-bodiedawaitforwarding toattendeeQueryService.GetAttendeeUserIdsAsync(cancellationToken), 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 as the scoped
INotificationRecipientProviderin the Notification module's application-layer DependencyInjection (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:24); consumed by SendPushNotificationHandler 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, the optional scope key, 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 at the client.[Rubric §9, API & Contract Design]. - Walkthrough:
required initmembers forId,Title,Body,SentByUserId,RecipientCount, andStatus(PushNotificationDTO.cs:11-26), then two plaininitmembers, the nullableScopeKey(line 29) andCreatedOn(line 32).ScopeKeyis optional rather than required precisely because unscoped notifications are the default (see PushNotification).DedupKeyis deliberately absent from the DTO: it is internal idempotency plumbing, not something a reader needs. - 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:16· Level 7 · class (sealed)
- What it is: the framework-level aggregate root for a push-notification broadcast. It records the title, body, sender, recipient count, delivery status, an optional deduplication key, and an optional scope key, and it raises a domain event on creation so the send and fan-out machinery can react.
- Depends on:
AuditableAggregateRootEntity<TIdentifierType>
(base class), PushNotificationInvariants and
CommonInvariants (validation),
PushNotificationStatus, the
PushNotificationCreated domain event, and
Result (factory return). It carries the
IdValueGeneratedAttribute
(
PushNotification.cs:15). - Concept, the aggregate-root factory idiom applied to a framework-owned entity (the canonical
entity-chain teaching is Group 02). Three design choices are
worth naming. First,
[IdValueGenerated](line 15) tells the persistence layer the database generates the id, soCreatesetsId = default(line 128) 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 internal delivery bookkeeping. Third, and this is the interesting one,DedupKey(lines 39-45) is the aggregate's idempotency anchor: the doc comment records that a filtered unique index on the column lets the database arbitrate a race between two retried sends, so the same notification is never delivered twice.[Rubric §29, Resilience & Business Continuity]assesses whether retries are safe; here the guarantee is pushed down to a database constraint rather than trusted to a read-then-write check.ScopeKey(lines 47-53) is explicitly not a security boundary: the comment states that a read supplying a scope sees the notifications carrying that scope plus every unscoped one, and a read supplying none still sees everything, so it is an opaque view filter[Rubric §11, Security]. - Walkthrough: two length constants first,
DedupKeyMaxLength = 128(line 19) andScopeKeyMaxLength = 128(line 22). Then sevenprivate setproperties,Title,Body,SentByUserId,RecipientCount,Status(lines 25-37), and the two nullable keysDedupKey(line 45) andScopeKey(line 53). A private parameterless constructor (lines 56-60) seeds non-null strings for EF materialization; a private all-args constructor (lines 62-77) setsStatus = PushNotificationStatus.Pending(line 74) and normalizes both optional keys, whitespace collapses tonull(lines 75-76), so "empty string" can never become a distinct dedup identity. The staticCreatefactory (lines 96-134) combines four invariant checks throughResult.Combine(lines 104-120): title, body, and the twoEnsureStringMaxLengthguards for the dedup and scope keys, each carrying its own error code ("PushNotification.DedupKey.TooLong", line 110;"PushNotification.ScopeKey.TooLong", line 117) and an invariant-culture message. On any failure it returnsResult.Failure<PushNotification>(result.Errors)(lines 121-124), aggregating every broken rule rather than reporting only the first. Otherwise it constructs the entity withId = default(lines 126-129), raisesPushNotificationCreated(default, title, recipientCount)(line 131), and returns success (line 133).MarkAsSentandMarkAsFailed(lines 139, 144) 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 ADC and Store without coupling to any app's module. ThePendingtoSent/Failedbookkeeping is intentionally event-free because it reflects an infrastructure callback, not a business decision. Both optional keys default tonullso every pre-existing caller keeps its original behavior: no dedup key means every send creates a new notification, and no scope key means the notification is visible to every read (lines 87-94). - Caveats / not-in-source: the filtered unique index that makes
DedupKeya real race arbiter is described in the entity's doc comment (lines 40-43) but is declared in the EF configuration and migration, not here; the aggregate itself performs no uniqueness check. - Where it's used: created by SendPushNotificationHandler (one per broadcast), which then fans out a UserNotification per recipient; read back through PushNotificationDTO and joined for the privacy export by UserNotificationExportService.
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 plus save),
IQueryableExecutor (async materialization),
TimeProvider(BCL, testable clock), MarkNotificationReadCommand (input), UserNotification (the aggregate it mutates), 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 yields 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 anError.NotFoundErrorfailure with code"UserNotification.NotFound"when there is no match (lines 30-36), otherwise calls the aggregate'sMarkAsRead(...)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)plus owner-scoped filter is the cheapest safe read, and idempotency lives in the aggregate, not the handler: UserNotification.MarkAsReadreturns immediately whenIsReadis already true (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:58-67), so a repeated mark neither overwrites the originalReadOnnor produces a spurious update. That keeps this handler 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 (internal 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 in
MMCA.ADC.Notification.Shared), IUnitOfWork, IQueryableExecutor, the framework UserNotification and PushNotification entities, and the UserNotificationExportItemDTO projection. - Concept, the privacy export read.
[Rubric §30, Compliance/Privacy/Data Governance]assesses whether a person can obtain the data held about them; this class is one section of that answer. It 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. Note the class isinternal sealed(line 15): only the interface is public, so nothing outside the assembly can bind to the implementation[Rubric §1, SOLID]. - Walkthrough:
GetUserNotificationExportAsync(lines 20-43) gets both repositories from the unit of work (lines 24-25), builds a LINQ query-syntax join ofUserNotificationtoPushNotificationonPushNotificationId(lines 27-28), filterswhere un.UserId == userIdand orders bypn.CreatedOn descending(lines 29-30), projects into UserNotificationExportItemDTO including theScopeKey(lines 31-39), materializes through the queryable executor (line 41), and returns a fresh list via a collection expression (line 42). - 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 as the scoped
IUserNotificationExportService by the module's application-layer
DependencyInjection
(
MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:28); 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). When the module is disabled, NotificationModule.RegisterDisabledStubssubstitutes DisabledUserNotificationExportService instead.
DependencyInjection
MMCA.ADC.Notification.Application ·
MMCA.ADC.Notification.Application·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Application/DependencyInjection.cs:13· 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 plus
AttendeeNotificationRecipientProvider,
IUserNotificationExportService plus
UserNotificationExportService, and the framework's
AddNotificationApplicationServices()registration. - Concept, the
extension(IServiceCollection)registration block. DI wiring here uses the C# preview extension-member syntax (DependencyInjection.cs:15), which lets a library add methods directly toIServiceCollection(taught in the primer).[Rubric §3, Clean Architecture]is visible in the split of responsibility: this method registers the module's app-specific choices (attendees as recipients, the export service) and then calls the framework'sAddNotificationApplicationServices()(line 32) for the reusable handlers, mapper, validator, and entity query service, so shared and app-specific wiring stay separate and only the app-specific half needs review when policy changes. - Walkthrough:
AddModuleNotificationApplication(lines 20-35) discards the unusedapplicationSettingswith_ = applicationSettings;(line 22, commented "Reserved for future use", which also keeps the analyzers quiet about an unused parameter on a fixed signature), registers AttendeeNotificationRecipientProvider as the scopedINotificationRecipientProvider(line 25), registers UserNotificationExportService as the scopedIUserNotificationExportServicefor the privacy export (line 29), calls the framework'sAddNotificationApplicationServices()(line 32), and returns the collection for chaining (line 34). Both module registrations useTryAddScoped(lines 25 and 29), so they apply only when no implementation of that interface is registered yet: a registration made before this method runs (a host override or a test double) wins, and calling the method twice does not stack a second descriptor. - Where it's used: called first thing by the API-layer
DependencyInjection
.AddNotificationModule(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:32), which is itself driven by NotificationModule.Register.
LiveChannelGrpcService
MMCA.ADC.Notification.Service ·
MMCA.ADC.Notification.Service.Grpc·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:31· 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,
MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:31; in this host it resolves to SignalRLiveChannelPublisher, registered byAddPushNotificationsbecause Notification is the host that maps the SignalR NotificationHub), the generatedLiveChannelPushServicebase (compiled from the.Contracts.proto,LiveChannelGrpcService.cs:32), 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 (doc commentLiveChannelGrpcService.cs:10-15). This is the server half; its client half is LiveChannelPublisherGrpcAdapter. - Walkthrough: the single
PushToChanneloverride (LiveChannelGrpcService.cs:47) null-guardsrequestandcontext(LiveChannelGrpcService.cs:51-52), validates the three fields (LiveChannelGrpcService.cs:54-56), then awaitspublisher.PublishAsync(request.ChannelKey, request.EventName, request.PayloadJson, context.CancellationToken)(LiveChannelGrpcService.cs:58-60) and returns an emptyPushToChannelResponse(LiveChannelGrpcService.cs:62). The transport still relays the event rather than modeling it, but it no longer accepts arbitrary strings: the class doc (LiveChannelGrpcService.cs:24-29) calls this the "bounded payloads" compensating control for an unauthenticated ingress.- Three caps (
LiveChannelGrpcService.cs:34-36):MaxChannelKeyLength= 200,MaxEventNameLength= 100,MaxPayloadBytes= 64 KiB (64 * 1024). AllowedChars(LiveChannelGrpcService.cs:43-44), aSearchValues<char>overabcdefghijklmnopqrstuvwxyz0123456789:.-, is the one character set shared by channel keys and event names; its doc comment (LiveChannelGrpcService.cs:38-42) notes it covers every producer format, NotificationScopeKey keys such asevent:1and the live-layer event names such aspoll.results-changed.ValidateName(LiveChannelGrpcService.cs:65-81) rejects a blank value, one over its cap, and one containing any character outsideAllowedChars(ContainsAnyExcept,LiveChannelGrpcService.cs:77).ValidatePayload(LiveChannelGrpcService.cs:83-89) rejects a payload whose UTF-8 byte count exceeds the cap; a null payload passes.- Every refusal is an
RpcExceptionwithStatusCode.InvalidArgumentbuilt byInvalidArgument(LiveChannelGrpcService.cs:91-92), thrown before the publisher is reached, with messages formatted underCultureInfo.InvariantCulture.[Rubric §11, Security]: input bounding is what limits the blast radius of a surface that cannot authenticate its caller.
- Three caps (
- Why it's built this way (security posture): there is deliberately no
[Authorize], and the endpoint is mapped with an explicit.AllowAnonymous()(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:293). The comment above it (Program.cs:289-292) says why the opt-out is stated rather than implied: the framework's fallback authorization policy gates any endpoint that declares nothing (SEC-Common-16), which would have silenced the whole live layer.[Rubric §11, Security]: the doc comment (LiveChannelGrpcService.cs:16-23) gives two reasons. First, 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 (it namesBookmarkCountsGrpcService). Second, authorization is not addable here: the publishing caller is Engagement's LiveChannelPublishProcessor background drain, which runs with noHttpContextand therefore forwards no bearer token. Transport-wise it rides the ADR-012 mixed-endpoint profile: Notification keeps its default endpointHttp1AndHttp2for SignalR WebSockets (Program.cs:72) and serves this h2c gRPC ingress on a dedicatedHttp2-only endpoint namedgrpc(port 8081 in the container, 5996 locally), declared in theKestrel:Endpointsconfig section rather than in code; the full reasoning is spelled out atProgram.cs:57-71. - Where it's used: mapped by the Notification service's
Program.cs(Program.cs:293); 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:29· Level 1 · class (sealed)
- What it is: the client half of the live-channel ingress. It is a hand-written adapter that implements the framework port ILiveChannelPublisher on top of the generated gRPC client, so a publishing service (Engagement) can keep calling the abstraction while the event actually travels to the Notification host over the wire.
- Depends on: ILiveChannelPublisher (Level 0, the implemented port); the
generated
LiveChannelPushService.LiveChannelPushServiceClientfrom the.Contracts.proto(its only constructor dependency, injected via the primary constructor,MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/LiveChannelPublisherGrpcAdapter.cs:29-30). It talks to LiveChannelGrpcService on the far side. - Concept introduced, the hand-written adapter over a generated gRPC client.
[Rubric §1, SOLID]assesses dependency inversion: Engagement's live handlers depend only onILiveChannelPublisher, and whether that resolves to the in-process SignalR publisher or this remote adapter is a composition-root decision (class doc,LiveChannelPublisherGrpcAdapter.cs:7-12).[Rubric §7, Microservices Readiness]assesses whether a module survives extraction: this class is exactly the piece that makes the extracted topology work without touching a single handler.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation, and the policy is enforced one layer up. Live channel events are ephemeral by contract (ADR-039), but a failure (transport, name resolution, an open circuit) propagates from this adapter (LiveChannelPublisherGrpcAdapter.cs:13-28, ADR-096). The only caller, Engagement'sLiveChannelPublishProcessor, runs every publish insideBestEffort.ExecuteAsyncoff the request path, which swallows the failure, logs one Warning and counts it onbesteffort.dispatch.failed(operationlive-channel-publish:*); swallowing in the adapter as well would hide every push failure from that metric (ADR-121). A publishing command never awaits this adapter, so a down Notification service still cannot fail it. - Walkthrough
PushDeadline(LiveChannelPublisherGrpcAdapter.cs:34), a privatestatic readonly TimeSpanof 2 seconds. The comment above it (:24-25) explains the choice: it is deliberately tighter than the shared resilience pipeline's 30-second attempt timeout, because a live event that takes longer than two seconds is stale anyway and the publishing drain should not be held hostage waiting for it.PublishAsync(channelKey, eventName, payloadJson, cancellationToken)(LiveChannelPublisherGrpcAdapter.cs:37) builds aPushToChannelRequestfrom the three opaque strings and awaitsclient.PushToChannelAsync(...)withdeadline: DateTime.UtcNow.Add(PushDeadline)and the caller's token (:33-41).- The only catch is a filtered
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested)(:51), which rethrows asOperationCanceledException(ex.Status.Detail, ex, cancellationToken)(:53). The gRPC client reports the caller's own cancellation as aCancelledRpcException, whichBestEffortwould otherwise count as a failed push on every host shutdown. Every other failure leaves untouched, so[Rubric §13, Observability]is served by the caller's one Warning and metric rather than a log line here (the adapter no longer takes anILogger).
- Why it's built this way: writing the adapter by hand (rather than letting consumers inject the
generated client directly) is what keeps the gRPC dependency out of the Engagement handlers. The
best-effort policy is stated once, in the caller's
BestEffortwrapper, so a swallow here would only blind that wrapper. Its sibling UserNotificationExportServiceGrpcAdapter also lets transport failures propagate, because the caller needs to know the export section is unavailable. - Where it's used: registered by the Notification.Contracts
DependencyInjection's
AddNotificationLiveChannelClient, which usesservices.Replace(...)so the adapter overwrites the framework's NullLiveChannelPublisher default (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:48). The one caller today is the Engagement service's composition root (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:291).
NullLiveChannelPublisher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Notifications.Live·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Live/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)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Live/NullLiveChannelPublisher.cs:14), whose whole body is=> Task.CompletedTask(NullLiveChannelPublisher.cs:15). No exception, no logging, no work. - Why it's built this way: the class doc comment (
NullLiveChannelPublisher.cs:5-10) states the contract: downstream apps override this with SignalRLiveChannelPublisher viaAddPushNotifications(), 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 as the framework default with
services.TryAddTransient<ILiveChannelPublisher, NullLiveChannelPublisher>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:327) so the port is always resolvable;AddPushNotificationsadds the SignalR implementation over it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:66), and in ADC Engagement's composition rootservices.Replace(...)overwrites it with the LiveChannelPublisherGrpcAdapter (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:48).
NullPushNotificationSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Notifications.Push·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/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(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NullPushNotificationSender.cs:13),SendToUsersAsync(NullPushNotificationSender.cs:17), andBroadcastAsync(NullPushNotificationSender.cs: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 (
NullPushNotificationSender.cs:5-9) notes downstream apps override this with SignalRPushNotificationSender viaAddPushNotifications(). 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
IPushNotificationSenderbyAddInfrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:326); superseded by the SignalR sender in any host that callsAddPushNotifications(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:65).
SendPushNotificationCommand
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:21· 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, carries an optional deduplication key, and marks the whole send as transactional. - Depends on: ICommandWithRequest<out TRequest> (Level 0), ITransactional (Level 0), SendPushNotificationRequest (Level 0).
- Concept: the command-wraps-request idiom (see
ICommandWithRequest<out TRequest>, G05).
The public HTTP request is the small
Title/Body/ScopeKeyrecord; 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-parameter positional record implementing both
ICommandWithRequest<SendPushNotificationRequest>andITransactional(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:21-23), plus one body member.Request(SendPushNotificationCommand.cs:22) is the validated DTO;SentByUserId(SendPushNotificationCommand.cs:23) is the audit/authorization context.DedupKey(SendPushNotificationCommand.cs:35), aninit-onlystring?, is the optional deduplication key, typically the caller'sIdempotency-Keyheader. Its doc comment (SendPushNotificationCommand.cs:25-34) states the contract: when present, a send whose key has already been seen returns the existing notification instead of creating a second one and sending again; when null (the default every existing caller gets) the send behaves exactly as before. The key is scoped to the sender (SendPushNotificationCommand.cs:31-33): the handler stores and looks up a SHA-256 of{SentByUserId}:{key}, never the raw client key, so two senders reusing one client key never collide, and the stored value always fits the column.[Rubric §29, Resilience & Business Continuity]assesses whether a retry is safe: this property is what makes a retried broadcast at-most-once at the delivery level (see the matching logic in SendPushNotificationHandler). It complements, and is distinct from, the HTTP-level IdempotentAttribute response cache (ADR-017).
- Why it's built this way (the
ITransactionalmarker): the class doc (SendPushNotificationCommand.cs:11-19) is unusually explicit, and it is the key to reading the handler. The handler writes the audit row first and the per-recipient inbox rows second, so without a transaction a fault between the two would leave a committed notification row carryingDedupKeythat nothing ever delivered, and every later retry with that key would short-circuit on the dedup lookup and report success forever. UnderITransactionalthe Transactional decorator in the CQRS pipeline (ADR-014) rolls the failed attempt back whole, so the retry genuinely re-runs the send. The doc also names the accepted cost: the two sender calls run inside the transaction, bounded by their own timeouts and holding locks only on rows this request just inserted.[Rubric §8, Data Architecture],[Rubric §29, Resilience]. - Where it's used: dispatched by the push-notification API endpoint (the organizer-only NotificationsController); handled by SendPushNotificationHandler.
SmtpEmailSender
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Mail·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/SmtpEmailSender.cs:15· Level 1 · class (sealed)
- What it is: the SMTP adapter for the IEmailSender port. It sends mail through a
System.Net.Mail.SmtpClientconfigured from bound SmtpSettings. This is the entire "email channel" of the notification subsystem, independent of the push/inbox flow. - Depends on: IEmailSender (Level 0);
SmtpSettings (Level 0), taken as
IOptions<SmtpSettings>and unwrapped once into readonly fields by both constructors (SmtpEmailSender.cs:16and:29-30); SmtpTransportSecurity, which the second constructor calls to resolve TLS (SmtpEmailSender.cs:33). Externals:Microsoft.Extensions.Options(IOptions<T>),Microsoft.Extensions.Configuration(IConfiguration),Microsoft.Extensions.Hosting(IHostEnvironment),Microsoft.Extensions.Logging(ILogger<SmtpEmailSender>),System.Net.Mail(SmtpClient,MailMessage), andSystem.Net(NetworkCredential). - Concept introduced, the options-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 §17, DevOps & Deployment]: host, port, credentials, and the default from/to addresses come from configuration bound at startup byAddInfrastructure(services.AddOptions<SmtpSettings>().Bind(configuration.GetSection(SmtpSettings.SectionName)),MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:98-99), never hard-coded, so the same code targets a real relay in production and a local SMTP container in development.[Rubric §11, Security](SEC-Common-54): TLS is no longer read verbatim fromSmtpSettings.EnableSsl. The container-preferred constructor resolves it through SmtpTransportSecurity: an explicitSmtp:EnableSslsetting wins, otherwise TLS defaults on outside Development (SmtpEmailSender.cs:29-34), so a deployed host that never set the flag no longer sends credentials in the clear by default.
- Walkthrough: two constructors, both storing into
_smtpSettingsand_enableSsl(SmtpEmailSender.cs:12-13).SmtpEmailSender(IOptions<SmtpSettings>)(SmtpEmailSender.cs:24-29): the legacy shape, kept for a caller that constructs the sender by hand; it null-checks the options (ArgumentNullException.ThrowIfNull,:26) and takesEnableSslexactly as configured (:28).SmtpEmailSender(IOptions<SmtpSettings>, IConfiguration, IHostEnvironment, ILogger<SmtpEmailSender>)(SmtpEmailSender.cs:41-46): the constructor the DI container prefers (most resolvable parameters), resolving_enableSslviaSmtpTransportSecurity.Resolve(...)(:45), which can log a one-time warning when a deployed host disables TLS deliberately.SendAsync(to, subject, body, isHtml, cancellationToken)(SmtpEmailSender.cs:61): guards the three strings withArgumentException.ThrowIfNullOrEmpty(SmtpEmailSender.cs:63-65), builds the client viaCreateClient(_smtpSettings, _enableSsl)inside ausing(:67), builds aMailMessagefrom_smtpSettings.From(alsousing,SmtpEmailSender.cs:69-72), and awaitsSendMailAsync(message, cancellationToken)(SmtpEmailSender.cs:74).SendAsync(subject, body, isHtml, cancellationToken)(SmtpEmailSender.cs:85-86): a convenience overload that forwards to the five-argument method with the default_smtpSettings.Torecipient, for admin/system mail with no explicit addressee.CreateClient(SmtpSettings, bool)(SmtpEmailSender.cs:96-105):internal static, factored out so the bounded timeout is assertable without a live relay. Builds theSmtpClientfromHost/Portwith aNetworkCredentialand the resolvedenableSsl(justified#pragma warning disable S5332at:97, restored at:106, because TLS is now resolved bySmtpTransportSecurityrather than read verbatim), and setsTimeoutfromSmtpSettings.TimeoutSeconds(:103):SmtpClient's own default is 100 seconds, longer than the callers in front of it, so an unresponsive relay would otherwise hold the request for the whole of it; the bound is validated at startup (ADR-070).
- 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. The doc comment (
SmtpEmailSender.cs:11-14on the original single constructor's era; now on the type) is explicit that a new client is created and disposed per send, no pooled long-lived connection. Two constructors rather than an optional parameter is the same patternEntityQueryServiceuses: the container has no notion of an optional dependency, and removing the original constructor would be a breaking public-API change. Unlike push, email has no null-object default:SmtpEmailSenderitself is whatAddInfrastructureregisters asIEmailSender(services.TryAddTransient<IEmailSender, SmtpEmailSender>(),MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:325), so a host that never configures SMTP settings still resolves a real sender that will fail at send time rather than silently no-op. - Where it's used: injected as
IEmailSenderby callers (the port, not this class); the transient registration resolves the four-argument constructor wheneverIConfiguration,IHostEnvironment, andILogger<SmtpEmailSender>are all available, which is the normal host case. It is not called from the push/inbox send flow. - Caveats / not-in-source:
IOptions<T>(notIOptionsMonitor<T>) plus the read-once fields mean a configuration change after startup is not picked up by an already-constructed instance. The registration is transient, so a new instance per resolve re-reads.Value, but the semantics of a live reload are not exercised anywhere in this file.
NotificationHub
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Notifications·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:25· 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, bounds per-user connection count, and manages channel (SignalR
group) membership. It does not itself construct or fan out messages, that work lives in
SignalRPushNotificationSender and
SignalRLiveChannelPublisher, both of which push through
IHubContext<NotificationHub>(doc commentMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/NotificationHub.cs:11-16). - Depends on:
PushNotificationSettings (read
through
IOptions<T>for the channel-key pattern andMaxConnectionsPerUser); IChannelJoinAuthorizer (optional, constructor-defaulted tonull); 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 and 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:24, header comment context), 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. Shape is not entitlement, though (SEC-ADC-25 / the join-authorizer note below): a well-formed key alone does not mean the caller is entitled to it, which is what IChannelJoinAuthorizer closes when a host registers one.OnConnectedAsync/OnDisconnectedAsync(NotificationHub.cs:59-93) additionally cap live connections per user identifier on this replica atPushNotificationSettings.MaxConnectionsPerUser(default 20,PushNotificationSettings.cs:46), the same per-process bound the in-memory rate limiters use: it constrains what one token can do to one process, the resource actually being exhausted.[Rubric §12, Performance & Scalability]assesses whether hot paths avoid repeated work. The compiledRegexis cached in a staticConcurrentDictionarykeyed by the pattern string (NotificationHub.cs:46-47, offsets unchanged in this span) so join/leave calls never recompile it, and each match runs under a 1-second timeout (ChannelKeyMatchTimeout) to bound worst-case matching (a defense against catastrophic-backtracking, ReDoS-style input).
- Walkthrough
- Primary-constructor DI of
IOptions<PushNotificationSettings>and an optionalIChannelJoinAuthorizer? joinAuthorizer = null, over theHubbase (NotificationHub.cs:25-27); the parameter is optional and defaulted so a host that registers none keeps its historical behavior unchanged (any authenticated caller may join any well-formed key). - The shared method-name constants:
ReceiveNotificationMethod="ReceiveNotification"andReceiveChannelEventMethod="ReceiveChannelEvent"are the client-listen method names the sender and publisher target;JoinChannelMethod="JoinChannel"andLeaveChannelMethod="LeaveChannel"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) and theChannelKeyRegexCache(StringComparer.Ordinal) back the validation helper.ConnectionsPerUser(NotificationHub.cs:58): a staticConcurrentDictionary<string, int>,StringComparer.Ordinal, tracking live connection count per user identifier on this replica.SlotItemKey(NotificationHub.cs:42): a private const,"mmca.hub.slot", the key of the per-connection marker inContext.Itemsthat says this connection holds a counted slot.OnConnectedAsync(NotificationHub.cs:61-81): whenMaxConnectionsPerUseris positive andContext.UserIdentifieris set, atomically increments the count withAddOrUpdate(:68); if the new count exceeds the cap, it gives the slot straight back viaDecrement(:73, so a refused connection cannot keep counting against the user and lock it out once the aborted ones drain) and throwsHubException("Too many concurrent connections for this account.")(:74); otherwise it marks the connection withContext.Items[SlotItemKey] = true(:77) and callsbase.OnConnectedAsync()(:80).OnDisconnectedAsync(NotificationHub.cs:84-93): decrements the caller's count only if the connection carries the marker (Context.Items.Remove(SlotItemKey),:87), then callsbase.OnDisconnectedAsync(exception). The guard exists because SignalR also runsOnDisconnectedAsyncfor a connection whoseOnConnectedAsyncthrew; releasing a slot for that refused connection would erode the cap one refusal at a time (doc comment onConnectionsPerUser,:52-57).Decrement(string userId)(NotificationHub.cs:99-118): a lock-free loop overTryGetValue/TryRemove/TryUpdatethat removes the entry entirely at zero, so the dictionary tracks only users who are actually connected rather than everyone who ever was.JoinChannelAsync(string channelKey)(NotificationHub.cs:128): attributed[HubMethodName(JoinChannelMethod)], it validates the key viaEnsureValidChannelKey(:121), then awaitsEnsureAuthorizedForChannelAsync(channelKey)(:122) before callingGroups.AddToGroupAsync(Context.ConnectionId, channelKey, Context.ConnectionAborted)(:127-128).LeaveChannelAsync(string channelKey)(NotificationHub.cs:145):[HubMethodName(LeaveChannelMethod)], validates thenGroups.RemoveFromGroupAsync(...)with the same connection token (:141-142). Leaving is not re-checked against the authorizer.EnsureAuthorizedForChannelAsync(NotificationHub.cs:158-173): a no-op whenjoinAuthorizerisnull; otherwise awaitsjoinAuthorizer.CanJoinAsync(Context.User, channelKey, Context.ConnectionAborted)and throwsHubException("Not authorized for this channel.")on afalseresult, so a caller not entitled to the channel is refused before the group membership is created and never receives a single published payload.- Both
JoinChannelAsync/LeaveChannelAsynctake noCancellationTokenparameter, and the comment atNotificationHub.cs:133-135explains why: a hub method signature is the client-visible RPC contract bound by SignalR's dispatcher, so the cancellation token comes from the connection (Context.ConnectionAborted) instead, and the repo'sCancellationTokenConventionTestscarry an explicit exemption for it.[Rubric §15, Best Practices & Code Quality]: the convention is enforced by a test, and the deviation is documented at the deviation site. EnsureValidChannelKey(NotificationHub.cs:175):GetOrAdds the cachedRegexforsettings.Value.ChannelKeyPattern; an empty or non-matching key throwsHubException("Invalid channel key.")(:174), 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, a gRPC ingress) 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 in PushNotificationSettings (it defaults to NotificationScopeKey.Pattern,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:33) so a host tunes which channels exist without touching code. The connection cap and the optional join authorizer are additive: neither changes the wire contract, and both arenull/zero-safe so an existing host that configures neither keeps behaving exactly as before. - Where it's used: mapped by a consuming host through the API-layer helper
MapNotificationHub, which reads the path from settings and only maps when push is enabled (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:22-31); the default path is/hubs/notifications(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:21), and in ADC the Notification service calls it atMMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:284. It is driven by SignalRPushNotificationSender (per-user notification delivery) and SignalRLiveChannelPublisher (ephemeral channel events), both viaIHubContext<NotificationHub>.AddPushNotificationsregisters theIUserIdProvider(ClaimBasedUserIdProvider) that maps a connection's claims to the user id the sender addresses (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:67). ADC'sLiveChannelJoinAuthorizer(MMCA.ADC.Notification.Service.Live) is the concreteIChannelJoinAuthorizera host registers to gate channel membership beyond the shape check.
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 and 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(MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/DomainEvents/PushNotificationCreated.cs:11-14). Note the doc comment onNotificationId(PushNotificationCreated.cs: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(PushNotificationCreated.cs:11-14), deriving fromBaseDomainEvent(PushNotificationCreated.cs:15). 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,MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:131) 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.Create(PushNotification.cs:131) and captured as an OutboxMessage onSaveChangesAsync. There is noIDomainEventHandler<PushNotificationCreated>anywhere in Common, ADC, or Store today: delivery happens inside SendPushNotificationHandler after the inbox rows are written, so this event is currently a published, persistable record with no consumer wired to it.
SignalRLiveChannelPublisher
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Notifications.Live·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Live/SignalRLiveChannelPublisher.cs:11· 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,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Live/SignalRLiveChannelPublisher.cs:11) 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 commentSignalRLiveChannelPublisher.cs:6-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)(SignalRLiveChannelPublisher.cs:14):hubContext.Clients.Group(channelKey).SendAsync(NotificationHub.ReceiveChannelEventMethod, channelKey, eventName, payloadJson, cancellationToken)(SignalRLiveChannelPublisher.cs:15-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: registered over NullLiveChannelPublisher by
AddPushNotificationsin any host that maps the hub (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:66); 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.Notifications.Push·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/SignalRPushNotificationSender.cs:12· 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(invariantToString). - Concept: cross-reference out-of-band delivery via
IHubContexttaught on SignalRLiveChannelPublisher.[Rubric §12, Performance & Scalability]here is about batching: a privateconst int BatchSize = 100(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/SignalRPushNotificationSender.cs:14) 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(SignalRPushNotificationSender.cs:19and:47) so the SignalR user-id keys are locale-stable. - Walkthrough
SendToUserAsync(userId, title, body, metadata, cancellationToken)(SignalRPushNotificationSender.cs:17): addressesClients.User(userId.ToString(CultureInfo.InvariantCulture))and invokesNotificationHub.ReceiveNotificationMethod(SignalRPushNotificationSender.cs:18-21).SendToUsersAsync(userIds, ...)(SignalRPushNotificationSender.cs:24): iteratesBatchUserIds(userIds)and sends each batch toClients.Users(batch)(SignalRPushNotificationSender.cs:26-32).BroadcastAsync(...)(SignalRPushNotificationSender.cs:36): addressesClients.All(SignalRPushNotificationSender.cs:37-39).BatchUserIds(userIds)(SignalRPushNotificationSender.cs:41): a private static iterator that accumulates invariant-culture id strings into aListandyield returns each time it reachesBatchSize, flushing the remainder at the end (SignalRPushNotificationSender.cs:43-57).
- 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: registered over NullPushNotificationSender by
AddPushNotifications(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:65); called by SendPushNotificationHandler after the inbox rows are persisted, inside a swallow-everythingtry/catchso a transport hiccup is recorded as a status rather than thrown.
SendPushNotificationRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationRequestValidator.cs:12· Level 8 · 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, and that an optional scope key stays within the aggregate's limit.
Depends on: SendPushNotificationRequest (Level 0, the validated type), PushNotificationInvariants (the domain limits), PushNotification (for
ScopeKeyMaxLength), andFluentValidation.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 §15, Best Practices & Code Quality]. Reusing the same constants here and in the PushNotification domain factory keeps the API limit and the entity limit from drifting apart.Walkthrough: the constructor (
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationRequestValidator.cs:14) defines three rules.Title(SendPushNotificationRequestValidator.cs:16-19) getsNotEmpty()plusMaximumLength(PushNotificationInvariants.TitleMaxLength)(200,MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:13).Body(SendPushNotificationRequestValidator.cs:21-24) getsNotEmpty()plusMaximumLength(PushNotificationInvariants.BodyMaxLength)(2000,PushNotificationInvariants.cs:16).ScopeKey(SendPushNotificationRequestValidator.cs:27-29) gets onlyMaximumLength(PushNotification.ScopeKeyMaxLength)(128,MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:22), noNotEmpty: the inline comment (SendPushNotificationRequestValidator.cs:26) records that a null scope key is the unscoped send every existing caller makes.
Each rule supplies a human-readable
WithMessage, with the limit interpolated underCultureInfo.InvariantCulture.Where it's used: registered by
AddValidatorsFromAssemblyContaining<SendPushNotificationRequestValidator>(includeInternalTypes: true)in the notification Application DI (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:72), which also makes this class the assembly anchor for every notification validator; invoked by the Validating command decorator in the CQRS pipeline (against the embeddedRequest, via ICommandWithRequest<out TRequest>) before SendPushNotificationHandler runs (ADR-014).
SendPushNotificationHandler
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.UseCases.Send·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:43· Level 9 · class (sealed partial)
- What it is: the command handler for the push-notification broadcast. It short-circuits duplicate sends by deduplication key, 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, records the resulting status, and returns 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<>(all injected via the primary constructor,MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:43-49); the persisted aggregates are PushNotification and UserNotification. Implements ICommandHandler<in TCommand, TResult> and returnsResult<PushNotificationDTO>(Result). - Concept introduced, one durable path plus two ephemeral push channels, guarded by a dedup key.
[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. The class doc (SendPushNotificationHandler.cs:21-31) states the atomicity contract that ties the durable half together: all three saves are one unit because SendPushNotificationCommand isITransactional. - Walkthrough (teaching order)
- deduplication gate (
SendPushNotificationHandler.cs:56-71): the command's DedupKey is normalized so whitespace counts as absent (SendPushNotificationHandler.cs:60-62, "a blank header cannot claim the single empty key"), and a present key is replaced by its sender-scoped hash (seeSenderScopedDedupKeybelow), so another caller reusing the same client key can neither suppress this send nor be handed this sender's notification (commentSendPushNotificationHandler.cs:57-59); when a key is present,FindByDedupKeyAsynclooks for an already-persisted notification and, on a hit, logs and returns the existing one mapped to a DTO without sending again (SendPushNotificationHandler.cs:65-70). - resolve recipients (
SendPushNotificationHandler.cs:74-75) via the app-specific INotificationRecipientProvider (in ADC, AttendeeNotificationRecipientProvider). An empty set short-circuits to aResult.Failurewith an ErrorValidationcodePushNotification.NoRecipients(SendPushNotificationHandler.cs:77-83), before any rows are written. - create the audit aggregate (
SendPushNotificationHandler.cs:86-92) viaPushNotification.Create(title, body, sentByUserId, recipientIds.Count, dedupKey, scopeKey); propagate errors on failure (SendPushNotificationHandler.cs:93-96), then add to the repository (SendPushNotificationHandler.cs:99-100). This is where the aggregate's PushNotificationCreated domain event is captured to the outbox (ADR-003). - first save, a lost dedup race propagates (
SendPushNotificationHandler.cs:102-103): a bareSaveChangesAsyncwith no catch. The dedup lookup in step 1 is a check-then-act, so two concurrent sends of one key can both pass it and the loser fails here, on the insert, against the filtered unique index onDedupKey. The class doc's "Deduplication race" paragraph (SendPushNotificationHandler.cs:32-41) states the contract: the failure propagates as the persistence exception (a 409 throughDbUpdateExceptionHandler), the transactional decorator rolls the attempt back, and the client's retry is then answered by the dedup lookup with the winner's notification. Recovering in place is not possible in this layer: the failed insert stays tracked (nothing in the Application contract can detach it) so the commit would throw, and on PostgreSQL the unique violation has already aborted the transaction a requery would run in. (An earlier swallow-and-requery shape modelled on EfInboxStore no longer exists here.) - durable inbox (
SendPushNotificationHandler.cs:105-113; second save:113): oneUserNotification.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 (
SendPushNotificationHandler.cs:115-134):pushNotificationSender.SendToUsersAsync(...)inside atry/catchwith its own justifiedCA1031suppression (SendPushNotificationHandler.cs:129-131). The live leg now carries the scope key as metadata,LiveMetadata(notification.ScopeKey)(SendPushNotificationHandler.cs:122), so a client viewing another scope can leave the notification out of its toast and badge.LiveMetadata(SendPushNotificationHandler.cs:172-181) returns a dictionary keyed by NotificationScopeKey.MetadataKeyfor a scoped send andnullfor an unscoped one. A delivery failure is non-fatal: success callsnotification.MarkAsSent()plus an info log (SendPushNotificationHandler.cs:126-127), failure callsnotification.MarkAsFailed()plus an error log (SendPushNotificationHandler.cs:133-134); the failure becomes recorded status, not a thrown exception. - best-effort native push (
SendPushNotificationHandler.cs:137-154, ADR-044):nativePushSender.SendToUsersAsync(...)in a thirdtry/catchwith its own suppression (SendPushNotificationHandler.cs:149-151). This is the OS-level channel that can reach devices whose app is backgrounded or killed (commentSendPushNotificationHandler.cs:137-140). It is purely additive: the SignalR leg above already decided the audit status, so a native-push failure only logs a warning (LogNativePushFailed,SendPushNotificationHandler.cs:153) and never touchesStatus. The default NullNativePushSender keeps this a no-op until a notification hub is configured. - persist final status (
SendPushNotificationHandler.cs:156) and return the mapped DTO (SendPushNotificationHandler.cs:158).
SenderScopedDedupKey(SendPushNotificationHandler.cs:168-170) derives the stored key asConvert.ToHexString(SHA256.HashData(...))over the invariant-culture string{sentByUserId}:{clientKey}, so the persisted value is always 64 hex characters (within the column) whatever the client sent (doc commentSendPushNotificationHandler.cs:161-167).[Rubric §11, Security]: the client key alone no longer identifies a notification across senders.FindByDedupKeyAsync(SendPushNotificationHandler.cs:188-197) resolves the read repository from the unit of work (unitOfWork.GetReadRepository<...>(),SendPushNotificationHandler.cs:190), never an injected IRepository<TEntity, TIdentifierType>, so the lookup runs against the same data source as the write (its doc comment says exactly that,SendPushNotificationHandler.cs:183-187).- Four source-generated
[LoggerMessage]methods close the file (SendPushNotificationHandler.cs:199-209): sent (Information), delivery-failed (Error), native-failed (Warning), and dedup hit (Information).[Rubric §13, Observability].
- deduplication gate (
- 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 §29, Resilience, Reliability & Business Continuity]). Treating each delivery failure as a recorded status or a logged warning rather than an exception keeps the audit trail honest while the audit and inbox writes stay durable even when a live push does not land. The dedup key plus the filtered unique index gives the retry safety a broadcast needs: the index is declared withIsUnique()and a soft-delete-aware[DedupKey] IS NOT NULLfilter in the EF configuration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:68-70), so the database, not the application, is the arbiter of "already sent". 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 NotificationsController (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: the three
SaveChangesAsynccalls (SendPushNotificationHandler.cs:103,:113,:156) are separate saves, not separate transactions: atomicity comes from the ambient transaction the Transactional decorator opens because the command implementsITransactional, so the decorator, not this file, is where the commit happens. A consequence the class doc accepts explicitly (SendPushNotificationHandler.cs:28-30) is that both sender calls run inside that unit, so a transient fault on the final status save re-runs them under the execution strategy: delivery is at-least-once for the live channels (SignalR and native) and exactly-once for the inbox rows. There is still 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:27· 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, scope key) 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,
MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Grpc/UserNotificationExportGrpcService.cs:27), the generatedUserNotificationExportService.UserNotificationExportServiceBase(compiled from the.Contracts.proto,UserNotificationExportGrpcService.cs:28), andGrpc.Core.ServerCallContext. Formats timestamps through the framework's GrpcWireFormat (MMCA.Common.Grpc, imported atUserNotificationExportGrpcService.cs:4), and projects into UserNotificationExportItemDTO shapes on the far side. - 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 (ADR-006), 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 underCultureInfo.InvariantCulturebyGrpcWireFormat.FormatUtc(MMCA.Common/Source/Presentation/MMCA.Common.Grpc/GrpcWireFormat.cs:42-43, called atUserNotificationExportGrpcService.cs:47and:49) so the export is locale-stable. - Walkthrough: the single
GetUserNotificationExportoverride (UserNotificationExportGrpcService.cs:31) null-guardsrequestandcontext(UserNotificationExportGrpcService.cs:35-36), then awaitsinner.GetUserNotificationExportAsync(request.UserId, context.CancellationToken)(UserNotificationExportGrpcService.cs:38-40) to get the in-process items. It builds aGetUserNotificationExportResponse(UserNotificationExportGrpcService.cs:42) andAddRanges a projection of each item into aUserNotificationExportItem(UserNotificationExportGrpcService.cs:43-54):NotificationId,Title,SentOnviaGrpcWireFormat.FormatUtc(UserNotificationExportGrpcService.cs:47),IsRead,ReadOnviaGrpcWireFormat.FormatUtcOrEmpty(string.Emptywhen the notification is unread,UserNotificationExportGrpcService.cs:49;GrpcWireFormat.cs:51-52), andScopeKey(alsostring.Emptywhen null,UserNotificationExportGrpcService.cs:53, because proto3 has no null string, per the comment at:51-52).FormatUtcstampsDateTimeKind.UtcwithDateTime.SpecifyKindbeforeToString("O", ...): the class doc (UserNotificationExportGrpcService.cs:20-25, restated once for every contract inGrpcWireFormat.cs:11-17) explains why, SQL Server hands backDateTimeKind.Unspecifiedvalues and the"O"format omits theZmarker for that kind, so the stamp only restores the marker the wire contract promises (the stored values are already UTC). Writing the format once in the framework means this server and its client adapter cannot drift apart. - Why it's built this way (security posture): unlike
LiveChannelGrpcService, this endpoint requires authorization: it is
mapped with
.RequireAuthorization()(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:301) and the class doc says why (UserNotificationExportGrpcService.cs:14-19).[Rubric §11, Security],[Rubric §30, Compliance, Privacy & Data Governance]: internal-only ingress is not sufficient here because the response carries personal data keyed by a rawUserId, so the calling service forwards the end user's JWT via JwtForwardingClientInterceptor and ownership is enforced at the REST edge. It is served on the same dedicatedHttp2-only "grpc" Kestrel endpoint as the live-channel ingress (ADR-012 mixed-endpoint profile,UserNotificationExportGrpcService.cs:11-13). - Where it's used: mapped by the Notification service's
Program.cs(Program.cs:301); 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. The in-process implementation it wraps is UserNotificationExportService; when the Notification module is disabled, DisabledUserNotificationExportService (registered by NotificationModule'sRegisterDisabledStubs) stands in for it.
UserNotificationExportServiceGrpcAdapter
MMCA.ADC.Notification.Contracts ·
MMCA.ADC.Notification.Contracts·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/UserNotificationExportServiceGrpcAdapter.cs:17· Level 9 · class (internal sealed)
- What it is: the client half of the data-subject export RPC. It implements
IUserNotificationExportService on top of the generated gRPC client,
so Identity's export aggregator keeps depending on the plain C# interface from
MMCA.ADC.Notification.Sharedwhile the inbox rows are actually fetched from the Notification service's database over the wire. - Depends on: IUserNotificationExportService (Level 1, the
implemented port); the generated
UserNotificationExportService.UserNotificationExportServiceClient(injected via the primary constructor,MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/UserNotificationExportServiceGrpcAdapter.cs:17-18); UserNotificationExportItemDTO (the returned shape); theUserIdentifierTypealias; GrpcWireFormat for parsing the wire timestamps. Its server counterpart is UserNotificationExportGrpcService. - Concept: the mirror image of
LiveChannelPublisherGrpcAdapter's error policy, and the contrast is
the lesson.
[Rubric §29, Resilience & Business Continuity]assesses degradation strategy per call site: the live-channel adapter swallows everything because a dropped ephemeral event is harmless, while this adapter deliberately lets transport failures propagate to the caller, because the caller needs to mark the Notifications section of the exportAvailable = falserather than silently emit an empty one (class doc,UserNotificationExportServiceGrpcAdapter.cs:7-16). Same transport, opposite contract.[Rubric §9, API & Contract Design]on version tolerance: the projection treats an empty proto3 string as "absent" in two places, so a peer replica that predates a field still deserializes. - Walkthrough
CallDeadline(UserNotificationExportServiceGrpcAdapter.cs:23), a privatestatic readonly TimeSpanof 5 seconds. The comment (:20-22) explains the sizing: far tighter than the shared resilience pipeline's 30-second attempt and 90-second total budget, because export aggregation is best-effort per section, so a hung (as opposed to refused) Notification peer should degrade its section quickly instead of stalling the whole export request.[Rubric §12, Performance & Scalability].GetUserNotificationExportAsync(userId, cancellationToken)(UserNotificationExportServiceGrpcAdapter.cs:26-28) issuesclient.GetUserNotificationExportAsyncwith aGetUserNotificationExportRequest { UserId = userId }, the deadline, and the caller's token (:30-36). Notry/catch: failures surface.- The projection (
:38-49) maps each wire item back into a UserNotificationExportItemDTO with a collection expression.SentOnis read byGrpcWireFormat.ParseUtc(:42). Two fields undo the proto3 encoding:ReadOnbecomesnullwhen the string is empty throughGrpcWireFormat.ParseUtcOrNull(:44), andScopeKeylikewise by an inline length check (:48), with the comment at:46-47recording that a peer replica predating the field and a genuinely unscoped notification both arrive as the empty string and mean the same thing. - The parsing itself lives in the framework, the counterpart of the server's
"O"formatting:ParseUtcisDateTime.ParseunderCultureInfo.InvariantCulturewithDateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal(MMCA.Common/Source/Presentation/MMCA.Common.Grpc/GrpcWireFormat.cs:37,:61-62). The comment there (GrpcWireFormat.cs:32-36) is worth reading in full: those two flags defend against a peer replica that still emits the marker-less form during a rolling deploy (without them a suffix-less value parses asKind=Unspecified, andAssumeUniversalalone yieldsKind=Local), while aZ-suffixed value takes the same path and keeps its instant.DateTimeStyles.RoundtripKindis deliberately absent becauseDateTime.Parserejects it alongside either flag with anArgumentException, and its job (preserving a non-UTC kind) is the opposite of what this contract wants. A malformed value throwsFormatException(GrpcWireFormat.cs:24-25), which this adapter lets propagate like any other failure.[Rubric §15, Best Practices & Code Quality]: a subtle BCL interaction is documented once, beside the shared helper both ends call.
- Why it's built this way: keeping the class
internal(:17) means nothing outside the.Contractspackage can bind to the concretion; consumers get it only through the DI helper below, always behind the interface. The rolling-deploy tolerance in the parser and the two empty-string mappings are what let Identity and Notification deploy independently, which is the point of ADR-008's topology. - Where it's used: registered by the Notification.Contracts
DependencyInjection's
AddNotificationUserExportClientviaservices.Replace(...)(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:84); the one caller today is the Identity service's composition root (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:327), whose ExportUserDataHandler consumes the interface.
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))
(The consumer-side gRPC client DI for the Notification service. Several other classes in this chapter
are also named DependencyInjection, one per layer; all of them keep the raw type name as their
heading.)
- What it is: the composition helper that a consuming service calls to point its notification ports at the extracted Notification service. Its two methods register a typed gRPC client and swap the local implementation for the matching adapter.
- Depends on: LiveChannelPublisherGrpcAdapter and
UserNotificationExportServiceGrpcAdapter (the registered
concretions); the ports ILiveChannelPublisher and
IUserNotificationExportService; the generated clients from the
.Contracts.proto; andAddTypedGrpcClient<TClient>fromMMCA.Common.Grpc(see the gRPC helper DependencyInjection), which brings the Aspire service discovery address, the JwtForwardingClientInterceptor, and the standard Polly resilience handler. Externals:Microsoft.Extensions.DependencyInjection.Extensions(ServiceCollectionDescriptorExtensions.Replace). - Concept introduced,
Replacerather thanTryAddat a consumer composition root. The framework registers defaults withTryAdd*, which is a no-op when something is already registered. A consumer that wants the remote adapter to win therefore cannot useTryAdd: it must overwrite. Both methods useservices.Replace(ServiceDescriptor.Scoped<TPort, TAdapter>())(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/DependencyInjection.cs:48and:84), and each carries an inline comment saying exactly what it is overwriting: the framework's NullLiveChannelPublisher in the first case (:46-47), a possible DisabledUserNotificationExportService stub in the second (:82-83). That in turn makes call order load-bearing, and the doc comments say so:AddNotificationLiveChannelClientmust run afterAddInfrastructure(...)(:36-37) andAddNotificationUserExportClientafterModuleLoader.DiscoverAndRegister(...)(:72-73).[Rubric §7, Microservices Readiness]and[Rubric §3, Clean Architecture]: the only thing that changes between the co-hosted monolith and the extracted topology is which descriptor sits in the container. - Walkthrough: one
extension(IServiceCollection services)block (DependencyInjection.cs:18) with two methods, each defaultingserviceNameto"_grpc.notification"and returning the collection for chaining.AddNotificationLiveChannelClient(string serviceName = "_grpc.notification")(DependencyInjection.cs:42) registersAddTypedGrpcClient<LiveChannelPushService.LiveChannelPushServiceClient>(serviceName)(:44) thenReplacesILiveChannelPublisherwith LiveChannelPublisherGrpcAdapter as scoped (:48).AddNotificationUserExportClient(string serviceName = "_grpc.notification")(DependencyInjection.cs:78) registersAddTypedGrpcClient<UserNotificationExportService.UserNotificationExportServiceClient>(serviceName)(:80) thenReplacesIUserNotificationExportServicewith UserNotificationExportServiceGrpcAdapter, also scoped (:84).- The named-endpoint default is the subtle part (
:26-33)."_grpc.notification"is Aspire service-discovery syntax for a named endpoint, not the service's default one. Notification's default endpoint has to stayHttp1AndHttp2for SignalR WebSockets, so its cleartext gRPC (h2c) lives on a separateHttp2-only Kestrel endpoint namedgrpc(ADR-012). Discovery resolveshttp://_grpc.notificationfrom theservices__notification__grpc__0configuration entry, injected by the AppHost'sWithReferencelocally and byinfra/main.bicepin production.
- Why it's built this way: putting both registrations in the
.Contractspackage (next to the.protoand the adapters) means a consuming service adds one line and inherits the correct endpoint, interceptor, resilience policy, and lifetime, with no knowledge of Notification's Kestrel layout. The second method's doc (:61-67) also records the consumer-side contract: the export aggregation is best-effort per section, so if this peer stays unreachable after the resilience pipeline the Identity handler marks the Notifications section unavailable instead of failing the whole export. - Where it's used:
AddNotificationLiveChannelClientis called by the Engagement service's application pipeline (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:291);AddNotificationUserExportClientby the Identity service's (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:327). The matching AppHost references that inject theservices__notification__grpc__0entry areengagementService.WithReference(notificationService)(MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:277) andidentityService.WithReference(notificationService)(Program.cs:288), both deliberately without aWaitForso neither consumer blocks on Notification at startup (comments atProgram.cs:270-276and:282-290).
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 IModule entry point for the Notification bounded context. It is discovered at startup, declares its dependency on Identity, and hands registration off to the module's DependencyInjection.
- Depends on: IModule (implements it),
ApplicationSettings,
IServiceCollection/IConfigurationBuilder, the API-layer DependencyInjection, and, for the disabled path, IUserNotificationExportService plus DisabledUserNotificationExportService. - Concept, the module manifest and its disabled stubs. The module system is taught in
Group 14; what this class shows is how thin a real module
manifest is. Three properties describe the module to
ModuleLoader, which registers modules in
topological (Kahn) order, and two methods cover the two states a module can be in.
RequiresDependencies => true(line 24) is the strict setting: Notification cannot come up without Identity, because its recipient provider needs the attendee roster.[Rubric §7, Microservices Readiness]assesses whether a module's boundary is explicit enough to extract:Dependencies => ["Identity"](line 21) is that boundary stated as data, which is also what lets ADC run Notification as its own service host.[Rubric §15, Best Practices & Code Quality]:RegisterDisabledStubs(lines 34-35) means a host that turns the module off still resolves every cross-module contract the module publishes, so Identity's export aggregation keeps compiling and running instead of failing at container build. - Walkthrough:
Name => "Notification"(line 18) is the key ModuleLoader uses to order and address the module;Dependencies => ["Identity"](line 21) andRequiresDependencies => true(line 24) declare the hard edge.Register(lines 27-31) is a one-line expression body forwarding toservices.AddNotificationModule(applicationSettings, (IConfiguration)configuration); the cast is needed because the interface hands the module anIConfigurationBuilder.RegisterDisabledStubs(lines 34-35) registers DisabledUserNotificationExportService as a singleton IUserNotificationExportService, a lifetime the live path does not use (the real service is scoped) because the stub holds no state and touches no database. - Why it's built this way: the module publishes exactly one cross-module service, the Notification half of the PRIVACY.md §7 data-subject export (lines 12-13), and the stub keeps that contract satisfiable when the module is absent. Modules as data-declaring classes rather than hand-ordered startup calls is the pattern behind ADRs 007 and 008.
- Where it's used: discovered by ModuleLoader in the ADC web host and in the standalone Notification service host.
NotificationPermissionGrants
MMCA.ADC.Notification.Shared ·
MMCA.ADC.Notification.Shared.Authorization·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/Authorization/NotificationPermissionGrants.cs:26· Level 3 · class (static)
- What it is: the one place ADC binds the Notification module's single capability to a role. Its
only member,
Apply(PermissionRegistryBuilder permissions), grants NotificationPermissions.Manage(sending push notifications and reading the send history) to RoleNames.Organizerand to no other role (lines 28-31). - Depends on: PermissionRegistryBuilder (the parameter it accumulates into), RoleNames, and NotificationPermissions.
- Concept, one grant map two callers can share. The binding used to be written inline as a lambda at
the one call site that needed it. Pulling it into a named, static
Applymethod lets a second host apply the identical grant instead of restating it: the token-minting Identity host also calls it (see Usage below), so the role that may broadcast push notifications cannot drift between the service that enforces the capability and the service that mints the token carrying it.[Rubric §11, Security]assesses whether a role-to-permission binding has one source of truth: a second caller of the same map is exactly the case this guards against, an accidental second grant that disagrees with the first. - Walkthrough:
Apply(line 33) null-guardspermissionswithArgumentNullException.ThrowIfNull(line 35), then callspermissions.Grant(RoleNames.Organizer, NotificationPermissions.Manage)(line 37). There is no other member. - Why it's built this way: a static class with one method keeps the grant a pure function of the registry builder, so calling it twice from two different hosts is safe and cannot pick up ambient state.
- Where it's used: called from the ADC Notification module's
DependencyInjection
.AddNotificationModuleand fromMMCA.ADC.Identity.Service'sTokenPermissionGrants, so the capability the Identity host encodes into a minted token matches the one the Notification host enforces.
LiveChannelJoinAuthorizer
MMCA.ADC.Notification.Service ·
MMCA.ADC.Notification.Service.Live·MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Live/LiveChannelJoinAuthorizer.cs:43· Level 4 · class (sealed, partial)
- What it is: the SignalR hub-join gate for the standalone Notification service's live channels. It
implements IChannelJoinAuthorizer and decides
whether an authenticated caller may join a given
event:{id}orsession:{id}channel key. - Depends on: IChannelJoinAuthorizer (its
interface); IEventLiveValidationService
(primary-constructor parameter, line 60); ConferenceReadAudience
(
PrivilegedRoles, line 76); NotificationScopeKey (EventPrefixandSessionPrefix, lines 101 and 110). Externals:System.Security.Claims(ClaimsPrincipal),Microsoft.Extensions.Logging([LoggerMessage], source-generated), andSystem.Globalization(CultureInfo.InvariantCulturefor the id parse). - Concept introduced, authorization by re-deriving public visibility, not by re-checking a role. A
privileged caller (a role in
ConferenceReadAudience.PrivilegedRoles) is admitted outright (lines 76-79). Everyone else is admitted only if the channel's subject is already something an ordinary attendee sees on the public agenda:IsPubliclyVisibleChannelAsync(line 94) parses the channel key and asks IEventLiveValidationService whether the event or session is published, and any failed lookup is a refusal (lines 107 and 118, and the doc comment at lines 90-93). The method never re-derives a role check: it re-derives the same published/unpublished question the public agenda already answers, so the live channel cannot show a caller more than the REST reads already would.[Rubric §11, Security]assesses whether a boundary check fails closed: a failed or unparseable lookup returnsfalse(lines 98, 121), nevertrue. - Walkthrough
CanJoinAsync(ClaimsPrincipal? user, string channelKey, CancellationToken)(line 64): an unauthenticated user is refused immediately (lines 69-72). A privileged role short-circuits totrue(lines 76-79). Otherwise it awaitsIsPubliclyVisibleChannelAsync(line 81), logs a refusal via the source-generatedChannelJoinRefused(line 84, event id 5301) when admission fails, and returns the result.IsPubliclyVisibleChannelAsync(line 94): calls the privateTryParseChannelKey(line 96); an unparseable key returnsfalse. ForNotificationScopeKey.EventPrefixit awaitsGetEventLiveInfoAsyncand returnsIsSuccess && Value!.IsPublished(lines 103-107). ForNotificationScopeKey.SessionPrefixit awaitsGetSessionLiveInfoAsyncand returns the same shape (lines 114-118); the comment at lines 112-113 notes this single check already covers "no such session", "service session" (BR-91), and any status that keeps a session off the public agenda, because the published flag covers the owning event. Any other scope prefix returnsfalse(line 121).TryParseChannelKey(string? channelKey, out string scope, out int id)(line 129): splits on the first:(line 139); a missing or leading separator, or a non-integer suffix parsed withNumberStyles.NoneunderCultureInfo.InvariantCulture(lines 145-152), returnsfalse. The doc comment (lines 124-128) states this re-parses rather than trusts the hub's own pattern match, so the gate does not depend on a pattern a host configuration could widen.
- Why it's built this way: re-deriving public visibility from the same live-validation service the REST agenda reads keeps the live channel and the public agenda from disagreeing about what is visible, instead of maintaining a second, parallel visibility rule inside the hub.
- Where it's used: registered as
IChannelJoinAuthorizerby the Notification service'sProgram.cs. Covered byLiveChannelJoinAuthorizerTests(MMCA.ADC/Tests/Services/MMCA.ADC.Services.Tests/Live/LiveChannelJoinAuthorizerTests.cs).
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
PushNotification to one user, giving each recipient a per-user inbox row with
IsRead/ReadOnstate. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>
(its base, closed over
UserNotificationIdentifierType,MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/UserNotifications/UserNotification.cs:12); 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 PushNotification produces N
UserNotificationrows, 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. - Walkthrough
- State:
UserId(UserNotification.cs:15),PushNotificationId(UserNotification.cs:18),IsRead(UserNotification.cs:21), and the nullableReadOntimestamp (UserNotification.cs:24), allprivate set. Two private constructors: the parameterless one EF Core needs (UserNotification.cs:27) and the state-setting one the factory calls (UserNotification.cs:31-36), which seedsIsRead = false. 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:49-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 SendPushNotificationHandler for each recipient; read by GetMyNotificationsQuery's handler for the inbox; flipped by MarkNotificationReadHandler and its mark-all sibling behind InboxController. Its rows are also the data UserNotificationExportService projects into the data-subject export.
PushNotificationInvariants
MMCA.Common.Domain ·
MMCA.Common.Domain.Notifications.PushNotifications.Invariants·MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/Invariants/PushNotificationInvariants.cs:10· Level 6 · class (static)
- What it is: the domain rule set for a push notification's text. It owns the two length limits (title 200, body 2000) and the two checks that enforce them, so nothing else has to restate what a valid notification title or body looks like.
- Depends on: CommonInvariants (every check
delegates to it) and Result (
Result.Combine). Externals:System.Globalization(CultureInfo.InvariantCulturefor the message text). It depends on no entity, not even PushNotification, which is what lets three different layers reference it without creating a cycle. - Concept: the static invariant class is taught at
CommonInvariants; this is that pattern applied
to one aggregate. What is worth studying here is the single source of truth for a length limit.
TitleMaxLengthandBodyMaxLengthare read in three places that would otherwise drift apart: the domain factory (MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:105-106), the request validator that rejects bad input before a command ever reaches a handler (SendPushNotificationRequestValidator,MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationRequestValidator.cs:18-19,23-24), and the EF column definition (PushNotificationConfiguration,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/Notifications/PushNotificationConfiguration.cs:29,33). Raising a limit is therefore one edit plus a migration, not four edits and a production truncation.[Rubric §4, DDD]assesses whether business rules live in the domain rather than in the layers that happen to call it: the rule is stated once, in the Domain assembly, and the Application and Infrastructure layers consume it rather than duplicate it.[Rubric §8, Data Architecture]assesses whether domain constraints and the physical schema agree: theHasMaxLengthcall literally reads the domain value, so the column can never be narrower or wider than the rule that guards it. - Walkthrough
TitleMaxLength = 200(line 13) andBodyMaxLength = 2000(line 16) arepublic static readonly int, notconst. The distinction has a visible consequence: astatic readonlyvalue is resolved at runtime, so it cannot be used as an attribute argument. That is why the one length an attribute needs,PushNotification.ScopeKeyMaxLengthbehind the inbox controller's[StringLength(...)], is declaredpublic const inton the entity instead (PushNotification.cs:22), while these two, which are only ever read from ordinary code, are not.EnsureTitleIsValid(string title, string source)(line 18) returnsResult.Combineover twoCommonInvariantscalls (lines 19-21):EnsureStringIsNotEmptywith code"PushNotification.Title.Empty", thenEnsureStringMaxLengthwith code"PushNotification.Title.TooLong"and a message interpolated fromTitleMaxLengthunderCultureInfo.InvariantCulture.Result.Combineaccumulates rather than short-circuits, so a caller sees every violated rule at once.EnsureBodyIsValid(string body, string source)(line 23) is the same shape overBodyMaxLengthand the"PushNotification.Body.*"codes (lines 24-26).- Both take a
sourcestring and forward it to the underlying helper, which puts it on theErrorfor tracing (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:30-34). Callers passnameof(Create), so a failure names the factory that rejected the input rather than the invariant helper. - Note the asymmetry inherited from the helpers:
EnsureStringMaxLengthlets null and empty pass (CommonInvariants.cs:47-51), which is exactly why each rule pairs it with the non-empty check.
- Why it's built this way: keeping the rules in a static class rather than on the entity lets the Application and Infrastructure layers reference them without referencing an aggregate they have no business constructing, and keeps the entity factory readable as a list of named rules.
- Where it's used: PushNotification
.Createcombines both checks with its own dedup-key and scope-key length checks (PushNotification.cs:104-120); SendPushNotificationRequestValidator reuses the values for its FluentValidation rules; the EF configuration reuses them for the column widths. Covered directly byPushNotificationInvariantsTests(MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/Notifications/PushNotificationInvariantsTests.cs:7).
PushNotificationDTOMapper
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.DTOs·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOMapper.cs:12· Level 8 · class (sealed, partial)
- What it is: the Mapperly-generated mapper that projects the PushNotification
domain entity onto its PushNotificationDTO response shape, one instance at a
time. It implements
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>closed overPushNotification/PushNotificationDTO/PushNotificationIdentifierType(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOMapper.cs:13). - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, PushNotification, PushNotificationDTO, PushNotificationStatus; externalRiok.Mapperly.Abstractions([Mapper],[MapProperty]) and the BCLArgumentNullException. - Concept: compile-time DTO mapping (ADR-001).
The
[Mapper]attribute (line 11) makes the Mapperly source generator emit the body of thepartialMapToDTOat build time, so the projection costs no reflection at runtime and a property that stops matching is a compile error rather than a silent null.[Rubric §9, API & Contract Design]assesses whether the domain type is kept off the wire: this mapper is the one place aPushNotificationbecomes a contract object, so the entity never leaks into a response body.[Rubric §15, Best Practices & Code Quality]assesses whether generated code replaces hand-copied boilerplate: the only hand-written lines here are the collection helper and one enum converter. - Walkthrough
MapToDTO(PushNotification entity)(line 17) ispartial: Mapperly writes the member-by-member copy. The single override is[MapProperty(nameof(PushNotification.Status), nameof(PushNotificationDTO.Status), Use = nameof(MapStatusToString))](line 16), which routes the status through the custom converter instead of a straight assignment. The override is needed because the entity'sStatusis aPushNotificationStatusenum while the DTO's is arequired string(MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/PushNotificationDTO.cs:26).MapToDTOs(IReadOnlyCollection<PushNotification> entityCollection)(line 20) is hand-written: it null-guards withArgumentNullException.ThrowIfNull(line 22) and then projects with a collection expression overSelect(MapToDTO)(line 23).MapStatusToString(PushNotificationStatus status)(line 26) is the private converter, a plainstatus.ToString(), so a client reads"Sent"rather than a numeric enum value.
- Why it's built this way: Mapperly keeps mapping fast and analyzer-checked while still allowing a per-property override where a straight copy would be wrong (the enum-to-string case). Rendering the status as its name rather than its ordinal means adding an enum member cannot renumber an existing contract value.
- Where it's used: registered twice by the Application-layer DependencyInjection
below (as itself and as the
IEntityDTOMapper<...>interface,MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:44-46), and consumed through it by SendPushNotificationHandler and GetNotificationHistoryHandler to shape what NotificationsController returns. It is not the only path: the mapper serves by-id reads and the non-projectable fallback, while qualifying list reads are served by PushNotificationDTOProjection (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:67).
PushNotificationDTOProjection
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.DTOs·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:22· Level 8 · class (internal, static, partial)
- What it is: the Mapperly-generated projection from PushNotification to
PushNotificationDTO. Where the mapper above converts an already-materialized
entity, this converts an
IQueryable<PushNotification>into anIQueryable<PushNotificationDTO>, so the database returns only the DTO's columns instead of whole notification rows that are mapped afterwards (documented atMMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:8-12). - Depends on: PushNotification, PushNotificationDTO;
externals
Riok.Mapperly.Abstractions([Mapper], line 21) andSystem.Linq(IQueryable<T>). It depends on no service: it is a static class with one generated member. - Concept introduced, mapping an expression tree instead of an object. A normal mapper is compiled
code that runs after rows arrive. A projection has to be an expression tree the database provider
can translate into SQL, which means every step of the mapping must be expressible in that tree. That
single constraint explains the whole design of this file. The instance mapper renders
Statuswith a custom method (Use = nameof(MapStatusToString)), and a projection cannot call a method the provider has never heard of. Mapperly's enum-to-string mapping is expressible, so it is inlined here as a conditional over the known members, which the provider emits as a SQLCASE, producing exactly the stringsPushNotificationStatus.ToString()would (explained at lines 13-20).[Rubric §12, Performance & Scalability]assesses whether reads move only the data they need: a projected list read transfers the DTO's columns rather than every mapped and unmapped column of the notification row, and it skips change-tracker materialization entirely.[Rubric §8, Data Architecture]assesses how the query layer shapes data at the source: pushing the shape into SQL is the difference between shaping at the database and shaping in the process. - Walkthrough
[Mapper](line 21) marks the class for the source generator;internal static partial class(line 22) keeps it out of the public package surface, because it is an implementation detail of the public wrapper below.ProjectToDTO(IQueryable<PushNotification> source)(line 27) is the one member, declaredinternal static partialand returningIQueryable<PushNotificationDTO>. Mapperly writes the body: asource.Select(x => new PushNotificationDTO { ... })whose initializer includes the inlined status conditional. Nothing is enumerated here; the caller still composes paging and ordering on top of the returned queryable.
- Why it's built this way: a second generated artifact rather than an attribute on the existing mapper, because the two have genuinely different capability sets (one may call arbitrary C#, the other may not) and because keeping them separate is what makes the equivalence testable. See ADR-055, which defines projection pushdown as an optional capability of the read contract.
- Where it's used: called only from
PushNotificationDTOProjector.ProjectTo
(
MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:43). Its output is pinned equal to the mapper's byPushNotificationDTOProjectorTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Notifications/PushNotificationDTOProjectorTests.cs:51-63, a[Theory]over everyPushNotificationStatusmember), and its SQL translatability is covered byPushNotificationProjectionTranslationTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/PushNotificationProjectionTranslationTests.cs:15), which runs the projection through a real SQLite provider. - Caveats / not-in-source: the generated body is not in the repository (it is emitted at build
time), so the exact SQL
CASEshape is not readable from these files; the tests above assert the observable result rather than the generated text.
DevicesController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:26· Level 9 · 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) andDELETE /Notifications/Devices/{installationId}removes it (called before logout), per the class documentation atMMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:14-20. - Depends on: ApiControllerBase (its base,
line 27); IPushDeviceRegistrar
(primary-constructor parameter, line 26);
ICurrentUserService (line 27);
DeviceInstallationRequest (the PUT body, line 34);
NotificationFeatures (line 23); Result
and Error. Externals: ASP.NET Core MVC (
[ApiController],[Route],[HttpPut],[HttpDelete],ProblemDetails),Asp.Versioning([ApiVersion("1.0")], line 22),Microsoft.AspNetCore.Authorization([Authorize], line 24), andMicrosoft.FeatureManagement.Mvc([FeatureGate], line 23). - Concept introduced, the ownership-stamped resource whose id comes from the client. Every other
controller in this group addresses server-generated ids. A device installation id is a
client-generated GUID instead, because the device mints it before it has ever talked to the server
(documented at lines 13-19). Two consequences are visible in the code. First, the owner is never taken
from the request: both actions re-derive
userIdfromICurrentUserService.UserId(lines 37 and 61) and pass it to the registrar, so a caller cannot register or delete a device on someone else's behalf. Second, the delete is deliberately indistinguishable between "no such installation" and "not yours": IPushDeviceRegistrar verifies the owninguser:{id}tag and reports both cases as success (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IPushDeviceRegistrar.cs:28-36), so the endpoint cannot be used as an existence oracle for other users' installation ids (restated on the action itself,DevicesController.cs:48-54).[Rubric §11, Security]assesses whether authorization and ownership are enforced at the boundary: class-level[Authorize](line 24) plus server-side ownership stamping plus a uniform 204 on delete is the whole posture here.[Rubric §9, API & Contract Design]assesses REST-shape consistency: both actions declare typed[ProducesResponseType]results (204 on success, plus aProblemDetailsfailure shape, 400 on the upsert at lines 31-32 and 401 on the delete at lines 55-56) and share theHandleFailure-or-success return shape used across the group. - Walkthrough
- Primary-constructor DI of the registrar and the current-user service (lines 25-27); the class is
sealedand derives fromApiControllerBase, which suppliesHandleFailure. UpsertAsync([FromBody] DeviceInstallationRequest request, ...)(line 33): readscurrentUserService.UserId(line 37); a null user short-circuits throughHandleFailure([Error.Unauthorized("PushDevice.Unauthorized", "User is not authenticated.")])(lines 38-41). Otherwise it awaitsregistrar.UpsertAsync(userId.Value, request, cancellationToken)(line 43) and returnsNoContent()(204) orHandleFailure(result.Errors)(line 44).DeleteAsync(string installationId, ...)(line 57): the id arrives from the route ([HttpDelete("{installationId}")], line 54). The same null-user guard runs (lines 61-65), thenregistrar.DeleteAsync(userId.Value, installationId, cancellationToken)(line 67), the ownership-scoped overload, and the action answers 204 on any non-failure (line 68).
- Primary-constructor DI of the registrar and the current-user service (lines 25-27); the class is
- Why it's built this way: the controller is wrapped in
[FeatureGate(NotificationFeatures.PushNotifications)](line 23), so with theNotification.PushNotificationsflag off the routes are simply not there rather than answering as dead endpoints. Routing all storage through IPushDeviceRegistrar keeps the controller free of any push-provider detail, which is what lets the default implementation stay a no-op until a notification hub is configured (ADR-044). - Where it's used: made routable by the API-layer DependencyInjection helper
below (
AddNotificationControllers); called by a native client's login and logout flows. Covered byDevicesControllerTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Notifications/DevicesControllerTests.cs:18).
InboxController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationInboxController.cs:29· Level 9 · class (sealed)
- What it is: the REST controller for one user's in-app notification inbox: read the inbox (paged),
read the unread count, mark one notification read, and mark everything read. An authenticated caller
reaches only their own inbox. Note the file name and the type name differ: the class is
InboxController, so the[controller]token in[Route("Notifications/[controller]")](line 25) yieldsNotifications/Inbox, but it lives inNotificationInboxController.cs. - Depends on: ApiControllerBase;
ICurrentUserService (line 34); the handler contracts
IQueryHandler<in TQuery, TResult>andICommandHandler<in TCommand, TResult>(lines 30-33); the four use cases GetMyNotificationsQuery, GetUnreadNotificationCountQuery, MarkNotificationReadCommand and MarkAllNotificationsReadCommand; UserNotificationDTO; PushNotification (for theScopeKeyMaxLengthconstant, line 46);PagedCollectionResult<T>, Result, Error; NotificationFeatures. Externals: ASP.NET Core MVC,Asp.Versioning,Microsoft.FeatureManagement.Mvc, andSystem.ComponentModel.DataAnnotations([Range],[StringLength]). - Concept introduced, the feature-gated controller that injects handlers directly. There is no
service layer between HTTP and the use cases: the constructor takes the four
IQueryHandler/ICommandHandlerclosures it needs (lines 30-33) and every action does exactly three things, resolve the caller, build the query or command, translate theResultto a status code. Everything cross-cutting (logging, caching, validation, transaction) is added by the CQRS decorator pipeline around those handlers, so none of it appears here.[Rubric §6, CQRS & Event-Driven]assesses whether presentation delegates to command/query handlers rather than to service wrappers: this class is the reference shape for that.[Rubric §11, Security]assesses boundary authorization: a plain[Authorize](line 28) gates the class, since any authenticated identity may have an inbox and no capability is required, and every action re-scopes tocurrentUserService.UserIdso the data read is user-scoped, not merely route-scoped. Contrast NotificationsController below, whose broadcast surface carries thenotifications:managecapability instead.[Rubric §9, API & Contract Design]assesses contract consistency:[ApiVersion("1.0")](line 26), typed[ProducesResponseType]on each action, and query-string paging with[Range(1, int.MaxValue)]validation (lines 44-45). - Concept introduced, the optional
scopefilter. Three of the four actions take an optionalscopequery parameter bounded by[StringLength(PushNotification.ScopeKeyMaxLength)](128 characters,MMCA.Common/Source/Core/MMCA.Common.Domain/Notifications/PushNotifications/PushNotification.cs:22), which narrows the inbox to notifications carrying that scope plus the unscoped ones; omitting it returns everything (documented lines 36-40). The parameter is threaded through the read, the badge count, and the bulk write on purpose: a scoped caller must never clear notifications its own inbox read hid from it (lines 105-109). See ADR-024. - Walkthrough
- Primary-constructor DI of two query handlers, two command handlers, and the current-user service (lines 29-34).
GetInboxAsync(line 43):[HttpGet](line 41);pageNumberdefaults to 1 andpageSizeto 20 (lines 44-45), both[Range(1, int.MaxValue)]; the optionalscopeis line 46. A null user short-circuits toError.Unauthorized("Notification.Unauthorized", ...)(lines 49-53). It buildsnew GetMyNotificationsQuery(userId.Value, pageNumber, pageSize, scope)(line 55), awaits the handler (lines 56-57), and returns 200 with thePagedCollectionResult<T>orHandleFailure(line 59).GetUnreadCountAsync(line 69): routeunread-count(line 66) and[ResponseCache(NoStore = true)](line 67), so the badge count is never served from a cache. It issuesnew GetUnreadNotificationCountQuery(userId.Value, scope)(line 79) and returns the rawint(line 82).MarkReadAsync(line 89): route{id:int}/read(line 86), the id bound[FromRoute]asUserNotificationIdentifierType(line 90). It issuesnew MarkNotificationReadCommand(id, userId.Value)(line 99) and returns 204 or, per its declared contract, a 404ProblemDetails(lines 87-88).MarkAllReadAsync(line 112): routeread-all(line 110), no body, optionalscope(line 113). It issuesnew MarkAllNotificationsReadCommand(userId.Value, scope)(line 122) and returns 204 (line 125).
- Why it's built this way: passing
userIdinto every query and command keeps authorization a data concern rather than only a routing concern, which is what makes the same handlers reusable from a non-HTTP caller. The class-level[FeatureGate(NotificationFeatures.PushNotifications)](line 27) makes the whole inbox surface disappear when the flag is off, so a host that has not opted into notifications does not advertise endpoints it cannot serve. - Where it's used: registered into MVC application parts by
AddNotificationControllers(DependencyInjection, API layer, below); consumed by the in-app inbox UI (the unread badge plus the notification list). Covered byNotificationInboxControllerTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Notifications/NotificationInboxControllerTests.cs:17).
NotificationsController
MMCA.Common.API ·
MMCA.Common.API.Controllers.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:30· Level 9 · class (sealed)
- What it is: the manage-only REST controller for push notifications:
POST /Notificationssends a notification to all recipients and returns 201,GET /Notificationsreturns the paged send history. Both actions require thenotifications:managecapability. - Depends on: the same controller family as InboxController
(ApiControllerBase,
ICurrentUserService,
Result, Error,
PagedCollectionResult<T>, NotificationFeatures), plus HasPermissionAttribute and NotificationPermissions (line 29), SendPushNotificationCommand, GetNotificationHistoryQuery, SendPushNotificationRequest, PushNotificationDTO, IdempotentAttribute (line 44) and IdempotencyHeaders (line 62). Externals: ASP.NET Core MVC,Asp.Versioning,Microsoft.FeatureManagement.Mvc, andSystem.Globalization(CultureInfo). - Concept: the feature-gated, handler-injecting controller shape is taught at
InboxController. What differs here is the authorization boundary:
[HasPermission(NotificationPermissions.Manage)](line 29) restricts both actions to callers holding thenotifications:managecapability, because sending is a broadcast while the inbox is per-user. The gate names a capability rather than a role, so each host decides which of its roles holds it (stated atNotificationsController.cs:20-24); see NotificationPermissions for the constant and HasPermissionAttribute for the machinery behind the attribute.[Rubric §11, Security]assesses least privilege: the write side carries its own stricter, capability-based gate instead of reusing the authenticated-only one the inbox uses.[Rubric §9, API & Contract Design]assesses REST correctness:SendAsyncanswersCreated(...)with a relativeLocationURI built from the new notification's id (line 74). - Concept introduced, two-level retry safety (response replay plus delivery dedup). The send path is
protected twice over, and the doc comment at lines 35-42 spells out why one level is not enough.
[Idempotent](line 44) makes the IdempotencyFilter replay the original HTTP response for a repeatedIdempotency-Key(ADR-017). That protects the response, but only while the cached entry survives. So the action ALSO reads the raw header itself and carries it into the domain as the command'sDedupKey(lines 60-69), where a key that has already been seen returns the existing notification instead of sending a second time (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:25-35). The filter protects the response; theDedupKeyprotects delivery when the cache is cold, evicted, or degraded. See ADR-024.[Rubric §29, Resilience & Business Continuity]assesses whether a retried or replayed request can cause duplicate side effects: the two levels together mean a client can retry a send freely. - Walkthrough
- Primary-constructor DI of the send command handler, the history query handler, and the current-user service (lines 30-33).
SendAsync([FromBody] SendPushNotificationRequest request, ...)(line 48): guards a null user withError.Unauthorized("Notification.Unauthorized", ...)(lines 52-56). It then readsIdempotencyHeaders.IdempotencyKeyoffRequest.Headersdirectly (lines 62-66); an absent or whitespace-only value leavesdedupKeynull, which is the behavior where every send creates a new notification (comment lines 58-59). The header is read manually rather than bound with[FromHeader](and Sonar's S6932 suppressed with that reason, lines 61 and 67) so protocol plumbing shared with the idempotency filter does not appear in the generated OpenAPI contract. It buildsnew SendPushNotificationCommand(request, userId.Value) { DedupKey = dedupKey }(line 69) and on success returnsCreated(new Uri(string.Create(CultureInfo.InvariantCulture, $"/notifications/{result.Value!.Id}"), UriKind.Relative), result.Value)(line 74).GetHistoryAsync(line 80):[HttpGet](line 78) with[Range(1, int.MaxValue)]paging,pageSizedefaulting to 10 (lines 81-82). It runsnew GetNotificationHistoryQuery(pageNumber, pageSize)(line 85) and returns 200 with the paged PushNotificationDTO collection (line 91). Unlike the inbox, history takes no user filter and no null-user guard: the capability gate already established who may read it, and a holder sees every send.
- Why it's built this way: splitting the broadcast surface from the per-user inbox surface lets each
controller carry exactly its own authorization gate, so widening the inbox's gate can never widen the
send gate by accident. Both stay behind
[FeatureGate(NotificationFeatures.PushNotifications)](line 28). - Where it's used: registered via
AddNotificationControllers(DependencyInjection, API layer, below); consumed by the organizer push-notification UI. Covered byNotificationsControllerTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Notifications/NotificationsControllerTests.cs:16).
PushNotificationDTOProjector
MMCA.Common.Application ·
MMCA.Common.Application.Notifications.PushNotifications.DTOs·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35· Level 9 · class (sealed)
- What it is: the injectable wrapper that adapts the static, generated
PushNotificationDTOProjection to the framework's projection contract
IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, closed overPushNotification/PushNotificationDTO/PushNotificationIdentifierType(line 36). Registering it is what switches notification list reads onto the server-side projection path. - Depends on:
IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, PushNotificationDTOProjection, PushNotification, PushNotificationDTO; BCLArgumentNullExceptionandIQueryable<T>. It has no constructor and no state, so it is trivially safe to resolve at any lifetime. - Concept introduced, opting a single aggregate into projection pushdown.
EntityQueryService<TEntity, TEntityDTO, TIdentifierType>declares two constructors: a five-argument one and a six-argument one that additionally takes anIEntityDTOProjector<...>(MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:70-78). The container picks the longer constructor when a projector is registered and the shorter one when it is not, because one parameter set is a strict superset of the other; that is the documented reason it is a second constructor rather than an optional parameter, sinceMicrosoft.Extensions.DependencyInjectionhas no notion of an optional dependency (EntityQueryService.cs:52-62). So registering this class in DependencyInjection below is the entire opt-in gesture: no query handler changes. The projection path is not taken unconditionally. The decision is taken per read atEntityQueryService.cs:339, andCanProjectrequires three things (EntityQueryService.cs:541-544): a projector is registered, the caller did not ask for tracking (a projection yields DTOs, which the change tracker has nothing to do with), and there are no unsupported cross-source includes (those are loaded row by row after materialization by the navigation populator, which a projection has no rows to hand it). Field shaping deliberately does not disqualify, because shaping runs after materialization over whatever object the pipeline produced (EntityQueryService.cs:536-538).[Rubric §1, SOLID]assesses dependency inversion and interface segregation: the contract has exactly one member, and the query service depends on that interface rather than on Mapperly or on this class.[Rubric §12, Performance & Scalability]assesses whether the read path scales with result shape: for notification history and inbox-style list reads, the row width sent over the wire drops to the DTO's columns. - Walkthrough
ProjectTo(IQueryable<PushNotification> source)(line 39) is the whole class. It null-guards withArgumentNullException.ThrowIfNull(source)(line 41), then returnsPushNotificationDTOProjection.ProjectToDTO(source)(line 43). It composes, it does not execute: the returned queryable is still open for the pipeline'sWhere,OrderBy,Skip, andTake.
- Why it's built this way: the generated projection is a static method, and DI cannot resolve a
static method. This short adapter is the minimum needed to make a generated artifact injectable and,
in the same move, overridable: because the Application-layer registration uses
TryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:51-53), a consuming app can register its own projector first and keep it. See ADR-055, which also records the trade-off: nothing tells a caller which path ran, so a projector that stops being registered loses the optimization with no signal beyond query latency. - Where it's used: registered twice by the Application-layer DependencyInjection
below (as itself and as the interface, lines 50-52), then resolved by
EntityQueryService<TEntity, TEntityDTO, TIdentifierType>closed over the notification triple, which serves GetNotificationHistoryHandler's paged reads. Directly exercised byPushNotificationDTOProjectorTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Notifications/PushNotificationDTOProjectorTests.cs:16), which asserts among other things thatProjectToreturns a still-composable queryable rather than a materialized list (lines 100-110).
DependencyInjection
MMCA.Common.API ·
MMCA.Common.API.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.API/Notifications/DependencyInjection.cs:9· Level 10 · class (static)
(API-layer notification DI. A second DependencyInjection, the Application-layer one, follows below;
both keep the raw type name as their heading.)
- What it is: the API-layer DI helper for the notification subsystem. Its one member,
AddNotificationControllers, adds this package's assembly to the MVC application parts so ASP.NET Core routing can discover the three notification controllers. - Depends on: the NotificationsController type, used only as an assembly
anchor (line 21); externals
Microsoft.Extensions.DependencyInjection(IMvcBuilder) and ASP.NET Core MVC application parts. - Concept introduced, application parts for controllers that ship in a NuGet package. ASP.NET Core
discovers controllers by scanning the host's own assembly (and its application parts). The notification
controllers live in
MMCA.Common.API, a referenced package, so without an explicit application part they exist but are never routed (stated in the member's own documentation, lines 13-17).AddNotificationControllersis written as anextension(IMvcBuilder builder)member (line 11) that callsbuilder.AddApplicationPart(typeof(NotificationsController).Assembly)(line 21), which registers all three controllers in one call because they share an assembly.[Rubric §7, Microservices Readiness]assesses whether a capability packages cleanly for reuse across hosts: shipping the controllers together with their own one-line registration means the whole notification HTTP surface moves into an extracted service without editing the controllers.[Rubric §3, Clean Architecture]assesses whether composition stays at the edge: the host opts in explicitly, so nothing is routed by a package the host merely references. - Walkthrough: inside the
extension(IMvcBuilder builder)block (line 11),AddNotificationControllers()(line 19) adds the application part (line 21) and returns the builder for fluent chaining (line 22). There is no other member; the class isstatic(line 9). - Why it's built this way: exposing the registration as a named
extension(IMvcBuilder)member keeps a host's composition root declarative and reads asAddControllers().AddNotificationControllers()(see primer §4). This is the API counterpart to the Application-layerAddNotificationApplicationServicesbelow: one call wires the handlers, the other exposes them over HTTP. - Where it's used: called from a consuming host's MVC setup, alongside the Application-layer registration.
DependencyInjection
MMCA.Common.Application ·
MMCA.Common.Application.Notifications·MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/DependencyInjection.cs:27· Level 10 · class (static)
(Application-layer notification DI. Distinct from the API-layer DependencyInjection directly above;
both keep the raw type name as their heading.)
- What it is: the Application-layer composition helper for the notification subsystem. Its single
extension(IServiceCollection)member,AddNotificationApplicationServices, registers every notification command and query handler, the DTO mapper, the DTO projector, the entity query service, the validators, and the default recipient provider. - Depends on: PushNotification,
INavigationPopulator<in TEntity>andNullNavigationPopulator<TEntity>;IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>andEntityQueryService<TEntity, TEntityDTO, TIdentifierType>;IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>and PushNotificationDTOMapper;IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>and PushNotificationDTOProjector; the handler contractsICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>with the six concrete handlers named below; SendPushNotificationRequestValidator; INotificationRecipientProvider and NullNotificationRecipientProvider; Result andPagedCollectionResult<T>. Externals:Microsoft.Extensions.DependencyInjection.Extensions(TryAddScoped) andFluentValidation(AddValidatorsFromAssemblyContaining). - Concept introduced, hand-registration with
TryAddfor a module that lives inside the framework assembly. Application modules are normally auto-registered by a scan over the module's own assembly. The notification types have no module assembly of their own: they live inMMCA.Common.Application, so they are wired explicitly here (line 35). Every registration except the validator scan usesTryAddScoped(lines 38-74), which is the override contract: a consuming app that registers its own handler, mapper, projector, or recipient provider before calling this helper keeps its own registration, becauseTryAddnever replaces an existing service descriptor. The class documentation states the same rule at lines 22-25.[Rubric §1, SOLID]assesses dependency inversion: the notification send path depends on INotificationRecipientProvider, and the default binding is the no-op NullNotificationRecipientProvider (line 74), so the framework ships a working default while an app supplies its real audience.[Rubric §3, Clean Architecture]assesses whether wiring stays out of the domain: all of it is one static class in the Application layer, and nothing below Application knows these types exist. - Walkthrough:
AddNotificationApplicationServices()(line 35), insideextension(IServiceCollection services)(line 28), registers in reading order:- The PushNotification aggregate's navigation populator
(
NullNavigationPopulator<PushNotification>, line 38) and itsEntityQueryService<TEntity, TEntityDTO, TIdentifierType>closed overPushNotification/PushNotificationDTO/PushNotificationIdentifierType(lines 39-40). The null populator is the explicit statement that this aggregate has no navigations to batch-load, which is also what keepsCanProjectsatisfiable (no unsupported includes). - The PushNotificationDTOMapper twice, as the concrete type and as the
IEntityDTOMapper<...>interface (lines 43-45), so both the query service (which resolves the interface) and any direct consumer get the same scoped instance shape. - The PushNotificationDTOProjector twice, as the concrete type
(line 50) and as the
IEntityDTOProjector<...>interface (lines 51-52). The comment at lines 47-49 records why this pair matters: registering it is what switches notification list reads onto the server-side projection path, because the query service resolves it through its longer constructor, and the projected values are pinned equal to the mapper's by test. - Three command handlers: SendPushNotificationHandler (lines 55-56), MarkNotificationReadHandler (lines 57-58), and MarkAllNotificationsReadHandler (lines 59-60).
- Three query handlers: GetNotificationHistoryHandler (lines 63-64), GetMyNotificationsHandler (lines 65-66), and GetUnreadNotificationCountHandler (lines 67-68). These six registrations are exactly the closures the two controllers above inject.
- The FluentValidation validators discovered from the
SendPushNotificationRequestValidator assembly with
includeInternalTypes: true(line 71). Note this one is a plainAddValidatorsFromAssemblyContaining, not aTryAdd. - The default recipient provider (line 74), then
return servicesfor chaining (line 76).
- The PushNotification aggregate's navigation populator
(
- Why it's built this way: hand-registration keeps the notification subsystem self-contained inside
the framework package while still honoring the framework-wide override contract, and the
extension(IServiceCollection)preview syntax lets it read as a first-classservices.AddNotificationApplicationServices()call (see primer §4). - Where it's used: called from a consuming host's Application composition root. Ordering matters in
one direction only: it must run before
AddApplicationDecorators(), because Scrutor'sTryDecoratecan only wrap handlers that are already registered. - Caveats / not-in-source: the infrastructure side of the subsystem (the SignalR sender, the device registrar, the email sender) is not registered here; those live in the Infrastructure-layer registration, which this file does not reference.
DependencyInjection
MMCA.ADC.Notification.API ·
MMCA.ADC.Notification.API·MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:17· Level 1 · class (static)
- What it is: the Notification module's outermost DI composition. Its single extension method
AddNotificationModuleassembles the whole module: application services, EF configuration, the SignalR push pipeline, the optional native-push channel, the one permission grant, and the framework controllers. - Depends on: ApplicationSettings and
IConfiguration(parameters), the application-layer DependencyInjection.AddModuleNotificationApplication,AddNotificationInfrastructure(),AddPushNotifications(configuration),AddNativePushNotifications(configuration),AddPermissions(...)plus NotificationPermissionGrants, andAddControllers().AddNotificationControllers(). - Concept, the module composition root as a single ordered method. Where the application-layer
DependencyInjection registers only ADC's policy choices, this one composes
every layer of the module in one readable sequence, so a reader can see the module's entire surface
without opening five files.
[Rubric §3, Clean Architecture]assesses layer separation: the calls descend from application to infrastructure to presentation in that order, and the framework pieces (AddPushNotifications,AddNotificationControllers) come fromMMCA.Commonwhile only the permission grant is app-specific.[Rubric §11, Security]shows in that grant: the doc comment (lines 21-29) states the whole authorization story for the module in one place, that NotificationPermissions.Manageis held by RoleNames.Organizerand by no other role, so an attendee cannot broadcast and no content editor holds a notification capability it was not given. The map itself now lives in NotificationPermissionGrants rather than an inline lambda here, because the token-minting Identity host applies the identical binding (comment lines 40-43). - Walkthrough: an
extension(IServiceCollection services)block (line 19) containingAddNotificationModule(ApplicationSettings, IConfiguration)(lines 30-50). It callsAddModuleNotificationApplication(applicationSettings)(line 32) for ADC's handlers and policies,AddNotificationInfrastructure()(line 33) for the EF configuration,AddPushNotifications(configuration)(line 34) for the SignalR sender and hub, andAddNativePushNotifications(configuration)(line 38) for the native third channel. The comment on that last call (lines 36-37) records that it is a no-op unless theNativePushconfiguration section is enabled and complete, which is why it is safe to call unconditionally in every environment. Then the single grant,services.AddPermissions(NotificationPermissionGrants.Apply)(line 44), and finallyservices.AddControllers().AddNotificationControllers()(line 47), which registers the framework's notification controllers as ASP.NET Core application parts so their routes are discovered even though they live in a referenced assembly. The collection is returned for chaining (line 49). - Why it's built this way: the framework ships the controllers, but a host only gets them if it opts
in through the application-part registration, so a host that does not want a push surface simply does
not load the module. Registering the native channel unconditionally and letting configuration decide
keeps environment differences in
appsettings, not in code (ADR-044). - Where it's used: called from NotificationModule
.Register(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:31).
⬅ Caching • Index • Navigation Metadata & Populators (EF-decoupled eager loading) ➡