Auth & the edge · No. 21
Notifications as a vertical slice: in-app inbox, real-time push, native push, and email

A framework is mostly horizontal layers and abstract base classes. Notifications is the one concrete bounded context MMCA.Common ships, and it is built as a textbook vertical slice: domain, application ports, infrastructure adapters, and API, all owned by one feature.
Open most "Clean Architecture" frameworks and you find a great deal of horizontal plumbing and very
little vertical feature. There is a Domain project, an Application project, an Infrastructure
project, and they are full of base classes and interfaces. That is fine, and it is necessary, but it
leaves a question unanswered for anyone trying to learn the framework: what does a complete feature
built on this thing actually look like, top to bottom?
MMCA.Common answers that by shipping exactly one concrete bounded context: Notifications. It is the worked example. And the way it is organized is the point of this article, because it is built as a vertical slice rather than smeared across the horizontal layers.
Horizontal layer cut vs. vertical slice
The horizontal instinct is to organize by technical role. All the controllers in one folder, all the handlers in another, all the EF configurations in a third. The cost shows up later: to change one behavior of one feature, you touch four folders, and nothing about the folder structure tells you which files belong together. Cohesion is low; the things that change together do not live together.
A vertical slice flips that. You organize by feature, and within the feature you keep the domain, the use cases, the adapters, and the API surface together. Adding a capability to the feature means working in one place. Notifications is that, and it touches every layer on purpose so it doubles as a complete port-and-adapter reference:
- Domain owns two small aggregates (
PushNotificationandUserNotification), thePushNotificationCreatedevent, and thePushNotificationStatusenum. - Application defines the ports (
IPushNotificationSender,INativePushSender,IPushDeviceRegistrar,IEmailSender,INotificationRecipientProvider) and the CQRS slices that orchestrate them. - Infrastructure supplies the adapters (
SignalRPushNotificationSender,AzureNotificationHubNativePushSender,AzureNotificationHubDeviceRegistrar,SmtpEmailSender,NotificationHub) plus their no-op fallbacks. - API exposes three REST controllers, split by who is allowed to call them.
Four loosely coupled delivery legs share one feature flag and one set of application ports: an in-app inbox (durable per-user record), a SignalR real-time push (best-effort delivery to connected clients), an OS-level native push (FCM and APNs to backgrounded or killed devices, added in ADR-044), and email (system mail).
Sending a push notification, end to end
The send flow is the most instructive because it touches distinct consistency concerns: a
deduplication check, a durable audit record, a per-user inbox, and two best-effort delivery legs
(real-time SignalR and OS-level native push). It is driven by SendPushNotificationHandler, reached
by POSTing to NotificationsController. The handler runs in a deliberate order:
- Deduplicate, if the caller opted in. The command carries an optional
DedupKey. A blank or whitespace-only value is normalized to null, so an empty header cannot claim the single "empty" key. When a key is present the handler looks the notification up first, and a hit returns the existing DTO without sending anything at all. No key means no lookup, which is the default path: every send creates a new notification. - Resolve recipients. The handler does not know who the audience is. It asks the injected
INotificationRecipientProviderfor the user IDs. This is the key swap point: the framework ships a no-opNullNotificationRecipientProviderthat returns an empty list, and the consuming app overrides it. ADC registersAttendeeNotificationRecipientProvider, which calls the Identity module to mean "every attendee." An empty recipient set short-circuits to aValidationfailure, so the framework stays domain-agnostic while the app decides scope. - Persist the audit aggregate, in its own save.
PushNotification.Create(title, body, sentByUserId, recipientCount, dedupKey, scopeKey)validates title and body viaPushNotificationInvariants, checks that the dedup key and the optional scope key each stay within 128 characters, and returns aResult<PushNotification>. It is saved withStatus = Pendingand a snapshotRecipientCount. That save stands alone precisely so a lost race on the dedup key can be caught and answered (next section). - Fan out the inbox rows, in a second save. For every recipient the handler creates a
UserNotificationrow, the durable per-user inbox entry that survives a missed real-time delivery. Both writes are issued before any transport is touched. Storage first, delivery second. - Deliver in real time, best-effort. The handler calls
IPushNotificationSender.SendToUsersAsync. That call is wrapped in a try/catch that swallows any exception by design: a SignalR or broker hiccup must not lose the already-persisted inbox rows. On success it callsnotification.MarkAsSent(), on failurenotification.MarkAsFailed(). This SignalR leg alone owns the audit status. - Deliver OS-level native push, best-effort (ADR-044). The handler then calls
INativePushSender.SendToUsersAsyncinside its own non-fatal try/catch, reaching devices the hub cannot (app backgrounded or killed). This leg is fire-and-forget: a failure is logged viaLogNativePushFailedand does not touch the audit status the SignalR leg already decided. The defaultNullNativePushSenderkeeps it a no-op until a notification hub is configured. - Save and return the DTO. The handler saves the observed status a third time, then maps the
saved aggregate and returns
201 Created.
Those three saves are one unit of work, not three. SendPushNotificationCommand declares
ITransactional, and TransactionalCommandDecorator wraps any command carrying that marker in a
single database transaction that commits when the handler returns and rolls back on a thrown
exception. The dedup key is the reason. A fault between the audit save and the inbox fan-out would
otherwise leave a committed notification row carrying a key that nothing ever delivered, and every
later retry of that key would short-circuit on the dedup lookup and report success forever. Rolling
the attempt back whole is what lets the retry re-run the send. A failed delivery does not trigger that
rollback: both delivery legs swallow their exceptions and MarkAsFailed ends in a success result, so
a send nobody received is still recorded.
// SendPushNotificationHandler, in order: dedup first, then audit + inbox are written
// BEFORE any delivery is attempted. The whole method runs in one transaction (ITransactional).
var dedupKey = string.IsNullOrWhiteSpace(command.DedupKey) ? null : command.DedupKey;
if (dedupKey is not null)
{
var alreadySent = await FindByDedupKeyAsync(dedupKey, ct);
if (alreadySent is not null) { return Result.Success(_mapper.MapToDTO(alreadySent)); } // no second send
}
var recipients = await _recipients.GetRecipientUserIdsAsync(ct); // app decides "who"
if (recipients.Count == 0) { return Result.Failure(...Validation...); }
var notification = PushNotification.Create(
title, body, sentByUserId, recipients.Count, dedupKey, scopeKey); // scopeKey: optional view filter
await _repository.AddAsync(notification, ct);
try { await _unitOfWork.SaveChangesAsync(ct); } // save 1: the audit row alone
catch { /* CA1031: lost the filtered-unique-index race? requery by key, return the winner */ throw; }
foreach (var userId in recipients) { /* create a UserNotification inbox row */ }
await _unitOfWork.SaveChangesAsync(ct); // save 2: the inbox rows, still no delivery
try { await _push.SendToUsersAsync(recipients, title, body, ct); notification.MarkAsSent(); }
catch { notification.MarkAsFailed(); } // CA1031: SignalR leg owns the status
try { await _nativePush.SendToUsersAsync(recipients, title, body, ct); } // ADR-044: OS-level leg
catch { /* LogNativePushFailed; native delivery is best-effort, audit status untouched */ } // CA1031
await _unitOfWork.SaveChangesAsync(ct); // record observed status; commit on return
Notice what this ordering buys you. PushNotificationStatus is an observed-delivery audit field, not a
gate, and it is decided by the SignalR leg alone. The answer to "what if the user is offline?" is built
in: they read it later from the inbox, the native leg tries their pocket, and the organizer sees a Sent
or Failed history. Durability lives in the database, not in any transport: the inbox row is what a
missed delivery falls back to, and it commits with the send rather than on a transport acknowledging
anything. This shape, a durable per-user
inbox plus two transient best-effort delivery legs (the SignalR real-time push recorded in ADR-024 and
the OS-level native push added in ADR-044) written in that order, keeps the inbox as the single source
of truth.
The send carries a second optional key alongside DedupKey: an opaque ScopeKey (for example
"event:2"), stamped by the sending application and stored on the audit aggregate. The framework
attaches no meaning to it. An app whose notifications belong to one edition of something sets it, and
every caller that omits it sends exactly as it always did. ADC resolves the current published event
and scopes to event:{id}, degrading to null (unscoped) if no event resolves, which is the safe
direction because scope is a view filter, not a security boundary. That is also why the column is
deliberately unindexed: the filter runs after the primary-key join from the inbox, over a table that
holds one row per send, so an index would cost writes without buying a read.
Retry safety on two levels: the idempotency filter and the dedup key
A broadcast is exactly the operation you never want to run twice, and the two ways it gets run twice are different problems. The client retries the HTTP call; or two retries arrive close enough together that both pass the same check. The slice answers both, at two different levels.
At the edge, the send endpoint carries [Idempotent]. The framework's idempotency filter caches the
first response against the caller's Idempotency-Key and replays it for a repeat, which is the cheap
answer to a retried HTTP call. Then the controller reads that same Idempotency-Key header itself
and passes it into the command as DedupKey (absent or whitespace-only stays null, which leaves the
send on the default undeduplicated path). The division of labor is the point: the filter protects
the response, the dedup key protects the delivery. When the filter's cache is cold, evicted, or
degraded (a restarted host, an expired entry, an unreachable distributed cache) the response replay is
gone, but the domain still refuses to send a second time.
Underneath, DedupKey is a real column on the PushNotification aggregate (max 128 characters,
validated in the factory), with a filtered unique index over it. That matters because the handler's
up-front lookup is a check-then-act: two concurrent retries of the same send can both pass it, and
the loser only discovers the conflict when it tries to insert. So the audit save is its own
SaveChangesAsync, wrapped in a catch that requeries by key: if the row exists now, the concurrent
send is the cause and the caller gets that notification back; anything else is rethrown untouched and
reaches the exception middleware. The database, not the application, arbitrates the race.
That catch is deliberately broad (a suppressed CA1031), and the reason is a layer rule rather than
laziness: Application has no EF Core dependency, so DbUpdateException is not a type this file is
allowed to name. The requery is what narrows it. It is the same swallow-and-requery shape the
framework's inbox store uses on its own unique index; only the nameable exception differs.
The push channel: an adapter chosen at composition, not branched at runtime
The real-time channel is two cooperating types. NotificationHub is an [Authorize]d SignalR Hub.
For this durable-push slice it contributes a ReceiveNotification method-name constant, the client-side
listener that SignalRPushNotificationSender targets. Connection-to-user mapping is handled by SignalR's
IUserIdProvider (a ClaimBasedUserIdProvider). SignalRPushNotificationSender then pushes out of band
through IHubContext<NotificationHub>. It never holds a hub connection itself; it addresses
Clients.User(id), Clients.Users(batch), or Clients.All. Large audiences are chunked into batches of
100 user IDs so a single send does not overwhelm the connection manager.
The hub is not method-less, though. Per ADR-039 the same hub also hosts a live-channel layer on the one
connection: JoinChannel and LeaveChannel methods that map a connection into SignalR groups for
ephemeral broadcasts (live polls, session Q&A, live counters) that are deliberately never persisted. That
layer is out of scope for this durable-notification slice; the next article in the series is the
deep-dive on it.
The important design decision is that which sender is live is a registration decision, not a runtime
branch. Infrastructure registers the safe default: AddInfrastructure calls AddServices, which
does TryAddTransient<IPushNotificationSender, NullPushNotificationSender>() (the no-op lives in
MMCA.Common.Infrastructure.Services, so it is an Infrastructure registration, not an Application one).
Infrastructure's AddPushNotifications(configuration) then replaces IPushNotificationSender with the
SignalR implementation, wires AddSignalR(), and, if a redis connection string is present, adds a
Redis backplane for SignalR scale-out across replicas. A host that never calls AddPushNotifications
keeps that no-op NullPushNotificationSender, so the send pipeline still resolves and runs (audit and
inbox rows are written) with no real-time transport at all.
// The null-object discipline: the handler depends on the port; the adapter is chosen at the root.
// Infrastructure default (AddServices): IPushNotificationSender -> NullPushNotificationSender (no-op)
// AddPushNotifications(configuration): IPushNotificationSender -> SignalRPushNotificationSender
// (+ AddSignalR, + Redis backplane if "redis" is configured)
This is dependency inversion done properly. The handler depends on IPushNotificationSender. There is
no if (signalRConfigured) anywhere in the handler. The choice between real and no-op lives at the
composition root, and the same null-object discipline applies to recipients via
NullNotificationRecipientProvider. The handler is genuinely transport-agnostic.
The native push channel: reaching devices the hub cannot
The SignalR leg stops at the edge of a connected client. A phone with the app backgrounded, killed, or offline hears nothing until the next launch, and conference announcements ("lunch is served", "room change") are exactly the messages that must reach pockets, not open tabs. ADR-044 adds a fourth delivery leg for that: OS-level native push through Firebase Cloud Messaging (Android) and APNs (iOS), fanned out by Azure Notification Hubs.
It follows the same abstraction discipline as the SignalR channel, with two new Application ports.
INativePushSender does the user-targeted send, and IPushDeviceRegistrar upserts and deletes the
per-device installations that sends target. Infrastructure TryAdds inert defaults
(NullNativePushSender and NullPushDeviceRegistrar), and AddNativePushNotifications(configuration)
swaps in the Azure Notification Hubs implementations (AzureNotificationHubNativePushSender and
AzureNotificationHubDeviceRegistrar) only when the NativePush configuration section is enabled and
complete. Hosts call it unconditionally, so a deployment flips the channel on by configuration alone, and
a build without push credentials is wired but inert end to end. Installations are tagged user:{id}, so
a send targets a user (never a raw token) and reaches every device that user registered.
The registration surface is DevicesController, the third REST controller. Any authenticated user
manages their own device installations: PUT /Notifications/Devices upserts (called after sign-in and
on token rotation), DELETE /Notifications/Devices/{installationId} removes one (called before
sign-out) and is idempotent. Ownership is stamped server-side from the current user, and installation
ids are client-generated GUIDs, so they are not enumerable.
The in-app inbox: pure CQRS read/write, scoped to the caller
The inbox is the durable channel, exposed by InboxController to any authenticated user (unlike the
organizer-only send and history endpoints). Four slices back it:
GetMyNotificationsHandlerjoinsUserNotificationtoPushNotification(title and body live on the push aggregate, read state lives on the per-user row) and projects aUserNotificationDTO, newest first, paginated and capped at 500 rows per page. This cross-aggregate read is done in the query, not via a navigation populator, because the two aggregates reference each other by ID only. That matters: in ADCUserNotificationlives in its own database.GetUnreadNotificationCountHandlercounts unread rows for the bell badge.MarkNotificationReadHandlerverifies the row belongs to the requesting user before flipping it. The domain methodUserNotification.MarkAsRead(DateTime readOnUtc)takes the read instant as a parameter (the handler supplies it from an injectedTimeProvider, keeping the domain clock-agnostic) and is itself idempotent: a second call preserves the original read timestamp.MarkAllNotificationsReadHandlerbulk-marks the caller's unread rows.
Three of those four slices accept the optional scope key, read from a scope query-string parameter
on the controller: the list, the unread count (so the badge and the list agree), and the bulk
mark-read (so a scoped client never marks rows it cannot see). A supplied scope narrows the read to
the notifications carrying that scope plus every unscoped one; omitting it returns everything,
exactly as before, which is what keeps the pre-scope callers working.
Both the per-row read and the mark-read scope by the caller's UserId from ICurrentUserService, so
one user cannot read or mutate another's inbox. Security is part of the slice, not a cross-cutting
afterthought.
The email channel, and the feature gate over all of it
Email is the smallest channel: one port IEmailSender and one SMTP adapter SmtpEmailSender, which
constructs and disposes a fresh SmtpClient per send. It is independent of the push and inbox flow;
nothing in the send handler calls it, and it is wired separately. Locally it points at an Aspire MailDev
container for visual inspection. Unlike push, email has no null-object default and is not opt-in:
Infrastructure's AddServices does TryAddTransient<IEmailSender, SmtpEmailSender>() unconditionally,
and AddInfrastructure always calls it, so every host that registers infrastructure gets the SMTP
sender. There is no NullEmailSender analogue.
The entire push, inbox, and device surface sits behind one feature flag,
NotificationFeatures.PushNotifications. All three controllers carry
[FeatureGate(NotificationFeatures.PushNotifications)], so the whole channel can be switched off
per-environment with no code change. Authorization splits along the three controllers:
NotificationsController (send plus history) requires the organizer policy, while InboxController
and DevicesController require only an authenticated caller. That asymmetry encodes the rule "anyone
reads their own inbox and registers their own devices, only organizers broadcast."
Identifier aliases keep the IDs honest
A small but consistent detail: the notification entities do not type their keys as bare int. The slice
uses solution-wide identifier aliases declared once in
MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs and linked into every project. A
UserNotification has an Id of UserNotificationIdentifierType and a foreign key
PushNotificationId of PushNotificationIdentifierType; a MarkNotificationReadCommand carries a
UserNotificationIdentifierType NotificationId and a UserIdentifierType UserId. They are global using ... = int; aliases today, but typing the keys distinctly makes a "passed the wrong id" mistake visible
at the call site and gives you a single place to change the underlying type later.
Trade-offs, honestly
- Best-effort real-time, durable record. The deliberate swallow of delivery exceptions means a push can fail silently as far as the WebSocket is concerned. That is the right call (the inbox row commits with the send either way), but it means real-time delivery is not a guarantee, it is an optimization. The status field is your audit trail, not a delivery receipt.
- The send is one transaction, so the sender calls run inside it.
ITransactionalis what keeps the dedup key honest: a fault between the audit row and the inbox rows would otherwise leave a committed key that nothing delivered, and every retry of that key would answer success forever. The price is that the SignalR and native calls happen with the transaction still open. Both are bounded by their own timeouts and hold locks only on rows this request just inserted, but it is still a transaction held across an out-of-process call. - Inbox fan-out cost. Step 4 writes one
UserNotificationrow per recipient. For a broadcast to a large audience that is a lot of rows in one save. It is the price of a durable per-user inbox with per-user read state, and it is bounded by the recipient count, but it is real write amplification. - Deduplication is opt-in, and it is check-then-act plus a database index. A send that carries no key behaves exactly as it always did: no protection at all. When a key is present, the up-front lookup is a race the handler cannot win on its own, so correctness rests on the filtered unique index and on a broad catch that has to requery to classify what it caught. That is a real cost in handler complexity, paid to keep Application free of an EF Core dependency.
- The scope key is a view filter, not a security boundary. It narrows what a scoped read returns,
but a read that supplies no scope still sees every notification, scoped ones included. It organizes
an inbox by edition; it does not isolate anything. Authorization remains the caller-scoped
UserIdcheck, and a real isolation requirement needs a real boundary, not this column. - Scale-out needs the backplane. SignalR across multiple replicas only works correctly with the
Redis backplane wired. Forget the
redisconnection string in a multi-replica deployment and pushes reach only the users connected to the replica that handled the send. - Native push is inert until provisioned, and fire-and-forget once live. The native leg stays a
no-op until a notification hub with platform credentials (a Firebase service account, an APNs key) is
provisioned and the
NativePushsection is enabled. Once live it does no per-device delivery tracking: the hub's telemetry is the observability surface, and the inbox remains the recovery path. - It is one example, not a notification platform. This is a reference vertical slice, not a full-featured notification product. There is no retry queue for failed real-time delivery, no template engine, no per-channel user preferences. The slice is shaped to teach the pattern and to be extended, not to be a drop-in SaaS.
Apply this even without MMCA
The vertical-slice discipline ports to any stack:
- Organize by feature, not by technical role. Keep a feature's domain, use cases, adapters, and endpoints together so the things that change together live together.
- Write the durable record before attempting best-effort delivery, make delivery failure update a status rather than abort the operation, and commit the writes as one unit so a fault partway through cannot leave a record nothing delivered. Offline users are a normal case, not an error.
- Choose adapters at the composition root, never branch on configuration in the handler. Ship a null-object default so the pipeline resolves and runs even when the real transport is absent.
- Scope every read and mutation to the caller inside the slice, rather than relying on a cross-cutting filter to remember.
- Let the database arbitrate a deduplication race. An application-level "does it already exist?" check is check-then-act and two retries will both pass it. Back it with a unique index and treat the insert failure as an answer, not just an error.
The takeaway: a framework earns trust by showing one complete feature built on its own rules. Build that feature as a cohesive vertical slice, and it teaches the patterns better than any amount of base-class documentation.
What we covered: why MMCA.Common ships notifications as the one concrete bounded context, how a
vertical slice beats a horizontal layer cut for cohesion, the end-to-end send flow (deduplicate,
resolve recipients, persist audit, fan out inbox, then best-effort deliver over SignalR and OS-level
native push, the whole sequence one ITransactional transaction), the two levels of retry safety (the [Idempotent] filter replaying the response, the
DedupKey plus its filtered unique index protecting the delivery), the optional ScopeKey that
narrows a read without isolating anything, why each
sender is a composition-root choice with a null-object fallback, the ADR-044 native push channel and its
DevicesController registration surface, the CQRS inbox scoped to the caller, the email channel and the
single feature gate over all three controllers, and the notification identifier aliases.
Next in the series: live channel push, the other half of this hub. Ephemeral events (live polls,
session Q&A, live counters) fan out through the same NotificationHub via channel groups without ever
being persisted, and the durable-vs-ephemeral decision is made at the publisher boundary
(IPushNotificationSender versus ILiveChannelPublisher).
MMCA.Common is Apache-2.0 licensed and open source. Star the repo, read the notifications slice as a worked
example, or dotnet add package MMCA.Common.API and try it.
- ⭐ Repo: https://github.com/ivanball/MMCA.Common
- 📚 Full series index: https://ivanball.github.io/writing.html
Tags: .NET, C Sharp, Software Architecture, SignalR, Vertical Slice