Lesson 8 / 32

Propagating Tenant Context

Carry the resolved tenant through every layer, including async code, background jobs and messages, without passing it by hand to every function.

By · 15 years in web development

Set once, read everywhere

Once the edge has resolved the tenant, every layer below needs it: repositories to scope queries, caches to prefix keys, logs to tag lines, metrics to label usage. Threading a tenant_id argument through every function is error-prone, so frameworks offer request-scoped context: Python's contextvars, Java's request-scoped beans or ThreadLocal, Node's AsyncLocalStorage, Go's context.Context. The middleware sets the tenant once and lower layers read it. Two rules make this safe. First, the context must be isolated per request, including when many requests share one thread or event loop; contextvars gives each asyncio task its own copy for exactly this reason. Second, context does not cross process boundaries by itself: when you enqueue a job or publish an event, copy tenant_id into the message explicitly, and have the consumer set the context before doing any work.

contextvars with concurrent requests

Two requests run at the same time on one event loop, yet each sees its own tenant.

import asyncio
from contextvars import ContextVar

current_tenant: ContextVar[str] = ContextVar("current_tenant")

async def load_orders():
    # Deep inside the call stack: no tenant parameter needed
    tenant = current_tenant.get()
    await asyncio.sleep(0.01)
    return f"orders for {tenant}"

async def handle_request(tenant_id: str):
    current_tenant.set(tenant_id)      # set once at the edge
    return await load_orders()

async def main():
    # Two requests run concurrently; each task has its own context copy
    results = await asyncio.gather(handle_request("acme"), handle_request("zen"))
    print(results)

asyncio.run(main())

Output:

['orders for acme', 'orders for zen']

Thread pools reuse threads

With ThreadLocal-style storage on a thread pool, a value set during one request can survive into the next request served by the same thread. Always clear the tenant in a finally block or use framework-managed request scopes, otherwise tenant B's request may run with tenant A's context.

The visitor badge

At an office reception you get a visitor badge naming the company you are visiting. Every guard and door on the way reads the badge instead of asking you again. When you leave, the badge is taken back so the next visitor cannot reuse it. Tenant context is that badge.

Quick check: A web request enqueues a background job to generate a PDF. How should the worker know which tenant the job is for?

  • The tenant_id is copied into the job message and the worker sets its context from it
  • The worker inherits the web request's contextvars automatically
  • The worker reads the tenant from the most recent HTTP request it saw
  • The worker processes the job without tenant context
Answer

The tenant_id is copied into the job message and the worker sets its context from it — In-process context does not travel through a queue. The producer must put tenant_id in the message and the consumer must restore context from it before touching data.