Multi-Tenant Patterns
Per-org email providers, per-user locale preferences, and scope resolution patterns.
Herald is designed for multi-tenant applications from the ground up. Every entity is scoped by appID, and scoped configurations support app, organization, and user-level overrides. This guide covers the key patterns for multi-tenant notification delivery.
Scope Resolution Chain
Herald resolves the notification provider for each send operation through a four-level fallback chain:
user scope → org scope → app scope → first enabled providerThe scope resolver (scope.Resolver) checks each level in order and returns the first match. This allows fine-grained provider overrides without affecting other tenants.
func (r *Resolver) ResolveProvider(
ctx context.Context,
appID, orgID, userID string,
channel string,
) (*ResolveResult, error) {
// 1. User-scoped override
if userID != "" {
if result := r.tryScope(ctx, appID, ScopeUser, userID, channel); result != nil {
return result, nil
}
}
// 2. Org-scoped override
if orgID != "" {
if result := r.tryScope(ctx, appID, ScopeOrg, orgID, channel); result != nil {
return result, nil
}
}
// 3. App-scoped default
if result := r.tryScope(ctx, appID, ScopeApp, appID, channel); result != nil {
return result, nil
}
// 4. Fallback: first enabled provider by priority
// ...
}The resolved result includes both the provider and the scoped configuration, so sender fields (from_email, from_name, from_phone, default_locale) are automatically applied.
Per-Organization Email Provider
A common requirement is letting each organization use its own email provider (for example, a whitelabel SaaS product where each org sends from their own domain).
Step 1: Create providers for each org
// Acme Corp uses Amazon SES.
acmeProvider := &provider.Provider{
ID: id.NewProviderID(),
AppID: "myapp",
Name: "Acme SES",
Channel: "email",
Driver: "smtp",
Credentials: map[string]string{
"host": "email-smtp.us-east-1.amazonaws.com",
"port": "587",
"username": "AKIA...",
"password": "...",
},
Settings: map[string]string{
"from": "noreply@acme.com",
},
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
_ = store.CreateProvider(ctx, acmeProvider)
// Globex uses Resend.
globexProvider := &provider.Provider{
ID: id.NewProviderID(),
AppID: "myapp",
Name: "Globex Resend",
Channel: "email",
Driver: "resend",
Credentials: map[string]string{
"api_key": "re_...",
},
Settings: map[string]string{
"from": "noreply@globex.io",
},
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
_ = store.CreateProvider(ctx, globexProvider)Step 2: Set org-scoped configuration
// Link Acme Corp to their SES provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeOrg,
ScopeID: "org-acme",
EmailProviderID: acmeProvider.ID.String(),
FromEmail: "noreply@acme.com",
FromName: "Acme Corp",
CreatedAt: now,
UpdatedAt: now,
})
// Link Globex to their Resend provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeOrg,
ScopeID: "org-globex",
EmailProviderID: globexProvider.ID.String(),
FromEmail: "noreply@globex.io",
FromName: "Globex Inc",
CreatedAt: now,
UpdatedAt: now,
})Step 3: Send with org context
When sending, include the OrgID in the request. The scope resolver automatically picks the correct provider:
// This sends via Acme's SES provider, from noreply@acme.com.
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
OrgID: "org-acme",
Channel: "email",
Template: "welcome",
To: []string{"alice@acme.com"},
Data: map[string]any{"name": "Alice"},
})
// This sends via Globex's Resend provider, from noreply@globex.io.
result, err = h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
OrgID: "org-globex",
Channel: "email",
Template: "welcome",
To: []string{"bob@globex.io"},
Data: map[string]any{"name": "Bob"},
})Via HTTP API
curl -X POST http://localhost:8080/herald/send \
-H "Content-Type: application/json" \
-d '{
"app_id": "myapp",
"org_id": "org-acme",
"channel": "email",
"template": "welcome",
"to": ["alice@acme.com"],
"data": {"name": "Alice"}
}'Per-User Locale Preferences
Herald supports locale-specific template versions. The locale can be resolved through the scoped configuration chain or specified explicitly on the send request.
Step 1: Create templates with multiple locale versions
tmpl := &template.Template{
ID: id.NewTemplateID(),
AppID: "myapp",
Slug: "invoice",
Name: "Invoice Notification",
Channel: "email",
Enabled: true,
}
_ = store.CreateTemplate(ctx, tmpl)
// English version
_ = store.CreateVersion(ctx, &template.Version{
ID: id.NewTemplateVersionID(),
TemplateID: tmpl.ID,
Locale: "en",
Subject: "Invoice #{{.invoice_id}}",
HTML: "<p>Dear {{.name}}, your invoice is ready.</p>",
Text: "Dear {{.name}}, your invoice is ready.",
Active: true,
})
// French version
_ = store.CreateVersion(ctx, &template.Version{
ID: id.NewTemplateVersionID(),
TemplateID: tmpl.ID,
Locale: "fr",
Subject: "Facture #{{.invoice_id}}",
HTML: "<p>Cher(e) {{.name}}, votre facture est prête.</p>",
Text: "Cher(e) {{.name}}, votre facture est prête.",
Active: true,
})
// Japanese version
_ = store.CreateVersion(ctx, &template.Version{
ID: id.NewTemplateVersionID(),
TemplateID: tmpl.ID,
Locale: "ja",
Subject: "請求書 #{{.invoice_id}}",
HTML: "<p>{{.name}} 様、請求書の準備ができました。</p>",
Text: "{{.name}} 様、請求書の準備ができました。",
Active: true,
})Step 2: Set user-level locale via scoped config
// French-speaking user
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeUser,
ScopeID: "user-jean",
DefaultLocale: "fr",
CreatedAt: now,
UpdatedAt: now,
})
// Japanese-speaking user
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeUser,
ScopeID: "user-yuki",
DefaultLocale: "ja",
CreatedAt: now,
UpdatedAt: now,
})Step 3: Send with explicit locale or let scope resolve it
// Explicit locale on the request.
result, _ := h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
Channel: "email",
Template: "invoice",
Locale: "fr",
To: []string{"jean@example.fr"},
Data: map[string]any{"name": "Jean", "invoice_id": "INV-001"},
})
// If Locale is empty, Herald falls back to the config default_locale ("en").
result, _ = h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
Channel: "email",
Template: "invoice",
To: []string{"alice@example.com"},
Data: map[string]any{"name": "Alice", "invoice_id": "INV-002"},
})Per-User Notification Preferences
Users can opt in or out of specific notification types per channel using the preference system.
Setting preferences
// Bob opts out of marketing emails but keeps transactional and push.
_ = store.SetPreference(ctx, &preference.Preference{
ID: id.NewPreferenceID(),
AppID: "myapp",
UserID: "user-bob",
Overrides: map[string]preference.ChannelPreference{
"marketing-weekly": {
Email: boolPtr(false),
Push: boolPtr(true),
},
"product-updates": {
Email: boolPtr(false),
Push: boolPtr(false),
},
},
UpdatedAt: now,
})How preferences affect delivery
When Send is called with a UserID and Template, Herald checks the user's preferences before sending:
// This is skipped because Bob opted out of marketing-weekly emails.
result, _ := h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
UserID: "user-bob",
Channel: "email",
Template: "marketing-weekly",
To: []string{"bob@example.com"},
Data: map[string]any{"name": "Bob"},
})
// result.Error = "user opted out"
// This still sends because Bob has push enabled for marketing-weekly.
result, _ = h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
UserID: "user-bob",
Channel: "push",
Template: "marketing-weekly",
To: []string{"bob-device-token"},
Data: map[string]any{"name": "Bob"},
})Preference management via HTTP API
# Get preferences
curl "http://localhost:8080/herald/preferences?app_id=myapp&user_id=user-bob"
# Update preferences
curl -X PUT http://localhost:8080/herald/preferences \
-H "Content-Type: application/json" \
-d '{
"app_id": "myapp",
"user_id": "user-bob",
"overrides": {
"marketing-weekly": {
"email": false,
"push": true
}
}
}'Scope Override Hierarchy Example
This example demonstrates all three scope levels working together:
// App-level: default SMTP provider.
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeApp,
ScopeID: "myapp",
EmailProviderID: defaultSMTP.ID.String(),
FromEmail: "noreply@myapp.com",
DefaultLocale: "en",
CreatedAt: now,
UpdatedAt: now,
})
// Org-level: Acme uses their own SES.
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeOrg,
ScopeID: "org-acme",
EmailProviderID: acmeSES.ID.String(),
FromEmail: "noreply@acme.com",
FromName: "Acme",
CreatedAt: now,
UpdatedAt: now,
})
// User-level: Acme's CEO uses a personal sender name.
_ = store.SetScopedConfig(ctx, &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeUser,
ScopeID: "user-ceo-acme",
FromName: "John from Acme",
FromEmail: "john@acme.com",
CreatedAt: now,
UpdatedAt: now,
})Resolution results:
| Request context | Resolved provider | From email | From name |
|---|---|---|---|
| No org, no user | Default SMTP | noreply@myapp.com | (default) |
org_id: org-acme | Acme SES | noreply@acme.com | Acme |
org_id: org-acme, user_id: user-ceo-acme | (from user config, or falls back to org) | john@acme.com | John from Acme |
Scoped Config via HTTP API
# Set app-level config
curl -X PUT http://localhost:8080/herald/config/app \
-H "Content-Type: application/json" \
-d '{
"app_id": "myapp",
"email_provider_id": "hpvd_...",
"from_email": "noreply@myapp.com",
"default_locale": "en"
}'
# Set org-level config
curl -X PUT http://localhost:8080/herald/config/org/org-acme \
-H "Content-Type: application/json" \
-d '{
"app_id": "myapp",
"email_provider_id": "hpvd_...",
"from_email": "noreply@acme.com",
"from_name": "Acme Corp"
}'
# Set user-level config
curl -X PUT http://localhost:8080/herald/config/user/user-ceo-acme \
-H "Content-Type: application/json" \
-d '{
"app_id": "myapp",
"from_email": "john@acme.com",
"from_name": "John from Acme"
}'
# List all configs for an app
curl "http://localhost:8080/herald/config?app_id=myapp"
# Delete an org config
curl -X DELETE "http://localhost:8080/herald/config/org/org-acme?app_id=myapp"Pattern Summary
| Pattern | Entity | Key field | Purpose |
|---|---|---|---|
| Per-org provider | scope.Config | ScopeOrg + provider IDs | Each org sends from its own email/SMS provider |
| Per-org sender | scope.Config | FromEmail, FromName, FromPhone | Custom sender identity per org |
| Per-user locale | scope.Config | DefaultLocale | Locale-aware template rendering |
| Per-user opt-out | preference.Preference | Overrides map | Users control which notifications they receive |
| Scope chain | scope.Resolver | user -> org -> app -> default | Hierarchical override resolution |