"Design a notification system that can send a message to a user through multiple channels — email, SMS, push. It should respect user preferences, avoid spamming any one user, and retry failed deliveries."
notify(userId, category, templateId, params, channels, idemKey):
if dedupeCache.seen(idemKey): return // idempotent replay guard
for channel in channels:
if not userPreference.isAllowed(userId, channel, category): continue
if not rateLimiter.tryAcquire(key=(userId, channel)): continue
content = template.render(templateId, channel, params)
result = channelImpl(channel).send(content, resolveAddress(userId, channel))
if result.failed:
retryScheduler.scheduleRetry(notification, attempt=1)
dedupeCache.record(idemKey)
RetryScheduler.onRetryDue(notification, attempt):
result = channelImpl(notification.channel).send(...)
if result.failed:
if attempt >= MAX_ATTEMPTS:
deadLetterQueue.record(notification, result.error)
else:
scheduleRetry(notification, attempt + 1, delay=backoff(attempt))
void notify(String userId, Category category, String templateId,
Map<String, Object> params, List<ChannelType> channels, String idemKey) {
if (dedupeCache.seen(idemKey)) return; // idempotent replay guard
for (ChannelType channel : channels) {
if (!userPreference.isAllowed(userId, channel, category)) continue;
if (!rateLimiter.tryAcquire(userId, channel)) continue;
RenderedContent content = template.render(templateId, channel, params);
DeliveryResult result = channelImpl(channel).send(content, resolveAddress(userId, channel));
if (result.failed()) {
retryScheduler.scheduleRetry(notification, 1);
}
}
dedupeCache.record(idemKey);
}
void onRetryDue(Notification notification, int attempt) {
DeliveryResult result = channelImpl(notification.getChannel()).send(/* ... */);
if (result.failed()) {
if (attempt >= MAX_ATTEMPTS) {
deadLetterQueue.record(notification, result.getError());
} else {
scheduleRetry(notification, attempt + 1, backoff(attempt));
}
}
}
void NotificationService::notify(const std::string& userId, Category category, const std::string& templateId,
const std::map<std::string, std::any>& params,
const std::vector<ChannelType>& channels, const std::string& idemKey) {
if (dedupeCache.seen(idemKey)) return; // idempotent replay guard
for (auto& channel : channels) {
if (!userPreference.isAllowed(userId, channel, category)) continue;
if (!rateLimiter.tryAcquire({userId, channel})) continue;
auto content = templateEngine.render(templateId, channel, params);
auto result = channelImpl(channel)->send(content, resolveAddress(userId, channel));
if (result.failed) {
retryScheduler.scheduleRetry(notification, 1);
}
}
dedupeCache.record(idemKey);
}
void RetryScheduler::onRetryDue(Notification& notification, int attempt) {
auto result = channelImpl(notification.channel)->send(/* ... */);
if (result.failed) {
if (attempt >= MAX_ATTEMPTS) {
deadLetterQueue.record(notification, result.error);
} else {
scheduleRetry(notification, attempt + 1, backoff(attempt));
}
}
}
notify(userId, category, templateId, params, channels, idemKey) {
if (this.dedupeCache.seen(idemKey)) return; // idempotent replay guard
for (const channel of channels) {
if (!this.userPreference.isAllowed(userId, channel, category)) continue;
if (!this.rateLimiter.tryAcquire({ userId, channel })) continue;
const content = this.template.render(templateId, channel, params);
const result = this.channelImpl(channel).send(content, this.resolveAddress(userId, channel));
if (result.failed) {
this.retryScheduler.scheduleRetry(notification, 1);
}
}
this.dedupeCache.record(idemKey);
}
onRetryDue(notification, attempt) {
const result = this.channelImpl(notification.channel).send(/* ... */);
if (result.failed) {
if (attempt >= MAX_ATTEMPTS) {
this.deadLetterQueue.record(notification, result.error);
} else {
this.scheduleRetry(notification, attempt + 1, backoff(attempt));
}
}
}
notify(userId: string, category: Category, templateId: string,
params: Map<string, unknown>, channels: ChannelType[], idemKey: string): void {
if (this.dedupeCache.seen(idemKey)) return; // idempotent replay guard
for (const channel of channels) {
if (!this.userPreference.isAllowed(userId, channel, category)) continue;
if (!this.rateLimiter.tryAcquire({ userId, channel })) continue;
const content: RenderedContent = this.template.render(templateId, channel, params);
const result: DeliveryResult = this.channelImpl(channel).send(content, this.resolveAddress(userId, channel));
if (result.failed) {
this.retryScheduler.scheduleRetry(notification, 1);
}
}
this.dedupeCache.record(idemKey);
}
onRetryDue(notification: Notification, attempt: number): void {
const result: DeliveryResult = this.channelImpl(notification.channel).send(/* ... */);
if (result.failed) {
if (attempt >= MAX_ATTEMPTS) {
this.deadLetterQueue.record(notification, result.error);
} else {
this.scheduleRetry(notification, attempt + 1, backoff(attempt));
}
}
}
A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.
// Notification System -- full LLD implementation with demo.
// Single-file Java translation of the Mermaid class diagram in section J.
import java.util.*;
public class p7 {
enum Category { TRANSACTIONAL, PROMOTIONAL, SECURITY_ALERT }
enum ChannelType { EMAIL, SMS, PUSH }
enum Status { PENDING, SENT, FAILED, DEAD_LETTERED }
// ---------- Notification (data record) ----------
static final class Notification {
final String id;
final String userId;
final Category category;
final String templateId;
final Map<String, Object> params;
final List<ChannelType> channels;
final String idempotencyKey;
Status status = Status.PENDING;
// Retry context: populated by NotificationService just before a scheduleRetry() call
// so RetryScheduler can re-attempt delivery on exactly the medium that failed.
ChannelType retryChannel;
RenderedContent retryContent;
String retryRecipient;
Notification(String id, String userId, Category category, String templateId,
Map<String, Object> params, List<ChannelType> channels, String idempotencyKey) {
this.id = id; this.userId = userId; this.category = category; this.templateId = templateId;
this.params = params; this.channels = channels; this.idempotencyKey = idempotencyKey;
}
}
/** Content once rendered from a template -- has no lifecycle of its own. */
static final class RenderedContent {
final String subject;
final String body;
RenderedContent(String subject, String body) { this.subject = subject; this.body = body; }
public String toString() { return (subject != null ? "[" + subject + "] " : "") + body; }
}
static final class DeliveryResult {
final boolean failed;
final String error;
static DeliveryResult ok() { return new DeliveryResult(false, null); }
static DeliveryResult fail(String error) { return new DeliveryResult(true, error); }
private DeliveryResult(boolean failed, String error) { this.failed = failed; this.error = error; }
}
// ---------- NotificationTemplate ----------
/** Content pattern per category+channel; substitutes {{placeholders}} with params at render time. */
static final class NotificationTemplate {
final String id;
final Map<ChannelType, String> subjectPatterns = new HashMap<>(); // optional, e.g. email only
final Map<ChannelType, String> bodyPatterns = new HashMap<>();
NotificationTemplate(String id) { this.id = id; }
NotificationTemplate withBody(ChannelType channel, String pattern) {
bodyPatterns.put(channel, pattern);
return this;
}
NotificationTemplate withSubject(ChannelType channel, String pattern) {
subjectPatterns.put(channel, pattern);
return this;
}
RenderedContent render(ChannelType channel, Map<String, Object> params) {
String bodyPattern = bodyPatterns.get(channel);
if (bodyPattern == null) throw new IllegalStateException("No template body for channel " + channel);
String subjectPattern = subjectPatterns.get(channel);
return new RenderedContent(substitute(subjectPattern, params), substitute(bodyPattern, params));
}
private String substitute(String pattern, Map<String, Object> params) {
if (pattern == null) return null;
String out = pattern;
for (Map.Entry<String, Object> e : params.entrySet()) {
out = out.replace("{{" + e.getKey() + "}}", String.valueOf(e.getValue()));
}
return out;
}
}
// ---------- UserPreference ----------
/** Per-user opt-in/opt-out. Default is allowed unless explicitly opted out. */
static final class UserPreference {
// key: userId|channel|category -> enabled
private final Map<String, Boolean> overrides = new HashMap<>();
private String key(String userId, ChannelType channel, Category category) {
return userId + "|" + channel + "|" + category;
}
void setPreference(String userId, ChannelType channel, Category category, boolean enabled) {
overrides.put(key(userId, channel, category), enabled);
}
boolean isAllowed(String userId, ChannelType channel, Category category) {
return overrides.getOrDefault(key(userId, channel, category), true); // opt-out model
}
}
// ---------- RateLimiter (reused from Problem 3) ----------
/** Fixed-window limiter: caps sends per key within a rolling window. */
static final class RateLimiter {
private final int maxPerWindow;
private final long windowMillis;
private final Map<String, Deque<Long>> history = new HashMap<>();
RateLimiter(int maxPerWindow, long windowMillis) {
this.maxPerWindow = maxPerWindow; this.windowMillis = windowMillis;
}
boolean tryAcquire(String key) {
long now = System.currentTimeMillis();
Deque<Long> timestamps = history.computeIfAbsent(key, k -> new ArrayDeque<>());
while (!timestamps.isEmpty() && now - timestamps.peekFirst() > windowMillis) timestamps.pollFirst();
if (timestamps.size() >= maxPerWindow) return false;
timestamps.addLast(now);
return true;
}
}
// ---------- NotificationChannel (Strategy interface) ----------
interface NotificationChannel {
DeliveryResult send(RenderedContent content, String recipientAddress);
}
static final class EmailChannel implements NotificationChannel {
public DeliveryResult send(RenderedContent content, String recipientAddress) {
System.out.println(" [EMAIL -> " + recipientAddress + "] " + content);
return DeliveryResult.ok();
}
}
/** Simulates a transient network failure: fails once per recipient, then succeeds. */
static final class SMSChannel implements NotificationChannel {
private final Set<String> failedOnce = new HashSet<>();
public DeliveryResult send(RenderedContent content, String recipientAddress) {
if (failedOnce.add(recipientAddress)) {
System.out.println(" [SMS -> " + recipientAddress + "] transient failure");
return DeliveryResult.fail("carrier timeout");
}
System.out.println(" [SMS -> " + recipientAddress + "] " + content);
return DeliveryResult.ok();
}
}
/** Simulates a permanently broken provider, to demonstrate dead-lettering. */
static final class PushChannel implements NotificationChannel {
public DeliveryResult send(RenderedContent content, String recipientAddress) {
System.out.println(" [PUSH -> " + recipientAddress + "] provider unavailable");
return DeliveryResult.fail("device token invalid");
}
}
// ---------- DeadLetterQueue ----------
static final class DeadLetterQueue {
private final List<String> entries = new ArrayList<>();
void record(Notification notification, String error) {
entries.add(notification.id + " (" + notification.retryChannel + "): " + error);
notification.status = Status.DEAD_LETTERED;
}
List<String> entries() { return entries; }
}
// ---------- RetryScheduler ----------
/** Re-attempts failed sends with backoff, up to MAX_ATTEMPTS, then dead-letters. */
static final class RetryScheduler {
private static final int MAX_ATTEMPTS = 3;
private final Map<ChannelType, NotificationChannel> channels;
private final DeadLetterQueue deadLetterQueue;
RetryScheduler(Map<ChannelType, NotificationChannel> channels, DeadLetterQueue deadLetterQueue) {
this.channels = channels; this.deadLetterQueue = deadLetterQueue;
}
/** In production this enqueues a delayed timer job; here we simulate the delay synchronously for the demo. */
void scheduleRetry(Notification notification, int attempt) {
long delayMs = backoff(attempt);
System.out.println(" retry scheduled for " + notification.id + " attempt=" + attempt + " delay=" + delayMs + "ms");
onRetryDue(notification, attempt);
}
private void onRetryDue(Notification notification, int attempt) {
NotificationChannel channel = channels.get(notification.retryChannel);
DeliveryResult result = channel.send(notification.retryContent, notification.retryRecipient);
if (result.failed) {
if (attempt >= MAX_ATTEMPTS) {
deadLetterQueue.record(notification, result.error);
} else {
scheduleRetry(notification, attempt + 1);
}
} else {
notification.status = Status.SENT;
}
}
private long backoff(int attempt) { return 100L * (1L << attempt); } // exponential backoff
}
// ---------- NotificationService (facade) ----------
static final class NotificationService {
private final Map<String, NotificationTemplate> templates;
private final UserPreference userPreference;
private final RateLimiter rateLimiter;
private final Map<ChannelType, NotificationChannel> channels;
private final RetryScheduler retryScheduler;
private final Set<String> dedupeCache = new HashSet<>();
private int counter = 0;
NotificationService(Map<String, NotificationTemplate> templates, UserPreference userPreference,
RateLimiter rateLimiter, Map<ChannelType, NotificationChannel> channels,
RetryScheduler retryScheduler) {
this.templates = templates; this.userPreference = userPreference; this.rateLimiter = rateLimiter;
this.channels = channels; this.retryScheduler = retryScheduler;
}
Notification notify(String userId, Category category, String templateId,
Map<String, Object> params, List<ChannelType> requestedChannels, String idempotencyKey) {
Notification notification = new Notification("N" + (++counter), userId, category, templateId,
params, requestedChannels, idempotencyKey);
if (dedupeCache.contains(idempotencyKey)) { // idempotent replay guard
System.out.println("Skipping duplicate notify() for idemKey=" + idempotencyKey);
return notification;
}
NotificationTemplate template = templates.get(templateId);
for (ChannelType channel : requestedChannels) {
if (!userPreference.isAllowed(userId, channel, category)) {
System.out.println(" " + channel + " skipped: user opted out for " + category);
continue;
}
if (!rateLimiter.tryAcquire(userId + "|" + channel)) {
System.out.println(" " + channel + " skipped: rate limit exceeded");
continue;
}
RenderedContent content = template.render(channel, params);
String recipient = resolveAddress(userId, channel);
DeliveryResult result = channels.get(channel).send(content, recipient);
if (result.failed) {
notification.status = Status.FAILED;
notification.retryChannel = channel;
notification.retryContent = content;
notification.retryRecipient = recipient;
retryScheduler.scheduleRetry(notification, 1);
} else if (notification.status != Status.SENT) {
notification.status = Status.SENT;
}
}
dedupeCache.add(idempotencyKey);
return notification;
}
private String resolveAddress(String userId, ChannelType channel) {
switch (channel) {
case EMAIL: return userId + "@example.com";
case SMS: return "+1-555-" + userId;
case PUSH: return "device-token-" + userId;
default: throw new IllegalArgumentException("Unknown channel: " + channel);
}
}
}
// ---------- Demo ----------
public static void main(String[] args) {
NotificationTemplate welcome = new NotificationTemplate("welcome")
.withSubject(ChannelType.EMAIL, "Welcome, {{name}}!")
.withBody(ChannelType.EMAIL, "Hi {{name}}, thanks for joining.")
.withBody(ChannelType.SMS, "Hi {{name}}, welcome aboard!")
.withBody(ChannelType.PUSH, "Welcome, {{name}}!");
Map<String, NotificationTemplate> templates = new HashMap<>();
templates.put("welcome", welcome);
UserPreference preferences = new UserPreference();
RateLimiter rateLimiter = new RateLimiter(5, 24L * 60 * 60 * 1000); // 5 per day per (user, channel)
Map<ChannelType, NotificationChannel> channels = new HashMap<>();
channels.put(ChannelType.EMAIL, new EmailChannel());
channels.put(ChannelType.SMS, new SMSChannel());
channels.put(ChannelType.PUSH, new PushChannel());
DeadLetterQueue dlq = new DeadLetterQueue();
RetryScheduler retryScheduler = new RetryScheduler(channels, dlq);
NotificationService service = new NotificationService(templates, preferences, rateLimiter, channels, retryScheduler);
Map<String, Object> params = new HashMap<>();
params.put("name", "Alice");
System.out.println("First notify (SMS will fail once then retry-succeed; PUSH will exhaust retries -> DLQ):");
service.notify("u1", Category.TRANSACTIONAL, "welcome", params,
List.of(ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH), "idem-1");
System.out.println("\nReplaying same idempotencyKey (should be skipped):");
service.notify("u1", Category.TRANSACTIONAL, "welcome", params,
List.of(ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH), "idem-1");
System.out.println("\nOpting out u2 from SMS/PROMOTIONAL, then attempting anyway:");
preferences.setPreference("u2", ChannelType.SMS, Category.PROMOTIONAL, false);
service.notify("u2", Category.PROMOTIONAL, "welcome", params, List.of(ChannelType.SMS), "idem-2");
System.out.println("\nDead letter queue entries: " + dlq.entries());
}
}
// Notification System -- full class-diagram implementation, single-threaded.
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <memory>
#include <chrono>
#include <stdexcept>
enum class Category { TRANSACTIONAL, PROMOTIONAL, SECURITY_ALERT };
enum class ChannelType { EMAIL, SMS, PUSH };
enum class Status { PENDING, SENT, FAILED, DEAD_LETTERED };
std::string toString(ChannelType c) {
switch (c) { case ChannelType::EMAIL: return "EMAIL"; case ChannelType::SMS: return "SMS"; default: return "PUSH"; }
}
std::string toString(Category c) {
switch (c) { case Category::TRANSACTIONAL: return "TRANSACTIONAL"; case Category::PROMOTIONAL: return "PROMOTIONAL"; default: return "SECURITY_ALERT"; }
}
// ---------- RenderedContent / DeliveryResult ----------
struct RenderedContent {
std::string subject; // may be empty
std::string body;
std::string toString() const { return (subject.empty() ? "" : "[" + subject + "] ") + body; }
};
struct DeliveryResult {
bool failed;
std::string error;
static DeliveryResult ok() { return {false, ""}; }
static DeliveryResult fail(const std::string& e) { return {true, e}; }
};
// ---------- Notification ----------
struct Notification {
std::string id, userId, templateId, idempotencyKey;
Category category;
std::vector<ChannelType> channels;
std::map<std::string, std::string> params;
Status status = Status::PENDING;
// Retry context, populated right before scheduleRetry().
ChannelType retryChannel = ChannelType::EMAIL;
RenderedContent retryContent;
std::string retryRecipient;
};
// ---------- NotificationTemplate ----------
class NotificationTemplate {
public:
std::string id;
explicit NotificationTemplate(std::string id_) : id(std::move(id_)) {}
NotificationTemplate& withBody(ChannelType c, const std::string& pattern) { bodyPatterns_[c] = pattern; return *this; }
NotificationTemplate& withSubject(ChannelType c, const std::string& pattern) { subjectPatterns_[c] = pattern; return *this; }
RenderedContent render(ChannelType channel, const std::map<std::string, std::string>& params) const {
auto bit = bodyPatterns_.find(channel);
if (bit == bodyPatterns_.end()) throw std::runtime_error("No template body for channel");
RenderedContent rc;
rc.body = substitute(bit->second, params);
auto sit = subjectPatterns_.find(channel);
if (sit != subjectPatterns_.end()) rc.subject = substitute(sit->second, params);
return rc;
}
private:
std::map<ChannelType, std::string> bodyPatterns_, subjectPatterns_;
static std::string substitute(std::string pattern, const std::map<std::string, std::string>& params) {
for (auto& kv : params) {
std::string token = "{{" + kv.first + "}}";
size_t pos;
while ((pos = pattern.find(token)) != std::string::npos) pattern.replace(pos, token.size(), kv.second);
}
return pattern;
}
};
// ---------- UserPreference ----------
class UserPreference {
public:
void setPreference(const std::string& userId, ChannelType channel, Category category, bool enabled) {
overrides_[key(userId, channel, category)] = enabled;
}
bool isAllowed(const std::string& userId, ChannelType channel, Category category) const {
auto it = overrides_.find(key(userId, channel, category));
return it == overrides_.end() ? true : it->second; // opt-out model: default allowed
}
private:
std::map<std::string, bool> overrides_;
static std::string key(const std::string& userId, ChannelType c, Category cat) {
return userId + "|" + toString(c) + "|" + toString(cat);
}
};
// ---------- RateLimiter ----------
class RateLimiter {
public:
RateLimiter(int maxPerWindow, long long windowMs) : maxPerWindow_(maxPerWindow), windowMs_(windowMs) {}
bool tryAcquire(const std::string& key) {
long long now = nowMs();
auto& q = history_[key];
while (!q.empty() && now - q.front() > windowMs_) q.pop_front();
if ((int)q.size() >= maxPerWindow_) return false;
q.push_back(now);
return true;
}
private:
int maxPerWindow_;
long long windowMs_;
std::unordered_map<std::string, std::deque<long long>> history_;
static long long nowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
};
// ---------- NotificationChannel (Strategy interface) ----------
struct NotificationChannel {
virtual ~NotificationChannel() = default;
virtual DeliveryResult send(const RenderedContent& content, const std::string& recipient) = 0;
};
struct EmailChannel : NotificationChannel {
DeliveryResult send(const RenderedContent& content, const std::string& recipient) override {
std::cout << " [EMAIL -> " << recipient << "] " << content.toString() << "\n";
return DeliveryResult::ok();
}
};
// Fails once per recipient (simulated transient failure), then succeeds.
struct SMSChannel : NotificationChannel {
std::unordered_set<std::string> failedOnce;
DeliveryResult send(const RenderedContent& content, const std::string& recipient) override {
if (failedOnce.insert(recipient).second) {
std::cout << " [SMS -> " << recipient << "] transient failure\n";
return DeliveryResult::fail("carrier timeout");
}
std::cout << " [SMS -> " << recipient << "] " << content.toString() << "\n";
return DeliveryResult::ok();
}
};
// Permanently broken provider, to demonstrate dead-lettering.
struct PushChannel : NotificationChannel {
DeliveryResult send(const RenderedContent&, const std::string& recipient) override {
std::cout << " [PUSH -> " << recipient << "] provider unavailable\n";
return DeliveryResult::fail("device token invalid");
}
};
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
public:
void record(Notification& n, const std::string& error) {
entries_.push_back(n.id + " (" + toString(n.retryChannel) + "): " + error);
n.status = Status::DEAD_LETTERED;
}
const std::vector<std::string>& entries() const { return entries_; }
private:
std::vector<std::string> entries_;
};
// ---------- RetryScheduler ----------
class RetryScheduler {
public:
RetryScheduler(std::map<ChannelType, std::shared_ptr<NotificationChannel>> channels,
std::shared_ptr<DeadLetterQueue> dlq)
: channels_(std::move(channels)), deadLetterQueue_(std::move(dlq)) {}
// Simulates a delayed timer job synchronously for this single-threaded demo.
void scheduleRetry(Notification& n, int attempt) {
long long delayMs = backoff(attempt);
std::cout << " retry scheduled for " << n.id << " attempt=" << attempt << " delay=" << delayMs << "ms\n";
onRetryDue(n, attempt);
}
private:
static const int MAX_ATTEMPTS = 3;
std::map<ChannelType, std::shared_ptr<NotificationChannel>> channels_;
std::shared_ptr<DeadLetterQueue> deadLetterQueue_;
void onRetryDue(Notification& n, int attempt) {
auto result = channels_[n.retryChannel]->send(n.retryContent, n.retryRecipient);
if (result.failed) {
if (attempt >= MAX_ATTEMPTS) deadLetterQueue_->record(n, result.error);
else scheduleRetry(n, attempt + 1);
} else {
n.status = Status::SENT;
}
}
long long backoff(int attempt) { return 100LL * (1LL << attempt); }
};
// ---------- NotificationService (facade) ----------
class NotificationService {
public:
NotificationService(std::map<std::string, NotificationTemplate> templates, std::shared_ptr<UserPreference> pref,
std::shared_ptr<RateLimiter> rateLimiter, std::map<ChannelType, std::shared_ptr<NotificationChannel>> channels,
std::shared_ptr<RetryScheduler> retryScheduler)
: templates_(std::move(templates)), userPreference_(std::move(pref)), rateLimiter_(std::move(rateLimiter)),
channels_(std::move(channels)), retryScheduler_(std::move(retryScheduler)) {}
Notification notify(const std::string& userId, Category category, const std::string& templateId,
std::map<std::string, std::string> params, std::vector<ChannelType> requestedChannels,
const std::string& idempotencyKey) {
Notification n;
n.id = "N" + std::to_string(++counter_);
n.userId = userId; n.category = category; n.templateId = templateId;
n.params = params; n.channels = requestedChannels; n.idempotencyKey = idempotencyKey;
if (dedupeCache_.count(idempotencyKey)) {
std::cout << "Skipping duplicate notify() for idemKey=" << idempotencyKey << "\n";
return n;
}
const NotificationTemplate& tmpl = templates_.at(templateId);
for (ChannelType channel : requestedChannels) {
if (!userPreference_->isAllowed(userId, channel, category)) {
std::cout << " " << toString(channel) << " skipped: user opted out for " << toString(category) << "\n";
continue;
}
if (!rateLimiter_->tryAcquire(userId + "|" + toString(channel))) {
std::cout << " " << toString(channel) << " skipped: rate limit exceeded\n";
continue;
}
RenderedContent content = tmpl.render(channel, params);
std::string recipient = resolveAddress(userId, channel);
DeliveryResult result = channels_[channel]->send(content, recipient);
if (result.failed) {
n.status = Status::FAILED;
n.retryChannel = channel; n.retryContent = content; n.retryRecipient = recipient;
retryScheduler_->scheduleRetry(n, 1);
} else if (n.status != Status::SENT) {
n.status = Status::SENT;
}
}
dedupeCache_.insert(idempotencyKey);
return n;
}
private:
std::map<std::string, NotificationTemplate> templates_;
std::shared_ptr<UserPreference> userPreference_;
std::shared_ptr<RateLimiter> rateLimiter_;
std::map<ChannelType, std::shared_ptr<NotificationChannel>> channels_;
std::shared_ptr<RetryScheduler> retryScheduler_;
std::unordered_set<std::string> dedupeCache_;
int counter_ = 0;
std::string resolveAddress(const std::string& userId, ChannelType channel) {
switch (channel) {
case ChannelType::EMAIL: return userId + "@example.com";
case ChannelType::SMS: return "+1-555-" + userId;
default: return "device-token-" + userId;
}
}
};
int main() {
NotificationTemplate welcome("welcome");
welcome.withSubject(ChannelType::EMAIL, "Welcome, {{name}}!")
.withBody(ChannelType::EMAIL, "Hi {{name}}, thanks for joining.")
.withBody(ChannelType::SMS, "Hi {{name}}, welcome aboard!")
.withBody(ChannelType::PUSH, "Welcome, {{name}}!");
std::map<std::string, NotificationTemplate> templates;
templates.emplace("welcome", welcome);
auto preferences = std::make_shared<UserPreference>();
auto rateLimiter = std::make_shared<RateLimiter>(5, 24LL * 60 * 60 * 1000);
std::map<ChannelType, std::shared_ptr<NotificationChannel>> channels;
channels[ChannelType::EMAIL] = std::make_shared<EmailChannel>();
channels[ChannelType::SMS] = std::make_shared<SMSChannel>();
channels[ChannelType::PUSH] = std::make_shared<PushChannel>();
auto dlq = std::make_shared<DeadLetterQueue>();
auto retryScheduler = std::make_shared<RetryScheduler>(channels, dlq);
NotificationService service(templates, preferences, rateLimiter, channels, retryScheduler);
std::map<std::string, std::string> params = { {"name", "Alice"} };
std::cout << "First notify (SMS fails once then retry-succeeds; PUSH exhausts retries -> DLQ):\n";
service.notify("u1", Category::TRANSACTIONAL, "welcome", params,
{ChannelType::EMAIL, ChannelType::SMS, ChannelType::PUSH}, "idem-1");
std::cout << "\nReplaying same idempotencyKey (should be skipped):\n";
service.notify("u1", Category::TRANSACTIONAL, "welcome", params,
{ChannelType::EMAIL, ChannelType::SMS, ChannelType::PUSH}, "idem-1");
std::cout << "\nOpting out u2 from SMS/PROMOTIONAL, then attempting anyway:\n";
preferences->setPreference("u2", ChannelType::SMS, Category::PROMOTIONAL, false);
service.notify("u2", Category::PROMOTIONAL, "welcome", params, {ChannelType::SMS}, "idem-2");
std::cout << "\nDead letter queue entries: ";
for (auto& e : dlq->entries()) std::cout << e << " | ";
std::cout << "\n";
return 0;
}
// Notification System -- full class-diagram implementation, single-threaded.
// Node-style, plain classes, CommonJS.
const Category = { TRANSACTIONAL: 'TRANSACTIONAL', PROMOTIONAL: 'PROMOTIONAL', SECURITY_ALERT: 'SECURITY_ALERT' };
const ChannelType = { EMAIL: 'EMAIL', SMS: 'SMS', PUSH: 'PUSH' };
const Status = { PENDING: 'PENDING', SENT: 'SENT', FAILED: 'FAILED', DEAD_LETTERED: 'DEAD_LETTERED' };
// ---------- Notification (data record) ----------
class Notification {
constructor(id, userId, category, templateId, params, channels, idempotencyKey) {
this.id = id;
this.userId = userId;
this.category = category;
this.templateId = templateId;
this.params = params;
this.channels = channels;
this.idempotencyKey = idempotencyKey;
this.status = Status.PENDING;
// Retry context, populated by NotificationService right before scheduleRetry().
this.retryChannel = null;
this.retryContent = null;
this.retryRecipient = null;
}
}
class RenderedContent {
constructor(subject, body) { this.subject = subject; this.body = body; }
toString() { return (this.subject ? `[${this.subject}] ` : '') + this.body; }
}
class DeliveryResult {
constructor(failed, error) { this.failed = failed; this.error = error; }
static ok() { return new DeliveryResult(false, null); }
static fail(error) { return new DeliveryResult(true, error); }
}
// ---------- NotificationTemplate ----------
class NotificationTemplate {
constructor(id) {
this.id = id;
this.subjectPatterns = new Map();
this.bodyPatterns = new Map();
}
withBody(channel, pattern) { this.bodyPatterns.set(channel, pattern); return this; }
withSubject(channel, pattern) { this.subjectPatterns.set(channel, pattern); return this; }
render(channel, params) {
const bodyPattern = this.bodyPatterns.get(channel);
if (!bodyPattern) throw new Error(`No template body for channel ${channel}`);
const subjectPattern = this.subjectPatterns.get(channel);
return new RenderedContent(
subjectPattern ? this._substitute(subjectPattern, params) : null,
this._substitute(bodyPattern, params)
);
}
_substitute(pattern, params) {
let out = pattern;
for (const [k, v] of Object.entries(params)) out = out.split(`{{${k}}}`).join(String(v));
return out;
}
}
// ---------- UserPreference ----------
class UserPreference {
constructor() { this.overrides = new Map(); }
_key(userId, channel, category) { return `${userId}|${channel}|${category}`; }
setPreference(userId, channel, category, enabled) {
this.overrides.set(this._key(userId, channel, category), enabled);
}
isAllowed(userId, channel, category) {
const key = this._key(userId, channel, category);
return this.overrides.has(key) ? this.overrides.get(key) : true; // opt-out model: default allowed
}
}
// ---------- RateLimiter (reused from Problem 3) ----------
class RateLimiter {
constructor(maxPerWindow, windowMillis) {
this.maxPerWindow = maxPerWindow;
this.windowMillis = windowMillis;
this.history = new Map();
}
tryAcquire(key) {
const now = Date.now();
if (!this.history.has(key)) this.history.set(key, []);
const timestamps = this.history.get(key);
while (timestamps.length && now - timestamps[0] > this.windowMillis) timestamps.shift();
if (timestamps.length >= this.maxPerWindow) return false;
timestamps.push(now);
return true;
}
}
// ---------- NotificationChannel (Strategy interface) ----------
class NotificationChannel {
send(_content, _recipientAddress) { throw new Error('send() not implemented'); }
}
class EmailChannel extends NotificationChannel {
send(content, recipientAddress) {
console.log(` [EMAIL -> ${recipientAddress}] ${content}`);
return DeliveryResult.ok();
}
}
// Simulates a transient failure: fails once per recipient, then succeeds.
class SMSChannel extends NotificationChannel {
constructor() { super(); this.failedOnce = new Set(); }
send(content, recipientAddress) {
if (!this.failedOnce.has(recipientAddress)) {
this.failedOnce.add(recipientAddress);
console.log(` [SMS -> ${recipientAddress}] transient failure`);
return DeliveryResult.fail('carrier timeout');
}
console.log(` [SMS -> ${recipientAddress}] ${content}`);
return DeliveryResult.ok();
}
}
// Permanently broken provider, to demonstrate dead-lettering.
class PushChannel extends NotificationChannel {
send(content, recipientAddress) {
console.log(` [PUSH -> ${recipientAddress}] provider unavailable`);
return DeliveryResult.fail('device token invalid');
}
}
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
constructor() { this.entriesList = []; }
record(notification, error) {
this.entriesList.push(`${notification.id} (${notification.retryChannel}): ${error}`);
notification.status = Status.DEAD_LETTERED;
}
entries() { return this.entriesList; }
}
// ---------- RetryScheduler ----------
class RetryScheduler {
static MAX_ATTEMPTS = 3;
constructor(channels, deadLetterQueue) {
this.channels = channels;
this.deadLetterQueue = deadLetterQueue;
}
// Simulates a delayed timer job synchronously for this demo (no real event loop needed).
scheduleRetry(notification, attempt) {
const delayMs = this._backoff(attempt);
console.log(` retry scheduled for ${notification.id} attempt=${attempt} delay=${delayMs}ms`);
this._onRetryDue(notification, attempt);
}
_onRetryDue(notification, attempt) {
const channel = this.channels.get(notification.retryChannel);
const result = channel.send(notification.retryContent, notification.retryRecipient);
if (result.failed) {
if (attempt >= RetryScheduler.MAX_ATTEMPTS) {
this.deadLetterQueue.record(notification, result.error);
} else {
this.scheduleRetry(notification, attempt + 1);
}
} else {
notification.status = Status.SENT;
}
}
_backoff(attempt) { return 100 * Math.pow(2, attempt); }
}
// ---------- NotificationService (facade) ----------
class NotificationService {
constructor(templates, userPreference, rateLimiter, channels, retryScheduler) {
this.templates = templates;
this.userPreference = userPreference;
this.rateLimiter = rateLimiter;
this.channels = channels;
this.retryScheduler = retryScheduler;
this.dedupeCache = new Set();
this.counter = 0;
}
notify(userId, category, templateId, params, requestedChannels, idempotencyKey) {
const notification = new Notification(
`N${++this.counter}`, userId, category, templateId, params, requestedChannels, idempotencyKey
);
if (this.dedupeCache.has(idempotencyKey)) { // idempotent replay guard
console.log(`Skipping duplicate notify() for idemKey=${idempotencyKey}`);
return notification;
}
const template = this.templates.get(templateId);
for (const channel of requestedChannels) {
if (!this.userPreference.isAllowed(userId, channel, category)) {
console.log(` ${channel} skipped: user opted out for ${category}`);
continue;
}
if (!this.rateLimiter.tryAcquire(`${userId}|${channel}`)) {
console.log(` ${channel} skipped: rate limit exceeded`);
continue;
}
const content = template.render(channel, params);
const recipient = this._resolveAddress(userId, channel);
const result = this.channels.get(channel).send(content, recipient);
if (result.failed) {
notification.status = Status.FAILED;
notification.retryChannel = channel;
notification.retryContent = content;
notification.retryRecipient = recipient;
this.retryScheduler.scheduleRetry(notification, 1);
} else if (notification.status !== Status.SENT) {
notification.status = Status.SENT;
}
}
this.dedupeCache.add(idempotencyKey);
return notification;
}
_resolveAddress(userId, channel) {
switch (channel) {
case ChannelType.EMAIL: return `${userId}@example.com`;
case ChannelType.SMS: return `+1-555-${userId}`;
case ChannelType.PUSH: return `device-token-${userId}`;
default: throw new Error(`Unknown channel: ${channel}`);
}
}
}
// ---------- Demo ----------
function main() {
const welcome = new NotificationTemplate('welcome')
.withSubject(ChannelType.EMAIL, 'Welcome, {{name}}!')
.withBody(ChannelType.EMAIL, 'Hi {{name}}, thanks for joining.')
.withBody(ChannelType.SMS, 'Hi {{name}}, welcome aboard!')
.withBody(ChannelType.PUSH, 'Welcome, {{name}}!');
const templates = new Map([['welcome', welcome]]);
const preferences = new UserPreference();
const rateLimiter = new RateLimiter(5, 24 * 60 * 60 * 1000); // 5 per day per (user, channel)
const channels = new Map([
[ChannelType.EMAIL, new EmailChannel()],
[ChannelType.SMS, new SMSChannel()],
[ChannelType.PUSH, new PushChannel()],
]);
const dlq = new DeadLetterQueue();
const retryScheduler = new RetryScheduler(channels, dlq);
const service = new NotificationService(templates, preferences, rateLimiter, channels, retryScheduler);
const params = { name: 'Alice' };
console.log('First notify (SMS fails once then retry-succeeds; PUSH exhausts retries -> DLQ):');
service.notify('u1', Category.TRANSACTIONAL, 'welcome', params,
[ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH], 'idem-1');
console.log('\nReplaying same idempotencyKey (should be skipped):');
service.notify('u1', Category.TRANSACTIONAL, 'welcome', params,
[ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH], 'idem-1');
console.log('\nOpting out u2 from SMS/PROMOTIONAL, then attempting anyway:');
preferences.setPreference('u2', ChannelType.SMS, Category.PROMOTIONAL, false);
service.notify('u2', Category.PROMOTIONAL, 'welcome', params, [ChannelType.SMS], 'idem-2');
console.log('\nDead letter queue entries:', dlq.entries());
}
if (require.main === module) main();
module.exports = {
Category, ChannelType, Status, Notification, RenderedContent, DeliveryResult,
NotificationTemplate, UserPreference, RateLimiter, NotificationChannel,
EmailChannel, SMSChannel, PushChannel, DeadLetterQueue, RetryScheduler, NotificationService,
};
// Notification System -- full class-diagram implementation, single-threaded.
// Note: the diagram's `Notification` class is named `NotificationRecord` here because
// TypeScript's default lib set includes DOM, which declares a global `Notification` type;
// this avoids that name collision while keeping identical fields/behavior.
enum Category { TRANSACTIONAL = 'TRANSACTIONAL', PROMOTIONAL = 'PROMOTIONAL', SECURITY_ALERT = 'SECURITY_ALERT' }
enum ChannelType { EMAIL = 'EMAIL', SMS = 'SMS', PUSH = 'PUSH' }
enum Status { PENDING = 'PENDING', SENT = 'SENT', FAILED = 'FAILED', DEAD_LETTERED = 'DEAD_LETTERED' }
// ---------- NotificationRecord (data record) ----------
class NotificationRecord {
status: Status = Status.PENDING;
// Retry context, populated by NotificationService right before scheduleRetry().
retryChannel: ChannelType | null = null;
retryContent: RenderedContent | null = null;
retryRecipient: string | null = null;
constructor(
public readonly id: string,
public readonly userId: string,
public readonly category: Category,
public readonly templateId: string,
public readonly params: Record<string, unknown>,
public readonly channels: ChannelType[],
public readonly idempotencyKey: string
) {}
}
class RenderedContent {
constructor(public readonly subject: string | null, public readonly body: string) {}
toString(): string { return (this.subject ? `[${this.subject}] ` : '') + this.body; }
}
class DeliveryResult {
private constructor(public readonly failed: boolean, public readonly error: string | null) {}
static ok(): DeliveryResult { return new DeliveryResult(false, null); }
static fail(error: string): DeliveryResult { return new DeliveryResult(true, error); }
}
// ---------- NotificationTemplate ----------
class NotificationTemplate {
private readonly subjectPatterns = new Map<ChannelType, string>();
private readonly bodyPatterns = new Map<ChannelType, string>();
constructor(public readonly id: string) {}
withBody(channel: ChannelType, pattern: string): this { this.bodyPatterns.set(channel, pattern); return this; }
withSubject(channel: ChannelType, pattern: string): this { this.subjectPatterns.set(channel, pattern); return this; }
render(channel: ChannelType, params: Record<string, unknown>): RenderedContent {
const bodyPattern = this.bodyPatterns.get(channel);
if (!bodyPattern) throw new Error(`No template body for channel ${channel}`);
const subjectPattern = this.subjectPatterns.get(channel);
return new RenderedContent(
subjectPattern ? this.substitute(subjectPattern, params) : null,
this.substitute(bodyPattern, params)
);
}
private substitute(pattern: string, params: Record<string, unknown>): string {
let out = pattern;
for (const [k, v] of Object.entries(params)) out = out.split(`{{${k}}}`).join(String(v));
return out;
}
}
// ---------- UserPreference ----------
class UserPreference {
private readonly overrides = new Map<string, boolean>();
private key(userId: string, channel: ChannelType, category: Category): string {
return `${userId}|${channel}|${category}`;
}
setPreference(userId: string, channel: ChannelType, category: Category, enabled: boolean): void {
this.overrides.set(this.key(userId, channel, category), enabled);
}
isAllowed(userId: string, channel: ChannelType, category: Category): boolean {
const k = this.key(userId, channel, category);
return this.overrides.has(k) ? this.overrides.get(k)! : true; // opt-out model: default allowed
}
}
// ---------- RateLimiter (reused from Problem 3) ----------
class RateLimiter {
private readonly history = new Map<string, number[]>();
constructor(private readonly maxPerWindow: number, private readonly windowMillis: number) {}
tryAcquire(key: string): boolean {
const now = Date.now();
if (!this.history.has(key)) this.history.set(key, []);
const timestamps = this.history.get(key)!;
while (timestamps.length && now - timestamps[0] > this.windowMillis) timestamps.shift();
if (timestamps.length >= this.maxPerWindow) return false;
timestamps.push(now);
return true;
}
}
// ---------- NotificationChannel (Strategy interface) ----------
interface NotificationChannel {
send(content: RenderedContent, recipientAddress: string): DeliveryResult;
}
class EmailChannel implements NotificationChannel {
send(content: RenderedContent, recipientAddress: string): DeliveryResult {
console.log(` [EMAIL -> ${recipientAddress}] ${content}`);
return DeliveryResult.ok();
}
}
// Simulates a transient failure: fails once per recipient, then succeeds.
class SMSChannel implements NotificationChannel {
private readonly failedOnce = new Set<string>();
send(content: RenderedContent, recipientAddress: string): DeliveryResult {
if (!this.failedOnce.has(recipientAddress)) {
this.failedOnce.add(recipientAddress);
console.log(` [SMS -> ${recipientAddress}] transient failure`);
return DeliveryResult.fail('carrier timeout');
}
console.log(` [SMS -> ${recipientAddress}] ${content}`);
return DeliveryResult.ok();
}
}
// Permanently broken provider, to demonstrate dead-lettering.
class PushChannel implements NotificationChannel {
send(content: RenderedContent, recipientAddress: string): DeliveryResult {
console.log(` [PUSH -> ${recipientAddress}] provider unavailable`);
return DeliveryResult.fail('device token invalid');
}
}
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
private readonly entriesList: string[] = [];
record(notification: NotificationRecord, error: string): void {
this.entriesList.push(`${notification.id} (${notification.retryChannel}): ${error}`);
notification.status = Status.DEAD_LETTERED;
}
entries(): string[] { return this.entriesList; }
}
// ---------- RetryScheduler ----------
class RetryScheduler {
private static readonly MAX_ATTEMPTS = 3;
constructor(
private readonly channels: Map<ChannelType, NotificationChannel>,
private readonly deadLetterQueue: DeadLetterQueue
) {}
// Simulates a delayed timer job synchronously for this demo (no real event loop needed).
scheduleRetry(notification: NotificationRecord, attempt: number): void {
const delayMs = this.backoff(attempt);
console.log(` retry scheduled for ${notification.id} attempt=${attempt} delay=${delayMs}ms`);
this.onRetryDue(notification, attempt);
}
private onRetryDue(notification: NotificationRecord, attempt: number): void {
const channel = this.channels.get(notification.retryChannel!)!;
const result = channel.send(notification.retryContent!, notification.retryRecipient!);
if (result.failed) {
if (attempt >= RetryScheduler.MAX_ATTEMPTS) {
this.deadLetterQueue.record(notification, result.error!);
} else {
this.scheduleRetry(notification, attempt + 1);
}
} else {
notification.status = Status.SENT;
}
}
private backoff(attempt: number): number { return 100 * Math.pow(2, attempt); }
}
// ---------- NotificationService (facade) ----------
class NotificationService {
private readonly dedupeCache = new Set<string>();
private counter = 0;
constructor(
private readonly templates: Map<string, NotificationTemplate>,
private readonly userPreference: UserPreference,
private readonly rateLimiter: RateLimiter,
private readonly channels: Map<ChannelType, NotificationChannel>,
private readonly retryScheduler: RetryScheduler
) {}
notify(
userId: string, category: Category, templateId: string, params: Record<string, unknown>,
requestedChannels: ChannelType[], idempotencyKey: string
): NotificationRecord {
const notification = new NotificationRecord(
`N${++this.counter}`, userId, category, templateId, params, requestedChannels, idempotencyKey
);
if (this.dedupeCache.has(idempotencyKey)) { // idempotent replay guard
console.log(`Skipping duplicate notify() for idemKey=${idempotencyKey}`);
return notification;
}
const template = this.templates.get(templateId)!;
for (const channel of requestedChannels) {
if (!this.userPreference.isAllowed(userId, channel, category)) {
console.log(` ${channel} skipped: user opted out for ${category}`);
continue;
}
if (!this.rateLimiter.tryAcquire(`${userId}|${channel}`)) {
console.log(` ${channel} skipped: rate limit exceeded`);
continue;
}
const content = template.render(channel, params);
const recipient = this.resolveAddress(userId, channel);
const result = this.channels.get(channel)!.send(content, recipient);
if (result.failed) {
notification.status = Status.FAILED;
notification.retryChannel = channel;
notification.retryContent = content;
notification.retryRecipient = recipient;
this.retryScheduler.scheduleRetry(notification, 1);
} else if (notification.status !== Status.SENT) {
notification.status = Status.SENT;
}
}
this.dedupeCache.add(idempotencyKey);
return notification;
}
private resolveAddress(userId: string, channel: ChannelType): string {
switch (channel) {
case ChannelType.EMAIL: return `${userId}@example.com`;
case ChannelType.SMS: return `+1-555-${userId}`;
case ChannelType.PUSH: return `device-token-${userId}`;
default: throw new Error(`Unknown channel: ${channel}`);
}
}
}
// ---------- Demo ----------
function main(): void {
const welcome = new NotificationTemplate('welcome')
.withSubject(ChannelType.EMAIL, 'Welcome, {{name}}!')
.withBody(ChannelType.EMAIL, 'Hi {{name}}, thanks for joining.')
.withBody(ChannelType.SMS, 'Hi {{name}}, welcome aboard!')
.withBody(ChannelType.PUSH, 'Welcome, {{name}}!');
const templates = new Map<string, NotificationTemplate>([['welcome', welcome]]);
const preferences = new UserPreference();
const rateLimiter = new RateLimiter(5, 24 * 60 * 60 * 1000); // 5 per day per (user, channel)
const channels = new Map<ChannelType, NotificationChannel>([
[ChannelType.EMAIL, new EmailChannel()],
[ChannelType.SMS, new SMSChannel()],
[ChannelType.PUSH, new PushChannel()],
]);
const dlq = new DeadLetterQueue();
const retryScheduler = new RetryScheduler(channels, dlq);
const service = new NotificationService(templates, preferences, rateLimiter, channels, retryScheduler);
const params = { name: 'Alice' };
console.log('First notify (SMS fails once then retry-succeeds; PUSH exhausts retries -> DLQ):');
service.notify('u1', Category.TRANSACTIONAL, 'welcome', params,
[ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH], 'idem-1');
console.log('\nReplaying same idempotencyKey (should be skipped):');
service.notify('u1', Category.TRANSACTIONAL, 'welcome', params,
[ChannelType.EMAIL, ChannelType.SMS, ChannelType.PUSH], 'idem-1');
console.log('\nOpting out u2 from SMS/PROMOTIONAL, then attempting anyway:');
preferences.setPreference('u2', ChannelType.SMS, Category.PROMOTIONAL, false);
service.notify('u2', Category.PROMOTIONAL, 'welcome', params, [ChannelType.SMS], 'idem-2');
console.log('\nDead letter queue entries:', dlq.entries());
}
main();