Override System
Per-tenant configuration overrides with resolution caching.
The override package provides per-tenant configuration overrides with a resolver that integrates with the config.Service. Overrides allow individual tenants to have custom values for configuration entries, while all other tenants receive the app-level default.
Architecture
override.Resolver (implements config.ValueResolver)
├── override.Store (per-tenant override persistence)
├── config.Store (app-level config fallback)
└── resolverCache (optional TTL cache)The Resolver sits between the config service and both stores. When a value is requested, it checks for a tenant override first, then falls back to the app-level config.
Installation
import "github.com/xraph/vault/override"Override entity
An Override represents a per-tenant value for a specific config key.
type Override struct {
vault.Entity
ID id.ID `json:"id"`
Key string `json:"key"`
Value any `json:"value"`
AppID string `json:"app_id"`
TenantID string `json:"tenant_id"`
Metadata map[string]string `json:"metadata,omitempty"`
}| Field | Type | Description |
|---|---|---|
ID | id.ID | Unique override identifier (TypeID) |
Key | string | The config key being overridden |
Value | any | The tenant-specific value |
AppID | string | Application scope identifier |
TenantID | string | Tenant this override applies to |
Metadata | map[string]string | Arbitrary key-value metadata |
CreatedAt | time.Time | From embedded vault.Entity |
UpdatedAt | time.Time | From embedded vault.Entity |
ListOpts
type ListOpts struct {
Limit int
Offset int
}Store interface
The store persists per-tenant overrides. Vault ships with in-memory and PostgreSQL implementations.
type Store interface {
GetOverride(ctx context.Context, key, appID, tenantID string) (*Override, error)
SetOverride(ctx context.Context, o *Override) error
DeleteOverride(ctx context.Context, key, appID, tenantID string) error
ListOverridesByTenant(ctx context.Context, appID, tenantID string) ([]*Override, error)
ListOverridesByKey(ctx context.Context, key, appID string) ([]*Override, error)
}| Method | Description |
|---|---|
GetOverride | Retrieves a single override for a key/app/tenant combination |
SetOverride | Creates or updates an override |
DeleteOverride | Removes a tenant override |
ListOverridesByTenant | Returns all overrides for a specific tenant within an app |
ListOverridesByKey | Returns all tenant overrides for a specific config key |
Resolver
The Resolver implements the config.ValueResolver interface. It resolves config values by checking for a tenant override first, then falling back to the app-level config.
Creating a Resolver
resolver := override.NewResolver(configStore, overrideStore,
override.WithLogger(logger),
override.WithCacheTTL(1*time.Minute),
)ResolverOption
| Option | Signature | Description |
|---|---|---|
WithLogger | WithLogger(l *slog.Logger) ResolverOption | Sets the logger for the resolver |
WithCacheTTL | WithCacheTTL(ttl time.Duration) ResolverOption | Enables result caching with the given TTL |
Resolution order
When Resolve is called:
- Cache check -- If caching is enabled and a cached value exists (not expired), return it.
- Tenant override -- If the context contains a tenant ID (via
vault.tenant_idcontext key), look up the tenant override in the override store. If found, cache and return it. - App-level fallback -- Read the config entry from the config store. Cache and return it.
func (r *Resolver) Resolve(ctx context.Context, key, appID string) (any, error)The tenant ID is extracted from the context using the vault.tenant_id key, which matches the key used by the scope package and the flag engine:
const ContextKeyTenantID contextKey = "vault.tenant_id"Invalidate
Removes cached entries for a specific config key and app ID. Called automatically by config.Service.Set and config.Service.Delete.
func (r *Resolver) Invalidate(key, appID string)InvalidateAll
Removes all cached entries. Useful when performing bulk updates.
func (r *Resolver) InvalidateAll()Resolution cache
When WithCacheTTL is configured, the resolver caches resolution results in memory:
- Cache keys are composite:
key + appID + tenantID. - Each entry has an independent TTL expiration.
- Cache is thread-safe (protected by
sync.RWMutex). Invalidate(key, appID)removes all tenant variants for that key/app combination.InvalidateAll()flushes the entire cache.
Integration with config.Service
The resolver is designed to plug into the config service via the WithResolver option:
import (
"github.com/xraph/vault/config"
"github.com/xraph/vault/override"
)
resolver := override.NewResolver(configStore, overrideStore,
override.WithCacheTTL(1*time.Minute),
)
svc := config.NewService(configStore,
config.WithAppID("myapp"),
config.WithResolver(resolver),
)Once connected:
- All type-safe accessors (
String,Bool,Int,Float,Duration,JSON) route through the resolver. config.Service.Setandconfig.Service.Deleteautomatically callInvalidateon the resolver.- Tenant context is automatically extracted and used for override lookups.
Full example
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/xraph/vault/config"
"github.com/xraph/vault/override"
"github.com/xraph/vault/scope"
)
func main() {
ctx := context.Background()
// Create the resolver.
resolver := override.NewResolver(configStore, overrideStore,
override.WithCacheTTL(1*time.Minute),
)
// Create the config service with resolver.
svc := config.NewService(configStore,
config.WithAppID("myapp"),
config.WithResolver(resolver),
)
// Set an app-level config.
_ = svc.Set(ctx, "max-users", 100, "myapp",
config.WithDescription("Maximum users per workspace"),
)
// Set a tenant override.
_ = overrideStore.SetOverride(ctx, &override.Override{
Key: "max-users",
Value: 500,
AppID: "myapp",
TenantID: "tenant-enterprise",
})
// Read without tenant context -- gets app default.
maxUsers := svc.Int(ctx, "max-users", 50)
fmt.Printf("Default max users: %d\n", maxUsers) // 100
// Read with tenant context -- gets override.
ctx = scope.WithTenantID(ctx, "tenant-enterprise")
maxUsers = svc.Int(ctx, "max-users", 50)
fmt.Printf("Enterprise max users: %d\n", maxUsers) // 500
// Read with a different tenant -- gets app default.
ctx = scope.WithTenantID(context.Background(), "tenant-starter")
maxUsers = svc.Int(ctx, "max-users", 50)
fmt.Printf("Starter max users: %d\n", maxUsers) // 100
// List all overrides for a key.
overrides, _ := overrideStore.ListOverridesByKey(ctx, "max-users", "myapp")
for _, o := range overrides {
fmt.Printf(" tenant=%s value=%v\n", o.TenantID, o.Value)
}
// List all overrides for a tenant.
tenantOverrides, _ := overrideStore.ListOverridesByTenant(ctx, "myapp", "tenant-enterprise")
for _, o := range tenantOverrides {
fmt.Printf(" key=%s value=%v\n", o.Key, o.Value)
}
}