zoobzio January 6, 2025 Edit this page

Multi-Tenant Patterns

Strategies for isolating tenant data using grub.

Isolation Strategies

StrategyIsolationComplexityCost
Key PrefixLogicalLowLow
Separate StoresStrongMediumMedium
Separate ProvidersCompleteHighHigh

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

RequirementStrategy
Cost-sensitiveKey prefix
Compliance (data residency)Separate providers
Performance isolationSeparate stores
Simple operationsKey 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