Help us shape the future of Go! Take the 2026 Go Developer Survey

tenancy

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 8 Imported by: 0

README

tenancy

Multi-tenancy for Go, on top of playsql.

Route every query to the right tenant's data — by database, by schema, or by a tenant_id column — with the tenant carried in context.Context and no global mutable state.

users, err := playsql.Query[User](db).WhereEq("active", true).Get(ctx)

That is a normal playsql query. db is tenant-aware and ctx decides whose rows come back. There is no Tenant::current(), nothing is "switched" per request, and two tenants can be served concurrently by the same process because the tenant lives in the ctx and not in the process.

go get github.com/martin3zra/tenancy

The three modes

Pick your isolation model once, at startup. New then rejects any wiring that contradicts it.

Mode Isolation How
ModeDatabase A database per tenant One *sql.DB per tenant. Queries route by ctx. No tenant_id columns anywhere.
ModeSchema A schema per tenant, one database One pool per tenant whose DSN pins the schema. Same routing as ModeDatabase.
ModeRow One database, one schema One shared pool. A scope appends tenant_id = ?. No routing at all.

Mode is not an interface and there is no dispatch at query time. It is a validated enum: you declare your architecture, and New tells you at startup when your wiring disagrees, instead of a query telling you at 3am.


A database per tenant

pc := tenancy.NewPoolCache(openTenantDB) // func(ctx, tenant) (*sql.DB, error)
defer pc.Close()

tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
    tenancy.Connect(pc.Connect),
    tenancy.Landlord(landlordDB),
)
if err != nil {
    log.Fatal(err)
}

db := tnc.DB() // hand this to your handlers

app := tnc.Middleware(tenancy.Subdomain("example.com"))(
    tenancy.EnsureSameTenant(tenantFromSession)(routes),
)
http.ListenAndServe(":8080", app)

Connect is yours: it maps a tenant key to a pool. PoolCache saves you writing the map (see below), but you are free not to use it.

Landlord is the pool that Without(ctx) resolves to — the database that holds the tenant list itself, and anything else that is nobody's tenant data.

A schema per tenant

Identical, with one requirement this package cannot check for you:

tnc, err := tenancy.New(tenancy.ModeSchema, "postgres",
    tenancy.Connect(func(ctx context.Context, tenant string) (*sql.DB, error) {
        // The schema must be pinned in the DSN.
        dsn := fmt.Sprintf("postgres://user:pass@host/app?options=-c%%20search_path=%s", tenant)
        return sql.Open("postgres", dsn)
    }),
    tenancy.Landlord(landlordDB),
)

Never SET search_path on a checked-out connection. Pooled connections are reused across requests, so the next request — a different tenant's — inherits it. That is a cross-tenant data leak with no error message and no stack trace. Pin the schema in the DSN, where it belongs to the pool and not to the checkout.

A tenant_id column

tnc, err := tenancy.New(tenancy.ModeRow, "postgres",
    tenancy.Shared(sharedDB),
    tenancy.Column("tenant_id"), // the default; omit it
)

db := tnc.DB()

A model opts in by declaring the scope:

type Post struct {
    ID       int    `db:"id"`
    TenantID string `db:"tenant_id"`
    Title    string `db:"title"`
}

func (Post) Scopes() []playsql.Scope { return []playsql.Scope{tnc.Scope()} }

Every query on Post now carries tenant_id = ?, filled from ctx.

A model that declares no scope is global. That is the whole mechanism — this package ships no "landlord model" marker, because not opting in is the marker.


Everything fails closed

A ctx with no tenant is an error, in every mode. It never degrades into landlord access, never returns the first tenant's rows, and never runs an unscoped query against a shared table.

posts, err := playsql.Query[Post](db).Get(context.Background())
// err is tenancy.ErrNoTenant. Zero rows, not everyone's rows.

The only way out is to ask for it out loud:

ctx = tenancy.Without(ctx)
// ModeRow:            skips the scope — every tenant's rows.
// ModeDatabase/Schema: resolves to the landlord pool.

Reach for Without in admin tooling, and in the login flow that must find which tenant an email belongs to before a tenant exists. Nowhere else.

⚠ The other escape hatch

playsql has its own, one method call on any query, and it predates this package:

playsql.Query[Post](db).WithoutGlobalScopes().Get(ctx) // no tenant_id filter

Under ModeRow that returns every tenant's rows. It is legitimate — other scopes exist besides tenancy — but it means tenancy.Without is not the only road out. If you are auditing for cross-tenant reads, grep for both:

grep -rn 'tenancy.Without\|WithoutGlobalScopes' .

Your session must carry the tenant

This is the one failure mode that ctx-propagation does not save you from, because it lives in auth, not in the data layer.

Under ModeDatabase, a user's identity is a per-tenant primary key. A session holding user_id = 5 means "user 5 of whichever database we happen to point at." Present that session to another tenant and you are logged in as their user 5. A different person. Possibly an admin.

Routing is doing exactly what it was told. It is auth that is confused.

Rule 1 — bind the tenant into the session or token, not just the user ID.

tnc.Middleware(tenancy.Subdomain("example.com"))(
    tenancy.EnsureSameTenant(func(r *http.Request) (string, bool) {
        claims, ok := claimsFrom(r) // your auth: JWT, signed cookie, whatever
        if !ok {
            return "", false
        }
        return claims.Tenant, true
    })(routes),
)

A request whose session names a different tenant than the request resolved to gets 401 and never reaches your handler. A request with no session tenant passes through — that is an anonymous visitor on the way to a login page.

Rule 2 — UUID user IDs blunt this, they do not fix it. If user IDs are globally unique, tenant B's database has no user 5 to log in as. But the rest of the session — flash messages, a cart, a half-finished wizard — still crosses tenants freely. Bind the tenant anyway.

ModeRow is not exempt. Same session, same replay, minus the ID collision.


The async boundary

context.Context does not survive a queue, a cron tick, or a go statement that outlives its request. A worker pulling a job off Redis has no tenant, hits ErrNoTenant, and dies. Everyone meets this on day two.

There is no code to ship here — Go has no global queue hook to piggyback on, which is exactly why Laravel needs one and we do not. There is a rule:

Carry the tenant key across every async boundary, explicitly. Enqueue with From. Consume with With.

// Enqueue — inside a request, where ctx has a tenant.
tenant, ok := tenancy.From(ctx)
if !ok {
    return tenancy.ErrNoTenant // do not enqueue a job with no tenant
}
queue.Push(ctx, Job{Tenant: tenant, PostID: post.ID})

// Consume — inside a worker, where there is no request and no ctx.
func handle(ctx context.Context, job Job) error {
    ctx = tenancy.With(ctx, job.Tenant) // before touching the database
    return publish(ctx, job.PostID)
}

Same for a goroutine that outlives its request. Hand it a ctx that carries the tenant, not the request's ctx — that one is cancelled the moment you respond:

tenant, _ := tenancy.From(r.Context())
go func() {
    ctx := tenancy.With(context.Background(), tenant)
    reindex(ctx, db)
}()

Console commands and cron entrypoints have no request, so they have no tenant. Set it explicitly, or use Each.


Every tenant, in a loop

Backfills, cron jobs, per-tenant migrations:

// Where the tenant list comes from is yours. Here: the landlord database.
landlord, _ := playsql.Use(landlordDB, "postgres")

tnc, _ := tenancy.New(tenancy.ModeDatabase, "postgres",
    tenancy.Connect(pc.Connect),
    tenancy.Landlord(landlordDB),
    tenancy.List(func(ctx context.Context) ([]string, error) {
        rows, err := playsql.Query[Tenant](landlord).Get(ctx)
        if err != nil {
            return nil, err
        }
        keys := make([]string, len(rows))
        for i, t := range rows {
            keys[i] = t.Slug
        }
        return keys, nil
    }),
)

err := tnc.Each(ctx, func(ctx context.Context) error {
    // ctx already carries the tenant; tnc.DB() routes to it
    return backfill(ctx, tnc.DB())
})

Each is sequential and stops on the first error. That is the safe default, not the fast one.

Provisioning stays out of scope — creating a tenant's database is a decision you make, not a loop. But iterating the tenants you already have is a for loop, and this is it.

Concurrent Each

Deliberately not shipped: you own the pools, so you own the concurrency budget — and only you know whether tenant 200 failing should stop tenant 201. Both answers are a few lines on top of Tenants:

tenants, err := tnc.Tenants(ctx)
if err != nil {
    return err
}

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // your pool budget, not ours

for _, tenant := range tenants {
    g.Go(func() error {
        return backfill(tenancy.With(ctx, tenant), tnc.DB())
    })
}
return g.Wait()

Note the pool arithmetic: 8 concurrent tenants under ModeDatabase means 8 open pools, each with its own MaxOpenConns. Size accordingly.


Pools

You own them. This package never calls sql.Open behind your back and never closes a connection you gave it.

PoolCache is opt-in convenience for the map that every ModeDatabase user ends up writing:

pc := tenancy.NewPoolCache(func(ctx context.Context, tenant string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsnFor(tenant))
    if err != nil {
        return nil, err
    }
    db.SetMaxOpenConns(10)
    return db, db.PingContext(ctx)
})
defer pc.Close() // nothing else closes these

It opens each tenant's pool exactly once, however many requests race for it — the hand-rolled map is where the double-open race lives. A failed open is not cached, so a database that was down for a minute does not stay unreachable for the life of the process.

What it does not do: eviction, LRU, TTL, idle reaping. One pool per tenant, held until Close. That is policy, and policy is a guess about your tenant count and your pool sizing — both of which you know and this package does not. If "forever" is wrong for ten thousand tenants, don't use it: write the cache your deployment needs. Connect is a plain func, which is what keeps that door open.


Finding the tenant in a request

tenancy.Subdomain("example.com") // acme.example.com  -> "acme"
tenancy.Header("X-Tenant")       // X-Tenant: acme    -> "acme"
tenancy.PathPrefix()             // /acme/posts       -> "acme"

Anything else is a func you write: func(*http.Request) (string, error).

A request whose tenant cannot be found gets 404 and never reaches your handler.

Header reads whatever arrives on the wire. It is safe only behind an edge that sets and strips that header. If clients can reach your app directly, anyone can send X-Tenant: someone-else. Use Subdomain, or read the tenant from the authenticated session.

PathPrefix does not rewrite the URL — your router still sees /acme/posts. Wrap in http.StripPrefix if you want it gone.


What this package is not

  • Not a task pipeline. Laravel needs SwitchTenantTask because making a tenant current means mutating global config. Go has no such globals; there is nothing to switch. The Go equivalent is http.Handler middleware, which already exists.
  • Not a provisioner. Creating tenant databases, running per-tenant migrations, seeding, tenant CRUD: yours.
  • Not a Tenant model. The package only ever needs the key — a string. Your tenant record and its lookup stay yours.
  • Not a strategy pattern. Mode is a validated enum. ModeDatabase and ModeSchema are the same code path; ModeRow shares none of it.
  • Not mixed-dialect. All tenants share one dialect, which is why it is a positional argument to New rather than an option you can forget.

API

func New(mode Mode, dialect string, opts ...Option) (*Tenancy, error)

// options
func Connect(fn func(context.Context, string) (*sql.DB, error)) Option // Database/Schema
func Landlord(db *sql.DB) Option                                       // Database/Schema
func Shared(db *sql.DB) Option                                         // Row
func Column(name string) Option                                        // Row, default "tenant_id"
func List(fn func(context.Context) ([]string, error)) Option           // enables Each

func (t *Tenancy) DB() *playsql.DB       // the handle your handlers query through
func (t *Tenancy) Scope() playsql.Scope  // ModeRow only; panics otherwise
func (t *Tenancy) Mode() Mode

func (t *Tenancy) Each(ctx context.Context, fn func(context.Context) error) error
func (t *Tenancy) Tenants(ctx context.Context) ([]string, error)

// context
func With(ctx context.Context, tenant string) context.Context
func From(ctx context.Context) (string, bool)
func Without(ctx context.Context) context.Context

// http
func (t *Tenancy) Middleware(find Finder) func(http.Handler) http.Handler
func EnsureSameTenant(sessionTenant func(*http.Request) (string, bool)) func(http.Handler) http.Handler
func Subdomain(base string) Finder
func Header(name string) Finder
func PathPrefix() Finder

// pools (opt-in)
func NewPoolCache(open func(context.Context, string) (*sql.DB, error)) *PoolCache
func (p *PoolCache) Connect(ctx context.Context, tenant string) (*sql.DB, error)
func (p *PoolCache) Close() error
func (p *PoolCache) Len() int

// errors
var ErrNoTenant, ErrNoList, ErrPoolCacheClosed error

Scope() panics outside ModeRow. Scopes() has no error return, and handing back a scope that fails at query time would defer a wiring mistake to 3am — which is the bug Mode exists to prevent. It panics at startup, next to the model that got it wrong.


Prior art

Built while reading spatie/laravel-multitenancy and the Mohamed Said series it grew out of — in particular Configuring sessions, which is where the session-replay failure mode above comes from.

Most of that package does not port, and SPEC.md explains why, concept by concept.

License

MIT

Documentation

Overview

Package tenancy routes every query to the right tenant's data — by database, by schema, or by a tenant_id column — with the tenant carried in context.Context and no global mutable state.

It is a router, a scope, and a validator. It is not a framework: it never opens a connection you did not hand it, never provisions a tenant, and never makes a tenant "current" by mutating the process.

tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
    tenancy.Connect(openTenantDB),
    tenancy.Landlord(landlordDB),
)
db := tnc.DB()

// handlers are ordinary playsql; ctx decides whose rows come back
users, err := playsql.Query[User](db).WhereEq("active", true).Get(ctx)

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoList = errors.New("tenancy: Each requires the List option")

ErrNoList is returned by Each when the Tenancy was built without List. Nothing else in this package needs to enumerate tenants — a request carries its own — so the ability to is opt-in.

View Source
var ErrNoTenant = errors.New("tenancy: no tenant in context")

ErrNoTenant is returned when a query runs on a ctx carrying neither a tenant nor an explicit Without. Every mode fails closed on it: a missing tenant is a bug, never a licence to read every tenant's rows.

View Source
var ErrPoolCacheClosed = errors.New("tenancy: pool cache is closed")

ErrPoolCacheClosed is returned by Connect after Close. A closed cache does not reopen: build a new one.

Functions

func EnsureSameTenant

func EnsureSameTenant(sessionTenant func(*http.Request) (string, bool)) func(http.Handler) http.Handler

EnsureSameTenant rejects a request whose session belongs to a different tenant than the request resolved to. Mount it inside Middleware, so there is a tenant in ctx to compare against.

Why this exists when nothing else about tenancy needs middleware

Under ModeDatabase a user's identity is a per-tenant primary key. A session holding user_id = 5 means "user 5 of whichever database we are pointed at" — so a session minted on tenant A, replayed against tenant B, logs its bearer in as B's user 5. A different person. Possibly an admin.

No amount of ctx-propagation saves you from that, because the data layer is doing exactly what it was told: it is auth that is confused, not routing. Hence the one piece of this package that lives outside the data layer.

sessionTenant is yours to supply, because only your app knows its own auth — pull the tenant out of the session cookie, the JWT claim, wherever you bound it. Two rules follow, and the README says them louder:

  1. Bind the tenant into the session or token, not just the user ID. A bare user_id is meaningless once the database underneath it can change.
  2. UUID user IDs blunt the log-in-as-someone-else attack — B's database holds no such user — but they do not stop the rest of the session (flash, cart, half-finished wizard) crossing tenants. Bind the tenant anyway.

A request with no session tenant passes through: that is an anonymous visitor on the way to a login page, and there is nothing yet to mismatch. It is once a session names a tenant that it must name this one.

ModeRow is not exempt. The same session presented against another tenant's rows is the same attack, minus the primary-key collision.

func From

func From(ctx context.Context) (string, bool)

From reports the tenant bound to ctx. ok is false when there is none — including on a ctx from Without, since the landlord is not a tenant.

Use it at every async boundary: read the tenant here, put it in the job payload, and call With on the other side.

Example (AsyncBoundary)

Carrying the tenant across a queue: From on the way in, With on the way out. Nothing enforces this — ctx does not survive a queue — so it is a rule.

type Job struct {
	Tenant string
	PostID int
}

// Enqueue, inside a request: refuse to enqueue a job with no tenant, or the
// worker will pull a job it cannot serve.
enqueue := func(ctx context.Context, postID int) (Job, error) {
	tenant, ok := tenancy.From(ctx)
	if !ok {
		return Job{}, tenancy.ErrNoTenant
	}
	return Job{Tenant: tenant, PostID: postID}, nil
}

// Consume, inside a worker: there is no request and no ctx, so bind the
// tenant back before touching the database.
consume := func(ctx context.Context, job Job, db *playsql.DB) error {
	ctx = tenancy.With(ctx, job.Tenant)
	_, err := playsql.Query[Post](db).WhereEq("id", job.PostID).First(ctx)
	return err
}

_, _ = enqueue, consume

func With

func With(ctx context.Context, tenant string) context.Context

With returns a ctx bound to tenant. Everything downstream — the connection a query resolves to, the tenant_id a scope appends — follows from this.

Middleware calls it per request. Workers call it per job, after reading the tenant back off the payload. A background goroutine that outlives its request must be handed a ctx built with it, not the request's (which is cancelled).

An empty tenant is not a tenant: With(ctx, "") leaves ctx untenanted rather than binding the empty string, so a header that failed to parse cannot quietly become a tenant key.

func Without

func Without(ctx context.Context) context.Context

Without returns a ctx that has deliberately left tenancy: under ModeRow the tenant scope is skipped, under ModeDatabase and ModeSchema queries resolve to the landlord pool. It is the only road out — a ctx that merely forgot to set a tenant is an error, not a free pass to every tenant's rows.

Reach for it in admin tooling, in the login flow that has to find which tenant an email belongs to before a tenant exists, and nowhere else.

Types

type Finder

type Finder func(*http.Request) (string, error)

Finder pulls the tenant key out of a request. Subdomain, Header and PathPrefix cover the usual shapes; anything else is a func you write.

Returning an error means "this request names no tenant I can serve" — the middleware answers 404 and the handler never runs. It does not mean "this tenant does not exist": a Finder reads the request, it does not consult a database. An unknown-but-well-formed key fails later, at Connect.

func Header(name string) Finder

Header reads the tenant from a request header — the shape an API gateway or a service mesh usually hands you.

Be sure the header is one your edge sets and strips, not one a client can forge: this reads whatever arrives. If clients reach your app directly, put the tenant in the authenticated session instead and read it from there.

func PathPrefix

func PathPrefix() Finder

PathPrefix reads the tenant from the first path segment: /acme/posts is "acme".

The path is not rewritten — your router still sees /acme/posts. Wrap the handler in http.StripPrefix, or route on the full path; either way the choice is yours to make explicitly, because silently mutating the URL a middleware was handed is how debugging sessions get long.

func Subdomain

func Subdomain(base string) Finder

Subdomain reads the tenant from the host: acme.example.com under Subdomain("example.com") is "acme".

The port is ignored, so it works behind a dev server on :8080. The base domain on its own (example.com, no subdomain) names no tenant and is an error — mount your landlord app on that host separately, rather than letting a bare domain fall through to some default tenant.

Deeper labels are not split: a.b.example.com yields "a.b", not "a". If that is not what you want, write a Finder that says what you do want.

type Mode

type Mode int

Mode is the isolation model: how a tenant's data is kept apart from every other tenant's. Three modes cover the field.

Mode is data, not polymorphism. There is no Strategy interface and no dispatch at query time — ModeDatabase and ModeSchema run the same code, and ModeRow shares none of it. What Mode buys is a validating constructor: you declare your architecture once, and New tells you at startup when your wiring contradicts it, instead of a query telling you at 3am.

const (
	// ModeDatabase gives every tenant its own database, and so its own *sql.DB.
	// Queries route by ctx; there are no tenant_id columns anywhere.
	ModeDatabase Mode = iota

	// ModeSchema gives every tenant its own schema inside one database. It is
	// byte-for-byte ModeDatabase — one pool per tenant, routed by ctx — with one
	// requirement this package cannot check for you:
	//
	// The schema must be pinned in the DSN your Connect func returns (Postgres:
	// options=-c search_path=<tenant>; MySQL: a db-qualified DSN). Never issue
	// SET search_path on a checked-out connection: pooled connections are reused
	// across requests, so the next tenant inherits it. That is a cross-tenant data
	// leak with no error message.
	ModeSchema

	// ModeRow keeps every tenant in the same tables, told apart by a discriminator
	// column (tenant_id by default). One shared pool, no connection routing: the
	// Scope appends the predicate. Models opt in by declaring it; a model that
	// does not is global.
	ModeRow
)

func (Mode) String

func (m Mode) String() string

type Option

type Option func(*Tenancy) error

Option configures a Tenancy. Which options are legal depends on the mode, and New rejects every combination that contradicts it — see New.

func Column

func Column(name string) Option

Column names the discriminator column the Scope filters on. ModeRow only; defaults to "tenant_id".

func Connect

func Connect(fn func(context.Context, string) (*sql.DB, error)) Option

Connect supplies the per-tenant pool. ModeDatabase and ModeSchema only.

It runs once per statement, so it must be cheap: a map lookup, not a sql.Open. Wrap your opener in a PoolCache, or write your own — either way the pools are yours, and closing them is yours too. Under ModeSchema the DSN it builds must pin the schema.

func Landlord

func Landlord(db *sql.DB) Option

Landlord supplies the pool that Without(ctx) resolves to: the database holding the tenant list itself, and anything else that is nobody's tenant data. ModeDatabase and ModeSchema only — under ModeRow the landlord is the same pool with the scope skipped.

func List

func List(fn func(context.Context) ([]string, error)) Option

List enumerates the tenants, and is the one thing this package otherwise never needs to know. It enables Each; without it, Each errors.

Where the list comes from is yours: a landlord table, a config file, a service call. Run it on a Without(ctx) if it reads the landlord database.

func Shared

func Shared(db *sql.DB) Option

Shared supplies the one pool every tenant queries through. ModeRow only.

type PoolCache

type PoolCache struct {
	// contains filtered or unexported fields
}

PoolCache keeps one *sql.DB per tenant, opened on first use.

"The caller owns the pools" is this package's rule, and this does not bend it: you construct the cache, you close it, and Tenancy never learns it exists — it only ever sees the Connect func.

pc := tenancy.NewPoolCache(openTenantDSN)
defer pc.Close()

tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
    tenancy.Connect(pc.Connect),
    tenancy.Landlord(landlordDB),
)

It ships because everyone writing ModeDatabase writes this map, and the hand-rolled version is where the double-open race lives: check the map, miss, two goroutines call sql.Open, one pool is orphaned with its connections still held. Here a tenant's pool is opened exactly once no matter how many requests race for it, and a slow open blocks only that tenant.

What it deliberately does not do:

  • No eviction, no LRU, no TTL, no idle reaping. One pool per tenant, held until Close. Those are policy, and policy is a guess about your tenant count and your pool sizing — both of which you know and this package does not. If "forever" is the wrong answer for ten thousand tenants, do not use this: write the cache your deployment actually needs. Connect being a plain func is what keeps that door open.
  • No health checks or reconnection. database/sql already pools, retries and reaps dead connections underneath each *sql.DB.

A failed open is not cached: the next request for that tenant tries again, so a database that was down for a minute does not stay unreachable for the life of the process.

Safe for concurrent use.

func NewPoolCache

func NewPoolCache(open func(context.Context, string) (*sql.DB, error)) *PoolCache

NewPoolCache returns a cache that opens tenant pools with open. open is called at most once per tenant (per successful open), off the cache's lock, so a slow or hanging open blocks only the tenant it is for.

func (*PoolCache) Close

func (p *PoolCache) Close() error

Close closes every pool the cache opened and returns their errors joined. Opens still in flight are waited for, then closed; opens not yet started never start. Calling it twice is fine — the second call is a no-op.

This is the other half of "you own the pools": nothing else in this package closes a connection, so if you do not call this, nothing does.

func (*PoolCache) Connect

func (p *PoolCache) Connect(ctx context.Context, tenant string) (*sql.DB, error)

Connect returns the tenant's pool, opening it on first use. Pass it to the Connect option: it has exactly that signature.

func (*PoolCache) Len

func (p *PoolCache) Len() int

Len reports how many tenant pools are currently open. For metrics and tests: an unbounded cache is a number worth watching.

type Tenancy

type Tenancy struct {
	// contains filtered or unexported fields
}

Tenancy is the wiring: a mode, the connections it implies, and the handle the app queries through. Build one at startup and pass it around; it holds no mutable state and is safe for concurrent use.

func New

func New(mode Mode, dialect string, opts ...Option) (*Tenancy, error)

New validates the wiring against the mode and builds the query handle.

dialect is positional because every tenant shares it: this package routes connections, it does not switch grammars, so a deployment cannot mix Postgres tenants with MySQL ones. It is a playsql dialect name ("postgres", "mysql", "sqlite", "sqlserver").

Every rejection below is a bug that would otherwise surface as a nil deref on the first request, or — worse — as a query that quietly returns the wrong tenant's rows:

ModeDatabase/ModeSchema without Connect   nowhere to route to
ModeDatabase/ModeSchema without Landlord  nowhere for Without(ctx) to go
ModeDatabase/ModeSchema with Shared/Column   two architectures mixed
ModeRow without Shared                    nothing to query
ModeRow with Connect or Landlord          two architectures mixed

One thing New cannot check: whether a ModeSchema Connect returns a DSN that actually pins the schema. Nothing in a DSN says "this is a schema". See ModeSchema.

Example (Database)

A database per tenant, wired end to end.

package main

import (
	"context"
	"database/sql"
	"log"
	"net/http"

	"github.com/martin3zra/tenancy"
)

func main() {
	var (
		landlordDB        *sql.DB
		routes            http.Handler
		tenantFromSession func(*http.Request) (string, bool)
	)

	pc := tenancy.NewPoolCache(func(ctx context.Context, tenant string) (*sql.DB, error) {
		db, err := sql.Open("postgres", "postgres://localhost/"+tenant)
		if err != nil {
			return nil, err
		}
		db.SetMaxOpenConns(10)
		return db, db.PingContext(ctx)
	})
	defer pc.Close()

	tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
		tenancy.Connect(pc.Connect),
		tenancy.Landlord(landlordDB),
	)
	if err != nil {
		log.Fatal(err)
	}

	app := tnc.Middleware(tenancy.Subdomain("example.com"))(
		tenancy.EnsureSameTenant(tenantFromSession)(routes),
	)
	_ = http.ListenAndServe(":8080", app)
}

func (*Tenancy) DB

func (t *Tenancy) DB() *playsql.DB

DB is the handle the app queries through: a ctx-routed playsql.Dynamic under ModeDatabase and ModeSchema, the shared pool under ModeRow. Either way, handlers are ordinary playsql and ctx decides whose rows come back.

func (*Tenancy) Each

func (t *Tenancy) Each(ctx context.Context, fn func(context.Context) error) error

Each runs fn once per tenant, with the tenant already bound in ctx. It is what backfills, cron jobs and per-tenant maintenance are made of:

err := tnc.Each(ctx, func(ctx context.Context) error {
    return backfill(ctx, tnc.DB()) // ctx routes to this tenant
})

Provisioning stays out of scope, but iteration is not provisioning: creating a tenant's database is a decision, looping over the ones you have is a for loop.

It is sequential and stops on the first error. A backfill across 500 tenants wants a bounded worker pool, and this package deliberately does not ship one: you own the pools, so you own the concurrency budget, and only you know whether tenant 200 failing should stop tenant 201. Both answers are a few lines with errgroup, on top of List. See the README.

ctx is the ctx the listing runs on and the parent of each tenant's ctx, so cancelling it stops the loop. If List reads the landlord database, hand it a Without(ctx). A tenant bound in the incoming ctx is replaced, not merged — Each visits every tenant, not the current one repeatedly.

Example

Every tenant in a loop: backfills, cron, per-tenant migrations.

package main

import (
	"context"
	"database/sql"
	"log"

	"github.com/martin3zra/playsql"
	"github.com/martin3zra/tenancy"
)

type User struct {
	ID     int    `db:"id"`
	Active bool   `db:"active"`
	Name   string `db:"name"`
}

type Tenant struct {
	ID   int    `db:"id"`
	Slug string `db:"slug"`
}

func main() {
	var landlordDB *sql.DB

	landlord, err := playsql.Use(landlordDB, "postgres")
	if err != nil {
		log.Fatal(err)
	}

	pc := tenancy.NewPoolCache(func(_ context.Context, tenant string) (*sql.DB, error) {
		return sql.Open("postgres", "postgres://localhost/"+tenant)
	})
	defer pc.Close()

	tnc, err := tenancy.New(tenancy.ModeDatabase, "postgres",
		tenancy.Connect(pc.Connect),
		tenancy.Landlord(landlordDB),
		tenancy.List(func(ctx context.Context) ([]string, error) {
			rows, err := playsql.Query[Tenant](landlord).Get(ctx)
			if err != nil {
				return nil, err
			}
			keys := make([]string, len(rows))
			for i, t := range rows {
				keys[i] = t.Slug
			}
			return keys, nil
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	// The list lives in the landlord database, so read it without a tenant.
	ctx := tenancy.Without(context.Background())

	err = tnc.Each(ctx, func(ctx context.Context) error {
		// ctx already carries the tenant; tnc.DB() routes to it.
		_, err := playsql.Query[User](tnc.DB()).Count(ctx)
		return err
	})
	if err != nil {
		log.Fatal(err)
	}
}

func (*Tenancy) Middleware

func (t *Tenancy) Middleware(find Finder) func(http.Handler) http.Handler

Middleware binds the tenant that find pulls off the request into the request's ctx, for the rest of the chain. It is the Go answer to Laravel's switch-tenant task pipeline: nothing is switched, nothing is mutated, and two requests for two tenants can run side by side because the tenant lives in ctx and not in the process.

app := tnc.Middleware(tenancy.Subdomain("acme.test"))(
    tenancy.EnsureSameTenant(tenantFromSession)(handler),
)

A request whose tenant cannot be found gets 404 and never reaches the handler — the alternative is a handler running with no tenant, which every query in this package then fails closed on anyway, just later and with a worse error.

func (*Tenancy) Mode

func (t *Tenancy) Mode() Mode

Mode reports the isolation model this Tenancy was built with.

func (*Tenancy) Scope

func (t *Tenancy) Scope() playsql.Scope

Scope is the playsql.Scope a model declares to become tenant-scoped. ModeRow only.

func (Post) Scopes() []playsql.Scope { return []playsql.Scope{tnc.Scope()} }

Every query on that model then gains "tenant_id = ?", filled from ctx. A model that does not declare it is global — which is why this package ships no landlord marker: not opting in is the marker.

It fails closed. A ctx with no tenant aborts the query with ErrNoTenant rather than running unscoped, because an unscoped query on a shared table is every tenant's rows at once. Without(ctx) skips the predicate — that is the escape hatch, and it is deliberate, explicit, and the only one.

Why this panics outside ModeRow

Scopes() has no error return, so there is nowhere to put one. The alternative — handing back a Scope whose Apply always fails — would defer a wiring mistake to the first query, which is exactly the 3am bug Mode exists to prevent. Under ModeDatabase and ModeSchema, "tenant_id = ?" is a bug either way: the column does not exist, or it does and you have double-filtered a database that already holds one tenant's rows. So this panics at declaration time, in the same breath as the model that got it wrong.

Example

A tenant_id column on a shared table. The handler is ordinary playsql.

package main

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"

	"github.com/martin3zra/playsql"
	"github.com/martin3zra/tenancy"
)

type User struct {
	ID     int    `db:"id"`
	Active bool   `db:"active"`
	Name   string `db:"name"`
}

func main() {
	var sharedDB *sql.DB

	tnc, err := tenancy.New(tenancy.ModeRow, "postgres",
		tenancy.Shared(sharedDB),
		tenancy.Column("tenant_id"),
	)
	if err != nil {
		log.Fatal(err)
	}

	// A model opts in by declaring the scope; see Post below. A model that
	// declares nothing is global.
	db := tnc.DB()

	http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
		users, err := playsql.Query[User](db).WhereEq("active", true).Get(r.Context())
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(w, "%d users", len(users))
	})
}

func (*Tenancy) Tenants

func (t *Tenancy) Tenants(ctx context.Context) ([]string, error)

Tenants returns the tenant keys List reports. Each is built on it; it is exported because the errgroup version of Each is yours to write, and this is the half of it that would otherwise be unreachable.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL