Stage 7 · Master
Phase 14 — Notification Service
Define an email provider as a small port other code depends on, and resolve subject and body from a tenant-overridable template instead of a hardcoded string.
The Domain Depends on an Interface, Not an SMTP Library
If application code imports a specific email vendor's SDK directly, switching providers later means rewriting every call site. notification-service defines the shape of 'send an email' once, as a Go interface, and keeps the concrete adapter behind it swappable without touching anything that depends on the port.
package domain
import (
"context"
"errors"
)
// ErrRetryable marks a failure the worker pool should retry with backoff —
// a timeout or a 5xx from the provider. ErrPermanent marks a failure that
// will never succeed on retry, such as a malformed recipient address.
var (
ErrRetryable = errors.New("email delivery failed but may succeed on retry")
ErrPermanent = errors.New("email delivery failed permanently")
)
type EmailMessage struct {
TenantID string
To string
Subject string
HTMLBody string
TextBody string
}
type ProviderMessageID string
// EmailSender is the only contract application code depends on. Every
// concrete provider adapter implements exactly this interface.
type EmailSender interface {
Send(ctx context.Context, msg EmailMessage) (ProviderMessageID, error)
}Splitting delivery failure into ErrRetryable and ErrPermanent at the port boundary means the worker pool (a later lesson) never has to guess whether a given error is worth retrying — the adapter that saw the actual provider response already knows.
Templates Resolve Per Tenant, With a Global Fallback
Every organization can rebrand outgoing email — sender name, subject wording, footer — without notification-service shipping a code change per tenant. A template lookup checks for an organization-specific override first and falls back to the platform default only when none exists.
CREATE TABLE notification_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid, -- NULL means the global default template
template_key text NOT NULL, -- e.g. 'payment_receipt', 'complaint_status_changed'
channel text NOT NULL, -- 'email' | 'sms' | 'push'
subject text,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, template_key, channel)
);
-- A partial unique index would forbid two NULL organization_id rows for the
-- same key under most engines; PostgreSQL treats NULL as distinct in UNIQUE,
-- so exactly one platform-default row per (template_key, channel) is instead
-- enforced by application-level seeding discipline, not the constraint alone.organization_id is nullable on purpose: a NULL row is the platform default, and PostgreSQL's UNIQUE constraint treats every NULL as distinct from every other NULL, which is why the seed data — not the constraint — is responsible for keeping exactly one default per key.
package postgresadapter
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrTemplateNotFound = errors.New("no template found for this key, even as a platform default")
type Template struct {
Subject string
Body string
}
type TemplateRepository struct {
pool *pgxpool.Pool
}
func NewTemplateRepository(pool *pgxpool.Pool) *TemplateRepository {
return &TemplateRepository{pool: pool}
}
// Resolve tries the organization's own override first, then the platform
// default. It never mixes fields from the two — an organization either
// gets its own fully-defined template or the platform's, never a blend.
func (r *TemplateRepository) Resolve(ctx context.Context, organizationID, templateKey, channel string) (Template, error) {
row := r.pool.QueryRow(ctx, `
SELECT subject, body FROM notification_templates
WHERE organization_id = $1 AND template_key = $2 AND channel = $3`,
organizationID, templateKey, channel,
)
var t Template
if err := row.Scan(&t.Subject, &t.Body); err == nil {
return t, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return Template{}, err
}
row = r.pool.QueryRow(ctx, `
SELECT subject, body FROM notification_templates
WHERE organization_id IS NULL AND template_key = $1 AND channel = $2`,
templateKey, channel,
)
var fallback Template
if err := row.Scan(&fallback.Subject, &fallback.Body); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Template{}, ErrTemplateNotFound
}
return Template{}, err
}
return fallback, nil
}Two separate queries, not one query with COALESCE across joined rows, keep the 'organization's own template, entirely' versus 'platform default, entirely' rule easy to read and to test independently.
Render Through html/template, Never string.Replace
A resident's own name or complaint description can contain characters that would break HTML if interpolated with plain string concatenation. Go's html/template package escapes untrusted values automatically wherever they land in the output, closing an entire class of injection bugs a naive templating approach would reopen every time a new field is added.
package application
import (
"bytes"
"html/template"
)
type RenderData struct {
ResidentName string
Amount string
OrganizationName string
}
func RenderEmailBody(rawTemplate string, data RenderData) (string, error) {
tmpl, err := template.New("email").Parse(rawTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}html/template — not text/template — is the entire security control here. A resident whose display name happens to contain "<script>" is escaped automatically; nothing else in this file needs to know that rule exists.
Copy-pasting this file and swapping the import to text/template compiles fine and produces output that renders identically in a quick manual test — until a resident's name or complaint text contains HTML-significant characters in production.
Confirm the Fallback Chain Behaves as Documented
Applied exercise
Add a locale dimension to template resolution
Meridian is expanding to organizations that want Hindi-language email alongside the English default.
- Decide whether locale becomes part of the uniqueness constraint or a separate lookup dimension.
- Extend Resolve's fallback order: organization+locale, then organization default locale, then platform default.
- Write the migration change needed to support the new column or constraint.
- State what happens today for an organization that requests a locale with no matching row at any level.
Deliverable
An updated migration, a revised Resolve implementation, and a written fallback order.
Completion checks
- The three-level fallback is explicit in code, not implied by query ordering alone.
- An unmatched locale request still resolves to something rather than erroring the whole send.
Why does Resolve run two separate queries instead of one query with a JOIN and COALESCE across the organization and platform-default rows?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.