Skip to content
GitHub

Architecture

Internal design of the lexigram-tenancy package.


flowchart BT
    Web[lexigram-web<br/>ASGI routing · middleware chain]
    Tenancy[lexigram-tenancy<br/>Resolution · Enforcement · Lifecycle<br/>Isolation · Config Overrides]
    Sql[lexigram-sql<br/>DB access · query scoping]
    Cache[lexigram-cache<br/>Key-value cache]
    Events[lexigram-events<br/>Domain event bus]
    Auth[lexigram-auth<br/>JWT · authentication]

    Web --> |resolves tenant|Tenancy
    Tenancy --> |scoped queries|Sql
    Tenancy --> |prefixed keys|Cache
    Tenancy --> |publishes events|Events
    Tenancy --> |reads claims|Auth

flowchart LR
    subgraph Models[Supported Isolation Models]
        RL[Row-level<br/>tenant_id column<br/>on every table]
        SP[Schema-per-tenant<br/>PostgreSQL schema<br/>per tenant]
        DP[Database-per-tenant<br/>Separate DB<br/>per tenant]
    end

    subgraph Defaults[Default Stack]
        DM[Row-level ✓<br/>Registry default]
        DS[Schema ✗<br/>Register explicitly]
        DD[Database ✗<br/>Must subclass]
    end

    RL --> DM
    SP --> DS
    DP --> DD

Row-level isolation is the default and requires no provisioning. Schema-per-tenant (SchemaIsolationStrategy) and database-per-tenant (DatabaseIsolationStrategy) are reference implementations that must be explicitly registered or subclassed.


The CompositeResolver iterates registered resolvers in priority order and returns the first non-None result. Priority is a trust signal — lower numbers are tried first.

flowchart LR
    Request[HTTP Request] --> JWT[JWTClaimTenantResolver<br/>priority 10]
    JWT -->|claim found| Done[Tenant ID resolved]
    JWT -->|no claim| Header[HeaderTenantResolver<br/>priority 20]
    Header -->|header found| Done
    Header -->|no header| Sub[SubdomainTenantResolver<br/>priority 30]
    Sub -->|subdomain match| Done
    Sub -->|no match| Path[PathTenantResolver<br/>priority 40]
    Path -->|path segment| Done
    Path -->|no match| None[None — no tenant]
ResolverSourcePriorityConfig Key
JWTClaimTenantResolverDecoded JWT claims["tenant_id"]10 (highest trust)jwt_claim_key
HeaderTenantResolverHTTP header (default x-tenant-id)20header_name
SubdomainTenantResolverHost subdomain (e.g. acme.app.com)30subdomain_pattern
PathTenantResolverURL path segment (e.g. /tenants/{tenant_id}/)40path_pattern

Resolution produces an immutable TenantResolutionContext (headers, host, path, claims) passed to every resolver. The resolved tenant ID is set in the shared Context under TENANT_ID and stored in scope["state"]["tenant"].


The bundle provider delegates to four focused sub-providers:

sequenceDiagram
    participant App as Application
    participant TP as TenancyProvider
    participant RP as TenantResolutionProvider
    participant LP as TenantLifecycleProvider
    participant CP as TenantConfigProvider
    participant IP as TenantIntegrationProvider
    participant MW as MiddlewareRegistry

    App->>TP: register(container)
    TP->>RP: register() — ResolverRegistry, CompositeResolver
    TP->>LP: register() — InMemoryTenantProvider, IsolationStrategyRegistry
    TP->>CP: register() — no-op (deferred to boot)
    TP->>IP: register() — no-op (deferred to boot)

    Note over App,IP: Container freeze

    App->>TP: boot(container)
    TP->>RP: boot() — TenantValidator, TenantContextMiddleware
    RP->>MW: register_middleware(factory)
    TP->>LP: boot() — TenantProvisioner, TenantLifecycleService
    TP->>CP: boot() — CachedTenantConfigProvider, TenantConfigService
    TP->>IP: boot() — TenantCacheKeyDecorator, TenantSQLContextBridge
    IP->>MW: register_middleware(bridge_factory)

Registration order is deterministic: resolution → lifecycle → config → integration. Boot follows the same order. Shutdown runs in reverse.

The sub-provider pattern mirrors lexigram-auth’s AuthBundleProvider — each sub-provider has a single responsibility and can be reasoned about independently.


From lexigram-contracts (lexigram.contracts.tenancy.*):

SymbolKindPurpose
TenantResolverProtocolProtocolResolve tenant ID from request context
TenantProviderProtocolProtocolCRUD operations on tenant records
TenantConfigProviderProtocolProtocolPer-tenant key-value config store
TenantIsolationStrategyProtocolProtocolPluggable data isolation mechanics
TenantInfoTypeCore tenant identity record (frozen dataclass)
TenantResolutionContextTypeImmutable request snapshot for resolution
TenantStatusEnumACTIVE, INACTIVE, SUSPENDED, PROVISIONING
CreateTenantCommandTypeCommand to create a new tenant
UpdateTenantCommandTypeCommand to update mutable tenant fields
TenantProvisionedEventPublished after tenant creation + provisioning
TenantActivatedEventPublished when tenant is activated
TenantDeactivatedEventPublished when tenant is deactivated
TenantSuspendedEventPublished when tenant is suspended
TenantConfigChangedEventPublished on per-tenant config mutation
TenantErrorExceptionBase tenancy domain error
TenantNotFoundErrorExceptionTenant not found
TenantInactiveErrorExceptionOperation on inactive tenant
TenantResolutionErrorExceptionResolution failure
TenantProvisioningErrorExceptionIsolation provisioning failure
TenantSlugConflictErrorExceptionDuplicate slug on creation
TenantSuspendedErrorExceptionOperation on suspended tenant
TenantConfigErrorExceptionConfig access/mutation failure

The TenancyConfig has four sub-configs loaded from the tenancy: key in application.yaml with LEX_TENANCY__* env var overrides:

lexigram/tenancy/di/provider.py
class TenancyProvider(Provider):
name = "tenancy"
priority = ProviderPriority.INFRASTRUCTURE
def __init__(self, config: TenancyConfig | None = None) -> None:
self._sub_providers: list[Provider] = [
TenantResolutionProvider(self._config.resolution),
TenantLifecycleProvider(self._config.lifecycle),
TenantConfigProvider(self._config.overrides),
TenantIntegrationProvider(self._config.integration),
]

Registration creates the resolver chain (populated via ResolverRegistry.from_config()), the default in-memory tenant store, the isolation strategy registry, the cached config provider, and integration bridges (cache key prefixing and SQL context propagation). Boot wires the ASGI middleware into lexigram-web’s middleware registry when available.


sequenceDiagram
    participant C as Client
    participant MW as TenantContextMiddleware
    participant R as CompositeResolver
    participant V as TenantValidator
    participant G as TenantGuard
    participant S as Service

    C->>MW: HTTP Request
    MW->>MW: build TenantResolutionContext
    MW->>R: resolve(context)
    R->>R: try JWTClaimTenantResolver
    R->>R: try HeaderTenantResolver
    R-->>MW: tenant_id
    MW->>V: validate(tenant_id)
    V->>V: cache lookup (TTL)
    V->>V: provider.get_tenant()
    V-->>MW: TenantInfo
    MW->>MW: set TENANT_ID in Context
    MW->>MW: store tenant in scope["state"]
    MW->>G: downstream middleware
    G->>S: guard allows request?
    S-->>G: Yes (tenant is ACTIVE)
    G-->>C: 200 OK
    Note over G,C: TenantGuard returns 403<br/>if no tenant or not ACTIVE

The middleware never rejects — it only resolves and sets context. TenantGuard performs route-level rejection via @use_guards(TenantGuard).


SymbolDescription
ENV_PREFIXLEX_TENANCY__
ENV_NESTED_DELIMITER__
DEFAULT_HEADER_NAMEx-tenant-id
DEFAULT_PATH_PATTERN/tenants/{tenant_id}/
DEFAULT_JWT_CLAIM_KEYtenant_id
DEFAULT_VALIDATOR_CACHE_TTL300 seconds
DEFAULT_CONFIG_CACHE_TTL60 seconds
__version__Package version

PointInterfaceDefaultCustomise by
Tenant storeTenantProviderProtocolInMemoryTenantProviderRegister your own singleton before boot
Config storeTenantConfigProviderProtocolCachedTenantConfigProvider wrapping in-memory storeRegister your own implementation
ResolversTenantResolverProtocolBuilt-in: JWT claim, header, subdomain, pathImplement protocol, register in ResolverRegistry
IsolationTenantIsolationStrategyProtocolRow-level (no-op)Implement protocol, register in IsolationStrategyRegistry
ValidationTenantValidatorBuilt-in (status check with TTL cache)Subclass or replace via container override
Lifecycle hooksHook names (tenant.resolved, etc.)NoneSubscribe via framework hook registry
Event handlingDomain events (TenantProvisioned, etc.)Published to event busSubscribe via lexigram-events handlers
CLI generatorslexigram tenancy gen resolverResolver scaffoldExtend cli/generators/resolver.py

Custom resolvers implement TenantResolverProtocol (requires name, priority, and resolve(context) -> str | None). Register via ResolverRegistry.register() in a provider override or with ResolverRegistry.from_config() by extending the from_config classmethod.