DEV Community

Cover image for Designing a Scalable Notification System using .NET, Azure Service Bus, MediatR, and SignalR
Chethan Ramaswamy
Chethan Ramaswamy

Posted on Edited on

Designing a Scalable Notification System using .NET, Azure Service Bus, MediatR, and SignalR

Designing a Scalable Notification System using .NET, Azure Service Bus, MediatR, and SignalR

Why I'm Writing This

“Send a notification when something happens.”

It sounds simple.

But in a real application, notifications can quickly become more complicated than expected.

You may need to support:

  • Real-time UI notifications
  • Email and SMS
  • Multiple consumers
  • Retries and failure handling
  • High traffic
  • Future integrations
  • Loose coupling between business logic and notification processing

The challenge is not simply how to send a notification.

The real question is:

How do we design notifications so they don't become tightly coupled to the core business flow?

This article walks through one approach using .NET, MediatR, Azure Service Bus, SignalR, and the Outbox Pattern.


The Problem with the "Simple" Approach

A common implementation starts with something like:

API
  ↓
Save to Database
  ↓
Send Email
  ↓
Send Notification
Enter fullscreen mode Exit fullscreen mode

It works.

At least initially.

But as the application grows, problems start appearing.

Every feature begins calling notification logic directly:

OrderService → NotificationService
PaymentService → NotificationService
UserService → NotificationService
Enter fullscreen mode Exit fullscreen mode

Then new requirements arrive:

  • Add SMS
  • Add push notifications
  • Add in-app notifications
  • Add analytics
  • Add audit events

The notification service gradually becomes tightly coupled to multiple parts of the application.

Another problem is failure handling.

What happens if the email provider is temporarily unavailable?

Should the order creation fail?

Usually, the answer is no.

The business operation and notification delivery have different reliability requirements.


A Better Approach: Event-Driven Notifications

The basic idea is:

Business Action
      ↓
Domain/Application Event
      ↓
Outbox
      ↓
Message Broker
      ↓
Notification Consumers
      ↓
Email / UI / SMS / Push / etc.
Enter fullscreen mode Exit fullscreen mode

The business operation completes without waiting for every notification channel to finish.

This creates a much cleaner boundary between:

"Something happened"

and

"What should we do because it happened?"


High-Level Architecture

The production-oriented architecture looks like this:

                         ┌──────────────────┐
                         │      Client      │
                         └────────┬─────────┘
                                  │
                                  ▼
                         ┌──────────────────┐
                         │   .NET Web API   │
                         └────────┬─────────┘
                                  │
                                  ▼
                         ┌──────────────────┐
                         │     MediatR      │
                         │ Command Handler  │
                         └────────┬─────────┘
                                  │
                                  ▼
                     ┌──────────────────────────┐
                     │    Database Transaction  │
                     │                          │
                     │  ┌────────┐ ┌─────────┐ │
                     │  │ Order  │ │ Outbox  │ │
                     │  └────────┘ └─────────┘ │
                     └────────────┬─────────────┘
                                  │
                                  ▼
                         ┌──────────────────┐
                         │ Outbox Processor │
                         └────────┬─────────┘
                                  │
                                  ▼
                     ┌────────────────────────┐
                     │  Azure Service Bus     │
                     │        Topic           │
                     └────────────┬───────────┘
                                  │
                   ┌──────────────┼──────────────┐
                   │              │              │
                   ▼              ▼              ▼
             ┌──────────┐   ┌───────────┐  ┌──────────┐
             │  Email   │   │  SignalR  │  │  Audit   │
             │ Consumer │   │  Consumer │  │ Consumer │
             └──────────┘   └─────┬─────┘  └──────────┘
                                  │
                                  ▼
                     ┌────────────────────────┐
                     │ Azure SignalR Service  │
                     └────────────┬───────────┘
                                  │
                                  ▼
                           ┌─────────────┐
                           │   Clients   │
                           │Browser/Mobile│
                           └─────────────┘
Enter fullscreen mode Exit fullscreen mode

The important principle is that each component has a specific responsibility.

The Outbox is part of the primary architecture, not an afterthought.


Where MediatR Fits

MediatR sits primarily inside the application layer.

Instead of having a controller directly coordinate business operations:

Controller
    ↓
Service
    ↓
Database
Enter fullscreen mode Exit fullscreen mode

we can use a request/handler model:

Controller
    ↓
MediatR
    ↓
Command Handler
    ↓
Business Logic
    ↓
Database
Enter fullscreen mode Exit fullscreen mode

For example:

public record CreateOrderCommand(
    int CustomerId,
    decimal Amount
) : IRequest<int>;
Enter fullscreen mode Exit fullscreen mode

The handler processes the command:

public class CreateOrderHandler
    : IRequestHandler<CreateOrderCommand, int>
{
    public async Task<int> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Validate business rules
        // Create order
        // Save order

        return orderId;
    }
}
Enter fullscreen mode Exit fullscreen mode

MediatR also provides a clean place for application-level cross-cutting concerns such as:

  • Validation
  • Logging
  • Performance measurement
  • Authorization
  • Transaction behavior

The important point is:

MediatR is not the message broker.

It helps with in-process application decoupling.

Azure Service Bus handles distributed messaging between processes or services.

That distinction becomes important as the system grows.


Why Azure Service Bus?

After the business operation is committed, an event such as OrderCreated needs to be delivered to downstream consumers.

With the Outbox Pattern, the event is first persisted as part of the same database transaction and is then published asynchronously.

For example:

{
  "eventType": "OrderCreated",
  "orderId": 12345,
  "customerId": 1001
}
Enter fullscreen mode Exit fullscreen mode

The resulting flow becomes:

Order Service
     ↓
Outbox
     ↓
Outbox Processor
     ↓
Azure Service Bus
     ↓
Notification Consumers
Enter fullscreen mode Exit fullscreen mode

Azure Service Bus provides the messaging infrastructure between the producer and consumers.

Instead of:

Order Service
     ↓
Notification Service
Enter fullscreen mode Exit fullscreen mode

we have:

Order Service
     ↓
Outbox
     ↓
Azure Service Bus
     ↓
Notification Consumers
Enter fullscreen mode Exit fullscreen mode

This provides several benefits.

1. Loose Coupling

The producer doesn't need to know which consumers exist.

Today:

OrderCreated
    ↓
Email
Enter fullscreen mode Exit fullscreen mode

Tomorrow:

OrderCreated
    ↓
Email
SMS
Analytics
Audit
Push Notification
Enter fullscreen mode Exit fullscreen mode

The order-processing logic doesn't need to change simply because another consumer was added.

2. Retry and Failure Handling

Azure Service Bus provides messaging and delivery mechanisms, while retry behavior is typically implemented through the Service Bus client and consumer/application logic.

Transient failures can therefore be handled without making the original business transaction depend on downstream notification delivery.

Messages that repeatedly fail processing can also be moved to a dead-letter queue for investigation or later recovery.

The exact retry strategy should be designed at the consumer level based on the type of failure.

For example:

Message
   ↓
Consumer
   ↓
Processing succeeds?
   ├── Yes → Complete message
   │
   └── No
       ↓
   Retry / Redelivery
       ↓
   Repeated failure?
       └── Dead-letter
Enter fullscreen mode Exit fullscreen mode

3. Independent Scaling

The notification consumers can scale independently from the API.

For example:

API instances
     ↓
Outbox Processor
     ↓
Service Bus
     ↓
Notification Workers
Enter fullscreen mode Exit fullscreen mode

If notification volume increases significantly, the consumer side can be scaled independently.

4. Future Integrations

The same event can potentially be consumed by additional systems without modifying the original producer.

This is particularly useful in larger enterprise environments.


Topics and Subscriptions

For multiple consumers, an Azure Service Bus Topic with multiple Subscriptions is a natural fit.

For example:

                  OrderCreated Event
                         │
                         ▼
                ┌─────────────────┐
                │ Azure Service   │
                │ Bus Topic       │
                └────────┬────────┘
                         │
             ┌───────────┼───────────┐
             │           │           │
             ▼           ▼           ▼
        Email Sub    SignalR Sub   Audit Sub
             │           │           │
             ▼           ▼           ▼
          Email       SignalR       Audit
Enter fullscreen mode Exit fullscreen mode

Each subscription can receive its own copy of the message.

This keeps consumers independent.

It also means that one consumer can fail or be delayed without preventing another subscription from processing its own copy of the event.


SignalR for Real-Time Notifications

For browser-based real-time notifications, polling can introduce unnecessary latency and traffic.

SignalR allows the server to push messages to connected clients.

For example:

OrderCreated Event
        ↓
SignalR Consumer
        ↓
SignalR Hub
        ↓
Azure SignalR Service
        ↓
Connected Client
Enter fullscreen mode Exit fullscreen mode

The client can receive something like:

"Order #12345 has been created successfully."
Enter fullscreen mode Exit fullscreen mode

This works particularly well for:

  • Dashboards
  • Order tracking
  • Monitoring systems
  • Alerts
  • Chat-like applications
  • In-app notifications

A SignalR connection can also be associated with a user or group, allowing notifications to be targeted appropriately.


SignalR at Scale

A single SignalR server is straightforward.

The challenge appears when the application is deployed across multiple instances.

For example:

                       ┌──────────────────────┐
                       │   Azure SignalR      │
                       │      Service         │
                       └──────────┬───────────┘
                                  │
                    ┌─────────────┼─────────────┐
                    │             │             │
                    ▼             ▼             ▼
               API Instance   API Instance   API Instance
Enter fullscreen mode Exit fullscreen mode

For Azure-hosted applications, Azure SignalR Service can provide the scale-out layer for SignalR connections.

This allows application instances to remain focused on application processing while Azure SignalR Service manages persistent client connections.

The exact deployment model depends on application scale, hosting model, and operational requirements.


End-to-End Flow

Let's put everything together.

Step 1 — User performs an action

The user creates an order.

Client → POST /orders
Enter fullscreen mode Exit fullscreen mode

Step 2 — API receives the request

The controller sends a command through MediatR.

Controller
    ↓
CreateOrderCommand
    ↓
MediatR
Enter fullscreen mode Exit fullscreen mode

Step 3 — Command handler processes the business operation

The handler validates the request and creates the order.

The order and the corresponding outbox event are persisted in the same database transaction.

Database Transaction
       │
       ├── Order
       │
       └── Outbox Event
Enter fullscreen mode Exit fullscreen mode

This is important because it prevents the application from successfully committing the order while completely losing the event because of a separate publish operation.

Step 4 — Outbox processor publishes the event

A background process reads unpublished events from the outbox and publishes them to Azure Service Bus.

Database
   ↓
Outbox Processor
   ↓
Azure Service Bus Topic
Enter fullscreen mode Exit fullscreen mode

The business transaction does not wait for the downstream consumers to finish.

Step 5 — Consumers process the event

Different consumers can react independently.

                 OrderCreated
                      ↓
              Azure Service Bus
                 /     |      \
                /      |       \
               ▼       ▼        ▼
            Email   SignalR    Audit
Enter fullscreen mode Exit fullscreen mode

Step 6 — Real-time update reaches the client

The SignalR consumer sends an update to the appropriate connected client.

Service Bus
     ↓
SignalR Consumer
     ↓
SignalR Hub
     ↓
Azure SignalR Service
     ↓
Browser / Mobile
Enter fullscreen mode Exit fullscreen mode

The key principle is:

Order creation should not depend on notification delivery succeeding.


One Important Production Consideration: The Outbox Pattern

There is a subtle issue with a simpler implementation.

Consider this sequence:

1. Save Order
2. Publish OrderCreated
Enter fullscreen mode Exit fullscreen mode

What happens if step 1 succeeds but step 2 fails?

The order exists in the database, but the event was never published.

This is a classic dual-write problem.

For production systems where reliable event publishing is important, an Outbox Pattern can help.

Instead of:

Save Order
     ↓
Publish Event
Enter fullscreen mode Exit fullscreen mode

we persist both the business data and the outgoing event in the same database transaction:

Database Transaction
       │
       ├── Order
       │
       └── Outbox Event
Enter fullscreen mode Exit fullscreen mode

A background worker then reads the outbox and publishes events to Service Bus:

Database
   ↓
Outbox Processor
   ↓
Azure Service Bus
   ↓
Consumers
Enter fullscreen mode Exit fullscreen mode

This separates the critical business transaction from downstream message delivery while preserving the event that needs to be published.


Handling Duplicate Messages

Messaging systems should generally be designed with the assumption that a message can potentially be delivered more than once.

Azure Service Bus commonly uses at-least-once delivery semantics with Peek-Lock processing, so consumers should be designed to handle duplicate delivery safely.

That means consumers should ideally be idempotent.

For example:

Message ID: 8f23...
Enter fullscreen mode Exit fullscreen mode

The consumer can maintain a record of processed message IDs.

If the same message arrives again:

Message
   ↓
Already processed?
   ├── Yes → Ignore / safely return
   └── No  → Process
Enter fullscreen mode Exit fullscreen mode

This becomes especially important when processing operations such as:

  • Sending emails
  • Updating notification state
  • Creating records
  • Calling external APIs

Azure Service Bus also provides duplicate detection capabilities, but duplicate detection should not be treated as a replacement for idempotent consumer design.


Trade-Offs

This architecture is not free.

You are introducing additional infrastructure:

  • Azure Service Bus
  • Outbox storage
  • Background consumers
  • SignalR / Azure SignalR Service
  • Monitoring
  • Retry handling
  • Dead-letter handling
  • Distributed tracing

There is also eventual consistency.

For example:

Order Created
      ↓
Outbox Event
      ↓
Event Published
      ↓
Consumer Processes Event
      ↓
Notification Appears
Enter fullscreen mode Exit fullscreen mode

The notification might appear milliseconds or seconds after the business operation.

But that trade-off can be worthwhile when the system needs:

  • Scalability
  • Resilience
  • Independent consumers
  • Extensibility
  • Loose coupling

The architecture should still be evaluated against the actual requirements.

A small application with one notification channel may not need all of these components.


Where This Architecture Works Well

This pattern can be useful in systems such as:

E-commerce

OrderPlaced
    ↓
Email
Inventory
Payment
Shipping
Customer Notification
Enter fullscreen mode Exit fullscreen mode

Banking

TransactionCompleted
    ↓
SMS
Email
Mobile Push
Audit
Fraud Monitoring
Enter fullscreen mode Exit fullscreen mode

Monitoring Platforms

AlertRaised
    ↓
Dashboard
Email
SMS
Incident Management
Enter fullscreen mode Exit fullscreen mode

Enterprise Applications

Any system where a business event may need to trigger multiple independent downstream actions can benefit from this approach.


Key Design Principles

The technology is important, but the architectural principles matter more.

MediatR

Use it for in-process application decoupling.

Azure Service Bus

Use it for asynchronous communication between distributed components.

Azure Service Bus Topics and Subscriptions

Use them when multiple independent consumers need to react to the same event.

SignalR

Use it for real-time communication with connected clients.

Azure SignalR Service

Consider it when SignalR needs to scale across multiple application instances in Azure.

Outbox Pattern

Use it when you need stronger guarantees between database state and event publishing.

Idempotent Consumers

Design consumers so duplicate messages don't create unintended side effects.

Dead-Letter Handling

Treat repeatedly failed messages as operational data that needs monitoring, investigation, and potentially controlled replay.


Final Thoughts

Notifications are easy to implement when a system is small.

They become much more interesting when the application needs to scale, support multiple channels, handle failures, and evolve over time.

A useful separation is:

Business Logic
      ↓
Database Transaction
      ↓
Outbox Event
      ↓
Message Broker
      ↓
Independent Consumers
      ↓
Notification Channels
Enter fullscreen mode Exit fullscreen mode

With this approach, the core business operation doesn't need to know whether the notification is delivered through email, SignalR, SMS, push notifications, or a future integration.

The result is an architecture that is more decoupled, scalable, resilient, and easier to evolve.

And perhaps the most important lesson:

Don't let notification delivery become part of your critical business transaction unless it truly needs to be.

If you've implemented a similar architecture, I'd be interested in hearing how you handled retries, ordering, duplicate messages, dead-letter queues, and the outbox problem.


dotnet #azure #systemdesign #microservices #softwarearchitecture #azureservicebus #signalr #mediatr #eventdrivenarchitecture

Top comments (0)