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
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
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.
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│
└─────────────┘
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
we can use a request/handler model:
Controller
↓
MediatR
↓
Command Handler
↓
Business Logic
↓
Database
For example:
public record CreateOrderCommand(
int CustomerId,
decimal Amount
) : IRequest<int>;
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;
}
}
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
}
The resulting flow becomes:
Order Service
↓
Outbox
↓
Outbox Processor
↓
Azure Service Bus
↓
Notification Consumers
Azure Service Bus provides the messaging infrastructure between the producer and consumers.
Instead of:
Order Service
↓
Notification Service
we have:
Order Service
↓
Outbox
↓
Azure Service Bus
↓
Notification Consumers
This provides several benefits.
1. Loose Coupling
The producer doesn't need to know which consumers exist.
Today:
OrderCreated
↓
Email
Tomorrow:
OrderCreated
↓
Email
SMS
Analytics
Audit
Push Notification
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
3. Independent Scaling
The notification consumers can scale independently from the API.
For example:
API instances
↓
Outbox Processor
↓
Service Bus
↓
Notification Workers
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
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
The client can receive something like:
"Order #12345 has been created successfully."
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
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
Step 2 — API receives the request
The controller sends a command through MediatR.
Controller
↓
CreateOrderCommand
↓
MediatR
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
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
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
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
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
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
we persist both the business data and the outgoing event in the same database transaction:
Database Transaction
│
├── Order
│
└── Outbox Event
A background worker then reads the outbox and publishes events to Service Bus:
Database
↓
Outbox Processor
↓
Azure Service Bus
↓
Consumers
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...
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
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
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
Banking
TransactionCompleted
↓
SMS
Email
Mobile Push
Audit
Fraud Monitoring
Monitoring Platforms
AlertRaised
↓
Dashboard
Email
SMS
Incident Management
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
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.
Top comments (0)