System Design (LLD) Full Course: Building a Production-Grade Notification Engine
A notification engine is an essential component across modern enterprise applications. It bridges host applications with external communication channels such as SMS, Email, Push Notifications, and messaging platforms like WhatsApp. This chapter covers the low-level design (LLD) of a plug-and-play, highly extensible notification engine built on robust object-oriented software engineering principles.
1. Problem Statement & System Requirements
When designing a notification service in LLD interviews, candidates often treat notification delivery as an external third-party black box. However, designing the internal architecture of a dedicated notification engine requires satisfying strict functional and non-functional requirements.
Functional Requirements
- Multi-Channel Dispatch: Support sending notifications over multiple channels including Email, SMS, and Popup notifications, with simple extensibility for future channels (e.g., WhatsApp, Slack).
- Dynamic Content Enrichment: Dynamically decorate notifications at runtime with contextual metadata such as timestamps, headers, and custom signature footers.
- Notification Logging: Log dispatched notifications to the console or log files for debugging and monitoring.
- Audit Trail / History Storage: Maintain an in-memory audit log of all notifications dispatched through the service instance.
Non-Functional Requirements
- Plug-and-Play Integration: Host applications must integrate the service with minimal setup and zero architectural refactoring.
- SOLID Compliance: Strict adherence to SOLID principles, specifically the Single Responsibility Principle (SRP) and Open/Closed Principle (OCP).
- Decoupled Architecture: High cohesion within components and loose coupling between notification creation, event dispatching, and message transmission.
2. Architectural Blueprint & Design Patterns
To satisfy these requirements without tightly coupled code, the engine composes four classic Gang of Four (GoF) design patterns into a cohesive pipeline.
Integrated Design Patterns
3. Dynamic Notification Hierarchy (Decorator Pattern)
Notifications often require dynamic runtime modifications. Using pure inheritance to support every combination (e.g., EmailWithTimestamp, SMSWithTimestampAndSignature) causes class explosion. The Decorator Design Pattern solves this by wrapping concrete notification objects at runtime.
C++ Implementation
#include <iostream>
#include <string>
#include <memory>
#include <vector>
// Abstract Component Interface
class INotification {
public:
virtual ~INotification() = default;
virtual std::string getContent() const = 0;
};
// Concrete Component
class SimpleNotification : public INotification {
private:
std::string text;
public:
explicit SimpleNotification(std::string msg) : text(std::move(msg)) {}
std::string getContent() const override {
return text;
}
};
// Base Abstract Decorator (IS-A and HAS-A INotification)
class INotificationDecorator : public INotification {
protected:
std::shared_ptr<INotification> wrappedNotification;
public:
explicit INotificationDecorator(std::shared_ptr<INotification> notification)
: wrappedNotification(std::move(notification)) {}
};
// Concrete Decorator 1: Timestamp
class TimestampDecorator : public INotificationDecorator {
public:
explicit TimestampDecorator(std::shared_ptr<INotification> notification)
: INotificationDecorator(std::move(notification)) {}
std::string getContent() const override {
// Appends timestamp metadata prior to evaluating wrapped content
std::string timestamp = "[2026-03-30 10:00:00] ";
return timestamp + wrappedNotification->getContent();
}
};
// Concrete Decorator 2: Signature
class SignatureDecorator : public INotificationDecorator {
private:
std::string signature;
public:
SignatureDecorator(std::shared_ptr<INotification> notification, std::string sign)
: INotificationDecorator(std::move(notification)), signature(std::move(sign)) {}
std::string getContent() const override {
// Appends footer signature to the evaluated wrapped content
return wrappedNotification->getContent() + " \n-- Regards: " + signature;
}
};
4. Decoupling Event Dispatch (Observer Pattern)
Once a notification object is instantiated and decorated, the system must trigger downstream operations (such as console logging and network transmission) without coupling the event creator to the event consumers. The Observer Pattern creates a subject/observer pub-sub mechanism.
C++ Implementation
// Forward declarations
class IObserver;
// Subject / Observable Interface
class IObservable {
public:
virtual ~IObservable() = default;
virtual void addObserver(std::shared_ptr<IObserver> observer) = 0;
virtual void removeObserver(std::shared_ptr<IObserver> observer) = 0;
virtual void notifyObservers() = 0;
};
// Observer Interface
class IObserver {
public:
virtual ~IObserver() = default;
virtual void update() = 0;
};
// Concrete Observable implementation
class NotificationObservable : public IObservable {
private:
std::vector<std::shared_ptr<IObserver>> observers;
std::shared_ptr<INotification> currentNotification;
public:
void addObserver(std::shared_ptr<IObserver> observer) override {
observers.push_back(observer);
}
void removeObserver(std::shared_ptr<IObserver> observer) override {
// Simple removal logic
for (auto it = observers.begin(); it != observers.end(); ++it) {
if (*it == observer) {
observers.erase(it);
break;
}
}
}
void notifyObservers() override {
for (const auto& observer : observers) {
observer->update();
}
}
void setNotification(std::shared_ptr<INotification> notification) {
currentNotification = notification;
// Automatically trigger broadcast upon state change
notifyObservers();
}
std::string getNotificationContent() const {
return currentNotification ? currentNotification->getContent() : "";
}
};
5. Flexible Multi-Channel Delivery (Strategy Pattern)
Transmitting a notification across networks requires channel-specific transport mechanics (SMTP protocols for Email, HTTP payloads for Twilio SMS, OS IPC APIs for Desktop Popups).
Instead of cluttering the notification processing engine with channel details, we encapsulate delivery mechanisms using the Strategy Design Pattern.
Design Variation Note: Traditional Strategy patterns establish a 1-to-1 relationship between client and strategy. However, user preferences often require dispatching a single notification across multiple channels simultaneously (e.g., both Email AND SMS). To support this, the
NotificationEnginemaintains a 1-to-Many vector of strategies.
C++ Implementation
// Strategy Interface
class INotificationStrategy {
public:
virtual ~INotificationStrategy() = default;
virtual void sendNotification(const std::string& content) = 0;
};
// Concrete Strategy 1: Email
class EmailStrategy : public INotificationStrategy {
private:
std::string emailId;
public:
explicit EmailStrategy(std::string email) : emailId(std::move(email)) {}
void sendNotification(const std::string& content) override {
std::cout << "[EMAIL DISPATCH] Destination: " << emailId
<< " | Payload:\n" << content << "\n";
}
};
// Concrete Strategy 2: SMS
class SmsStrategy : public INotificationStrategy {
private:
std::string phoneNumber;
public:
explicit SmsStrategy(std::string phone) : phoneNumber(std::move(phone)) {}
void sendNotification(const std::string& content) override {
std::cout << "[SMS DISPATCH] Destination: " << phoneNumber
<< " | Payload:\n" << content << "\n";
}
};
// Concrete Strategy 3: Desktop Popup
class PopupStrategy : public INotificationStrategy {
public:
void sendNotification(const std::string& content) override {
std::cout << "[POPUP DISPATCH] Rendered On Screen:\n" << content << "\n";
}
};
6. Centralizing Lifecycle Management (Singleton Service)
To prevent fragmented state, orphan observables, or competing history logs across host application threads, the entry-point NotificationService is structured as a Singleton. It acts as a bridge between high-level application code and low-level pub-sub mechanics.
class NotificationService {
private:
static std::shared_ptr<NotificationService> instance;
std::shared_ptr<NotificationObservable> observable;
std::vector<std::shared_ptr<INotification>> history;
// Private Constructor enforces Singleton policy
NotificationService() {
observable = std::make_shared<NotificationObservable>();
}
public:
// Delete copy constructor and assignment operator
NotificationService(const NotificationService&) = delete;
NotificationService& operator=(const NotificationService&) = delete;
static std::shared_ptr<NotificationService> getInstance() {
if (instance == nullptr) {
instance = std::shared_ptr<NotificationService>(new NotificationService());
}
return instance;
}
std::shared_ptr<NotificationObservable> getObservable() const {
return observable;
}
void sendNotification(std::shared_ptr<INotification> notification) {
// 1. Persist notification to audit log history
history.push_back(notification);
// 2. Publish notification to observable bus
observable->setNotification(notification);
}
const std::vector<std::shared_ptr<INotification>>& getHistory() const {
return history;
}
};
// Initialize static member
std::shared_ptr<NotificationService> NotificationService::instance = nullptr;
7. Step-by-Step Evolution: Manual Wiring vs. Plug-and-Play Decoupling
A key requirement in LLD interviews is evaluating how easily client code integrates with your system.
Naive Implementation (Heavy Client Boilerplate)
In a naive setup, the client application is responsible for creating observers, manually querying the service for the underlying observable instance, and wiring them together explicitly.
// NAIVE APPROACH: Client manages manual observer registration
auto service = NotificationService::getInstance();
auto observable = service->getObservable();
// Client manually wires dependencies
auto logger = std::make_shared<LoggerObserver>(observable);
auto engine = std::make_shared<NotificationEngineObserver>(observable);
observable->addObserver(logger);
observable->addObserver(engine);
Optimized Refactored Implementation (True Plug-and-Play)
By shifting dependency wiring into the constructor of the concrete observer objects, observers fetch the NotificationService singleton instance and register themselves automatically upon instantiation (this).
// Concrete Observer 1: Logger with Auto-Registration
class Logger : public IObserver, public std::enable_shared_from_this<Logger> {
private:
std::shared_ptr<NotificationObservable> observable;
public:
Logger() {
// Auto-fetches context and auto-registers
observable = NotificationService::getInstance()->getObservable();
}
void init() {
observable->addObserver(shared_from_this());
}
void update() override {
std::cout << "[AUDIT LOG] New Notification Intercepted:\n"
<< observable->getNotificationContent() << "\n";
}
};
// Concrete Observer 2: Notification Engine with Auto-Registration
class NotificationEngine : public IObserver, public std::enable_shared_from_this<NotificationEngine> {
private:
std::shared_ptr<NotificationObservable> observable;
std::vector<std::shared_ptr<INotificationStrategy>> strategies;
public:
NotificationEngine() {
observable = NotificationService::getInstance()->getObservable();
}
void init() {
observable->addObserver(shared_from_this());
}
void addStrategy(std::shared_ptr<INotificationStrategy> strategy) {
strategies.push_back(strategy);
}
void update() override {
std::string content = observable->getNotificationContent();
// Dispatches payload across all dynamic channel strategies
for (const auto& strategy : strategies) {
strategy->sendNotification(content);
}
}
};
8. End-to-End Execution Flow Walkthrough
The following trace illustrates what happens when a client triggers sendNotification():
Complete End-to-End Execution Code
int main() {
// Step 1: Access Notification Service (Singleton)
auto service = NotificationService::getInstance();
// Step 2: Instantiate Observers (Auto-bound to Service Observable)
auto logger = std::make_shared<Logger>();
logger->init();
auto engine = std::make_shared<NotificationEngine>();
engine->init();
// Step 3: Configure Channel Strategies dynamically based on client config
engine->addStrategy(std::make_shared<EmailStrategy>("user@example.com"));
engine->addStrategy(std::make_shared<SmsStrategy>("+1-555-0199"));
engine->addStrategy(std::make_shared<PopupStrategy>());
// Step 4: Construct and Decorate Notification dynamically
std::shared_ptr<INotification> notification =
std::make_shared<SimpleNotification>("Your order #88412 has been dispatched!");
// Apply Timestamp Decorator
notification = std::make_shared<TimestampDecorator>(notification);
// Apply Signature Decorator
notification = std::make_shared<SignatureDecorator>(notification, "Logistics Customer Care");
// Step 5: Dispatch via Service
std::cout << "--- INITIATING NOTIFICATION DISPATCH ---\n";
service->sendNotification(notification);
return 0;
}
9. Tabular Design Pattern & Component Synthesis
10. Real-World Production Considerations & Debugging Mindset
In production applications, this architecture serves as the foundation for high-throughput distributed systems. When discussing this design with an interviewer, highlight these production considerations:
1. Asynchronous Queueing & Non-Blocking I/O
Calling network operations (like third-party Email/SMS HTTP endpoints) synchronously inside sendNotification() blocks the application caller thread.
- Production Solution: The
NotificationEngineshould publish payloads to an asynchronous message broker (e.g., Apache Kafka, RabbitMQ, or AWS SQS). Background worker pools consume messages off these queues asynchronously.
2. Fault Tolerance, Retries, and Dead Letter Queues (DLQ)
Network calls to external SMS gateways frequently fail due to rate limits or transient outages.
- Production Solution: Wrap strategy executions inside a retry mechanism using exponential backoff with jitter. If a message continuously fails after attempts, route the message to a Dead Letter Queue (DLQ) for manual inspection or retry processing.
3. Concurrency & Thread Safety
The singleton NotificationService and NotificationObservable contain shared mutable state (the history vector and observer list).
- Production Solution: Protect history appends and observer iterations using thread synchronization primitives such as
std::mutexorstd::shared_mutexto enable thread-safe reads and writes across concurrent application threads.
Summary and Key Takeaways
- Plug-and-Play Decoupling: Combining Singleton, Observer, Decorator, and Strategy patterns yields a notification engine that integrates into any application with zero structural refactoring.
- Dynamic Decorators over Static Inheritance: Wrapping base notifications with decorators (
TimestampDecorator,SignatureDecorator) provides clean runtime customization while avoiding class explosion. - Observer-Based Event Bus: Relying on an
IObservable/IObserverpub-sub model isolates event dispatching from processing logic like logging and network transmission. - 1-to-Many Strategy Pattern: Modifying the traditional 1-to-1 Strategy pattern to a vector-backed multi-strategy setup enables broadcasting a single event across multiple communication channels concurrently.
- Constructor Auto-Registration: Observers can query the
NotificationServiceSingleton and self-register (this) inside their constructors, drastically reducing client-side boilerplate. - Production Readiness: Transitioning from low-level design to production systems requires introducing thread synchronization, async queueing (Kafka/RabbitMQ), exponential backoff retries, and Dead Letter Queues (DLQ).