Important
This repository is archived. Active development moved to the lockstep monorepo:
dignite-projects/abp-modules.
New Notifications source and documentation:
abp-modules/notifications.
Please open new issues and pull requests in
dignite-projects/abp-modules.
Package IDs, namespaces, and published package names are unchanged. Only the source repository changed.
Contributors should clone the new repository, or update an existing remote to
https://github.com/dignite-projects/abp-modules.git.
An extensible, event-driven notification framework for the ABP Framework, plus an optional Notification Center (persistent inbox, subscriptions, read/unread state, REST API) with MVC and Angular UI libraries.
- Event-driven, pluggable notifiers. The core publishes one stable, independently claimable
NotificationDeliveryRequestedEtoper recipient and channel. Channels can be added, removed, or deployed independently without touching the core. - Two operation modes, one framework. Run Core-only with process-local delivery state and no inbox, or install Notification Center for durable delivery state, persistent inbox, subscriptions, read/unread state, REST API, and operator retry.
- Dual persistence. EF Core and MongoDB implement the same inbox and delivery-state abstractions.
- Contract-driven & headless. Every payload carries a stable type discriminator, so any consumer — .NET, JS/TS, or the shipped Angular library — can deserialize and render it. The Notification Center is headless (REST API); UI is optional.
- Multi-tenant & permission-aware — ABP
IMultiTenantthroughout, with optional ABP Identity permission gating.
.NET 10 · ABP 10.5.0 · LGPL-3.0-only
Requirements: the .NET 10 SDK and an ABP 10.5.0 host application. Contract layers
multi-target netstandard2.0;netstandard2.1;net10.0 so remote and older consumers can reference
them.
| Dignite release | ABP Framework | Runtime | Angular library | Angular peer range |
|---|---|---|---|---|
10.0.0-rc.3 |
10.5.x (built against 10.5.0) |
.NET 10 | 10.0.0-rc.3 |
Angular ^21.2.0 |
The NuGet and npm packages always use the same version. Pre-release npm packages use the next
dist-tag; stable releases use latest. npm requires every package to have a latest tag, so until
the first stable version exists the initial pre-release is necessarily also exposed as latest.
Core framework (core/):
| Package | Purpose |
|---|---|
Dignite.Abp.Notifications.Abstractions |
Shared contracts: NotificationData, NotificationDeliveryRequestedEto, [NotificationDataType], INotificationDefinitionProvider, INotificationNotifier. Notifiers and remote clients depend on only this. |
Dignite.Abp.Notifications |
The core: definitions, the publish/distribute pipeline, the INotificationStore abstraction + NullNotificationStore. |
Dignite.Abp.Notifications.SignalR |
Real-time push notifier (SignalR hub at /signalr-hubs/notifications). |
Dignite.Abp.Notifications.Emailing |
Email notifier (ABP IEmailSender). |
Dignite.Abp.Notifications.Emailing.Identity |
Optional ABP Identity-backed email address resolver for the Emailing notifier. |
Dignite.Abp.Notifications.Identity |
Permission gating and active-user audience paging via ABP Identity. |
Optional Notification Center (notification-center/) — persistence + REST API + UI, depends on
Core:
| Package | Purpose |
|---|---|
Dignite.NotificationCenter.Domain.Shared |
Enums / constants (NotificationSeverity, UserNotificationState). |
Dignite.NotificationCenter.Domain |
Aggregates: Notification, UserNotification, NotificationSubscription. |
Dignite.NotificationCenter.Application / .Application.Contracts |
Inbox / subscription app service + DTOs. |
Dignite.NotificationCenter.HttpApi |
REST controllers at /api/notification-center. |
Dignite.NotificationCenter.HttpApi.Client |
C# client proxies for remote consumers. |
Dignite.NotificationCenter.EntityFrameworkCore |
INotificationStore on EF Core (+ NotificationCenterDbContext). |
Dignite.NotificationCenter.MongoDB |
INotificationStore on MongoDB. |
Dignite.NotificationCenter.Web |
MVC UI: notification-bell view component + subscriptions page. |
notification-center (Angular, angular/projects/) |
Angular UI: proxy service + bell & subscriptions components. |
Core never references the Notification Center — the two trees are independently installable, and Core keeps working with
NullNotificationStorealone. Thehost/(runnable ABP MVC demo) andangular/(demo Angular app) folders are local-dev demos only; they are not packaged or published.
The commands below show all packages installed into one host project for clarity. In a layered ABP
solution, add each package to the matching layer and put the corresponding [DependsOn] entry in
that layer's module.
Install Core plus at least one external delivery channel. This example uses SignalR:
dotnet add path/to/MyApp.csproj package Dignite.Abp.Notifications --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.Abp.Notifications.SignalR --version 10.0.0-rc.3Email is optional:
dotnet add path/to/MyApp.csproj package Dignite.Abp.Notifications.Emailing --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.Abp.Notifications.Emailing.Identity --version 10.0.0-rc.3dotnet add path/to/MyApp.csproj package Dignite.Abp.Notifications.SignalR --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.NotificationCenter.Application --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.NotificationCenter.HttpApi --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.NotificationCenter.EntityFrameworkCore --version 10.0.0-rc.3
dotnet add path/to/MyApp.csproj package Dignite.NotificationCenter.Web --version 10.0.0-rc.3Dignite.NotificationCenter.Web is optional. For MongoDB, replace
Dignite.NotificationCenter.EntityFrameworkCore with
Dignite.NotificationCenter.MongoDB. Permission gating and active-user audience paging through
Dignite.Abp.Notifications.Identity are also optional.
For an Angular host, install the version-matched UI library:
npm install @dignite/ng.notification-center@10.0.0-rc.3Then follow The two operation modes for the module dependencies and Defining and publishing a notification for the first end-to-end notification.
10.x is a from-scratch rewrite of the legacy Dignite.Abp.Notifications* and
Dignite.NotificationCenter* packages, not an in-place compatible upgrade. The major version
tracks the targeted ABP Framework major and also ensures that this implementation supersedes the
legacy 3.8.2 packages on NuGet.org.
Before changing an existing application from 3.x:
- Treat the change as a module replacement and review every package/module dependency; legacy UI and notifier package names do not map one-for-one to this repository.
- Give every custom
NotificationDatatype a stable[NotificationDataType("...")]discriminator and register it throughNotificationDataOptions. - Generate a new host-owned database migration for the three Notification Center aggregates. This repository does not ship a migration or an automated legacy-data converter.
- Plan any migration of historical notifications and subscriptions explicitly before pointing the new module at a production database.
- Regenerate or replace legacy clients with the new REST/C#/Angular clients and verify custom rendering and entity links.
Keep the application pinned to 3.8.2 until that migration has been tested; installing 10.x over a legacy production database without a migration plan is unsupported.
Install the core plus one or more notifiers. There is no inbox or subscription: you pass explicit
recipient userIds, the notifier pushes to connected clients, and nothing is stored
(NullNotificationStore).
[DependsOn(
typeof(AbpNotificationsModule),
typeof(AbpNotificationsSignalRModule) // real-time channel
)]
public class MyHostModule : AbpModule { }Also install the Notification Center plus a persistence provider (EF Core or MongoDB). This adds a
persistent per-user inbox, subscriptions, read/unread state, and the /api/notification-center REST API.
[DependsOn(
typeof(AbpNotificationsSignalRModule), // real-time channel
// typeof(AbpNotificationsEmailingModule), // optional: email channel
// typeof(AbpNotificationsEmailingIdentityModule), // optional: UserId -> Email via ABP Identity
typeof(AbpNotificationsIdentityModule), // optional: permission gating
typeof(NotificationCenterApplicationModule), // inbox / subscription logic
typeof(NotificationCenterHttpApiModule), // REST API at /api/notification-center
typeof(NotificationCenterEntityFrameworkCoreModule), // persistence (or ...MongoDbModule)
typeof(NotificationCenterWebModule) // optional: MVC bell + subscriptions UI
)]
public class MyHostModule : AbpModule { }Fold the store into your host's own DbContext (this repo ships no DbMigrator — the host owns its
migrations):
public class MyHostDbContext : AbpDbContext<MyHostDbContext>, INotificationCenterDbContext
{
public DbSet<Notification> Notifications { get; set; } = default!;
public DbSet<UserNotification> UserNotifications { get; set; } = default!;
public DbSet<NotificationSubscription> NotificationSubscriptions { get; set; } = default!;
public DbSet<NotificationDeliveryRecord> NotificationDeliveries { get; set; } = default!;
public MyHostDbContext(DbContextOptions<MyHostDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureNotificationCenter(); // maps the four tables
}
}Then add a migration in your host and update the database, exactly as for any other ABP module.
Subscription identity is the tuple (TenantId, UserId, NotificationName, EntityTypeName?, EntityId?).
The two entity values are either both null (subscribe to every entity for that notification definition)
or both present (subscribe to exactly that entity). Notification Center persists three normalized,
non-null keys so this tuple is enforced with the same ordinal semantics in relational databases and
MongoDB: TenantKey, NotificationNameKey, and ScopeKey.
When upgrading an existing Notification Center database, create a host-owned migration/data migration that:
- adds the three keys as temporarily nullable;
- backfills every subscription by calling
NotificationSubscriptionIdentity.GetTenantKey,GetNotificationNameKey, andGetScopeKeywith its existing natural values; - removes or repairs legacy rows with only one entity field and resolves any pre-existing duplicate identities before adding the unique index;
- makes the keys required and replaces the old nullable natural-value indexes with the EF Core indexes
from
ConfigureNotificationCenter, or the equivalent MongoDB indexes declared byNotificationCenterMongoDbContext.CreateModel.
Do not add required keys with a shared empty-string default: existing rows would collide and the value would not preserve ordinal identity. This repository intentionally ships no migration because the consuming host owns its database and migration history. MongoDB consumers must likewise complete the backfill before deploying the new unique index.
Distributing a notification writes the per-user inbox rows and publishes one NotificationDeliveryRequestedEto per
tenant/notification/user/channel. Those writes and outgoing event records commit together only when the host
enables ABP's transactional outbox. The process that actually hosts the selected channel consumes the work event
and calls the channel notifier once; processes that do not host that channel ignore the event. Delivery is
best-effort — there is no per-recipient delivery record, lease, or retry (see invariant §4) — so the inbox row is
the authoritative record. Atomic rollback of the inbox writes requires an ambient transactional ABP unit of
work; an outbox cannot make a non-transactional unit of work atomic.
| Setup | Persist + publish atomic | Failure/cancellation after a completed batch |
|---|---|---|
| EF Core, outbox + transactional UoW | yes, within one distributor/job invocation | the transaction rolls back inbox and outbox records |
| EF Core, no outbox + transactional UoW | no | inbox writes roll back, but an already published external event may have escaped |
| EF Core, non-transactional UoW | no | completed inbox batches and already published events can remain |
| MongoDB, outbox + transactional UoW on a supported topology | yes, within one distributor/job invocation | the transaction rolls back inbox and outbox records |
| MongoDB, no outbox or non-transactional UoW | no | completed inbox batches and already published events can remain |
| Core-only channel consumer | no inbox | the channel event is fire-once; nothing is retained across process exit |
If you use the shipped NotificationCenterDbContext, one line enables both:
Configure<AbpDistributedEventBusOptions>(options => options.UseNotificationCenterEfCoreOutbox());If you folded the tables into your own DbContext as above, that extension points at the wrong
context. Implement the two marker interfaces, map the event tables, and route the outbox to your own
context instead:
public class MyHostDbContext : AbpDbContext<MyHostDbContext>,
INotificationCenterDbContext, IHasEventInbox, IHasEventOutbox
{
public DbSet<IncomingEventRecord> IncomingEvents { get; set; } = default!;
public DbSet<OutgoingEventRecord> OutgoingEvents { get; set; } = default!;
// ...the four notification DbSets
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureEventInbox();
builder.ConfigureEventOutbox();
builder.ConfigureNotificationCenter();
}
}
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Outboxes.Configure(config => config.UseDbContext<MyHostDbContext>());
options.Inboxes.Configure(config => config.UseDbContext<MyHostDbContext>());
});The shipped MongoDB context has an equivalent one-line opt-in; it configures both ABP event boxes:
Configure<AbpDistributedEventBusOptions>(options => options.UseNotificationCenterMongoDbOutbox());The transactional outbox requires a transaction-capable MongoDB deployment: a replica set running MongoDB 4.0 or
later with logical sessions. Standalone servers cannot commit the outbox transaction. The host must also use
transactional ABP units of work; do not set AbpUnitOfWorkDefaultOptions.TransactionBehavior to Disabled. For
production with more than one application instance, configure an ABP distributed-lock provider for the outbox
sender and inbox processor. (An earlier release shipped a startup self-probe that opened a throwaway transaction to
verify this; it was removed as over-engineering — it duplicated ABP/driver transaction-capability detection. Rely
on your deployment topology instead.)
Existing MongoDB hosts should upgrade in this order:
- convert the deployment to a MongoDB 4.0+ replica set and verify transactions independently;
- ensure the application role can create indexes and read/write the ABP event-box collections;
- inspect existing
AbpEventInboxrecords and collapse duplicate non-emptyMessageIdvalues before the new unique index is created (retain the processed/discarded record when one exists, otherwise the oldest pending row); - deploy the new provider package and add
UseNotificationCenterMongoDbOutbox(); - keep notification distribution inside transactional units of work, then monitor the ABP outbox/inbox workers before enabling traffic.
Notification business data needs no backfill and no collection is renamed. The provider uses ABP's conventional AbpEventOutbox and
AbpEventInbox names and creates indexes matching the EF Core query shapes: CreationTime for outbox sending,
Status + CreationTime for inbox processing/cleanup, and MessageId for duplicate detection. MongoDB makes
MessageId unique so two concurrent check-then-insert deliveries cannot create two handler-visible records. A
losing concurrent transaction is retried by the broker; ABP's normal existence check then observes the winner.
EF Core retains ABP's conventional non-unique MessageId index, which is a remaining provider difference.
Breaking for custom context implementers: INotificationCenterMongoDbContext now extends ABP's
IHasEventInbox and IHasEventOutbox. A consumer-owned implementation must expose
IMongoCollection<IncomingEventRecord> IncomingEvents and IMongoCollection<OutgoingEventRecord> OutgoingEvents,
call ConfigureEventInbox() and ConfigureEventOutbox() from CreateModel, and add the three indexes described
above (including MongoDB's unique MessageId index). The non-generic
UseNotificationCenterMongoDbOutbox() targets the shipped NotificationCenterMongoDbContext; a custom context
must instead configure both boxes explicitly:
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Outboxes.Configure(config => config.UseMongoDbContext<MyHostMongoDbContext>());
options.Inboxes.Configure(config => config.UseMongoDbContext<MyHostMongoDbContext>());
});Ensure every resolved database is a transaction-capable replica set before accepting traffic.
Processed/discarded inbox records are retained for ABP's deduplication window and then removed by ABP's built-in
cleanup worker. Tune AbpEventBusBoxesOptions.WaitTimeToDeleteProcessedInboxEvents and
CleanOldEventTimeIntervalSpan for the host's redelivery window and storage budget. Outbox rows are deleted after
successful broker publication. Operational TTL indexes are not created because they can bypass ABP's status-aware
cleanup semantics.
EF Core requires host-owned schema migrations for AbpEventOutbox/AbpEventInbox; MongoDB creates collections
and indexes through its model initialization instead. Both providers use ABP's dispatcher/inbox terminology and
require a transactional unit of work for atomic persist-and-record. The MongoDB-specific unique inbox index and
replica-set startup probe are the remaining reliability/configuration differences. Neither provider claims
exactly-once external delivery or atomicity across all independently scheduled fan-out jobs.
Without the opt-in, a crash between inbox persistence and work-event publication can leave a notification with no channel delivery. Cancellation remains a boundary for stopping new work, not compensation for completed work.
Notification Center does not auto-expire anything. A UserNotification inbox row lives until its owner deletes
it (the bell UI's per-row delete) or the host removes it; a Notification payload row lives until the host removes
it. This mirrors a device notification center — entries stay until the user swipes them away. Retention is not a
behavior the module schedules on the host's behalf.
Inbox growth (publishes × recipients) is real, but when to delete, which tenant context to delete in, and
which instance runs the scan in a cluster are host-infrastructure decisions a library cannot make correctly from
the inside. A host that needs time-based cleanup schedules it itself — an ABP AsyncPeriodicBackgroundWorkerBase, a
database agent job, whatever already fits its operations — honoring two rules the store relies on:
- Never delete
Unreadrows. Only agedReadinbox rows are safe to remove; deleting an unread row silently drops a notification and corrupts the bell's unread count. - Delete a
Notificationpayload only once no inbox row references it. The payload is shared across every recipient's inbox row, so an orphan check must precede its deletion.
Relational example, run per tenant context the host owns (CurrentTenant.Change(tenantId) for each tenant, plus the
host null scope), in bounded batches on a large store. Table names use the configurable
NotificationCenterDbProperties.DbTablePrefix (default Abp):
-- 1) Age out READ inbox rows; Unread (State = 0) rows are always retained.
DELETE FROM AbpUserNotifications
WHERE State = 1 AND CreationTime < @cutoff;
-- 2) Delete payloads no inbox row references any more.
DELETE FROM AbpNotifications n
WHERE n.CreationTime < @cutoff
AND NOT EXISTS (SELECT 1 FROM AbpUserNotifications u WHERE u.NotificationId = n.Id);Scope every statement by TenantId. On the MongoDB provider, prefer a two-step orphan check (collect unreferenced
payload ids, then delete) over a cross-collection join.
| Record | Owner | Deletion rule |
|---|---|---|
UserNotification inbox row |
Notification Center / current user | Users delete their own rows via the inbox. A host may additionally age out Read rows; Unread rows must be retained. |
Notification base payload |
Host retention job | Delete when older than the host's window and no inbox row still references it. |
NotificationSubscription |
User subscription settings | Not time-based. Delete only by the exact subscription identity through the subscription APIs. |
| ABP event inbox/outbox records | ABP distributed event bus | Use ABP's status-aware event-box cleanup windows. Do not add TTL deletes that bypass processed/in-progress state. |
Most features need no new entity — Notification / UserNotification are generic containers for
any NotificationData.
1. Define the payload with a stable discriminator — never a CLR type name; this is what keeps stored and remote JSON readable across assembly-version bumps:
[NotificationDataType("Demo.OrderShipped")]
public class OrderShippedNotificationData : NotificationData
{
public string OrderNumber { get; set; } = default!;
public int ItemCount { get; set; }
}For a plain text message you don't need a subclass — use the built-in
MessageNotificationData.
2. Register the payload type so it (de)serializes via its discriminator:
Configure<NotificationDataOptions>(options =>
{
options.Add<OrderShippedNotificationData>();
});Discriminators use ordinal, case-sensitive comparison. Registering the same discriminator for two CLR types, or the same CLR type under two discriminators, fails during application startup with both sides named in the error. Repeating the exact same discriminator/type pair is safe and idempotent.
The wire/storage envelope carries only the stable type discriminator — there is no schema-version field or
upcaster chain (an earlier design added event-sourcing-style versioning + N→N+1 upcasters; it was removed as
over-engineering, since notifications are read-once, not a replayable event stream). To change a payload's JSON
shape, prefer adding members: a newer payload of a known type reads leniently, with unknown members landing in
ExtensionData.
INotificationDataSerializer.Deserialize(json) is the single programmatic read boundary, and every read through
it is tolerant: an unknown discriminator or malformed known payload becomes UnsupportedNotificationData,
preserving the original discriminator and escaped raw JSON without activating an arbitrary CLR type, instead of
throwing. One bad historical row therefore cannot fail the rest of an inbox page. Notification Center inbox reads,
distributed-event deserialization, and HTTP server/client converters all go through this same tolerant path. The
MVC and Angular libraries render the placeholder as a generic unsupported-notification message and do not display
its raw diagnostic JSON. Writing an unregistered CLR type still throws — that fail-fast is unconditional.
3. Register the notification definition through an INotificationDefinitionProvider — its name,
display text, optional feature/permission gating, and explicit channel routing:
public class ShopNotificationDefinitionProvider : NotificationDefinitionProvider
{
public override void Define(INotificationDefinitionContext context)
{
context.Add(new NotificationDefinition(
"Demo.OrderShipped",
new FixedLocalizableString("Order shipped"))
.UseChannels(SignalRNotifier.ChannelName));
}
}Definition names use ordinal, case-sensitive comparison. Every duplicate name is a startup error, and the error identifies both provider types; an equivalent-looking second definition is not treated as idempotent because definitions are mutable after construction. Provider types are convention-discovered across modules; registering the same provider type more than once is idempotent and the provider executes once. Empty and whitespace-only definition names are rejected by the constructor.
An explicitly empty userIds array remains a true no-op and returns before definition resolution.
4. Publish from your business code via INotificationPublisher:
await _publisher.PublishAsync(
"Demo.OrderShipped",
new OrderShippedNotificationData { OrderNumber = "SO-1001", ItemCount = 3 },
entityIdentifier: new NotificationEntityIdentifier("Demo.Order", "1001"),
severity: NotificationSeverity.Success,
userIds: new[] { customerId });PublishAsync distributes small explicit fan-outs inline and larger ones via a background job (the
threshold is configurable — see Configuration). Recipient semantics are deliberate:
userIds: null resolves subscribers, an empty array is an intentional no-op, and a non-empty array
targets those users explicitly. Duplicate explicit IDs are removed before the threshold is evaluated,
inbox rows are persisted, or channel delivery is published.
A definition's permission and feature requirements govern both who may subscribe and who may receive the
notification. The distributor applies the same INotificationDefinitionManager.IsAvailableAsync filter to both
subscription-derived and explicitly targeted candidates: caller-supplied exclusions are removed first, then each
remaining candidate must satisfy PermissionName and FeatureName (configured through RequirePermission(...) /
RequireFeature(...)) or is filtered out without an inbox row or channel event. An explicit userIds array is
not an authorization bypass. This is a delivery policy, not publisher authorization: publishing code still
needs its own application permission checks where appropriate.
Eligibility is evaluated in the notification's recorded TenantId, not whichever tenant happens to be ambient
when an inline call or background job executes. A tenant notification therefore uses that tenant's feature values
and permission context, while a host notification is evaluated in the host context. Recipient IDs are never logged.
Both explicit and subscription-derived recipients flow through the same bounded pipeline: resolve a candidate
batch → filter by definition eligibility → write inbox rows → publish one NotificationDeliveryRequestedEto per
eligible recipient/channel. The batch size is NotificationDistributionOptions.RecipientBatchSize (default 256;
must be between 1 and MaxBatchSize = 10,000, validated at host startup). The built-in subscription scan uses a
database-side distinct query with an exclusive user-ID keyset cursor rather than offset paging, so inserts/deletes
before the cursor cannot repeat or skip later recipients. NullNotificationStore implements the same contract
without persistence. All store operations accept a cancellation token, observed between candidate, persistence,
and delivery batches (not during a provider operation already in flight). Notification data must fit the chosen
transport's message-size limit.
PublishAsync distributes explicit fan-outs at or below DirectDistributionUserThreshold (default 5) inline, and
larger ones through a single background job carrying the caller's Guid[]; the job's distributor batches
recipients internally. DirectDistributionUserThreshold is capped by the same 10,000 safeguard.
INotificationPublisher records CurrentTenant.Id automatically. Code that calls INotificationDistributor
directly must populate NotificationInfo.TenantId for tenant notifications. That value is authoritative for
subscription lookup, eligibility, inbox persistence, and event/outbox publication; null explicitly means
host, even when the direct caller currently has an ambient tenant.
A notifier implements the single canonical INotificationNotifier contract and relays one
NotificationDeliveryRequestedEto to a single channel. Name is the stable routing key. DeliverAsync receives a
CancellationToken; delivery is best-effort, so a notifier that intentionally skips a recipient simply returns,
and throwing is logged and dropped by the Core handler (not retried). The Core-owned distributed-event handler is
the transport adapter, so a channel plugin does not implement an event-handler interface.
-
SignalR — clients connect to the hub at
/signalr-hubs/notifications(an ABPAbpHub, mapped automatically; the host must not callMapHub) and receive a trimmedNotificationPayloadwith the recipient list stripped, so siblings' user IDs never leak to each other. -
Emailing — resolves each recipient's email address and sends via ABP's
IEmailSender. Addresses come from an orderedIEmailNotificationAddressResolverchain:EmailNotifiertakes the first non-null address result. The result may also carry a recipient culture used while building that user's email. The base Emailing package registers no resolver, so nothing is sent (and a warning is logged) until one exists. InstallDignite.Abp.Notifications.Emailing.Identityto get the account email as the built-in fallback, and register your own resolver atNotificationEmailProviderOrders.Defaultto claim specific notifications — for example, sending an order shipped mail to the contact address recorded on the order rather than the account address:public class OrderEmailNotificationAddressResolver : IEmailNotificationAddressResolver, ITransientDependency { public int Order => NotificationEmailProviderOrders.Default; public async Task<EmailNotificationAddress?> GetEmailOrNullAsync( EmailNotificationAddressResolveContext context, CancellationToken cancellationToken = default) { if (context.Notification.NotificationName != "Demo.OrderShipped") { return null; // not mine — fall through to the Identity fallback } var contact = await _orders.FindContactAsync( context.Notification.EntityId!, context.UserId, cancellationToken); return contact == null ? null // no address — fall through : EmailNotificationAddress.To(contact.Email, contact.CultureName); } }
A resolver returns the address for this user in this entity context, never "the entity's address" —
EmailNotifierbuilds the body for that same user and sends one email per recipient. The optionalCultureNameonEmailNotificationAddressselects the culture for that recipient's content build; when it is omitted,NotificationEmailOptions.DefaultCultureis used. Culture is scoped only around one recipient's content build and is restored before the next recipient is processed. When ABP Setting Management is installed, the Identity integration uses its user-targeted setting manager and fallback chain; otherwise the configuredNotificationEmailOptions.DefaultCultureis used. Never callCurrentTenant.Changein a resolver: ABP's event bus has already entered the notification's tenant.context.TenantIdexists for resolvers that must forward the tenant across a boundary the ambient scope cannot cross, such as a remote user service. The host still owns SMTP /IEmailSenderconfiguration.The body comes from the parallel
INotificationEmailContentProviderchain. Derive fromNotificationEmailContentProvider<TData>and the payload is narrowed for you — forgetting the type check would otherwise make your provider claim every notification in the system:public class OrderShippedEmailContentProvider : NotificationEmailContentProvider<OrderShippedNotificationData>, ITransientDependency { protected override Task<NotificationEmail?> BuildOrNullAsync( NotificationEmailBuildContext context, OrderShippedNotificationData data, CancellationToken cancellationToken) { return Task.FromResult<NotificationEmail?>( new NotificationEmail($"Order {data.OrderNumber} shipped", RenderBody(data), isBodyHtml: true)); } }
Subclasses of
TDatamatch too, so a provider typed on a base payload keeps handling payloads derived from it. ImplementINotificationEmailContentProviderdirectly only for the rare provider that handles two unrelated payload types.
Write your own (Web Push, FCM, SMS, Webhook, …): create a project depending on
Dignite.Abp.Notifications.Abstractions only, and handle the event:
public class WebPushNotifier
: INotificationNotifier, ITransientDependency
{
public const string ChannelName = "WebPush";
public string Name => ChannelName;
public async Task DeliverAsync(
NotificationDeliveryRequestedEto request,
CancellationToken cancellationToken = default)
{
var payload = NotificationPayload.FromRequest(request, _dataSerializer);
await _webPush.SendAsync(request.UserId, payload, cancellationToken);
}
}DeliverAsync handles one recipient/channel request and observes cancellation. Delivery is best-effort: to skip a
recipient (e.g. no address), simply return; throwing is logged and dropped by the Core handler, not retried.
The event carries the payload pre-serialized as discriminator-tagged JSON (request.DataJson) so it survives any
transport serializer — ABP's event bus (outbox/inbox included) serializes ETOs with plain System.Text.Json, without
the application's JSON options. Inject INotificationDataSerializer (it ships with Abstractions) and hydrate through
NotificationPayload.FromRequest; the read is tolerant, so an unknown or malformed payload becomes
UnsupportedNotificationData instead of throwing.
Use UseChannels(...) only for external delivery channels. If a definition omits UseChannels(...),
it is NotificationCenter inbox-only: the notification is persisted for the recipient to read later,
but no SignalR, Email, Web Push, or other notifier event is published. This keeps adding another
notifier from accidentally fanning out existing notification types to a new channel:
context.Add(new NotificationDefinition("Demo.OrderShipped", new FixedLocalizableString("Order shipped"))
.UseChannels(SignalRNotifier.ChannelName, EmailNotifier.ChannelName));For inbox-only Notification Center entries, omit UseChannels(...):
context.Add(new NotificationDefinition("Demo.AuditLog", new FixedLocalizableString("Audit log")));In stateless forwarding mode (NullNotificationStore, no NotificationCenter installed), an inbox-only
definition has nowhere to persist; publishing it fails fast. Configure at least one external channel in
that mode.
Two controllers expose the current user's notification center under /api/notification-center:
UserNotificationController (the inbox) and NotificationSubscriptionController (subscriptions).
| Method & route | Purpose |
|---|---|
GET /api/notification-center/notifications |
List the caller's notifications (paged; filter by state / date) |
GET /api/notification-center/notifications/unread-count |
Unread notification count (for the bell badge) |
POST /api/notification-center/notifications/{id}/mark-as-read |
Mark one notification read |
POST /api/notification-center/notifications/mark-all-as-read |
Mark all read |
DELETE /api/notification-center/notifications/{id} |
Delete one |
DELETE /api/notification-center/notifications/read |
Delete all read notifications (unread are preserved) |
GET /api/notification-center/subscriptions |
List the caller's subscriptions |
POST /api/notification-center/subscriptions |
Subscribe to the definition-wide or exact entity scope in the JSON body |
DELETE /api/notification-center/subscriptions |
Unsubscribe only the definition-wide or exact entity scope in the query |
All endpoints are scoped to the authenticated caller. Use ...HttpApi.Client for a typed C# proxy, or the
ABP-generated Angular services.
The subscribe/unsubscribe request contains notificationName plus optional entityTypeName and entityId; the
two entity fields must be supplied together (a definition-wide subscription omits both). GET subscriptions
returns the definition-wide row for each available definition and every persisted entity-specific row separately,
so clients must use the full three-field scope rather than infer state from a flattened notification name.
For subscription-driven distribution, a notification without an entity matches only definition-wide subscriptions. A notification for a concrete entity matches the union of definition-wide subscriptions and that exact entity scope; a user present in both receives one inbox row and one channel delivery.
This pre-stable revision moves the REST surface under the /api/notification-center prefix and splits the
user-facing service/controller into an inbox half and a subscription half. Recompile consuming code, update
hard-coded URLs, and regenerate ABP clients (JS / Angular proxies) after applying these source-level changes:
| Before | Now | Consumer action |
|---|---|---|
NotificationsController @ /api/notifications |
UserNotificationController @ /api/notification-center/notifications + NotificationSubscriptionController @ /api/notification-center/subscriptions |
Update hard-coded URLs; regenerate JS / Angular proxies. |
IUserNotificationAppService (inbox + subscriptions) |
IUserNotificationAppService (inbox) + INotificationSubscriptionAppService (subscriptions) |
Inject the subscription service for GetSubscriptions / Subscribe / Unsubscribe. |
SubscribeScopedAsync(dto) / UnsubscribeScopedAsync(dto) (+ the name-only …Async(string) overloads) |
SubscribeAsync(dto) / UnsubscribeAsync(dto) |
One method each; a definition-wide subscription leaves both entity fields null. |
INotificationAppService / NotificationAppService |
IUserNotificationAppService / UserNotificationAppService |
Rename injected service types and replacements. |
GetCountAsync (count by state) |
GetUnreadCountAsync() (no parameter) |
Now unread-only, for the bell badge; get other counts from GetListAsync's TotalCount. Route: GET /api/notification-center/notifications/unread-count. |
DeleteAllAsync(state?) |
DeleteAllReadAsync() (no parameter) |
Bulk delete now removes only read notifications; delete unread ones individually via DeleteAsync. Route: DELETE /api/notification-center/notifications/read. |
IUserNotificationManager / UserNotificationManager |
removed | Use INotificationStore for inbox queries and state mutations. |
INotificationSubscriptionManager |
concrete NotificationSubscriptionManager |
Use the manager only for validated subscription mutations; use INotificationStore for reads. |
| Subscription-manager read methods | removed | Use INotificationStore.GetSubscriptionsAsync / IsSubscribedAsync directly in query paths. |
INotificationRetentionCleanupService / NotificationRetentionCleanupService / NotificationRetentionManager |
removed | Schedule inbox/payload cleanup in the host — see "Retention and lifecycle cleanup". |
INotificationDefinitionManager remains intentionally replaceable because consuming hosts can provide a custom
definition registry and availability policy; startup resolves that replacement before definition initialization.
INotificationStore likewise remains a genuine extension boundary.
- MVC (
Dignite.NotificationCenter.Web): a notification-bell view component and a subscriptions page. Configure the hub URL and per-type rendering viaNotificationCenterWebOptions—SignalRHubUrl,DataViewComponents(keyed by discriminator), andEntityLinkResolvers. - Angular (
angular/projects/notification-center): an ABP-generated proxy service plus bell and subscriptions components, built against/api/notification-centerand the SignalR hub.
Both bells open a SignalR connection to /signalr-hubs/notifications and refresh from the REST inbox when a
ReceiveNotification message arrives or the connection reconnects (auto-reconnect handled by the SignalR client);
the REST inbox is always the authoritative source, since SignalR does not replay missed notifications. If the
SignalR notifier isn't installed or @microsoft/signalr isn't loaded, the bell degrades to a non-live view. MVC
reads the hub URL from NotificationCenterWebOptions.SignalRHubUrl; for remote deployments point that (or the
Angular API URL) at the externally reachable hub while keeping /api/notification-center/notifications as the inbox source.
Configure<NotificationDefinitionRegistration>(options =>
{
// Provider types are normally convention-discovered; explicit registration is also supported.
options.DefinitionProviders.Add(typeof(MyNotificationDefinitionProvider));
});
Configure<NotificationDistributionOptions>(options =>
{
// Explicit recipients above this count distribute on a background job instead of inline. Default: 5.
options.DirectDistributionUserThreshold = 10;
// Recipients resolved, persisted, and published per batch. Between 1 and MaxBatchSize (10,000). Default: 256.
options.RecipientBatchSize = 256;
});
Configure<NotificationEmailOptions>(options =>
{
// Used when an email address resolver does not supply a recipient culture.
options.DefaultCulture = "en-US";
});
// EF Core Notification Center hosts can opt in to ABP's transactional outbox so the persisted
// inbox rows and NotificationDeliveryRequestedEto outbox records commit together.
Configure<AbpDistributedEventBusOptions>(options =>
{
options.UseNotificationCenterEfCoreOutbox();
});
// MongoDB hosts use the equivalent opt-in on a transaction-capable MongoDB 4.0+ replica set with
// transactional ABP units of work.
Configure<AbpDistributedEventBusOptions>(options =>
{
options.UseNotificationCenterMongoDbOutbox();
});Notifications.Abstractions ── data model + NotificationDeliveryRequestedEto + notifier contract
│
Notifications (Core) ── define → distribute → publish delivery events (best-effort)
│
┌────┴───────────────────┐
Notifiers NotificationCenter (optional)
(SignalR / Email / …) inbox · subscriptions · REST API · UI
(EF Core / MongoDB)
Publish → distribute → notify:
- Business code calls
INotificationPublisher.PublishAsync(...). - Small explicit fan-outs distribute inline; larger ones enqueue a
NotificationDistributionJob. - The distributor resolves bounded recipient batches (explicit
userIds, or subscribers fromINotificationStore), checks the definition's feature/permission availability, persists bounded inbox groups (a no-op underNullNotificationStore), then publishes oneNotificationDeliveryRequestedEtoper recipient/channel when external channels are configured. - Only a process hosting the selected channel handles the work: the Core-owned event handler resolves the channel
notifier and calls
DeliverAsynconce. Delivery is best-effort — a channel that throws is logged and dropped, not retried; the inbox row is the authoritative record.
NotificationDeliveryRequestedEto is the load-bearing boundary between scheduling and delivery and the extension
point for any new channel. Under either Notification Center persistence provider, hosts should opt in to ABP's
transactional outbox (see Configuration) so notification, inbox, and outgoing work records
commit together.
Serialization invariant: every
NotificationDatasubclass must carry a stable[NotificationDataType]discriminator and round-trip through System.Text.Json only — never a CLR type name /AssemblyQualifiedName, never Newtonsoft. The envelope carries only that discriminator (no schema version, no upcaster chain); a newer payload of a known type reads leniently. That is what keeps historical and remote payloads readable and lets non-.NET clients render them.
# Build / test everything (core + notification-center) from the one solution
dotnet build Dignite.Abp.Notifications.slnx
dotnet test Dignite.Abp.Notifications.slnx # starts an embedded mongod for the MongoDB provider tests
# Core only (skips the embedded mongod):
dotnet test core/test/Dignite.Abp.Notifications.Tests
# Pack for local testing (version / license come from Directory.Build.props)
dotnet pack Dignite.Abp.Notifications.slnx -c ReleaseEF Core integration tests run on in-memory Sqlite and MongoDB tests on an embedded mongod, so
dotnet test needs no database install and no migration step.
Run the demo host — a runnable ABP MVC host wiring the whole stack (SignalR + Identity + EF Core
- MVC UI) end-to-end, with a demo notification type and a publish button:
dotnet run --project host/Dignite.NotificationCenter.Web.HostThe angular/ workspace consumes the same API for the Angular demo.
core/ core framework (Abstractions, Notifications, Identity, Emailing, Emailing.Identity, SignalR) + tests
notification-center/ optional persistence + REST API + MVC UI + tests (EF Core & MongoDB)
angular/ Angular UI library (projects/notification-center) + demo app ── local dev only
host/ runnable ABP MVC demo host ── local dev only
Dignite.Abp.Notifications.slnx one solution aggregating core/ + notification-center/
Licensed under LGPL-3.0-only.