Stage 7 · Master
Phase 14 — Notification Service
SMS
Normalize phone numbers to E.164 before they ever reach a provider, and refuse to send to anyone who has withdrawn consent — even if a caller asks for it.
What Changes When the Channel Costs Money and Cannot Be Unsent
The port keeps the shape the email lesson established, so it is shown here only where SMS forces a difference — and it forces three. A message is capped at 160 GSM-7 characters before it silently becomes two billable segments, so templates must be length-checked rather than merely rendered. Delivery is fire-and-forget with no equivalent of an email bounce, so a permanent failure is often invisible. And every send has a marginal cost, which makes consent and volume policy part of the delivery path rather than a compliance footnote.
package domain
import (
"context"
"errors"
"regexp"
)
var ErrInvalidPhoneNumber = errors.New("phone number is not a valid E.164 number")
var e164Pattern = regexp.MustCompile(`^\+[1-9]\d{7,14}$`)
func NormalizePhone(raw string) (string, error) {
if !e164Pattern.MatchString(raw) {
return "", ErrInvalidPhoneNumber
}
return raw, nil
}
type SMSMessage struct {
TenantID string
To string // always E.164 by the time it reaches this struct
Body string
}
type SMSSender interface {
Send(ctx context.Context, msg SMSMessage) (ProviderMessageID, error)
}Normalization happens once, at construction time for SMSMessage, rather than being re-validated by every caller — a message value that exists at all is guaranteed to carry a valid E.164 number.
Withdrawn Consent Overrides Every Other Signal
SMS carries a real per-message cost and a real regulatory obligation: a resident who has opted out must never receive another marketing or even routine SMS, regardless of what any calling service requests. The consent check happens inside notification-service itself — the one place every SMS, from every caller, is guaranteed to pass through — rather than being re-implemented, and potentially forgotten, in Payment or Complaint.
CREATE TABLE notification_preferences (
organization_id uuid NOT NULL,
resident_id uuid NOT NULL,
channel text NOT NULL, -- 'email' | 'sms' | 'push'
category text NOT NULL, -- 'payment_reminders' | 'maintenance_updates' | ...
opted_in boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (organization_id, resident_id, channel, category)
);A missing row means opted_in by default — a resident who has never touched their preferences still receives normal service communication, but a row that exists always wins over that default.
package application
import (
"context"
"errors"
"github.com/hoa-platform/backend/services/notification-service/internal/domain"
)
var ErrRecipientOptedOut = errors.New("recipient has opted out of this category on this channel")
type PreferenceChecker interface {
IsOptedIn(ctx context.Context, tenantID, residentID, channel, category string) (bool, error)
}
type SendSMS struct {
sender domain.SMSSender
preferences PreferenceChecker
}
func NewSendSMS(sender domain.SMSSender, preferences PreferenceChecker) *SendSMS {
return &SendSMS{sender: sender, preferences: preferences}
}
func (s *SendSMS) Execute(ctx context.Context, tenantID, residentID, category, phone, body string) error {
optedIn, err := s.preferences.IsOptedIn(ctx, tenantID, residentID, "sms", category)
if err != nil {
return err
}
if !optedIn {
return ErrRecipientOptedOut
}
normalized, err := domain.NormalizePhone(phone)
if err != nil {
return err
}
_, err = s.sender.Send(ctx, domain.SMSMessage{TenantID: tenantID, To: normalized, Body: body})
return err
}IsOptedIn runs before NormalizePhone runs before Send. Ordering matters: there is no path through this function that reaches the provider adapter for a resident who has opted out, even if the phone number is malformed and would have errored anyway.
A Per-Process Budget Is a Real Limiter, With a Real Limitation
Every SMS costs money, and a bug upstream that fires the same reminder in a loop should not be free to run up an unbounded bill. A simple in-memory token bucket, reset hourly, caps how many SMS one process will send per organization.
package application
import (
"sync"
"time"
)
// PerProcessBudget is a deliberately simple guardrail: it caps SMS volume
// per organization within THIS process only. Two replicas of
// notification-service each enforce their own limit, so the effective
// organization-wide budget is (limit × replica count), not limit alone.
// This gap is closed once Redis Integration proves the shared-counter
// need in the TTL lesson — introducing Redis here, before that need is
// demonstrated, would be solving a problem this service does not yet
// have evidence for.
type PerProcessBudget struct {
mu sync.Mutex
limit int
window time.Duration
counts map[string]int
resetAt map[string]time.Time
}
func NewPerProcessBudget(limit int, window time.Duration) *PerProcessBudget {
return &PerProcessBudget{limit: limit, window: window, counts: map[string]int{}, resetAt: map[string]time.Time{}}
}
func (b *PerProcessBudget) Allow(organizationID string) bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
if now.After(b.resetAt[organizationID]) {
b.counts[organizationID] = 0
b.resetAt[organizationID] = now.Add(b.window)
}
if b.counts[organizationID] >= b.limit {
return false
}
b.counts[organizationID]++
return true
}The doc comment names the gap outright rather than hiding it: this is the right amount of engineering for the evidence available today, and the exact spot Module 15 returns to once a real multi-replica bill spike makes the case for a shared counter.
PerProcessBudget will under-limit an organization once notification-service runs more than one replica. That is written down, in the one place a future reader will look, rather than discovered the hard way in an incident review.
Confirm Opt-Out Wins and Malformed Numbers Never Reach the Provider
Applied exercise
Add a global do-not-disturb window
Residents have complained about maintenance SMS arriving at 11pm local time.
- Decide where the current local time for an organization comes from without notification-service owning timezone data itself.
- Add a check that defers non-urgent SMS categories outside a configured quiet window instead of sending immediately.
- State what should happen to an urgent category like a security alert during the quiet window.
- Write one test proving a deferred SMS is not silently dropped.
Deliverable
An updated SendSMS execution path plus a test proving deferral, not loss, for a quiet-window message.
Completion checks
- Urgent categories are explicitly exempted, not accidentally deferred.
- A deferred message is provably still delivered later, not just delayed forever.
Why does PerProcessBudget's doc comment explicitly describe its multi-replica gap instead of the code simply working correctly across replicas from the start?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.