Skip to main content

Overview

The multitenancy middleware extracts and provides tenant information for multi-tenant SaaS applications. It supports various resolution strategies including subdomain, header, path, and query parameters.

Installation

import "github.com/go-mizu/mizu/middlewares/multitenancy"

Quick Start

app := mizu.New()

// Resolve tenant from subdomain
app.Use(multitenancy.New(multitenancy.SubdomainResolver()))

app.Get("/", func(c *mizu.Ctx) error {
    tenant := multitenancy.Get(c)
    return c.JSON(200, map[string]string{
        "tenant_id": tenant.ID,
    })
})

Configuration

OptionTypeDefaultDescription
ResolverResolverSubdomainTenant resolution function
ErrorHandlerfunc(*mizu.Ctx, error) error-Error handler
RequiredbooltrueRequire tenant resolution

Tenant Structure

type Tenant struct {
    ID       string
    Name     string
    Domain   string
    Metadata map[string]any
}

Resolution Strategies

Subdomain Resolution

// tenant1.example.com → tenant_id: "tenant1"
app.Use(multitenancy.New(multitenancy.SubdomainResolver()))

Header Resolution

// X-Tenant-ID: tenant1 → tenant_id: "tenant1"
app.Use(multitenancy.New(multitenancy.HeaderResolver("X-Tenant-ID")))

Path Resolution

// /tenant1/users → tenant_id: "tenant1", path: /users
app.Use(multitenancy.New(multitenancy.PathResolver()))

Query Parameter Resolution

// /users?tenant=tenant1 → tenant_id: "tenant1"
app.Use(multitenancy.New(multitenancy.QueryResolver("tenant")))

Examples

Basic Subdomain Tenant

app.Use(multitenancy.New(multitenancy.SubdomainResolver()))

app.Get("/dashboard", func(c *mizu.Ctx) error {
    tenant := multitenancy.Get(c)
    data := loadDashboard(tenant.ID)
    return c.JSON(200, data)
})

Database Lookup

resolver := multitenancy.LookupResolver(
    multitenancy.SubdomainResolver(),
    func(id string) (*multitenancy.Tenant, error) {
        // Look up full tenant info from database
        var tenant Tenant
        err := db.QueryRow(
            "SELECT id, name, plan FROM tenants WHERE slug = ?",
            id,
        ).Scan(&tenant.ID, &tenant.Name, &tenant.Plan)

        if err != nil {
            return nil, multitenancy.ErrTenantNotFound
        }

        return &multitenancy.Tenant{
            ID:   tenant.ID,
            Name: tenant.Name,
            Metadata: map[string]any{
                "plan": tenant.Plan,
            },
        }, nil
    },
)

app.Use(multitenancy.New(resolver))

Chain Multiple Resolvers

// Try subdomain first, then header, then query param
resolver := multitenancy.ChainResolver(
    multitenancy.SubdomainResolver(),
    multitenancy.HeaderResolver("X-Tenant-ID"),
    multitenancy.QueryResolver("tenant"),
)

app.Use(multitenancy.New(resolver))

Custom Error Handler

app.Use(multitenancy.WithOptions(multitenancy.Options{
    Resolver: multitenancy.SubdomainResolver(),
    ErrorHandler: func(c *mizu.Ctx, err error) error {
        return c.JSON(400, map[string]string{
            "error":   "Invalid tenant",
            "message": err.Error(),
        })
    },
}))

Optional Tenant

app.Use(multitenancy.WithOptions(multitenancy.Options{
    Resolver: multitenancy.SubdomainResolver(),
    Required: false, // Allow requests without tenant
}))

app.Get("/", func(c *mizu.Ctx) error {
    tenant := multitenancy.Get(c)
    if tenant == nil {
        return c.JSON(200, "Welcome to the platform")
    }
    return c.JSON(200, "Welcome, "+tenant.Name)
})

MustGet (Panic on Missing)

app.Get("/settings", func(c *mizu.Ctx) error {
    // Panics if tenant not found - use in required tenant routes
    tenant := multitenancy.MustGet(c)
    settings := loadSettings(tenant.ID)
    return c.JSON(200, settings)
})

Custom Resolver

func JWTTenantResolver() multitenancy.Resolver {
    return func(c *mizu.Ctx) (*multitenancy.Tenant, error) {
        claims := c.Get("jwt_claims").(jwt.MapClaims)
        tenantID, ok := claims["tenant_id"].(string)
        if !ok || tenantID == "" {
            return nil, multitenancy.ErrTenantNotFound
        }

        return &multitenancy.Tenant{
            ID:   tenantID,
            Name: tenantID,
        }, nil
    }
}

app.Use(jwtauth.New(jwtSecret))
app.Use(multitenancy.New(JWTTenantResolver()))

Tenant-Scoped Database

app.Get("/users", func(c *mizu.Ctx) error {
    tenant := multitenancy.Get(c)

    // Use tenant ID for database scoping
    users, err := db.Query(
        "SELECT * FROM users WHERE tenant_id = ?",
        tenant.ID,
    )
    if err != nil {
        return err
    }

    return c.JSON(200, users)
})

Tenant Middleware Chain

// Create tenant-aware middleware
func TenantRateLimit() mizu.Middleware {
    return func(next mizu.Handler) mizu.Handler {
        return func(c *mizu.Ctx) error {
            tenant := multitenancy.Get(c)
            limiter := rateLimiters[tenant.ID]

            if !limiter.Allow() {
                return c.Text(429, "Rate limit exceeded")
            }
            return next(c)
        }
    }
}

app.Use(multitenancy.New(multitenancy.SubdomainResolver()))
app.Use(TenantRateLimit())

Tenant Metadata

resolver := multitenancy.LookupResolver(
    multitenancy.SubdomainResolver(),
    func(id string) (*multitenancy.Tenant, error) {
        tenant := loadTenantFromDB(id)
        return &multitenancy.Tenant{
            ID:     tenant.ID,
            Name:   tenant.Name,
            Domain: tenant.Domain,
            Metadata: map[string]any{
                "plan":          tenant.Plan,
                "max_users":     tenant.MaxUsers,
                "features":      tenant.Features,
                "custom_domain": tenant.CustomDomain,
            },
        }, nil
    },
)

// Access metadata in handlers
app.Get("/plan", func(c *mizu.Ctx) error {
    tenant := multitenancy.Get(c)
    plan := tenant.Metadata["plan"].(string)
    return c.JSON(200, map[string]string{"plan": plan})
})

Built-in Resolvers

ResolverSourceExample
SubdomainResolver()Subdomaintenant1.example.com
HeaderResolver(h)HTTP HeaderX-Tenant-ID: tenant1
PathResolver()URL Path/tenant1/api/users
QueryResolver(p)Query Param?tenant=tenant1
ChainResolver(...)MultipleTries each in order
LookupResolver(r, fn)DatabaseEnriches with lookup

API Reference

func New(resolver Resolver) mizu.Middleware
func WithOptions(opts Options) mizu.Middleware
func Get(c *mizu.Ctx) *Tenant
func FromContext(c *mizu.Ctx) *Tenant  // Alias
func MustGet(c *mizu.Ctx) *Tenant

// Resolvers
func SubdomainResolver() Resolver
func HeaderResolver(header string) Resolver
func PathResolver() Resolver
func QueryResolver(param string) Resolver
func LookupResolver(resolver Resolver, lookup func(id string) (*Tenant, error)) Resolver
func ChainResolver(resolvers ...Resolver) Resolver

// Errors
var ErrTenantNotFound = errors.New("tenant not found")
var ErrTenantInvalid = errors.New("tenant invalid")

Technical Details

Architecture

The multitenancy middleware uses Go’s context package to store and retrieve tenant information throughout the request lifecycle. It implements a resolver pattern that allows flexible tenant identification strategies.

Context Storage

The middleware stores tenant information in the request context using a private contextKey{} type. This ensures type safety and prevents key collisions with other middleware or application code.
type contextKey struct{}

// Stored in context
ctx := context.WithValue(c.Context(), contextKey{}, tenant)

Resolver Chain

The resolver pattern allows composing multiple tenant identification strategies:
  1. Each resolver implements the Resolver function type
  2. Resolvers return (*Tenant, error) allowing error propagation
  3. ChainResolver tries resolvers sequentially until one succeeds
  4. LookupResolver wraps a resolver with a database lookup function

Path Rewriting

The PathResolver automatically rewrites the request path to remove the tenant prefix:
// Original: /tenant1/api/users
// Rewritten: /api/users
// Tenant ID: "tenant1"
This allows routes to be defined without tenant prefixes while maintaining tenant isolation.

Error Handling

The middleware supports both required and optional tenant resolution:
  • Required mode (default): Returns error via ErrorHandler when tenant not found
  • Optional mode: Continues request processing with nil tenant
  • Custom error handler: Allows application-specific error responses

Performance Considerations

  • Context lookups are O(1) operations
  • Subdomain parsing uses efficient string operations
  • Header lookups use Go’s optimized HTTP header map
  • Consider caching with LookupResolver for database-backed tenants

Best Practices

  • Use subdomain resolution for user-friendly URLs
  • Implement database lookup for rich tenant data
  • Use chain resolver for flexibility
  • Always validate tenant access to resources
  • Include tenant ID in all database queries
  • Cache tenant lookups for performance

Testing

The middleware includes comprehensive test coverage for all resolvers and scenarios:
Test CaseDescriptionExpected Behavior
TestNewBasic middleware creation with custom resolverTenant stored in context and accessible via Get()
TestWithOptions_RequiredRequired tenant resolution failsReturns 400 Bad Request when tenant not found
TestWithOptions_NotRequiredOptional tenant resolutionContinues without tenant, Get() returns nil
TestSubdomainResolverExtract tenant from subdomainResolves “acme” from “acme.example.com”
TestHeaderResolverExtract tenant from HTTP headerResolves tenant from “X-Tenant-Id” header
TestPathResolverExtract tenant from URL path prefixResolves “acme” from “/acme/api/users” and rewrites path to “/api/users”
TestQueryResolverExtract tenant from query parameterResolves tenant from “?tenant=my-tenant”
TestLookupResolverDatabase lookup enrichmentEnhances basic tenant with full data from lookup function
TestChainResolverMultiple resolver fallbackTries header, then query, then subdomain in order
TestFromContextAlias function for GetFromContext() returns same tenant as Get()
TestMustGetGet tenant or panicReturns tenant when present
TestMustGet_PanicPanic on missing tenantPanics with “tenant not found” message when tenant absent
TestErrorsError constantsValidates error messages for ErrTenantNotFound and ErrTenantInvalid
TestWithOptions_ErrorHandlerCustom error handlingUses custom error handler to return 401 Unauthorized