LLD-012 · LOW LEVEL DESIGN
Notification Dispatcher
Separate intent, preferences, provider attempts and delivery evidence under retries and reordered receipts.
Local Java 21 reference verified: 26 checks. Learner drafts are not executed. Author self-review; independent review pending.
Existing introductory lesson · Version 1
Understand the problem
Design a local notification coordinator with channel adapters, versioned plain-text templates, preference checks, bounded retries and delivery receipts. Explain what accepted, delivered, unknown and cancelled mean, and why provider failover after a timeout can create duplicates.
Before you start
- Adapter and dependency injection
- Idempotent command identity
- State machines and retry backoff
- External outcome ambiguity
Scope and explicit decisions
- A local in-memory coordinator for EMAIL/SMS/PUSH provider ports. Tests use synthetic recipients and fake providers; no real messages are sent. Recipient IDs are opaque and address lookup/authentication is an adapter responsibility.
- Templates are plain text with named {field} placeholders. All referenced fields must exist and unused supplied fields reject. Values use literal replacement; HTML escaping, localization, attachment handling and provider payload limits are not implemented.
- Template version, text and input fields are part of submit identity; rendered body is frozen in Delivery. A production registry must prevent reusing a version label for different semantics; this reference stores the actual accepted body so old deliveries do not change.
- Preferences are default-deny and checked immediately before a QUEUED delivery is claimed. Opt-out after the provider call starts cannot retract that in-flight effect. Quiet hours and multi-channel fallback policy are extensions.
- dispatch claims SENDING under a monitor, invokes the provider outside it, then conditionally accepts the result. One delivery ID is the provider idempotency key across retry attempts; provider support for that key is assumed by the adapter contract, not guaranteed by this class.
- RETRYABLE means a definite retryable no-acceptance result. Bounded exponential backoff is deterministic without jitter in this reference; caller workers must trigger due dispatches. UNKNOWN or exceptions do not auto-retry or fail over.
- ACCEPTED means the provider accepted the send; DELIVERED requires a trusted correlated receipt. Webhooks can finish during the call; a later synchronous ACCEPTED must not regress DELIVERED. Late failure receipts cannot regress DELIVERED.
- receipt is a trusted adapter API using an internal delivery correlation; it does not authenticate provider webhooks or validate provider signatures itself. Delivered may override a prior failure as new authoritative evidence. Receipts for QUEUED/CANCELLED deliveries reject and need reconciliation if they contradict the declared no-acceptance contract.
- cancel supports QUEUED only (and repeated CANCELLED); it cannot unsend SENDING/UNKNOWN/ACCEPTED. Attempt/history/dedupe records are unbounded in memory; there is no durable queue, outbox, background scheduler or restart recovery.
Worked examples
Input: QUEUED v1 body, allowed email, retryable result at t=0 with baseDelay10.
Outcome: Requeued for t=10 using the same delivery ID. t=9 rejects; t=10 may become ACCEPTED.
Retry schedule, attempt identity and delivery status are distinct concerns.
Input: A delivery receipt arrives while send is still in flight, then send returns ACCEPTED.
Outcome: The record stays DELIVERED.
Outcome precedence protects stronger evidence from response reordering.
Try before revealing
Hint 1
Which event proves acceptance, and which proves delivery?
Hint 2
What identity survives a retry, and when are preferences authoritative?
Hint 3
How can a webhook arrive before send returns without corrupting status?
Requirements and invariants
R1 — Submit retries preserve one immutable delivery identity/body. submit protects I1.
R2 — Only one dispatch owns the active SENDING attempt; provider I/O is outside the monitor. dispatch protects I2.
R3 — DELIVERED cannot regress due to a late weaker result. receipt protects I3.
R4 — Ineligible recipients are cancelled before provider invocation. setAllowed protects I2.
I1: A submit key maps one structured request fingerprint to a stable Delivery and frozen body.
I2: Only a due QUEUED record passing current preference check can become SENDING, and one caller owns that transition.
I3: Trusted DELIVERED evidence is terminal against later weaker acceptance/failure; receipt IDs reject conflicting reuse.
Derive the design
A synchronous notifier needs more than a channel switch
A simple notifier can choose email or SMS and call a provider, but one success boolean conflates provider acceptance and actual delivery. The refined design stores immutable intent, isolates channel details behind Provider, claims one attempt and treats receipts as separate evidence. It preserves a stable delivery identity through retry instead of recreating intent each time.
Alternative: An event/outbox plus durable worker is appropriate for production fan-out, but those components do not define business semantics automatically. You still need preference-check timing, provider-result categories, duplicate identity and unknown-outcome policy.
Define acknowledgement vocabulary first
Accepted, delivered, read, failed and unknown are not interchangeable. The reference supports accepted/delivered and makes unknown nonretryable until evidence arrives.
Keep payload and command identity stable
Render an immutable body once; fingerprint actual template and fields. This allows a retry key mismatch to reveal accidental intent changes rather than send another message.
Claim locally, call remotely, then compare state
The synchronized claim prevents two workers calling for one due record. Provider I/O happens outside ownership; final conditional update avoids overwriting a receipt that completed meanwhile.
Make cancellation timing honest
Before send, current preferences can cancel work. After a remote side effect starts, local cancellation cannot guarantee retraction. Keep unsupported receipt contradictions visible for reconciliation.
Responsibilities and ownership
Dispatcher
Owns: Intent identity, preferences, attempt counts, due times and receipt outcomes
Methods: submit, dispatch, receipt, cancel
Serializes local state, not the remote provider side effect.
Provider
Owns: Channel-specific send mapping and remote idempotency contract
Methods: send(delivery,idempotencyKey)
Returns normalized ACCEPTED/RETRYABLE/PERMANENT/UNKNOWN; performs no domain-state mutation directly.
Template / render
Owns: Immutable text/version and strict field substitution
Methods: render
Plain text only; not a safe HTML templating engine.
Delivery / Receipt
Owns: Frozen recipient/channel/body and evidence identity
Methods: Immutable records
A state snapshot is not a proof of external exactly-once delivery.
Tests use fake providers only. This is a local component, not a live messaging service.
- Immutable intent + preference
↓ Default deny and stable key
Claim due QUEUED→SENDING↓ One local owner
Provider call outside monitor↓ Preserve stronger concurrent evidence
Conditional result / trusted receipt
Accepted and delivered remain distinct. Permanent failures stop ordinary retry.
Same delivery key on retry
Do not blindly retry or fail over
Data model, schema and relationships
Notification intent, rendered content, recipient preference, channel delivery and provider attempt are distinct entities. Provider accepted and user delivered are different evidence states.
Implementation boundary: The reference implements strict plain-text templates, default-deny preferences and simulated providers. Durable tables, authenticated receipts and real delivery are extensions.
These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Entity definitions and field contracts
recipient
Stable destination owner.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Recipient identity |
address | text · VALUE | Validated channel address |
Code mapping: Recipient identity/input.
- Address visibility/access and verification belong to the delivery adapter.
preference
Channel permission by recipient.
| Field | Type / key | Meaning and reference |
|---|---|---|
recipient_id | uuid · PK,FK | Preference owner · recipient.id |
channel | enum · PK | Email SMS etc |
enabled | boolean · VALUE | Explicit permission |
Code mapping: Preference map.
- PK(recipient_id,channel); missing preference means deny.
template
Versioned rendering contract.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Template/version identity |
body | text · VALUE | Strict plain-text placeholders |
Code mapping: Template record.
- Missing or unexpected variable policy must be explicit.
notification
Accepted immutable intent.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Intent identity |
template_id | text · FK | Pinned template · template.id |
payload | json · VALUE | Validated rendering variables |
Code mapping: Notification intent.
- Stable caller key binds full request meaning.
delivery
One recipient/channel logical message.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Delivery identity |
notification_id | uuid · FK | Intent · notification.id |
recipient_id | uuid · FK | Destination owner · recipient.id |
channel | enum · VALUE | Chosen channel |
state | enum · VALUE | Accepted delivered failed unknown etc |
next_due | timestamp? · VALUE | Retry eligibility instant after bounded provider policy |
Code mapping: Delivery state.
- Unique(notification_id,recipient_id,channel); apply preference before external send.
send_attempt
Provider request evidence.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Attempt identity |
delivery_id | uuid · FK | Logical delivery · delivery.id |
external_key | text · VALUE | Stable retry identity |
outcome | enum · VALUE | Known or unknown result |
Code mapping: Attempt/retry evidence.
- Attempts reuse logical provider key when provider guarantees dedupe.
Relationship cardinalities and their reasons
| Parent → child | Multiplicity | FK / why |
|---|---|---|
| recipient → preference controls channels | 1 parent per child; 0..* children per parent | preference.recipient_id → recipient.idMissing permission never silently opts a user in. |
| template → notification renders intent | 1 parent per child; 0..* children per parent | notification.template_id → template.idIntent pins template meaning to avoid retry changing text. |
| notification → delivery targets | 1 parent per child; 0..* children per parent | delivery.notification_id → notification.idOne intent expands into explicitly owned deliveries. |
| recipient → delivery receives | 1 parent per child; 0..* children per parent | delivery.recipient_id → recipient.idDelivery is scoped to a recipient/channel. |
| delivery → send_attempt attempts send | 1 parent per child; 0..* children per parent | send_attempt.delivery_id → delivery.idAttempt count differs from number of logical messages. |
Transaction boundaries
- Accept: validate template/variables and operation fingerprint; persist logical deliveries and send intent together.
- Receipt: authenticate provider evidence outside this core, deduplicate it and apply monotonic state precedence; never regress DELIVERED to a late ACCEPTED.
Indexes and uniqueness
- Unique delivery scope and provider message/key identities; index due delivery(state,next_due).
- Index notification caller idempotency scope; store request fingerprint and retention contract.
Schema traps and failure cases
- Provider failover after UNKNOWN can duplicate delivery unless reconciled/deduped across providers.
- Removing preference after send cannot retract a delivered message; define evaluation time.
Trace the records through one operation
- Recipient opts into email but has no SMS preference; SMS stays denied.
- Email provider returns UNKNOWN; retry/reconcile retains stable delivery identity.
- A delivered receipt dominates a later accepted receipt.
Method contracts
submit(key,recipient,channel,Template,fields) → Delivery
Validate configured adapter and strict rendering. Store request fingerprint and immutable QUEUED body; duplicate mismatch rejects.
setAllowed(recipient,channels)
Replace the immutable local preference set. Missing recipients are denied; dispatch rechecks before claim.
dispatch(deliveryId,now) → Delivery
Validate due time/state; claim once and increment attempt. Release monitor for provider I/O; apply normalized result only if still SENDING.
receipt(eventId,deliveryId,delivered) → Delivery
Trusted correlated terminal evidence only after an attempt. Deduplicate event IDs; DELIVERED does not regress to failure. Reject unsupported queued/cancelled timing.
cancel(deliveryId)
Cancel unsent QUEUED work, including a retry waiting after definite no-acceptance. In-flight or unknown outcomes cannot be retracted through this API.
What, when, why and how
Each concept is attached to the requirement it protects, its implementation, an alternative and a test.
Adapter and normalized provider outcome
- what
- Map different vendor responses into a precise domain result.
- when
- Email/SMS/push differ in payloads, receipts and failure codes.
- why
- Domain retry logic should not spread vendor-specific conditionals across the coordinator.
- how
- Provider returns ACCEPTED, definite RETRYABLE, PERMANENT or UNKNOWN with an optional provider ID; accepted results require a nonblank provider ID. Exceptions become UNKNOWN, with interruption restored.
Code: Provider, ProviderResult, dispatch · Invariants: I2
Alternatives, cost and failure example
Alternative: A dedicated adapter per channel/provider can also own signature validation and status-query behavior. Do not fake unavailable delivery guarantees.
Cost: Normalization can lose important distinctions; preserve raw/provider metadata in a durable implementation for audit.
Counterexample: Map a network timeout to RETRYABLE even though the provider might already have accepted.
Test: Throw from a fake provider and verify UNKNOWN cannot be automatically redispatched.
Immutable rendered intent and preference boundary
- what
- Freeze what was requested, while checking whether sending is still allowed at the declared moment.
- when
- Templates or recipient choices change while work is queued.
- why
- A later template edit must not silently alter old messages, and a queued opt-out must be respected before send.
- how
- submit renders once and stores body/version. dispatch checks current allowed channels under the same monitor that claims SENDING.
Code: submit, render, setAllowed, dispatch · Invariants: I1, I2
Alternatives, cost and failure example
Alternative: Some products intentionally re-render or re-evaluate channel selection at send time; then snapshot/version that decision and test reproducibility.
Cost: The local preference check cannot retract an already started remote send. Payload/recipient validation still belongs to trusted adapters.
Counterexample: A recipient opts out before dispatch but a stale enqueue-time preference still triggers a call.
Test: Update preferences after submission; assert CANCELLED and unchanged provider call count.
Bounded retry with stable delivery identity
- what
- Retry one logical delivery only when the prior outcome permits it.
- when
- Provider gives a definite transient no-acceptance result.
- why
- Fresh identities can duplicate the message; unbounded retries overload providers and hide permanent failures.
- how
- Attempt cap is 1..10. Retry time is now+baseDelay×2^(attempt-1) with checked arithmetic; the delivery ID stays the provider key. Unknown is not requeued.
Code: dispatch, Delivery · Invariants: I1, I2
Alternatives, cost and failure example
Alternative: Add injected bounded jitter for fleet behavior; deterministic backoff here makes fixtures exact but is not a recommendation for synchronized production retries.
Cost: No background scheduler exists. Queue storage and due execution are responsibilities of a real worker/outbox extension.
Counterexample: Retry at t=9 when nextAt=10, or retry an unknown result through another provider.
Test: Test due boundary, attempt cap and identical provider key across retryable→accepted calls.
Monotonic evidence under reordered callbacks
- what
- Stronger terminal delivery evidence cannot be overwritten by a later weaker result.
- when
- A webhook arrives before the synchronous provider response or a late failure follows delivery.
- why
- Naïve last-write-wins can turn DELIVERED back into ACCEPTED/FAILED.
- how
- receipt deduplicates identity and writes DELIVERED; dispatch applies its response only while still SENDING. Later weaker receipts leave delivered unchanged.
Code: receipt, dispatch · Invariants: I3
Alternatives, cost and failure example
Alternative: A richer evidence/event log can retain every provider fact and derive status by a versioned precedence rule rather than discard weaker history.
Cost: Provider-specific semantics and contradictions need audit/review; the local boolean receipt is a deliberately simplified trusted contract.
Counterexample: Webhook sets DELIVERED while send is blocked; send later returns ACCEPTED and overwrites it.
Test: Use latches to deliver the receipt during the provider call and assert final status stays DELIVERED.
Read and run the Java reference
Reveal complete Solution.java
Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.
render uses literal replacement
Matcher.quoteReplacement prevents field values containing dollar/backslash from becoming replacement syntax. Missing and unused fields reject before a delivery is stored.
dispatch prepares retry arithmetic before send
The next deadline is checked before publishing SENDING. A regular overflow error cannot occur after the external send just because backoff calculation was delayed.
provider result applies conditionally
If a webhook changed state while the call ran, dispatch returns that state instead of imposing its stale response.
receipt protects monotonic delivery
Duplicate event identity resolves current delivery. Conflicting reuse rejects; late false evidence cannot regress a prior DELIVERED record.
Complexity and limits
- Template rendering is O(T+output+F) for template length T and field map F under the declared placeholder syntax; literal replacement avoids interpreting dollar/backslash as regex substitution.
- submit/receipt lookups are expected O(1) plus copied request/body fields. dispatch local work is expected O(1), excluding arbitrary provider latency.
- History, operations, preferences and receipts grow with accepted work; no bounded durable fan-out queue is implemented. Attempts per delivery are bounded, not total retained data.
- Provider calls are outside the monitor. Shared-state contention remains local; there is no distributed claim/lease and no exactly-once remote guarantee.
Walk through the actual reference
These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.
Retry only a definite retryable result
Step 1 of 3: Submit immutable v1 body
QUEUED with stable delivery ID
Invariant: Retries preserve logical identity
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Submit immutable v1 body | QUEUED with stable delivery ID | Retries preserve logical identity |
| Provider returns RETRYABLE at t=0 | QUEUED for t=10 | Backoff and attempt cap enforced |
| Retry at t=10 then delivery receipt | ACCEPTED then DELIVERED | Provider acceptance is not delivery |
Webhook races synchronous provider response
Step 1 of 3: Claim SENDING and call provider outside monitor
Second dispatcher rejected
Invariant: One current send attempt
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Claim SENDING and call provider outside monitor | Second dispatcher rejected | One current send attempt |
| Delivery receipt arrives before return | DELIVERED | Authoritative receipt wins |
| Provider call returns ACCEPTED | Remains DELIVERED | No regression to weaker status |
Mistakes and counterexamples
Treat provider acceptance as delivery
Failing case: The provider accepts a message that later bounces.
Repair: Use separate accepted and trusted receipt states.
Retry every exception
Failing case: A timeout follows a successful remote acceptance.
Repair: Classify unknown separately and reconcile a stable key.
Check preferences only at enqueue
Failing case: Recipient opts out while queued.
Repair: Recheck at the authoritative send claim and document the remaining in-flight window.
Use last callback wins
Failing case: A late ACCEPTED response replaces DELIVERED.
Repair: Define evidence precedence and conditional updates.
Engineer → Senior → Staff
Separate intent, rendering and channel adapter.
Executed: strict plain-text templates, stable submit identity, default-deny preferences and normalized outcomes.
Change exercise
Add a provider adapter that maps permanent invalid-recipient errors differently from transient throttling.
Worked answer guidance
Keep vendor details in the adapter and preserve domain categories. A timeout is UNKNOWN, not a definite retryable rejection.
Interview follow-ups
Why are unused fields rejected?
The strict contract catches template/parameter mismatches instead of silently ignoring stale inputs. A more permissive policy is possible if explicitly chosen.
Does a template version guarantee immutable semantics?
Not by itself. A registry must enforce that rule; this reference additionally stores actual accepted text/body.
Your design attempt
Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.
Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.
Run program · Java 21
No unsaved changes · 208/20000
Self-review
Checks apply to this visit. Self-review is not a passed submission or verified mastery.
Provenance and scope of evidence
Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.
Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.