Skip to main content
Version: 0.7.0

Multi-Tenant

TenantRouter implements Quark's ClientProvider interface, so the same query entry point works for single-tenant and multi-tenant code:

users, err := quark.For[User](ctx, client).List()
users, err = quark.For[User](tenantCtx, router).List()

The router resolves a tenant ID from context.Context and chooses one of three isolation strategies.

Strategy Matrix

StrategyHow it isolatesRequiresBest fit
DatabasePerTenantSeparate *sql.DB / Client per tenant.factory func(tenantID string) (*Client, error)Strong isolation, custom DSNs, tenant-specific scaling.
SchemaPerTenantPrefixes table names with the tenant ID as schema.BaseClientShared database with schema namespaces.
RowLevelSecurityInjects WHERE tenant_id = ? into every query.BaseClient, TenantColumnShared tables, application-level tenant filters.

Tenant IDs must match ^[a-z0-9_-]+$. This protects schema names and cache keys from unsafe values.

Resolve Tenants from Context

Keep tenant resolution small and deterministic:

type tenantKey struct{}

func WithTenant(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, tenantKey{}, tenantID)
}

func ResolveTenant(ctx context.Context) string {
tenantID, _ := ctx.Value(tenantKey{}).(string)
return tenantID
}

Pass ResolveTenant to the router. If it returns an empty string or an invalid tenant ID, query execution returns an error before SQL is built.

Row-Level Isolation

Row-level isolation uses one shared client and injects a tenant predicate:

type Document struct {
ID int64 `db:"id" pk:"true"`
TenantID string `db:"tenant_id"`
Title string `db:"title"`
}

cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.RowLevelSecurity
cfg.BaseClient = client
cfg.TenantColumn = "tenant_id"

router := quark.NewTenantRouter(cfg, ResolveTenant, nil)

tenantCtx := WithTenant(ctx, "acme")

docs, err := quark.For[Document](tenantCtx, router).
Where("title", "LIKE", "Invoice%").
Limit(50).
List()

The effective query includes tenant_id = 'acme' even though application code does not repeat that filter.

Or() groups inherit the tenant predicate. Because SQL operator precedence parses A AND B OR C as (A AND B) OR C, an OR branch built by the callback must carry its own tenant_id = ? predicate to stay isolated. Quark does this automatically:

docs, err := quark.For[Document](tenantCtx, router).
Where("status", "=", "draft").
Or(func(q *quark.Query[Document]) *quark.Query[Document] {
return q.Where("status", "=", "published")
}).
List()
// emitted: WHERE tenant_id = ? AND status = ? OR (tenant_id = ? AND status = ?)

Writes also inherit tenant context. If the model has the configured tenant column and the field is zero, Quark fills it before insert.

doc := Document{Title: "Invoice 1001"}
err := quark.For[Document](tenantCtx, router).Create(&doc)
// doc.TenantID == "acme"

Schema-per-Tenant

Schema-per-tenant uses a shared client and prefixes table names with the resolved tenant ID:

cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.SchemaPerTenant
cfg.BaseClient = client

router := quark.NewTenantRouter(cfg, ResolveTenant, nil)

users, err := quark.For[User](WithTenant(ctx, "tenant_acme"), router).List()

For tenant tenant_acme, Quark builds table references like:

"tenant_acme"."users"

The current router uses the tenant ID directly as the schema name. If your database uses a prefix such as tenant_acme, return that full schema name from the resolver.

Run schema creation and migrations once per tenant schema. client.Migrate operates on the base client; tenant-schema migration orchestration is an application concern in the current release.

Database-per-Tenant

Database-per-tenant asks your factory to create a client for each tenant:

cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.DatabasePerTenant
cfg.MaxCachedPools = 100

router := quark.NewTenantRouter(
cfg,
ResolveTenant,
func(tenantID string) (*quark.Client, error) {
return quark.New("postgres", dsnForTenant(tenantID),
quark.WithMaxOpenConns(10),
quark.WithMaxIdleConns(10),
)
},
)

users, err := quark.For[User](WithTenant(ctx, "acme"), router).Limit(100).List()

The router keeps an LRU cache of tenant clients. When the cache exceeds MaxCachedPools, the least recently used client is evicted and its underlying database connection is closed.

active := router.ActiveTenants()

Use database-per-tenant only after you have a clear pool budget. With 100 active tenants and db.SetMaxOpenConns(10), the process may open up to 1000 database connections.

TenantConfig Reference

type TenantConfig struct {
Strategy TenantStrategy
MaxCachedPools int
BaseClient *quark.Client
TenantColumn string
}
FieldUsed byDefaultNotes
StrategyAllDatabasePerTenantOne of DatabasePerTenant, SchemaPerTenant, RowLevelSecurity.
MaxCachedPoolsDatabase per tenant100Maximum cached clients before LRU eviction.
BaseClientSchema / row-levelnilRequired for shared-database strategies.
TenantColumnRow-leveltenant_idColumn injected into queries and writes.

Relations and Cache Isolation

Tenant context is carried into relation loading. If a preloaded related model has the configured tenant column, Quark adds the tenant predicate to the relation query too.

Cache keys include tenant ID and schema, so two tenants do not share cached results for the same SQL shape.

Operational Guidance

ConcernGuidance
Missing tenantFail fast. Do not silently default to a public tenant.
Tenant ID formatReturn only lowercase letters, digits, underscores, and hyphens.
Row-level modelsInclude tenant_id on every tenant-owned table.
Shared lookup tablesUse the base client for truly global tables.
MigrationsRun migrations per database or schema before routing traffic.
ReportingUse explicit reporting clients instead of bypassing tenant filters accidentally.
Defense in depthCombine app-level row filters with native database RLS where required.

TenantRouter centralizes tenant selection, but it does not replace database permissions, audit logging, backups, or operational safeguards. Treat it as the ORM layer in a broader tenancy design.