Skip to main content

ADR-010: Multi-tenancy via Metadata

StatusDateDeciders
Accepted2024-02-20Core Team

Context

Many applications need to support multiple tenants (customers, organizations) while:

  1. Data Isolation: Tenants cannot see each other's data
  2. Query Scoping: Queries automatically filter by tenant
  3. Flexibility: Support different isolation strategies
  4. Performance: Tenant filtering should be efficient

Common multi-tenancy approaches:

  • Database per tenant: Complete isolation, complex operations
  • Schema per tenant: Good isolation, schema management overhead
  • Shared table with tenant column: Simpler, requires careful filtering
  • Shared table with metadata: Flexible, works with event sourcing

Decision

We will implement multi-tenancy via event metadata with middleware-based enforcement.

Metadata-Based Tenant Tracking

Every event carries tenant information in metadata:

type Metadata struct {
CorrelationID string `json:"correlationId,omitempty"`
CausationID string `json:"causationId,omitempty"`
UserID string `json:"userId,omitempty"`
TenantID string `json:"tenantId,omitempty"` // Tenant identifier
Custom map[string]string `json:"custom,omitempty"`
}

Tenant Context

type tenantKey struct{}

// WithTenant adds tenant ID to context
func WithTenant(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, tenantKey{}, tenantID)
}

// TenantFromContext retrieves tenant ID from context
func TenantFromContext(ctx context.Context) (string, bool) {
tenantID, ok := ctx.Value(tenantKey{}).(string)
return tenantID, ok
}

Tenant Middleware

// TenantMiddleware enforces tenant isolation on commands
func TenantMiddleware() Middleware {
return func(next MiddlewareFunc) MiddlewareFunc {
return func(ctx context.Context, cmd Command) (CommandResult, error) {
tenantID, ok := TenantFromContext(ctx)
if !ok {
return CommandResult{}, NewValidationError("", "tenant ID required")
}

// Inject tenant into command metadata if supported
if tm, ok := cmd.(TenantAware); ok {
tm.SetTenantID(tenantID)
}

return next(ctx, cmd)
}
}
}

// TenantAware interface for commands that support tenancy
type TenantAware interface {
SetTenantID(tenantID string)
GetTenantID() string
}

Event Store Integration

Events automatically include tenant from context:

func (s *EventStore) Append(ctx context.Context, streamID string, events []EventData, expectedVersion int64) ([]StoredEvent, error) {
// Inject tenant ID into event metadata
tenantID, _ := TenantFromContext(ctx)

for i := range events {
if events[i].Metadata.TenantID == "" {
events[i].Metadata.TenantID = tenantID
}
}

return s.adapter.Append(ctx, streamID, events, expectedVersion)
}

Tenant-Scoped Queries

// Subscribe with tenant filter
func (s *EventStore) SubscribeByTenant(ctx context.Context, tenantID string, fromPosition uint64) (<-chan StoredEvent, error) {
return s.adapter.Subscribe(ctx, fromPosition, EventFilter{
TenantID: tenantID,
})
}

// Query projections by tenant
func (r *Repository) QueryByTenant(ctx context.Context, tenantID string, query Query) ([]*T, error) {
return r.Query(ctx, query.Where("TenantID", Eq, tenantID))
}

Stream Naming Convention

Optionally prefix streams with tenant ID:

// Stream ID includes tenant
func StreamID(tenantID, aggregateType, aggregateID string) string {
return fmt.Sprintf("%s/%s-%s", tenantID, aggregateType, aggregateID)
}

// Example: "tenant-123/order-456"

PostgreSQL Index for Tenant Queries

-- Index for tenant-scoped queries
CREATE INDEX idx_events_tenant ON mink_events ((metadata->>'tenantId'));

-- Partial index for specific tenant (high-volume tenants)
CREATE INDEX idx_events_tenant_abc ON mink_events (global_position)
WHERE metadata->>'tenantId' = 'tenant-abc';

Consequences

Positive

  1. Flexible: Works with any isolation requirement
  2. Simple: No database or schema changes per tenant
  3. Queryable: Can query across tenants if needed (admin)
  4. Auditable: Tenant info in every event
  5. Portable: Same approach works across databases

Negative

  1. Query Overhead: Every query must include tenant filter
  2. Bug Risk: Forgetting tenant filter exposes data
  3. Index Size: Tenant index adds storage overhead
  4. No Physical Isolation: All data in same tables

Neutral

  1. Migration: Can add tenancy to existing systems
  2. Testing: Need to test tenant isolation explicitly

Isolation Strategies

Strategy 1: Metadata Only (Default)

All tenants share tables, isolated by metadata filter.

// Good for: Most SaaS applications
store := mink.New(adapter)
bus.Use(mink.TenantMiddleware())

Strategy 2: Stream Prefix

Tenant ID in stream name provides implicit isolation.

// Good for: Clear tenant separation in stream names
streamID := fmt.Sprintf("%s/%s", tenantID, aggregateID)

Strategy 3: Schema per Tenant

Use PostgreSQL schemas for isolation.

// Good for: Regulatory requirements, large tenants
adapter := postgres.NewAdapter(connStr,
postgres.WithSchema(tenantID))

Strategy 4: Database per Tenant

Separate database connections per tenant.

// Good for: Maximum isolation, enterprise customers
adapters := map[string]*postgres.Adapter{
"tenant-1": postgres.NewAdapter(connStr1),
"tenant-2": postgres.NewAdapter(connStr2),
}

Example Implementation

// HTTP middleware to extract tenant
func TenantExtractor(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract from header, JWT, subdomain, etc.
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" {
http.Error(w, "Tenant ID required", http.StatusBadRequest)
return
}

ctx := mink.WithTenant(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// Command handler with tenant enforcement
func (h *OrderHandler) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (CommandResult, error) {
tenantID, ok := mink.TenantFromContext(ctx)
if !ok {
return CommandResult{}, errors.New("tenant required")
}

order := NewOrder(cmd.OrderID)
order.TenantID = tenantID

if err := order.Create(cmd.CustomerID, cmd.Items); err != nil {
return CommandResult{}, err
}

return h.store.SaveAggregate(ctx, order)
}

// Projection respects tenant
func (p *OrderSummaryProjection) Apply(ctx context.Context, event StoredEvent) error {
tenantID := event.Metadata.TenantID

switch event.Type {
case "OrderCreated":
var e OrderCreated
json.Unmarshal(event.Data, &e)

return p.repo.Insert(ctx, &OrderSummary{
TenantID: tenantID, // Always include tenant
OrderID: e.OrderID,
// ...
})
}
return nil
}

Alternatives Considered

Alternative 1: Row-Level Security (PostgreSQL)

Description: Use PostgreSQL RLS policies.

Pros:

  • Database-enforced isolation
  • Can't forget filter

Rejected as primary because:

  • PostgreSQL-specific
  • Complex policy management
  • Performance overhead
  • Can be added on top if needed

Alternative 2: Separate Event Stores

Description: Different EventStore instance per tenant.

Pros:

  • Complete isolation
  • Easy to reason about

Rejected as default because:

  • Resource overhead
  • Complex routing
  • Hard to query across tenants

Alternative 3: Encryption per Tenant

Description: Encrypt events with tenant-specific keys.

Pros:

  • Strong isolation
  • Key-based access control

Rejected as primary because:

  • Performance overhead
  • Key management complexity
  • Can be added for sensitive data

References