Multi-Tenant Patterns
Strategies for isolating tenant data using grub.
Isolation Strategies
| Strategy | Isolation | Complexity | Cost |
|---|---|---|---|
| Key Prefix | Logical | Low | Low |
| Separate Stores | Strong | Medium | Medium |
| Separate Providers | Complete | High | High |
Key Prefix Isolation
Simplest approach: prefix all keys with tenant ID.
package tenant
import (
"context"
"fmt"
"time"
"github.com/zoobz-io/grub"
)
type TenantStore[T any] struct {
store *grub.Store[T]
}
func NewTenantStore[T any](store *grub.Store[T]) *TenantStore[T] {
return &TenantStore[T]{store: store}
}
func (t *TenantStore[T]) key(tenantID, key string) string {
return fmt.Sprintf("tenant:%s:%s", tenantID, key)
}
func (t *TenantStore[T]) Get(ctx context.Context, tenantID, key string) (*T, error) {
return t.store.Get(ctx, t.key(tenantID, key))
}
func (t *TenantStore[T]) Set(ctx context.Context, tenantID, key string, val *T, ttl time.Duration) error {
return t.store.Set(ctx, t.key(tenantID, key), val, ttl)
}
func (t *TenantStore[T]) Delete(ctx context.Context, tenantID, key string) error {
return t.store.Delete(ctx, t.key(tenantID, key))
}
func (t *TenantStore[T]) Exists(ctx context.Context, tenantID, key string) (bool, error) {
return t.store.Exists(ctx, t.key(tenantID, key))
}
func (t *TenantStore[T]) List(ctx context.Context, tenantID, prefix string, limit int) ([]string, error) {
fullPrefix := t.key(tenantID, prefix)
keys, err := t.store.List(ctx, fullPrefix, limit)
if err != nil {
return nil, err
}
// Strip tenant prefix from results
baseLen := len(t.key(tenantID, ""))
result := make([]string, len(keys))
for i, key := range keys {
result[i] = key[baseLen:]
}
return result, nil
}
Usage
store := grub.NewStore[Session](redis.New(client))
tenantStore := NewTenantStore(store)
// Each tenant's data is isolated by prefix
tenantStore.Set(ctx, "acme", "session:123", &session, time.Hour)
tenantStore.Set(ctx, "beta", "session:123", &session, time.Hour)
// Listing shows only tenant's keys
keys, _ := tenantStore.List(ctx, "acme", "session:", 100)
// Returns ["session:123"], not "tenant:acme:session:123"
Context-Based Tenant Resolution
Extract tenant from context for cleaner API.
package tenant
import (
"context"
"errors"
)
type contextKey string
const tenantKey contextKey = "tenant_id"
var ErrNoTenant = errors.New("no tenant in context")
func WithTenant(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, tenantKey, tenantID)
}
func FromContext(ctx context.Context) (string, error) {
id, ok := ctx.Value(tenantKey).(string)
if !ok || id == "" {
return "", ErrNoTenant
}
return id, nil
}
Store with Context Tenant
type ContextTenantStore[T any] struct {
inner *TenantStore[T]
}
func (c *ContextTenantStore[T]) Get(ctx context.Context, key string) (*T, error) {
tenantID, err := FromContext(ctx)
if err != nil {
return nil, err
}
return c.inner.Get(ctx, tenantID, key)
}
func (c *ContextTenantStore[T]) Set(ctx context.Context, key string, val *T, ttl time.Duration) error {
tenantID, err := FromContext(ctx)
if err != nil {
return err
}
return c.inner.Set(ctx, tenantID, key, val, ttl)
}
// ... other methods
Usage
// Middleware sets tenant
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-ID")
ctx := WithTenant(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Handler uses store without explicit tenant
func (h *Handler) GetSession(w http.ResponseWriter, r *http.Request) {
session, err := h.sessions.Get(r.Context(), sessionID)
// Tenant is extracted from context automatically
}
Separate Stores Per Tenant
Stronger isolation with dedicated stores.
package tenant
import (
"context"
"sync"
"time"
"github.com/zoobz-io/grub"
)
type StoreFactory[T any] func(tenantID string) (*grub.Store[T], error)
type MultiTenantStore[T any] struct {
factory StoreFactory[T]
stores sync.Map // tenantID -> *grub.Store[T]
}
func NewMultiTenantStore[T any](factory StoreFactory[T]) *MultiTenantStore[T] {
return &MultiTenantStore[T]{factory: factory}
}
func (m *MultiTenantStore[T]) getStore(tenantID string) (*grub.Store[T], error) {
if store, ok := m.stores.Load(tenantID); ok {
return store.(*grub.Store[T]), nil
}
store, err := m.factory(tenantID)
if err != nil {
return nil, err
}
actual, _ := m.stores.LoadOrStore(tenantID, store)
return actual.(*grub.Store[T]), nil
}
func (m *MultiTenantStore[T]) Get(ctx context.Context, tenantID, key string) (*T, error) {
store, err := m.getStore(tenantID)
if err != nil {
return nil, err
}
return store.Get(ctx, key)
}
func (m *MultiTenantStore[T]) Set(ctx context.Context, tenantID, key string, val *T, ttl time.Duration) error {
store, err := m.getStore(tenantID)
if err != nil {
return err
}
return store.Set(ctx, key, val, ttl)
}
With BoltDB (Separate Buckets)
func BoltStoreFactory[T any](db *bbolt.DB) StoreFactory[T] {
return func(tenantID string) (*grub.Store[T], error) {
return grub.NewStore[T](bolt.New(db, tenantID)), nil
}
}
// Usage
db, _ := bbolt.Open("tenants.db", 0600, nil)
multiStore := NewMultiTenantStore(BoltStoreFactory[Session](db))
With Redis (Separate Databases)
func RedisStoreFactory[T any](baseOpts *goredis.Options) StoreFactory[T] {
var dbIndex int
var mu sync.Mutex
tenantDBs := make(map[string]int)
return func(tenantID string) (*grub.Store[T], error) {
mu.Lock()
defer mu.Unlock()
if db, ok := tenantDBs[tenantID]; ok {
opts := *baseOpts
opts.DB = db
return grub.NewStore[T](redis.New(goredis.NewClient(&opts))), nil
}
dbIndex++
tenantDBs[tenantID] = dbIndex
opts := *baseOpts
opts.DB = dbIndex
return grub.NewStore[T](redis.New(goredis.NewClient(&opts))), nil
}
}
Separate Providers Per Tenant
Complete isolation with dedicated infrastructure.
package tenant
import (
"context"
"fmt"
"sync"
"github.com/zoobz-io/grub"
"github.com/zoobz-io/grub/badger"
badgerdb "github.com/dgraph-io/badger/v4"
)
type TenantConfig struct {
ID string
DataDir string
}
type IsolatedTenantStore[T any] struct {
configs map[string]TenantConfig
stores sync.Map
}
func (i *IsolatedTenantStore[T]) getStore(tenantID string) (*grub.Store[T], error) {
if store, ok := i.stores.Load(tenantID); ok {
return store.(*grub.Store[T]), nil
}
config, ok := i.configs[tenantID]
if !ok {
return nil, fmt.Errorf("unknown tenant: %s", tenantID)
}
opts := badgerdb.DefaultOptions(config.DataDir)
opts.Logger = nil
db, err := badgerdb.Open(opts)
if err != nil {
return nil, err
}
store := grub.NewStore[T](badger.New(db))
i.stores.Store(tenantID, store)
return store, nil
}
Multi-Tenant Bucket
Same patterns apply to blob storage.
type TenantBucket[T any] struct {
bucket *grub.Bucket[T]
}
func (t *TenantBucket[T]) key(tenantID, key string) string {
return fmt.Sprintf("%s/%s", tenantID, key)
}
func (t *TenantBucket[T]) Get(ctx context.Context, tenantID, key string) (*grub.Object[T], error) {
obj, err := t.bucket.Get(ctx, t.key(tenantID, key))
if err != nil {
return nil, err
}
// Strip tenant prefix from key
obj.Key = key
return obj, nil
}
func (t *TenantBucket[T]) Put(ctx context.Context, tenantID string, obj *grub.Object[T]) error {
obj.Key = t.key(tenantID, obj.Key)
return t.bucket.Put(ctx, obj)
}
func (t *TenantBucket[T]) List(ctx context.Context, tenantID, prefix string, limit int) ([]grub.ObjectInfo, error) {
fullPrefix := t.key(tenantID, prefix)
infos, err := t.bucket.List(ctx, fullPrefix, limit)
if err != nil {
return nil, err
}
// Strip tenant prefix
baseLen := len(t.key(tenantID, ""))
for i := range infos {
infos[i].Key = infos[i].Key[baseLen:]
}
return infos, nil
}
Multi-Tenant Database
For SQL, use row-level or schema-level isolation.
Row-Level Isolation
type TenantUser struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
Email string `json:"email" db:"email"`
}
// Query with tenant filter using builder
users, _ := db.Query().
Where("tenant_id", "=", "tenant_id").
Exec(ctx, map[string]any{"tenant_id": tenantID})
Schema-Level Isolation (PostgreSQL)
func SetSearchPath(db *sqlx.DB, tenantID string) error {
schema := fmt.Sprintf("tenant_%s", tenantID)
_, err := db.Exec(fmt.Sprintf("SET search_path TO %s", schema))
return err
}
// Usage per request
func TenantDBMiddleware(db *sqlx.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-ID")
SetSearchPath(db, tenantID)
next.ServeHTTP(w, r)
})
}
}
Tenant Data Operations
Export Tenant Data
func ExportTenantData[T any](
ctx context.Context,
store *TenantStore[T],
tenantID string,
) (map[string]*T, error) {
keys, err := store.List(ctx, tenantID, "", 0)
if err != nil {
return nil, err
}
data := make(map[string]*T)
for _, key := range keys {
val, err := store.Get(ctx, tenantID, key)
if err != nil {
continue
}
data[key] = val
}
return data, nil
}
Delete Tenant Data
func DeleteTenantData[T any](
ctx context.Context,
store *TenantStore[T],
tenantID string,
) error {
for {
keys, err := store.List(ctx, tenantID, "", 100)
if err != nil {
return err
}
if len(keys) == 0 {
break
}
for _, key := range keys {
if err := store.Delete(ctx, tenantID, key); err != nil {
return err
}
}
}
return nil
}
Tenant Quotas
Enforce storage limits per tenant.
type QuotaEnforcedStore[T any] struct {
store *TenantStore[T]
quotas map[string]int64 // tenantID -> max bytes
usage sync.Map // tenantID -> current bytes
}
func (q *QuotaEnforcedStore[T]) Set(ctx context.Context, tenantID, key string, val *T, ttl time.Duration) error {
// Estimate size (simplified)
data, _ := json.Marshal(val)
size := int64(len(data))
current, _ := q.usage.LoadOrStore(tenantID, int64(0))
currentUsage := current.(int64)
quota, ok := q.quotas[tenantID]
if ok && currentUsage+size > quota {
return fmt.Errorf("tenant %s quota exceeded", tenantID)
}
if err := q.store.Set(ctx, tenantID, key, val, ttl); err != nil {
return err
}
q.usage.Store(tenantID, currentUsage+size)
return nil
}
Best Practices
1. Choose Isolation Level Based on Requirements
| Requirement | Strategy |
|---|---|
| Cost-sensitive | Key prefix |
| Compliance (data residency) | Separate providers |
| Performance isolation | Separate stores |
| Simple operations | Key prefix |
2. Always Include Tenant in Logging
log.Info("operation completed",
"tenant", tenantID,
"key", key,
"duration", elapsed,
)
3. Validate Tenant Access
func (s *Service) Get(ctx context.Context, key string) (*Data, error) {
tenantID, err := tenant.FromContext(ctx)
if err != nil {
return nil, err
}
// Never trust key from user input
if !strings.HasPrefix(key, "allowed:") {
return nil, errors.New("invalid key")
}
return s.store.Get(ctx, tenantID, key)
}
4. Plan for Tenant Migration
Design so tenant data can be exported/imported:
- Use consistent key structures
- Document data formats
- Test export/import regularly