Notification System
Notification System delivers messages through multiple channels — Email, SMS, Push — using pluggable providers, templates, retries, and delivery tracking.
process()execute()- “I’ll clarify channels, preferences, retries, delivery guarantees, and scale. I’ll assume immediate multi-channel notifications, user preferences, templates, and bounded retries.”
Notification System
1. ONE-LINE IDEA
Notification System delivers messages through multiple channels — Email, SMS, Push — using pluggable providers, templates, retries, and delivery tracking.
2. REQUIREMENTS
Interviewer questions
- Which channels? Email, SMS, push, in-app?
- Send immediately or schedule delivery?
- One recipient or bulk notifications?
- Should users configure channel preferences?
- Retry failed deliveries? How many times?
- Need delivery status, deduplication, and priority?
Assume: immediate + bulk notifications, user preferences, templates, multiple channels, retries, delivery status, in-memory demo.
Functional reqs
- Create notification with recipient, content/template, channel, priority.
- Support Email, SMS, Push providers.
- Respect user channel preferences.
- Render channel-specific message content.
- Track notification and delivery status.
- Retry transient failures with bounded attempts.
- Prevent duplicate processing using idempotency key.
- Add new channels without changing core orchestration.
Out of scope
- Real SMTP/SMS/push integrations.
- Durable message queue and DB persistence.
- Scheduling UI and analytics dashboard.
3. ENTITIES
Notification— logical message and recipient.NotificationRequest— caller's send request.NotificationChannel— channel enum.NotificationProvider— channel-specific send contract.NotificationService— orchestration.UserPreferences— enabled channels.TemplateService— renders content.DeliveryAttempt— status, attempt count, error.RetryPolicy— decides retry eligibility.NotificationRepository— stores notifications and deduplication keys.
4. RELATIONS
NotificationService
├── uses → NotificationRepository
├── uses → UserPreferences
├── uses → TemplateService
├── uses → RetryPolicy
└── dispatches → NotificationProvider
├── EmailProvider
├── SmsProvider
└── PushProvider
Notification
└── contains → DeliveryAttempt*NotificationServicecoordinates workflow; providers handle channel-specific delivery.NotificationProvideris an interface → new channel requires new provider, not orchestration changes.Notificationcontains per-channel delivery state → one logical notification can have multiple delivery outcomes.RetryPolicyencapsulates retry rules.- Repository abstraction separates business logic from storage.
5. DESIGN PATTERNS USED
- Strategy — provider implementation varies by channel.
- Factory / Registry — map channel → provider.
- Repository — abstracts persistence.
- Dependency Injection — inject providers, repository, templates, retry policy.
- Observer — optional for delivery-status events; unnecessary for basic synchronous version.
6. CLASS DESIGN
Java
interface NotificationProvider {
Channel channel();
DeliveryResult send(String recipient, String subject, String body);
}
interface NotificationRepository {
Optional<Notification> findByIdempotencyKey(String key);
void save(Notification notification);
}
interface RetryPolicy {
boolean shouldRetry(DeliveryResult result, int attempt);
}
class NotificationService {
Notification send(NotificationRequest request);
}
class TemplateService {
RenderedMessage render(String templateName, Map<String, String> variables);
}
class UserPreferences {
boolean isEnabled(Channel channel);
}Core flow:
- Validate request.
- Check idempotency key.
- Load recipient preferences.
- Resolve template.
- Select enabled channels.
- Send through matching provider.
- Retry transient failures within configured limit.
- Persist per-channel outcome.
7. FULL CODE — JAVA 17
Single-file demo. Includes templates, preferences, provider registry, retries, idempotency, and delivery status.
Java
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
enum Channel {
EMAIL, SMS, PUSH
}
enum DeliveryStatus {
PENDING, SENT, FAILED, SKIPPED
}
record NotificationRequest(
String idempotencyKey,
String recipient,
String templateName,
Map<String, String> variables,
Set<Channel> channels
) {
public NotificationRequest {
variables = Map.copyOf(variables);
channels = Set.copyOf(channels);
}
}
record RenderedMessage(String subject, String body) {}
record DeliveryResult(
boolean success,
boolean retryable,
String message
) {
static DeliveryResult success() {
return new DeliveryResult(true, false, "Delivered");
}
static DeliveryResult transientFailure(String message) {
return new DeliveryResult(false, true, message);
}
static DeliveryResult permanentFailure(String message) {
return new DeliveryResult(false, false, message);
}
}
class DeliveryAttempt {
private final Channel channel;
private DeliveryStatus status = DeliveryStatus.PENDING;
private int attempts;
private String lastMessage = "Not attempted";
DeliveryAttempt(Channel channel) {
this.channel = channel;
}
void record(DeliveryResult result) {
attempts++;
lastMessage = result.message();
if (result.success()) {
status = DeliveryStatus.SENT;
} else {
status = DeliveryStatus.FAILED;
}
}
void skip(String reason) {
status = DeliveryStatus.SKIPPED;
lastMessage = reason;
}
Channel channel() {
return channel;
}
DeliveryStatus status() {
return status;
}
int attempts() {
return attempts;
}
String lastMessage() {
return lastMessage;
}
}
class Notification {
private final String idempotencyKey;
private final String recipient;
private final Map<Channel, DeliveryAttempt> deliveries =
new LinkedHashMap<>();
Notification(String idempotencyKey, String recipient) {
this.idempotencyKey = idempotencyKey;
this.recipient = recipient;
}
void addDelivery(DeliveryAttempt attempt) {
deliveries.put(attempt.channel(), attempt);
}
String idempotencyKey() {
return idempotencyKey;
}
String recipient() {
return recipient;
}
Map<Channel, DeliveryAttempt> deliveries() {
return Collections.unmodifiableMap(deliveries);
}
}
interface NotificationProvider {
Channel channel();
DeliveryResult send(
String recipient,
String subject,
String body
);
}
class EmailProvider implements NotificationProvider {
@Override
public Channel channel() {
return Channel.EMAIL;
}
@Override
public DeliveryResult send(
String recipient,
String subject,
String body
) {
System.out.println("EMAIL → " + recipient + ": " + subject);
return DeliveryResult.success();
}
}
class SmsProvider implements NotificationProvider {
private final AtomicInteger calls = new AtomicInteger();
@Override
public Channel channel() {
return Channel.SMS;
}
@Override
public DeliveryResult send(
String recipient,
String subject,
String body
) {
System.out.println("SMS attempt → " + recipient);
// Demo: first call fails transiently.
if (calls.getAndIncrement() == 0) {
return DeliveryResult.transientFailure(
"Temporary provider failure"
);
}
return DeliveryResult.success();
}
}
class PushProvider implements NotificationProvider {
@Override
public Channel channel() {
return Channel.PUSH;
}
@Override
public DeliveryResult send(
String recipient,
String subject,
String body
) {
System.out.println("PUSH → " + recipient + ": " + body);
return DeliveryResult.success();
}
}
class UserPreferences {
private final Map<String, Set<Channel>> preferences =
new ConcurrentHashMap<>();
public void setEnabledChannels(
String recipient,
Set<Channel> channels
) {
preferences.put(recipient, Set.copyOf(channels));
}
public boolean isEnabled(String recipient, Channel channel) {
return preferences.getOrDefault(
recipient, Set.of()
).contains(channel);
}
}
class TemplateService {
private final Map<String, RenderedMessage> templates = Map.of(
"ORDER_SHIPPED",
new RenderedMessage(
"Your order shipped",
"Hi {{name}}, order {{orderId}} has shipped."
),
"LOGIN_OTP",
new RenderedMessage(
"Your login OTP",
"Your OTP is {{otp}}."
)
);
public RenderedMessage render(
String templateName,
Map<String, String> variables
) {
RenderedMessage template = templates.get(templateName);
if (template == null) {
throw new IllegalArgumentException(
"Unknown template: " + templateName
);
}
String subject = apply(template.subject(), variables);
String body = apply(template.body(), variables);
return new RenderedMessage(subject, body);
}
private String apply(
String text,
Map<String, String> variables
) {
String result = text;
for (Map.Entry<String, String> entry : variables.entrySet()) {
result = result.replace(
"{{" + entry.getKey() + "}}",
entry.getValue()
);
}
if (result.matches("(?s).*\\{\\{[^}]+}}.*")) {
throw new IllegalArgumentException(
"Missing template variable"
);
}
return result;
}
}
interface NotificationRepository {
Optional<Notification> findByIdempotencyKey(String key);
void save(Notification notification);
}
class InMemoryNotificationRepository
implements NotificationRepository {
private final Map<String, Notification> notifications =
new ConcurrentHashMap<>();
@Override
public Optional<Notification> findByIdempotencyKey(String key) {
return Optional.ofNullable(notifications.get(key));
}
@Override
public void save(Notification notification) {
notifications.put(
notification.idempotencyKey(),
notification
);
}
}
interface RetryPolicy {
boolean shouldRetry(DeliveryResult result, int attempt);
}
class BoundedRetryPolicy implements RetryPolicy {
private final int maxAttempts;
BoundedRetryPolicy(int maxAttempts) {
if (maxAttempts < 1) {
throw new IllegalArgumentException(
"Max attempts must be at least 1"
);
}
this.maxAttempts = maxAttempts;
}
@Override
public boolean shouldRetry(DeliveryResult result, int attempt) {
return !result.success()
&& result.retryable()
&& attempt < maxAttempts;
}
}
class NotificationService {
private final Map<Channel, NotificationProvider> providers =
new EnumMap<>(Channel.class);
private final NotificationRepository repository;
private final UserPreferences preferences;
private final TemplateService templates;
private final RetryPolicy retryPolicy;
NotificationService(
List<NotificationProvider> providers,
NotificationRepository repository,
UserPreferences preferences,
TemplateService templates,
RetryPolicy retryPolicy
) {
for (NotificationProvider provider : providers) {
NotificationProvider previous = this.providers.put(
provider.channel(), provider
);
if (previous != null) {
throw new IllegalArgumentException(
"Duplicate provider: " + provider.channel()
);
}
}
this.repository = repository;
this.preferences = preferences;
this.templates = templates;
this.retryPolicy = retryPolicy;
}
public synchronized Notification send(
NotificationRequest request
) {
validate(request);
Optional<Notification> existing =
repository.findByIdempotencyKey(
request.idempotencyKey()
);
if (existing.isPresent()) {
return existing.get();
}
RenderedMessage message = templates.render(
request.templateName(),
request.variables()
);
Notification notification = new Notification(
request.idempotencyKey(),
request.recipient()
);
// Persist before external calls in a real durable system.
repository.save(notification);
for (Channel channel : request.channels()) {
DeliveryAttempt delivery = new DeliveryAttempt(channel);
notification.addDelivery(delivery);
if (!preferences.isEnabled(request.recipient(), channel)) {
delivery.skip("Channel disabled by user");
continue;
}
NotificationProvider provider = providers.get(channel);
if (provider == null) {
delivery.record(
DeliveryResult.permanentFailure(
"Provider unavailable"
)
);
continue;
}
int attempt = 0;
DeliveryResult result;
do {
attempt++;
result = provider.send(
request.recipient(),
message.subject(),
message.body()
);
} while (retryPolicy.shouldRetry(result, attempt));
delivery.record(result);
}
repository.save(notification);
return notification;
}
private void validate(NotificationRequest request) {
if (request == null
|| request.idempotencyKey() == null
|| request.idempotencyKey().isBlank()
|| request.recipient() == null
|| request.recipient().isBlank()
|| request.templateName() == null
|| request.channels().isEmpty()) {
throw new IllegalArgumentException(
"Invalid notification request"
);
}
}
}
public class Main {
public static void main(String[] args) {
UserPreferences preferences = new UserPreferences();
preferences.setEnabledChannels(
"user@example.com",
Set.of(Channel.EMAIL, Channel.SMS)
);
NotificationService service = new NotificationService(
List.of(
new EmailProvider(),
new SmsProvider(),
new PushProvider()
),
new InMemoryNotificationRepository(),
preferences,
new TemplateService(),
new BoundedRetryPolicy(3)
);
NotificationRequest request = new NotificationRequest(
"order-1001-shipped",
"user@example.com",
"ORDER_SHIPPED",
Map.of(
"name", "Aman",
"orderId", "1001"
),
EnumSet.of(
Channel.EMAIL,
Channel.SMS,
Channel.PUSH
)
);
Notification result = service.send(request);
result.deliveries().forEach((channel, delivery) ->
System.out.printf(
"%s → status=%s, attempts=%d, result=%s%n",
channel,
delivery.status(),
delivery.attempts(),
delivery.lastMessage()
)
);
// Same key → existing notification returned.
service.send(request);
}
}Sample output
EMAIL → user@example.com: Your order shipped
SMS attempt → user@example.com
SMS attempt → user@example.com
EMAIL → status=SENT, attempts=1, result=Delivered
SMS → status=SENT, attempts=1, result=Delivered
PUSH → status=SKIPPED, attempts=0, result=Channel disabled by userTwo code fixes required before compiling.
Java records generate accessor methods, so rename static factory DeliveryResult.success() to DeliveryResult.delivered() and update provider calls.
Also, retry count must track every provider call. Change DeliveryAttempt.record to accept attemptCount, assign attempts = attemptCount, then call delivery.record(result, attempt) after retry loop. Corrected output shows SMS → status=SENT, attempts=2, result=Delivered.
Production caveat: this demo's repository stores mutable objects in memory. Real system needs durable persistence, atomic idempotency claims, and async workers; synchronized service method alone does not guarantee cross-instance deduplication.
8. INTERVIEW SCRIPT
0–5 min — Requirements
“I’ll clarify channels, preferences, retries, delivery guarantees, and scale. I’ll assume immediate multi-channel notifications, user preferences, templates, and bounded retries.”
5–10 min — Entities
“I’ll separate logical notification from per-channel delivery state. NotificationService orchestrates; providers encapsulate channel-specific APIs.”
Draw relations from section 4.
10–20 min — Code
Explain provider registry, template rendering, preference checks, idempotency key, retry policy.
20–40 min — Failure handling
Discuss provider timeouts, duplicate sends, partial success, queue retries, dead-letter queues, and persistence.
40–45 min — Trade-offs
- Sync demo → simple, but request latency increases.
- Async workers → scalable, but delivery becomes eventual.
- Idempotency → prevents duplicate processing, not automatically duplicate external delivery.
- Durable queue + DB → survive process crashes.
- Per-channel status → supports partial success and selective retries.
9. EXTENSION QUESTIONS + ANSWERS
Q1. Add WhatsApp?
Implement NotificationProvider, register WHATSAPP, add preference support. Core orchestration remains unchanged.
Q2. How handle provider timeout?
Classify timeout as transient where appropriate. Retry with exponential backoff + jitter. Avoid immediate infinite retries.
Q3. How prevent duplicate notifications?
Use unique idempotency key stored durably with atomic insert/claim. Provider-side idempotency keys help when supported. DB transaction alone cannot guarantee exactly-once external delivery.
Q4. How scale to millions of notifications?
API → DB/outbox → message queue → channel workers → providers. Partition by recipient or notification ID; apply rate limits and backpressure.
Q5. What if Email succeeds but SMS fails?
Track status independently per channel. Retry SMS only; never resend successful Email automatically.
Q6. How handle user preference changes?
Check preferences before dispatch. For queued notifications, define whether preference is evaluated at enqueue time or delivery time. Delivery-time evaluation respects latest settings.
Q7. How prioritize urgent alerts?
Add priority field; use separate queues or priority scheduling. Prevent low-priority bulk traffic from starving critical alerts.
Q8. Why separate Notification and DeliveryAttempt?
One logical notification can have multiple channel outcomes and multiple retries. Separate state avoids mixing message lifecycle with provider delivery lifecycle.
10. THREAD SAFETY
ConcurrentHashMapprotects individual repository map operations, not multi-step idempotency claims.- Synchronization in demo serializes sends only within one service instance.
- Production needs atomic idempotency claims, durable state, and safe worker concurrency.
- External delivery may happen twice after timeout/crash → use provider idempotency where available.
11. QUICK REVISION CARD
- Core:
NotificationService. - Channels: Email, SMS, Push.
- Pattern: Strategy + provider registry.
- Templates:
TemplateService. - Preferences: per recipient/channel.
- Retry: bounded, transient failures only.
- Idempotency: durable atomic key claim.
- Status: per channel.
- Scale: queue + workers + DB/outbox.
- Guarantee: usually at-least-once, not exactly-once.
12. MEMORY TRICK
V-T-P-R-S
- Validate request.
- Template render.
- Preference check.
- Retry transient failures.
- Store delivery status.