Interview Practice

Chapter 5 of 5

Designing for change: a notification service

Separate what varies (channels, templates, policies) from what must stay reliable (delivery state).
Read aloud is unavailable in this browser. Reading preferences still work.

Clarify the axes of change first

A notification service usually grows along three independent axes: new channels such as email, SMS and push; new message types with their own templates; and policy rules such as quiet hours, user preferences and rate limits. Ask which of these the interviewer expects to change and how often. Each independent axis deserves its own abstraction, so adding an SMS provider does not touch template rendering and adding a template does not touch retries.

Core objects and responsibilities

A Notification is an immutable request: recipient, type, data and idempotency key. A Channel interface sends a rendered message and returns a result that distinguishes permanent failures, such as an invalid address, from transient ones, such as a timeout. A TemplateRenderer turns type and data into content. A DeliveryAttempt records state transitions from PENDING to SENT, FAILED_RETRYABLE or FAILED_PERMANENT. A Dispatcher coordinates them and owns no channel-specific logic.

Reliability is part of the design

Retries need bounded attempts with exponential backoff and jitter, and only for transient errors. The idempotency key prevents duplicate sends when a caller retries. Preferences and quiet hours are policies applied before dispatch, modelled as a chain of filters that can each veto or delay. Under concurrency, two workers must not claim the same attempt; a conditional state update such as compare-and-set on the attempt status gives single ownership without a global lock.

Worked Java example

Illustrative snippet: imports, surrounding types or collaborators may be omitted. Read the assumptions before adapting it.

sealed interface SendResult permits Sent, Retryable, Permanent {}
record Sent(String providerId) implements SendResult {}
record Retryable(String reason) implements SendResult {}
record Permanent(String reason) implements SendResult {}

interface Channel { String name(); SendResult send(Recipient to, Rendered message); }
interface Policy { Optional<Instant> deferUntil(Notification n, Instant now); }

final class Dispatcher {
    private final Map<String, Channel> channels;
    private final List<Policy> policies;
    private final TemplateRenderer renderer;
    private final AttemptStore attempts;

    Dispatcher(Map<String, Channel> channels, List<Policy> policies,
               TemplateRenderer renderer, AttemptStore attempts) {
        this.channels = Map.copyOf(channels);
        this.policies = List.copyOf(policies);
        this.renderer = renderer;
        this.attempts = attempts;
    }

    void dispatch(Notification n, Instant now) {
        if (!attempts.claim(n.idempotencyKey())) return;          // already handled
        for (Policy p : policies) {
            Optional<Instant> later = p.deferUntil(n, now);
            if (later.isPresent()) { attempts.reschedule(n, later.get()); return; }
        }
        Channel channel = channels.get(n.channel());
        SendResult result = channel.send(n.recipient(), renderer.render(n));
        attempts.record(n, result);                                // state machine transition
    }
}

Walkthrough

Adding a push channel means one new Channel implementation registered in the map; Dispatcher does not change. Quiet hours are a Policy that returns the next allowed time. claim uses the idempotency key with a conditional insert, so a caller retrying the same request or two workers racing on it produce one send. AttemptStore.record maps Retryable to a scheduled retry with backoff and Permanent to a terminal failure, keeping retry policy out of every channel.

Practice it yourself

Product asks for a fallback: if push fails permanently, send an SMS instead. Where does this belong, and what must you avoid?

Check your reasoning

Model fallback as routing configuration owned by the dispatcher or a FallbackPolicy, not inside PushChannel, so channels stay unaware of each other. On a Permanent result from push, enqueue a new notification for SMS with a derived idempotency key such as original-key + ":sms". Avoid falling back on Retryable results, which would double-notify once the push retry succeeds, and avoid unbounded fallback chains.

Recall checklist

  • Explain the main invariant or contract without looking.
  • Trace the example and test an edge case.
  • State time, space, input variables and assumptions where relevant.
  • Explain one alternative and when you would choose it.

Read further