Skip to content
GitHub

Guide

PackageRequiredPurpose
lexigramYesCore framework
lexigram-contractsYesProtocol definitions
lexigram-sqlOptionalSchema-per-tenant
lexigram-cacheOptionalCached tenant config

Multi-tenant SaaS applications need to:

  1. Identify which tenant owns each request
  2. Validate the tenant is active and not suspended
  3. Isolate tenant data at the database level
  4. Provide per-tenant configuration overrides

Doing this ad-hoc leads to scattered tenant checks, inconsistent isolation, and subtle data leaks. lexigram-tenancy solves this with a declarative resolution chain, pluggable validation, and strategy-based isolation.

HTTP Request
[Resolution Chain] ── tries resolvers in priority order
│ header → subdomain → path → JWT claim
[Tenant ID] ──► [Validator] ──► [Context TENANT_ID set]
[Per-tenant config overrides]
[Isolation Strategy] ──► row / schema / database level

Tenancy runs as ASGI middleware. Every request flows through resolution regardless of whether the route is tenant-scoped — the resolved tenant_id is set on Context and available to all downstream services.

Resolvers are tried in priority order. The first to return a non-None tenant ID wins. Built-in resolvers:

ResolverNamePriorityReads
JWT claimjwt_claim10scope["state"]["auth_claims"]["tenant_id"]
Headerheader20X-Tenant-Id HTTP header
Subdomainsubdomain30Host header (acme.app.comacme)
Pathpath40URL path (/tenants/acme/...)

Configure order and naming via ResolutionConfig.resolvers.

TenantValidator caches TenantInfo lookups and checks that the tenant is not INACTIVE or SUSPENDED. The resolution middleware never rejects — it resolves if possible and sets scope["state"]["tenant"] for downstream guards.

StrategynameHow it works
Row-levelrow_levelAll tenants share tables; tenant_id column filters queries
SchemaschemaEach tenant gets a Postgres schema
DatabasedatabaseEach tenant gets a separate database

Set via LifecycleConfig.isolation_strategy.

TenantConfigService provides a cached key-value overlay on top of TenantConfigProviderProtocol. Application config defaults are overridden per-tenant without code changes. Supports optional event bus integration for config change notifications.

from lexigram import Application
from lexigram.tenancy import (
TenancyModule,
TenancyConfig,
ResolutionConfig,
LifecycleConfig,
)
config = TenancyConfig(
resolution=ResolutionConfig(
resolvers=["header", "jwt_claim"],
header_name="x-tenant-id",
),
lifecycle=LifecycleConfig(
isolation_strategy="row_level",
auto_provision_isolation=True,
),
)
app = Application(name="my-app")
app.add_module(TenancyModule.configure(config=config))
  • lexigram-webTenantContextMiddleware registers automatically as ASGI middleware when lexigram-web is present
  • lexigram-cacheTenantCacheKeyDecorator prefixes cache keys with the active tenant ID (opt-in via IntegrationConfig)
  • lexigram-sqlTenantSQLContextBridge syncs the tenant context into SQL scoped sessions (opt-in via IntegrationConfig)
  • Default to row-level isolation — it’s the simplest, most portable strategy
  • Use header resolution in production — it’s explicit and works with any client
  • Provide a JWT claim fallback — for machine-to-machine auth where headers are insufficient
  • Set auto_provision_isolation=True — isolation setup happens atomically with tenant creation
  • Don’t rely on subdomain resolution in devlocalhost has no meaningful subdomain
  • Don’t resolve tenants in background workers — there’s no request context; use explicit tenant IDs instead