Multi-Tenancy
App-scoped data isolation and scoped provider resolution in Herald.
Herald is multi-tenant by design. Every entity carries an AppID field that scopes it to a logical application, and the scope resolution system provides layered overrides at the user, organization, and app levels.
App-scoped data isolation
All Herald entities include an app_id field:
// Every entity is scoped to an app.
type Provider struct {
ID id.ProviderID `json:"id"`
AppID string `json:"app_id"`
// ...
}
type Template struct {
ID id.TemplateID `json:"id"`
AppID string `json:"app_id"`
// ...
}This applies to all entity types:
| Entity | AppID field | Additional scope fields |
|---|---|---|
| Provider | app_id | -- |
| Template | app_id | -- |
| Message | app_id | env_id |
| Notification | app_id | env_id, user_id |
| Preference | app_id | user_id |
| Scoped Config | app_id | scope + scope_id |
All store queries include app_id in their WHERE clause, ensuring complete data isolation between applications. A provider created for app "myapp" is never visible to app "otherapp".
The scope resolution chain
When Herald sends a notification, it must resolve which provider to use. The scope.Resolver walks a layered resolution chain:
User scope --> Org scope --> App scope --> Global fallbackAt each level, the resolver checks for a scoped configuration that specifies a provider override for the requested channel. The first match wins.
Resolution steps
// 1. User-scoped override
// Check for a ScopedConfig where scope="user" and scope_id=userID.
// If it has a provider for this channel, use it.
// 2. Org-scoped override
// Check for a ScopedConfig where scope="org" and scope_id=orgID.
// If it has a provider for this channel, use it.
// 3. App-scoped default
// Check for a ScopedConfig where scope="app" and scope_id=appID.
// If it has a provider for this channel, use it.
// 4. Global fallback
// List all enabled providers for this app + channel,
// sort by priority (lower = higher priority),
// and use the first enabled provider.How it works in code
The resolver is initialized internally when you create a Herald instance:
// Internal wiring (done automatically by herald.New).
resolver := scope.NewResolver(scopeStore, providerStore, logger)During Send, Herald calls the resolver:
resolved, err := h.resolver.ResolveProvider(ctx, req.AppID, req.OrgID, req.UserID, channel)
// resolved.Provider -- the provider to use
// resolved.Config -- the scoped config (if resolved via scope, nil for global fallback)The ResolveResult contains both the provider and the scoped configuration, so Herald can extract sender overrides (FromEmail, FromName, FromPhone) from the config:
type ResolveResult struct {
Provider *provider.Provider
Config *scope.Config
}Setting up scoped configs
Create scoped configs to override the default provider resolution:
import "github.com/xraph/herald/scope"
// App-level default: use Resend for all emails.
appConfig := &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeApp,
ScopeID: "myapp",
EmailProviderID: resendProviderID.String(),
FromEmail: "noreply@myapp.com",
FromName: "MyApp",
}
store.SetScopedConfig(ctx, appConfig)
// Org-level override: Acme Corp uses their own SMTP server.
orgConfig := &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeOrg,
ScopeID: "org-acme",
EmailProviderID: acmeSMTPProviderID.String(),
FromEmail: "noreply@acme.com",
FromName: "Acme Corp",
}
store.SetScopedConfig(ctx, orgConfig)
// User-level override: VIP user gets SMS via a premium provider.
userConfig := &scope.Config{
ID: id.NewScopedConfigID(),
AppID: "myapp",
Scope: scope.ScopeUser,
ScopeID: "user-42",
SMSProviderID: premiumTwilioProviderID.String(),
FromPhone: "+15551234567",
}
store.SetScopedConfig(ctx, userConfig)Environment isolation
Messages and inbox notifications carry an optional EnvID field for environment-level isolation. This separates delivery logs and inbox data across environments (e.g. development, staging, production) within the same app.
// SendRequest includes an optional EnvID.
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
EnvID: "env_staging",
Channel: "email",
// ...
})The EnvID is written to both the Message and Notification records:
type Message struct {
// ...
AppID string `json:"app_id"`
EnvID string `json:"env_id,omitempty"`
// ...
}
type Notification struct {
// ...
AppID string `json:"app_id"`
EnvID string `json:"env_id,omitempty"`
// ...
}This allows you to query delivery logs and inbox items for a specific environment without mixing test data with production data.
User preference scoping
User preferences are scoped to an app + user combination. Each user has at most one preference record per app:
// Get preferences for user-42 in myapp.
pref, err := store.GetPreference(ctx, "myapp", "user-42")
// Set preferences.
store.SetPreference(ctx, &preference.Preference{
ID: id.NewPreferenceID(),
AppID: "myapp",
UserID: "user-42",
Overrides: map[string]preference.ChannelPreference{
"marketing.newsletter": {
Email: boolPtr(false), // opt out of marketing emails
},
},
})During Send, Herald automatically checks preferences before dispatching:
// Internally:
if req.UserID != "" && req.Template != "" {
pref, _ := h.store.GetPreference(ctx, req.AppID, req.UserID)
if pref != nil && pref.IsOptedOut(req.Template, channel) {
// Skip delivery -- return "user opted out" result
}
}Sending with scope context
To take advantage of the full resolution chain, provide AppID, OrgID, and UserID in the send request:
result, err := h.Send(ctx, &herald.SendRequest{
AppID: "myapp",
OrgID: "org-acme", // enables org-level scope resolution
UserID: "user-42", // enables user-level scope resolution + preference check
Channel: "email",
Template: "order.shipped",
To: []string{"alice@acme.com"},
Data: map[string]any{"order_id": "ORD-123"},
})Resolution for this request:
- Check if
user-42has a scoped config with an email provider override. - If not, check if
org-acmehas a scoped config with an email provider override. - If not, check if
myapphas an app-level scoped config. - If not, fall back to the highest-priority enabled email provider for
myapp.
Multi-channel delivery
Use Herald.Notify to send across multiple channels with a single call. The scope resolution runs independently per channel:
results, err := h.Notify(ctx, &herald.NotifyRequest{
AppID: "myapp",
OrgID: "org-acme",
UserID: "user-42",
Template: "order.shipped",
To: []string{"alice@acme.com"},
Channels: []string{"email", "push", "inapp"},
Data: map[string]any{"order_id": "ORD-123"},
})Each channel resolves its own provider independently through the scope chain. The email channel may use Resend while the push channel uses FCM.
Single-tenant deployments
For single-tenant applications, use a constant AppID:
const appID = "default"
result, err := h.Send(ctx, &herald.SendRequest{
AppID: appID,
Channel: "email",
// ...
})All Herald features work identically. The AppID simply never varies. You can migrate to multi-tenant later by changing this value per request.