Sweaty Imposter

Notification Service

Design the object model for a notification service that sends email, SMS, and push messages. Callers send an event like 'order shipped'; the service renders a template, respects user preferences and quiet hours, retries failures, and avoids duplicate sends.

Clarifying Questions

  • Which channels are in scope now, and how soon will new ones arrive?
  • Who picks the channel: the caller, the user's preferences, or a fallback chain?
  • Are there quiet hours and per-user rate caps?
  • Do we need delivery receipts and a history the user can see?
  • Do templates need localization?

Core Requirements

  • Accept a notification request with recipient, event type, and data.
  • Choose channels from user preferences and event defaults.
  • Render a channel-specific template in the user's locale.
  • Send through a provider per channel, with retries and a fallback provider.
  • Deduplicate requests that share an idempotency key.
  • Record each attempt and the final status.

Canonical class breakdown

NotificationService: Entry point that validates a request, deduplicates it, and runs the pipeline.
NotificationRequest: Immutable input: recipient ID, event type, payload, idempotency key, and priority.
PreferenceResolver: Applies opt-outs, quiet hours, and event defaults to choose channels.
TemplateRenderer: Renders subject and body per channel and locale from an event's template set.
Channel (interface) / EmailChannel, SmsChannel, PushChannel: Formats the rendered message for its medium and sends it through a provider.
Provider (interface): Wraps one vendor, such as SES, Twilio, or APNs; a channel can hold a primary and a fallback.
RetryPolicy: Backoff schedule plus the rules for which errors are retryable.
DeliveryAttempt: Record of channel, provider, status, error, and timestamp for each try.
DedupStore: Remembers recent idempotency keys with a TTL.

Java sketchjava

// Java skeleton

enum ChannelType { EMAIL, SMS, PUSH }
enum Priority { LOW, NORMAL, HIGH }
enum AttemptStatus { SENT, RETRYABLE_FAILURE, PERMANENT_FAILURE, SKIPPED }

record NotificationRequest(String recipientId, String eventType, Map<String, Object> payload,
                           String idempotencyKey, Priority priority) {}

record RenderedMessage(String subject, String body, String locale) {}

record SendResult(AttemptStatus status, String providerMessageId, String error) {}

interface PreferenceResolver {
    List<ChannelType> channelsFor(String recipientId, String eventType, Instant now); // opt-outs, quiet hours
}

interface TemplateRenderer {
    RenderedMessage render(String eventType, ChannelType channel, String locale, Map<String, Object> payload);
}

interface Provider {
    SendResult send(String address, RenderedMessage message);
}

interface Channel {
    ChannelType type();
    SendResult send(String recipientId, RenderedMessage message);
}

class EmailChannel implements Channel {
    private final Provider primary;   // e.g. SES
    private final Provider fallback;  // e.g. SendGrid
    ...
}

class SmsChannel implements Channel { ... }
class PushChannel implements Channel { ... }

class RetryPolicy {
    private final int maxAttempts;
    private final Duration baseDelay;
    public boolean shouldRetry(SendResult r, int attempt) { ... }
    public Duration delayFor(int attempt) { /* exponential backoff with jitter */ }
}

record DeliveryAttempt(String requestKey, ChannelType channel, String provider,
                       AttemptStatus status, String error, Instant at) {}

interface DedupStore {
    boolean firstSeen(String idempotencyKey, Duration ttl); // atomic set-if-absent
}

class NotificationService {
    private final DedupStore dedup;
    private final PreferenceResolver preferences;
    private final TemplateRenderer renderer;
    private final Map<ChannelType, Channel> channels;
    private final RetryPolicy retries;
    public void send(NotificationRequest req) {
        /* dedup, resolve channels, render per channel, send with retries, record attempts */
    }
}

Key Decisions to Defend

  • Channel is a Strategy, so adding WhatsApp means one class and a template while the pipeline stays unchanged.
  • Channel and Provider are separate seams: email stays one channel while SES and SendGrid swap behind it.
  • Preferences are checked at send time, so an opt-out made a minute ago applies to queued messages.
  • Dedup by idempotency key happens before any work; callers retry, and users notice a duplicate text.
  • Errors are classified as retryable or permanent per provider; a bad phone number should never be retried six times.

Likely follow-ups

  • · A provider is down for 20 minutes, how do circuit breakers and fallbacks interact?
  • · Add a digest that batches low-priority notifications into one daily email.
  • · Users report getting the same push twice, walk through where duplicates can arise.
  • · Quiet hours end at 8am in 30 time zones, how do you avoid a send spike?

Dive Deeper

Notification service questions test whether a pipeline stays open to new channels without changes to its core. Interviewers look for Channel and Provider as separate seams, preferences evaluated at send time, and an explicit dedup step. Senior answers also classify errors, since retrying a permanent failure wastes money and can get a sender flagged as spam.

All OOD Prompts