Skip to main content

Tenant Resolver & Caching

The Tenant Resolver system handles multi-tenant resolution with a cache-first architecture. Its goal is to resolve which tenant a request belongs to with minimal database load.

Architecture Overview

The system uses a three-layer architecture:

LayerResponsibility
TenantServiceProviderRequest bootstrap, domain extraction
TenantResolverServiceRouting, short-circuit optimization
TenantResolverUnifiedQueryServiceCache management, query execution
CurrentTenantSingleton container, app configuration

Request Lifecycle

Resolution happens during Laravel's boot phase, before any controller code runs. The TenantServiceProvider extracts the domain from the request, resolves the tenant (using cache when possible), and configures the application.

The tenant() helper returns the current tenant instance from the CurrentTenant singleton. All application code uses this to access the resolved tenant.

Caching Strategy

Two-Tier Caching

The system uses a domain pointer pattern:

domain:example.com  →  "42"           (pointer to ID)
id:42 → {full payload} (actual data)

Why this design?

  • IDs are stable, domains change. When a tenant updates their domain, only the pointer needs updating.
  • Many domains can point to one tenant. Custom domains, subdomains, and aliases all resolve to the same payload.
  • Single source of truth. The ID-based cache is authoritative, simplifying invalidation logic.

TTL Randomization

Cache entries expire with a randomized TTL (700-1000 seconds).

Why randomize? After a deploy or cache flush, all workers would otherwise hit the database simultaneously when cache expires. Randomized TTL spreads the refresh across time, preventing load spikes.

Lock Mechanism

When cache misses occur, a lock prevents multiple workers from executing the same query:

  1. First worker acquires lock, queries database, stores result
  2. Other workers wait (up to 3 seconds) for the lock
  3. When lock releases, waiting workers use the cached result
  4. If lock times out, worker queries independently (graceful degradation)

Why locks? Without them, a cache miss on a popular tenant could trigger hundreds of identical queries from concurrent workers.

Unified Payload

The cache stores a complete tenant payload in a single entry:

DataWhy Included
TenantCore tenant record
TenantMetaExtended settings and configuration
SalesSettingsPayment and commerce configuration
SignUpIntentPlan and billing information
FeaturesPre-computed feature flags and limits

Why unified?

  • Eliminates N+1 queries. A single JOIN query fetches everything instead of separate queries per relation.
  • Single cache entry. One cache hit returns all tenant data needed for request handling.
  • Features pre-computed. The Features System values are computed at cache time, making feature checks instant.

Cache Invalidation

Invalidation happens automatically via Laravel model observers:

WhenWhat's Invalidated
Tenant saved/deletedID cache + all domain pointers
TenantMeta saved/deletedID cache (via tenant lookup)
SalesSettings saved/deletedID cache (via tenant lookup)
SignUpIntent saved/deletedID cache (via tenant lookup)
Plan/Tenant features changedID cache (via tenant lookup)

Domain pointer healing: When a domain pointer references an expired or flushed ID cache, the next request triggers a fresh query and repopulates both caches.

Manual invalidation: Flush via the unified query service to force cache refresh.

Components Reference

ComponentLocationPurpose
TenantServiceProviderapp/Providers/Boots tenant resolution on every request; extracts domain from Host header or X-Farfalla-Tenant-Id
TenantResolverServiceapp/Domains/Shared/Services/Routing layer with short-circuit optimization; returns current tenant immediately if domain matches
TenantResolverUnifiedQueryServiceapp/Domains/Shared/Services/Execution engine; manages cache, locks, unified queries, and payload hydration
CurrentTenantapp/Singleton holding resolved tenant; configures app URL, locale, cache prefix, and storage paths
X

Graph View