Multi-tenancy
A multi-tenant Gebweb app resolves the active tenant per request,
exposes it to handlers, and (in the shared-schema model) scopes
queries by tenant_id. The framework provides the resolver
plumbing and a handful of helpers; the storage layer remains the
app's responsibility.
Wiring
import gebweb;
let app = gebweb.app([HomeController()]);
gebweb.useTenant(app, func(dict<string, any> request): ?gebweb.Tenant {
let headers = request["headers"] as dict<string, any>;
if (!headers.contains("Host")) { return null; }
let host = headers["Host"] as string;
let dot = host.indexOf(".");
if (dot <= 0) { return null; }
return gebweb.Tenant(host.substring(0, dot));
});
The resolver is any callable returning a Tenant (or null). When
null is returned and opts.required is true (the default), the
middleware short-circuits with a 400 Problem Details response.
Pass {"required": false} to let unauthenticated routes through
without a tenant.
The Tenant value lives on request["_tenant"].
| Field | Type | Description |
|---|---|---|
id |
string |
Internal key (UUID, slug, numeric). |
name |
string |
Optional human-readable label. |
attributes |
dict<string, any> |
Anything else the resolver wanted to attach (plan tier, feature flags). |
Tenant injection
Handlers declare gebweb.Tenant as a typed parameter:
class WidgetController {
@Get("/widgets")
func list(gebweb.Tenant tenant): list<Widget> {
return widgetRepo.findAllForTenant(tenant.id);
}
}
Services that don't receive the request directly can resolve from
the request dict via gebweb.currentTenant(request) and
gebweb.currentTenantId(request).
Shared-schema helpers
The 1.1 line supports the most common SaaS shape: one database,
one schema, a tenant_id column on every multi-tenant table.
class WidgetRepo implements repository.Repository<Widget> {
db.Conn conn;
func WidgetRepo(db.Conn conn) { this.conn = conn; }
func save(Widget w): Widget {
if (w.id == "") { w.id = secrets.randomHex(8); }
gebweb.stampTenant(w, currentTenant);
this.conn.exec(
"INSERT INTO widgets (id, tenant_id, name) VALUES (?, ?, ?)",
w.id, w.tenant_id, w.name);
return w;
}
func list(repository.Page page, gebweb.Tenant tenant): list<Widget> {
let q = gebweb.scopedQuery(query.Query("widgets"), tenant)
.orderBy(query.asc("id"))
.limit(page.size)
.offset(page.offset);
let stmt = q.select([]);
...
}
func find(string id, gebweb.Tenant tenant): ?Widget {
let row = this.conn.queryOne(
"SELECT * FROM widgets WHERE id = ? LIMIT 1", id);
if (row == null) { return null; }
let w = Widget(...);
if (!gebweb.tenantOwns(w, tenant)) { return null; }
return w;
}
}
| Helper | Behaviour |
|---|---|
gebweb.stampTenant(entity, tenant) |
Sets entity.tenant_id. |
gebweb.tenantOwns(entity, tenant) |
True when entity.tenant_id equals tenant.id. Use as a guard before returning an entity fetched by id. |
gebweb.scopedQuery(query, tenant) |
Appends tenant_id = ? to a query.Query. |
Per-tenant DB connections
The 1.1 line does not include a connection-per-tenant DI
helper. The recommended pattern: write a service that looks up the
right db.Conn for the resolved tenant and inject the service
into your repositories.
A first-class useTenantDatabase(app, connectionFor) helper plus
the tenant-aware migration runner are scoped for 1.2.
Reference
| Helper | Purpose |
|---|---|
gebweb.useTenant(app, resolver, opts) |
Mount the resolver middleware. |
gebweb.Tenant |
Typed parameter; injected from request["_tenant"]. |
gebweb.currentTenant(request) |
Read the resolved tenant; null when absent. |
gebweb.currentTenantId(request) |
Convenience accessor; empty string when absent. |
gebweb.stampTenant(entity, tenant) |
Set tenant_id. |
gebweb.tenantOwns(entity, tenant) |
Ownership guard. |
gebweb.scopedQuery(query, tenant) |
Add tenant_id = ? to a query. |