Short answer: for an edtech marketplace, let Node.js publish one immutable new-order event, but make a separate notification policy service resolve user channel preferences, opt-outs, and the suppression list before every email or SMS attempt; persist that decision beside the event so compliance evidence does not depend on provider logs.
The hard part isn't calling two delivery APIs. It is proving why seller seller_2048 was eligible to receive order ord_84219 on a particular channel at a particular time, especially when a preference change races a queued message. The operational invariant is blunt: a worker may deliver only when it can attach a current policy decision to the attempt. No decision, no send.
What failure does an evidence-first design prevent?
Consider a bounded incident, without pretending it is a measured production story. A buyer places an order at 14:02:11, the Node.js checkout service appends order.created, and the notification worker schedules email and SMS. At 14:02:12 the seller opts out of SMS. A worker that copied preferences into the original event can still send the queued text because its input is already stale; a worker that reads only the latest profile may suppress it, but leaves an auditor unable to reconstruct which policy version it evaluated.
Both implementations can look correct in a happy-path test. The race exposes the difference.
Race it.
The durable record needs four separate facts: the business event, the address or phone reference rather than raw contact data wherever practical, the policy observation, and the delivery attempt. Keep their identifiers distinct. An order event says something happened; it does not grant permission to contact someone. A preference says what the user selected; it may be superseded. A suppression entry is a channel-specific deny signal. The decision joins those inputs at a recorded instant and produces allow or deny with a reason such as user_opt_out, global_suppression, or channel_disabled.
That distinction matters for SLOs. “99.9% of messages sent” rewards the wrong behavior because suppressed messages should never be sent. I would define separate indicators: policy decisions completed within the dispatch budget, allowed attempts accepted by the configured channel adapter, and prohibited attempts that reached an adapter. The last one has a target of zero. This is a capacity-planning problem too — preference reads and suppression checks scale with attempts, not merely orders, because one order can fan out to multiple channels and retries.
How should a Node.js event notification system enforce user channel preferences, email and SMS opt-out, and suppression lists?
Put the policy check at the last responsible moment. Node.js should publish a canonical order envelope with a stable event ID, seller ID, event type, and occurrence time. A dispatcher may then create one candidate per channel, but a candidate is not authorization. Immediately before an adapter call, the worker evaluates the current preference and suppression state in one consistent read, stores the result with the versions it observed, and sends only after an allow result has been committed.
There is a catch: a database commit and an external delivery request are not one atomic operation. Treat the attempt as an outbox state machine. A unique key such as (event_id, recipient_id, channel) prevents duplicate candidates; an attempt token makes retries traceable; terminal policy denials remain records rather than disappearing from the queue. If the process stops after committing allowed but before calling the adapter, the next worker can resume the same attempt. If it stops after the adapter accepts the request but before recording that fact, deduplication depends on the adapter contract, so the boundary must be tested rather than assumed.
Here is the preventative core in Go. The repository methods are intentionally generic; implementations should use a transaction or another consistency mechanism that preserves the same invariant.
package notify
import (
"context"
"errors"
"time"
)
type Channel string
const (
Email Channel = "email"
SMS Channel = "sms"
)
type Candidate struct {
EventID string
SellerID string
Channel Channel
ContactRef string
}
type PolicyView struct {
PreferenceVersion string
SuppressionVersion string
ChannelEnabled bool
Suppressed bool
}
type Decision struct {
Allowed bool
Reason string
PreferenceVersion string
SuppressionVersion string
EvaluatedAt time.Time
}
type Repository interface {
LoadPolicy(context.Context, Candidate) (PolicyView, error)
RecordDecision(context.Context, Candidate, Decision) error
}
func EvaluateAndRecord(ctx context.Context, repo Repository, c Candidate, now time.Time) (Decision, error) {
view, err := repo.LoadPolicy(ctx, c)
if err != nil {
return Decision{}, err // Fail closed: absence of evidence is not permission.
}
decision := Decision{
Allowed: view.ChannelEnabled && !view.Suppressed,
PreferenceVersion: view.PreferenceVersion,
SuppressionVersion: view.SuppressionVersion,
EvaluatedAt: now.UTC(),
}
switch {
case !view.ChannelEnabled:
decision.Reason = "channel_disabled"
case view.Suppressed:
decision.Reason = "suppression_match"
default:
decision.Reason = "eligible"
}
if err := repo.RecordDecision(ctx, c, decision); err != nil {
return Decision{}, errors.New("decision was not durably recorded")
}
return decision, nil
}
Do not make the contact value the suppression key in logs. Normalize it at a controlled boundary, derive a stable lookup token, encrypt the deliverable value separately, and limit who can reverse that mapping. The exact retention window and evidence fields depend on the jurisdiction, message class, and your organization's policy. I'm not sure which artifact a particular auditor will accept without seeing that control framework; resolve that with counsel and the compliance owner, then encode the answer as a versioned schema rather than a wiki promise.
The opt-out ingestion path deserves the same discipline. An inbound opt-out should update the applicable suppression state before it acknowledges completion, and queued work must evaluate that new state. CTIA's messaging principles are useful primary guidance for messaging practices, but the engineering control is broader: every source of denial — user preference, inbound keyword handling, administrative suppression, or channel-wide pause — has to converge on the same decision boundary. Don't scatter four slightly different checks across workers.
The evidence ledger is the product boundary
For each candidate, retain the event ID, recipient reference, channel, policy versions, decision time, reason, attempt token, and resulting adapter state. Avoid storing message content by default; evidence that a template version was selected is often more useful and less sensitive than a full rendered body. Access to the ledger should itself be auditable. Retention and deletion need explicit ownership because “keep everything for compliance” creates a second compliance problem. One row is not enough: preference changes and suppression changes are append-only policy events, while the current view is a projection used for fast evaluation. That gives operations both paths they need, a low-latency lookup for dispatch and a replayable history for investigation. The projection can be rebuilt; the evidence event cannot. Capacity plans should therefore cover write amplification, replay time, and the peak fan-out caused by an order burst, not just average delivery throughput. I would alert on policy-evaluation age, queue age by channel, denial-reason distribution, duplicate candidate conflicts, and attempts without a decision reference. A sudden rise in channel_disabled may reflect a legitimate campaign or a preference ingestion problem; the metric alone cannot tell you which. Trace the order event through candidate, decision, and attempt IDs, while keeping contact data out of trace attributes. Short-lived delivery logs are operational telemetry. The decision ledger is evidence. Mixing them makes both harder to govern.
Prove it.
Testing should force the race instead of hoping unit tests stumble into it. Hold a candidate after it is enqueued, apply an SMS opt-out, release the worker, and assert that no adapter call occurs and that a denial decision names the new suppression version. Repeat with concurrent workers and the same event ID; exactly one candidate should survive the uniqueness constraint. Then exercise replay from the immutable event log, confirming that replay does not silently redeliver a completed attempt. These are deterministic invariants, so they belong in deployment gates, not in a manual runbook.
Should the team build policy control or buy delivery orchestration?
Separate ownership of the compliance decision from ownership of transport. Buying transport does not remove the need to define evidence, and building transport can add on-call work without improving that evidence. The choice should follow control boundaries and failure budgets, not a feature checklist.
| Approach | Best fit | Compliance-evidence advantage | Operational catch |
|---|---|---|---|
| Self-host policy and transport | Specialized routing or strict infrastructure control | One schema and one change history under team control | Highest queue, deliverability, carrier, and on-call burden |
| Self-host policy with managed channel adapters | Most marketplace teams | Evidence remains provider-independent while adapters absorb channel mechanics | Adapter semantics and deduplication still need contract tests |
| Managed multichannel orchestration | Small teams with standard workflows | Faster initial control surface if exports contain the required fields | Evidence portability, retention controls, and policy timing require verification |
| Separate managed email and SMS services | Teams with strong channel specialists | Each channel can use its preferred transport | Two delivery state models must map into one decision ledger |
The middle option is my default decision rule for this scenario: keep consent evaluation and evidence under the platform team's control, then treat delivery as replaceable adapters. It is not suitable when policy rules must execute inside a provider-managed workflow, when the team cannot operate the ledger to its SLO, or when contractual evidence already lives in an approved orchestration system. In those cases, stick with managed orchestration and test export completeness, timestamps, retention, and suppression timing before committing.
The reverse boundary also exists. Self-host the transport only when channel behavior is a genuine product differentiator or a regulatory control requires it. Otherwise, the extra paging surface is difficult to justify. Your mileage may vary with message volume and staffing, but headcount must be part of the capacity model: a design that meets latency targets while consuming the team's entire error budget in maintenance is not healthy.
For the edtech order path, deployment is complete only after a shadow evaluation compares old and new policy decisions without sending twice, migrations preserve policy version history, dashboards distinguish denied from failed work, and rollback does not revert a newly recorded opt-out. That final condition is easy to miss. Application rollback should never roll consent backward.
Top comments (0)