A production notification service is more than a set of email and messaging API calls. It needs to accept notification requests reliably, choose the right channel, handle provider failures, and keep enough delivery history to explain what happened. The design should also make adding SMS or Telegram feel like adding an adapter, not rebuilding the application.
A useful starting point is to separate the decision to notify from the act of delivering a notification. Business code records what should be sent; a background process handles delivery through the appropriate provider.
Start with a durable notification flow
For a production application, avoid sending email or WhatsApp messages inline with an HTTP request. A slow provider can hold up the request, and a temporary outage can turn a successful business operation into a failed one. Instead, use a durable flow:
- The application creates a notification request as part of its business operation.
- The request is saved to an outbox or durable queue.
- A background worker reads pending requests and dispatches them to a channel adapter.
- The service records the provider response and updates delivery status.
- Provider webhooks update final states such as delivered, bounced, or read when that information is available.
If a business database and a message broker are both involved, a transactional outbox helps prevent a common failure: the business change commits, but publishing its notification fails. Store the business change and outbox row in the same database transaction, then have a separate publisher move unsent rows to the queue.
Keep the channel boundary small
The core service should know that it needs to send a notification, but it should not know the details of SMTP, Firebase, or a WhatsApp API request. Define a small contract for channel adapters and keep provider-specific code behind it.
public sealed record NotificationCommand(
string Channel,
string Recipient,
string TemplateKey,
IReadOnlyDictionary<string, string> Data,
string IdempotencyKey);
public sealed record DeliveryResult(
bool Accepted,
string? ProviderMessageId,
string? ErrorCode);
public interface INotificationChannel
{
// Each adapter identifies the channel it can deliver through.
string Name { get; }
// The adapter translates the command into the provider's API request.
Task<DeliveryResult> SendAsync(
NotificationCommand command,
CancellationToken cancellationToken);
}
public sealed class NotificationDispatcher
{
private readonly IReadOnlyDictionary<string, INotificationChannel> _channels;
public NotificationDispatcher(IEnumerable<INotificationChannel> channels)
{
// A case-insensitive map makes channel lookup predictable.
_channels = channels.ToDictionary(
channel => channel.Name,
StringComparer.OrdinalIgnoreCase);
}
public Task<DeliveryResult> SendAsync(
NotificationCommand command,
CancellationToken cancellationToken)
{
// Fail clearly when configuration requests an unregistered channel.
if (!_channels.TryGetValue(command.Channel, out var channel))
{
throw new InvalidOperationException(
$"Notification channel '{command.Channel}' is not registered.");
}
return channel.SendAsync(command, cancellationToken);
}
}
The dispatcher is deliberately simple. In a real application, a hosted worker should call it after reading a message from a durable queue. That worker—not a controller—should own retry scheduling, delivery-state updates, and dead-letter handling.
What each adapter is responsible for
- Email: Convert a template into subject, plain-text, and HTML content, then send it through a provider such as an email API or SMTP relay. Track bounces and complaints where the provider supports them.
- Push notifications: Send through the relevant push provider, commonly Firebase Cloud Messaging or Apple Push Notification service. Treat device tokens as changeable: remove or deactivate tokens that providers report as invalid.
- WhatsApp: Integrate through the WhatsApp Business Platform or an approved provider. Account for recipient consent, approved message templates, phone-number formatting, and the platform’s rules for when free-form messages are allowed.
These channels do not all deliver the same kind of content. An email template may contain rich HTML, a push notification needs a compact title and body, and WhatsApp may require an approved template with named parameters. Keep channel-specific rendering explicit—for example, with a renderer keyed by channel and template—or let each adapter map a shared set of business data to its own approved template. Avoid assuming that one block of text can be copied unchanged everywhere.
Adding SMS or Telegram later
With the adapter boundary in place, SMS and Telegram become additional implementations of INotificationChannel. Register them with dependency injection and give each a stable channel name, such as sms or telegram. The queue consumer and dispatcher can continue to handle them without provider-specific branches scattered through business services.
That does not mean every channel should have identical rules. SMS needs phone-number validation, sender configuration, and careful handling of message length and regional regulations. Telegram requires a suitable bot and a chat identifier. Store channel-specific recipient details in a contact or endpoint model rather than assuming every recipient is just an email address or a single universal user ID.
Reliability details that matter in production
Retries and duplicate delivery
Retry transient failures such as timeouts and rate limits with exponential backoff and jitter. Treat permanent errors—an invalid address, a revoked token, or a rejected WhatsApp template—as failures that need correction, not endless retries. After a configured limit, move the message to a dead-letter state and make it inspectable by operators.
Most queues provide at-least-once processing, so the same notification may be picked up more than once. Give every request a stable idempotency key and persist delivery attempts and results. If the provider supports idempotency keys, pass one through. Be clear about the limit: if a process crashes after a provider accepts a message but before your database records that acceptance, local deduplication alone cannot prove whether the external send happened. Design for that uncertainty rather than promising exactly-once delivery.
Preferences, consent, and privacy
Resolve user preferences before enqueueing or dispatching: a user may have opted out of marketing email while still receiving security alerts. Keep consent and suppression rules specific to the channel and message purpose. WhatsApp and SMS in particular should not be treated as channels you can use just because a phone number exists.
Notification payloads can contain personal or sensitive information. Keep secrets and full message bodies out of routine logs; record identifiers, template keys, provider message IDs, timestamps, and sanitized error details instead. Protect provider credentials with a secret manager, and give each provider integration only the permissions it needs.
Operations and observability
Track queue depth, oldest pending message, send latency, retry counts, provider error rates, and delivery outcomes by channel. Include a correlation ID from the originating business operation so support staff can trace a request without searching logs by recipient details. Add per-provider rate limits and concurrency controls; one provider’s slowdown should not consume every worker and block healthy channels.
Finally, test adapters with provider fakes and contract tests, then verify real webhook handling in a staging environment. Webhooks should be authenticated, processed idempotently, and mapped to your own delivery-state model. A clean architecture here is not about having the most abstractions—it is about being able to add a channel, inspect a failed send, or swap a provider without rewriting business workflows.