The fundamental purpose of a notification system, even a monolithic one, is to decouple the event from the delivery mechanism, allowing a system to react to something happening without needing to know the intricate details of how to inform users.

Let’s imagine a simple e-commerce order confirmation. When an order is placed, the order service doesn’t directly call an email API, an SMS gateway, and a push notification service. Instead, it publishes an OrderConfirmed event. A separate notification service then consumes this event and, based on user preferences and available channels, dispatches the appropriate messages.

Here’s a peek at how this might look in practice.

Event Publishing (Simplified Example - using a hypothetical message queue)

# In the order service
class OrderService:
    def confirm_order(self, order_id, user_id, email_address, phone_number):
        # ... business logic to confirm order ...
        event_data = {
            "event_type": "OrderConfirmed",
            "payload": {
                "order_id": order_id,
                "user_id": user_id,
                "email": email_address,
                "phone": phone_number,
                "order_details": "..." # truncated for brevity
            }
        }
        message_queue.publish("notifications", event_data)
        return "Order confirmed and notification queued."

Notification Service (Consuming and Dispatching)

# In the notification service
class NotificationService:
    def process_event(self, event):
        if event["event_type"] == "OrderConfirmed":
            payload = event["payload"]
            user_id = payload["user_id"]
            email = payload["email"]
            phone = payload["phone"]

            # Logic to check user preferences (e.g., from a database)
            user_prefs = self.get_user_preferences(user_id)

            if user_prefs.get("email_notifications"):
                self.send_email(email, "Your Order Confirmation", payload["order_details"])

            if user_prefs.get("sms_notifications"):
                self.send_sms(phone, f"Order {payload['order_id']} confirmed.")

            if user_prefs.get("push_notifications"):
                self.send_push_notification(user_id, "Order Confirmed", "Your order has been placed successfully.")

    def send_email(self, recipient, subject, body):
        # ... actual email sending logic using SMTP or an email API ...
        print(f"Sending email to {recipient} with subject '{subject}'")

    def send_sms(self, phone_number, message):
        # ... actual SMS sending logic using Twilio, Vonage, etc. ...
        print(f"Sending SMS to {phone_number}: '{message}'")

    def send_push_notification(self, user_id, title, body):
        # ... actual push notification logic using FCM, APNS, etc. ...
        print(f"Sending push to user {user_id}: '{title}' - '{body}'")

    def get_user_preferences(self, user_id):
        # In a real system, this would query a database
        return {"email_notifications": True, "sms_notifications": False, "push_notifications": True}

This simple example illustrates the core idea: the OrderService doesn’t care how the user is notified, only that they should be. The NotificationService handles the "how" for each channel.

The Mental Model:

  1. Event Source: A service that detects a significant occurrence (e.g., user signed up, payment processed, item shipped).
  2. Event Bus/Queue: A central hub where events are published. This decouples the source from the consumers. It ensures that if a notification channel is temporarily down, the event isn’t lost; it can be retried later.
  3. Notification Service: A dedicated worker that subscribes to relevant events from the bus.
  4. Channel Adapters: Within the notification service, specific logic for interacting with each delivery mechanism (SMTP for email, Twilio for SMS, FCM/APNS for push).
  5. User Preferences: A way to store and retrieve how a user wants to be notified for different event types. This is crucial for avoiding notification fatigue.
  6. Templating: For richer notifications (especially email and push), templates are used to dynamically insert event data into pre-defined message structures.

Configuration Levers:

  • Event Subscriptions: Which events does the notification service listen to? This is usually configured by the notification service itself, often through its connection to the message queue.
  • Channel Endpoints: The API keys, hostnames, and credentials for each external notification provider (e.g., smtp.sendgrid.net, api.twilio.com).
  • Rate Limiting: How many messages of a certain type can be sent per user or globally within a time window to prevent abuse and manage costs.
  • Retry Policies: How many times should a notification attempt be retried if it fails, and with what delay?
  • Message Templates: The actual content and structure of the notifications, often stored in a database or configuration files, allowing for easy updates without code deployments.

The most impactful aspect of a monolithic notification system is its potential for fan-out from a single event. When an OrderShipped event occurs, the notification service can simultaneously trigger an email, an SMS, and a push notification, all without the order service needing to be aware of any of these channels. This centralization makes it easier to manage global notification logic, such as suppressing certain types of alerts during maintenance windows or implementing a unified "do not disturb" feature.

A common challenge is managing the state of notifications across different channels. For instance, if a user receives an email and a push notification for the same event, and then marks the email as read, there’s no inherent mechanism in this simple model for the push notification to be automatically dismissed or updated.

Want structured learning?

Take the full Monolith course →