Multi-Tenancy¶
Use this guide to run one event-sourced application for many tenants: bind a tenant to the current request, stamp that tenant onto every event you emit, and have the repository reject writes that mix tenants.
Multi-tenancy support gives you four pieces to wire together, split across
domain/tenant_context.py, domain/tenant_events.py,
application/aggregates/tenant_repository.py, and domain/exceptions.py —
all reachable from the top-level eventsource package except
TenantAwareRepository, which was never re-exported from the front door:
- Tenant context —
tenant_scope,tenant_scope_sync,set_current_tenant,clear_tenant_context,get_current_tenant, andget_required_tenant, backed by thetenant_contextContextVarso the tenant follows your code acrossawaitboundaries. TenantDomainEvent— aDomainEventsubclass wheretenant_idis required rather than optional, withwith_tenant_context(...)to fill it in from the ambient scope.TenantAwareRepository— a wrapper aroundAggregateRepositorythat validates the tenant of every uncommitted event before it is persisted, and can optionally require tenant context on load.TenantContextNotSetErrorandTenantMismatchError— the two failures you need to handle at your request boundary.
Follow the steps in order: context first, then events, then the repository wrapper, then the configuration and error handling that match how strict you need to be. The final sections cover nesting scopes for background work and the isolation guarantees this wrapper does not provide — read those before you rely on it as your only tenant boundary.
Before you start¶
You need:
- A working aggregate and repository.
TenantAwareRepositorywraps an existingAggregateRepository[TAggregate]; it never constructs one for you. If you do not have a repository wired to an event store yet, set that up first. - A tenant identifier that is a
UUID. Every entry point in this package is typed onuuid.UUID:tenant_contextis aContextVar[UUID | None],set_current_tenantandtenant_scopetake aUUID, andTenantDomainEvent.tenant_idis a requiredUUIDfield. String tenant slugs must be resolved to a UUID at your request boundary. - A place to establish context per request. Middleware, a framework
dependency, or a job wrapper — anywhere you can wrap the unit of work in
async with tenant_scope(tenant_id)(orwith tenant_scope_sync(...)in sync code). Context is per execution context, not global.
Imports come from two places:
from eventsource import (
TenantContextNotSetError,
TenantDomainEvent,
TenantMismatchError,
)
from eventsource import (
TenantAwareRepository,
clear_tenant_context,
get_current_tenant,
get_required_tenant,
set_current_tenant,
tenant_context,
tenant_scope,
tenant_scope_sync,
)
TenantDomainEvent, both tenant exceptions, and every context helper shown
above are re-exported from the top-level eventsource package.
TenantAwareRepository is the one exception — it is not re-exported from
the front door; import it directly from
eventsource.application.aggregates.tenant_repository.
No extra dependency is required: multi-tenancy is part of the core install and
uses nothing beyond pydantic and the standard library's contextvars.
Two decisions to make before you write code, because they change what the rest of this guide tells you to configure:
- Whether every event on your aggregates is tenant-scoped. Genuinely global
events can stay plain
DomainEventsubclasses —DomainEventdeclarestenant_id: UUID | None, so an absent tenant is representable. See "Mixing tenant and non-tenant events on one aggregate". - Whether you are adopting this on an existing store. Events written before
you introduced tenants will not carry a
tenant_id. Plan to start in migration mode (validate_on_save=False) rather than the default, and read Step 4 before flipping any switch.
Finally, know the boundary of this feature up front: TenantAwareRepository
validates the tenant of the events you are about to write. It does not filter
events on load and it does not partition storage — physical isolation remains
your event store's responsibility. See "Limitations" at the end of this guide.
Step 1: Establish tenant context at the request boundary¶
Everything downstream — with_tenant_context(...), TenantAwareRepository,
get_required_tenant() — reads one ContextVar:
Set it once, as far out as you can: HTTP middleware, a framework dependency, a consumer loop that reads the tenant off a message, a job wrapper. Do not set it inside handlers or aggregates — by then it is too late for the code that ran before it, and too easy to miss a path.
Prefer the scoped context managers over the raw setters. They restore the
previous value in a finally block, so context is cleaned up even when the
request raises.
Async entrypoints: async with tenant_scope(tenant_id)¶
tenant_scope is an async context manager that sets the tenant on entry, yields
it, and resets it on exit:
from uuid import UUID
from eventsource import tenant_scope
async def handle_request(request) -> Response:
tenant_id: UUID = resolve_tenant(request) # your own lookup
async with tenant_scope(tenant_id):
return await process(request)
The yielded value is the tenant ID, so async with tenant_scope(tid) as tenant:
works if you want it bound to a name.
As ASGI middleware, wrap the downstream call:
class TenantMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
tenant_id = tenant_from_headers(scope["headers"])
async with tenant_scope(tenant_id):
await self.app(scope, receive, send)
Because the reset happens in finally, an exception raised inside the scope
propagates normally and the previous tenant is restored on the way out. The
reset is token-based (tenant_context.reset(token)), not a clear, so it
restores whatever was set before — None at the top level, the outer tenant
inside a nested scope. See "Nesting tenant scopes" below.
Entry and exit are logged at DEBUG on eventsource.domain.tenant_context
("Tenant scope entered: %s" / "Tenant scope exited: %s"), which is the
quickest way to confirm the scope wraps the whole request rather than part of
it.
Sync entrypoints: with tenant_scope_sync(tenant_id)¶
tenant_scope_sync is the same context manager for synchronous code — a WSGI
app, a Celery task, a CLI command, a test fixture. It sets the tenant on entry,
yields it, and resets it in a finally on exit:
from uuid import UUID
from eventsource import tenant_scope_sync
def run_report(tenant_id: UUID) -> Report:
with tenant_scope_sync(tenant_id):
return build_report()
Like the async variant it yields the tenant ID, so with
tenant_scope_sync(tid) as tenant: binds it to a name, and its reset is
token-based — leaving the block restores whatever was set before, not None.
Nesting therefore behaves the same way:
with tenant_scope_sync(tenant_a):
assert get_current_tenant() == tenant_a
with tenant_scope_sync(tenant_b):
assert get_current_tenant() == tenant_b
assert get_current_tenant() == tenant_a
An exception raised inside the block propagates as normal and the previous tenant is still restored on the way out.
Both variants write to the same tenant_context variable, so mixed-colour call
stacks work: a sync entrypoint that enters tenant_scope_sync and then calls
asyncio.run(...) passes the tenant into the coroutine, because the task
asyncio.run creates copies the current context. The reverse — an async request
that offloads work with asyncio.to_thread(...) — also carries the tenant into
the worker thread. What does not carry it is a bare ThreadPoolExecutor.submit
or threading.Thread; see "How context propagates" below.
Pick the variant that matches the colour of the function you are wrapping. Do
not wrap an await in tenant_scope_sync: a synchronous with block does not
survive a suspension point, so a second task resuming on the same event loop can
observe your tenant. Use async with tenant_scope(...) in any coroutine.
Entry and exit log at DEBUG on eventsource.domain.tenant_context as
"Tenant scope (sync) entered: %s" / "Tenant scope (sync) exited: %s" —
distinct strings from the async variant, so you can tell from a log which one a
given code path took.
Manual control: set_current_tenant / clear_tenant_context and the returned Token¶
Use the raw setters only when the set and the reset cannot live in one lexical
block — a framework with separate before_request / after_request hooks, a
test fixture with setup and teardown phases. Everywhere else, prefer the scope
managers above; they do exactly this, correctly, in a finally.
from eventsource import (
clear_tenant_context,
set_current_tenant,
tenant_context,
)
token = set_current_tenant(tenant_id)
try:
do_work()
finally:
tenant_context.reset(token)
set_current_tenant(tenant_id) is a thin wrapper over
tenant_context.set(tenant_id) that logs and returns the
contextvars.Token[UUID | None] the ContextVar produced. Note the import:
tenant_context itself is exported from eventsource (defined in
eventsource.domain.tenant_context), and you need it — the example above
bypasses reset_tenant_context(token), so
restoring goes through the ContextVar directly.
Keep the token. Passing it to tenant_context.reset(token) restores
whatever the value was immediately before that particular set — None at the
top level, the outer tenant inside a nested set. That is what makes nesting
correct. If you discard the token you can only clear, not restore.
Standard contextvars rules apply to the token, and they are the sharp edges of
doing this by hand:
- A token may be reset once; a second
resetwith the same token raisesRuntimeError. - A token must be reset in the same context that created it. A token created
in a request handler and reset inside a task spawned from it raises
ValueError. Framework hooks that run the before/after pair in different contexts (some worker pools do) will fail this way — if you cannot guarantee one context, wrap the unit of work intenant_scopeinstead.
clear_tenant_context() is a different operation, not a token-free reset. It
sets the variable to None and invalidates every outstanding token in the
current execution context:
set_current_tenant(tenant_a)
token = set_current_tenant(tenant_b)
clear_tenant_context()
assert get_current_tenant() is None # tenant_a is gone, not restored
reset_tenant_context(token) # raises TenantContextResetError: token is invalidated
So calling it inside a nested scope wipes the outer tenant, and a later
reset_tenant_context() call — including the implicit one an enclosing
tenant_scope()/tenant_scope_sync() performs on exit — raises
TenantContextResetError instead of restoring anything, because the token it
holds no longer matches the current state. Never call clear_tenant_context()
inside an active scope unless you want that scope's exit to fail loudly; it
is a hard reset for the outermost boundary — the end of a request in a pooled
worker, or between tests — not a tool to use mid-scope.
Its docstring carries one more caveat worth repeating: clearing affects only the current execution context. Concurrent tasks each hold their own copy, so clearing in one does not clear the others.
Both functions log at DEBUG on the eventsource.domain.tenant_context logger
("Tenant context set: %s" and "Tenant context cleared"), distinct from the
scope managers' messages, so a debug log tells you which mechanism a code path
used.
Reading context: get_current_tenant() vs get_required_tenant()¶
Two readers, both of which do nothing more than read tenant_context. They
differ only in what they do when nothing is set:
get_current_tenant() # -> UUID | None; never raises
get_required_tenant() # -> UUID; raises TenantContextNotSetError when unset
Reach for get_current_tenant() where an absent tenant is a legitimate state
you want to branch on — optional filtering, diagnostics, logging, helpers that
run both inside a request and from a maintenance script. Its contract is that
it never raises, which also makes it the safe choice in except blocks and
teardown paths:
from eventsource import get_current_tenant
tenant_id = get_current_tenant()
if tenant_id is None:
logger.info("running outside a tenant scope; skipping tenant filter")
Reach for get_required_tenant() in code that has no meaningful behaviour
without a tenant. It fails at the point the tenant was needed, with a message
that names the fix:
No tenant context set. Use set_current_tenant() or tenant_scope() before
performing multi-tenant operations.
That is far more actionable in a log than the None dereference or validation
error you would otherwise hit a few frames later. TenantContextNotSetError
takes no constructor arguments — the message is fixed — so catch it by type,
never by matching the string.
from uuid import UUID
from eventsource import TenantContextNotSetError
from eventsource import get_required_tenant
def audit_prefix() -> str:
tenant_id: UUID = get_required_tenant()
return f"tenant:{tenant_id}"
Both readers see exactly what the enclosing scope set, and nothing once it exits:
from eventsource import (
get_current_tenant,
get_required_tenant,
tenant_scope,
)
assert get_current_tenant() is None # outside any scope
async with tenant_scope(tenant_id):
assert get_current_tenant() == tenant_id
assert get_required_tenant() == tenant_id
assert get_current_tenant() is None # restored on exit
You will rarely call get_required_tenant() yourself on the write path,
because the library already calls it for you:
TenantDomainEvent.with_tenant_context(...) uses it to fill in tenant_id,
and TenantAwareRepository uses it in save() and — when configured to
enforce on load — in its load paths. A TenantContextNotSetError raised from
either means the same thing as one raised from your own code: the unit of work
was not wrapped in a scope. Fix that at the request boundary (Step 1) rather
than catching the error where it surfaced.
A rule that covers the choice: use get_required_tenant() whenever the next
line would be wrong with None, and get_current_tenant() only when you
actually write the if ... is None branch. A bare get_current_tenant() whose
result is passed straight into a query filter or an event field is the pattern
that silently produces untenanted rows.
How context propagates across await, asyncio.create_task, and thread pools¶
Nothing in eventsource.domain.tenant_context implements propagation — tenant_context
is a plain ContextVar[UUID | None], and standard contextvars semantics
decide where the tenant is visible. Internalise those semantics, because the
failure mode is silent: a task that sees the wrong tenant, or none, produces
mis-stamped or rejected events far from the code that lost the context.
The single rule: context flows forward into work started while it is set, and never backward or sideways into work that already exists.
-
Across
await: the tenant follows the coroutine. A task keeps one context for its whole lifetime, so anything youawaitinside a scope sees the tenant, however deep the call stack — including the repository's internalget_required_tenant()calls. -
asyncio.create_task/asyncio.gatherinside a scope: the child copies the current context at creation time, so it inherits the tenant. A task created insidetenant_scope(tenant_id)reads that tenant even though it never entered a scope of its own:
async def inner() -> UUID | None:
return get_current_tenant()
async with tenant_scope(tenant_id):
assert await asyncio.create_task(inner()) == tenant_id
- Concurrent tasks are isolated: because each child gets a copy, not a share, tasks that interleave on the same event loop cannot see each other's tenant, and a scope entered inside a child never leaks back to the parent. Enter one scope per task and let them run together:
async def handle(tenant_id: UUID) -> None:
async with tenant_scope(tenant_id):
await asyncio.sleep(0) # any suspension point
assert get_current_tenant() == tenant_id
await asyncio.gather(handle(tenant_a), handle(tenant_b))
This holds at any width — twenty tasks with twenty tenants and staggered awaits each still observe only their own.
-
Tasks created before the scope: a task created outside (or before) the scope copied the context as it was then, and entering a scope in the parent afterwards does not reach it. Establish context before spawning work.
-
Background tasks that outlive the request: a task spawned inside
tenant_scopekeeps the tenant after the scope exits, because it holds its own copy — the parent'stenant_context.reset(token)does not touch it. That is usually what you want for fire-and-forget work. If it is not, pass the tenant explicitly and open a fresh scope inside the task. -
asyncio.to_thread(...): copies the current context into the worker thread, so the tenant carries over andtenant_scope_syncis not needed inside the callable. -
Bare threads and executors:
ThreadPoolExecutor.submit(...)and a manually constructedthreading.Threaddo not copy context. A new thread starts empty,get_current_tenant()returnsNone, andget_required_tenant()raisesTenantContextNotSetError. Either re-establish the tenant inside the worker:
tenant_id = get_required_tenant() # capture on the calling thread
def work() -> None:
with tenant_scope_sync(tenant_id): # re-establish inside the worker
do_work()
executor.submit(work)
or submit through a captured context: executor.submit(copy_context().run,
work).
asyncio.run(...)from sync code: the loop's main task copies the calling thread's context, sowith tenant_scope_sync(tid): asyncio.run(main())carries the tenant into the coroutine.
Two consequences worth calling out. First, clear_tenant_context() clears only
the calling context — concurrent tasks hold their own copies and are unaffected,
which is why it is safe as a per-request reset but useless as a global one.
Second, a contextvars.Token can only be reset in the context that created it;
handing a token across a task boundary and resetting it there raises
ValueError. Both are reasons to prefer the scope managers over manual setters
anywhere work fans out.
Scope a projection to the request's tenant (callable filter)¶
When one projection instance serves many tenants — a request-scoped read model,
or a single reader whose tenant is decided by the ambient context — pass a
zero-argument callable returning UUID | None instead of a fixed UUID:
from eventsource import get_current_tenant, tenant_scope
from eventsource.application.projections import DeclarativeProjection
projection = OrderSummaryProjection(tenant_filter=get_current_tenant)
async with tenant_scope(acme):
await projection.handle(order_created) # filtered against `acme`
get_current_tenant matches the required signature exactly (no arguments,
returns UUID | None), so it can be handed over as-is — do not call it at
construction time, or you would freeze whatever tenant happened to be current
then.
_get_tenant_filter_value() invokes the callable once per event, from
_process_event, so the effective tenant is re-resolved on every handle()
call and may differ between events:
async with tenant_scope(acme):
await projection.handle(acme_event) # processed
async with tenant_scope(globex):
await projection.handle(globex_event) # processed
await projection.handle(acme_event) # skipped
Any callable works, not just the contextvar getter — a closure over a mutable cell, a lookup on a request object, or a method bound to your web framework's context:
def current_tenant_for_worker() -> UUID | None:
return worker_state.tenant_id
projection = OrderSummaryProjection(tenant_filter=current_tenant_for_worker)
Two consequences worth planning for:
- Outside any tenant scope the callable returns
None, andNonemeans no filtering rather than no events — every event is processed. If a stray unscopedhandle()call would corrupt the read model, either assert the scope yourself before dispatching (get_required_tenant()raisesTenantContextNotSetErrorwhen unset) or use a staticUUIDfilter instead. - The callable runs on the projection's event-handling path, so keep it
cheap and non-blocking. A
ContextVarread is effectively free; a database lookup per event is not — resolve that once and cache it in the cell your callable reads.
If the projection is driven by a subscription runner rather than by your request
handler, remember that the runner, not the request, calls handle(). Establish
the scope around that call — for example inside the handler you register with
the subscription — or fall back to one instance per tenant (see "Run one
projection instance per tenant").
How filter values are resolved: _get_tenant_filter_value¶
Both filter styles funnel through one method,
DeclarativeProjection._get_tenant_filter_value(), which normalizes whatever
you passed to tenant_filter into a plain UUID | None:
tenant_filter you passed |
Resolved value |
|---|---|
None (default) |
None — no filtering |
a UUID |
that same UUID, unchanged |
| a callable | whatever the callable returns, re-evaluated per call |
The implementation is exactly that three-branch check: None short-circuits,
isinstance(self._tenant_filter, UUID) returns the stored value, and anything
else is invoked with no arguments. There is no coercion step — a filter that is
neither None nor a UUID is called, so passing a str raises TypeError
at event-handling time rather than at construction. Convert strings yourself:
The resolved value feeds _should_process_event(event), which treats None as
"process everything". So a callable returning None — get_current_tenant
outside any tenant scope, for instance — disables filtering for that event
instead of dropping it.
Use the method directly when you want to see what a projection would filter on right now, without dispatching an event:
from eventsource import get_current_tenant, tenant_scope
projection = OrderSummaryProjection(tenant_filter=get_current_tenant)
assert projection._get_tenant_filter_value() is None # unscoped
async with tenant_scope(acme):
assert projection._get_tenant_filter_value() == acme
Two behaviours to keep in mind when your callable is not a cheap contextvar read:
- It is called more than once per skipped event.
_process_eventresolves the value inside_should_process_event, then twice more while building the DEBUG skip log's message andextrafields. A callable with side effects (a counter, a lazily-opened session) will see those extra invocations, and an expensive one pays for them. - Nothing is cached between events. The stored
tenant_filteris kept as-is; only the resolution is repeated. That is what makes the callable form track the ambient tenant, and why a staticUUIDfilter is the cheaper choice when the tenant never changes for that instance.
What gets processed and what gets skipped¶
The decision is made by DeclarativeProjection._should_process_event(event),
called at the top of _process_event before any handler lookup. It compares the
resolved filter value (previous section) against
getattr(event, "tenant_id", None) — four cases, all listed below.
Events whose tenant_id matches the filter -> processed¶
The comparison is plain UUID equality — bool(event_tenant == filter_value) —
so a UUID rebuilt from its string form matches a UUID constructed any other
way, and neither side is normalized, parsed, or string-compared:
acme = UUID("11111111-1111-1111-1111-111111111111")
projection = OrderSummaryProjection(tenant_filter=acme)
event = OrderCreated(aggregate_id=uuid4(), tenant_id=UUID(str(acme)))
await projection.handle(event) # processed
On a match, _should_process_event returns True and _process_event
continues straight into the normal dispatch path: handler lookup in the
registry, then dispatch (inside the eventsource.projection.handler span when
tracing is enabled). Nothing else about the event is inspected, and behaviour is
identical to a projection constructed with no filter at all — including the
unregistered-event path, so a matching event with no registered handler still
follows your unregistered_event_handling setting.
The same holds for the callable form, resolved per event: an event is processed whenever the filter at that moment equals the event's tenant.
async with tenant_scope(acme):
await projection.handle(acme_event) # resolved filter == acme -> processed
Checkpointing is unaffected: a processed event advances the checkpoint exactly as it always would, after the handler returns.
Events whose tenant_id differs -> skipped silently (logged at DEBUG)¶
When _should_process_event returns False, _process_event emits one DEBUG
record and returns. There is no other signal — plan around these four
consequences:
- No exception, no DLQ entry.
_handle_with_retrysees a normal return, so the retry loop never engages and nothing is written to the DLQ repository. - The handler is never looked up. The filter check runs before
self._handler_registry.get_handler(type(event)), sounregistered_event_handling="error"cannot fire for a skipped event: a filtered-out event of an otherwise unhandled type is simply dropped. - The checkpoint still advances. After
_process_eventreturns,_handle_with_retrycallsrecord_checkpoint(...)(whencheckpoint_repois configured) and logs its usual "processed event" DEBUG line — a skip is indistinguishable from a successful handle at that layer. That is the behaviour you want for a per-tenant reader: it must not stall or rewind on another tenant's traffic. It also means resuming from the checkpoint will not replay skipped events, so changing an instance'stenant_filterdoes not backfill the newly included tenant. - The only trace is the DEBUG record, whose message begins
Skipping event <event_id>: tenant <event tenant> doesn't match filter <filter value>, carryingextrafieldsprojection,event_id,event_type,event_tenant_id, andfilter_tenant_id(the last two stringified, so an absent tenant appears as"None").
Because the message is a DEBUG record, skips are invisible at the default INFO level. Turn them on for the projections logger when you are diagnosing a read model that is missing rows:
Note that the filter value is resolved again while building the message and the
extra mapping, so a callable filter is invoked three times for each skipped
event — one more reason to keep it a cheap contextvar read.
Events with no tenant_id attribute or tenant_id=None -> processed anyway (legacy/system events)¶
_should_process_event reads the tenant defensively —
getattr(event, "tenant_id", None) — so two different situations collapse to
the same None:
- the event class declares
tenant_idbut this instance left it unset.DomainEvent.tenant_idisUUID | Noneand defaults toNone, so this is the common case for anything not built throughTenantDomainEventorwith_tenant_context(...); - the event class has no
tenant_idattribute at all — a model that predates the field, or a non-DomainEventpayload routed through the projection.
Either way the next branch returns True before the equality check ever runs,
so the event is handled exactly as if no filter were set:
acme = UUID("11111111-1111-1111-1111-111111111111")
projection = OrderSummaryProjection(tenant_filter=acme)
await projection.handle(OrderCreated(aggregate_id=uuid4())) # processed
await projection.handle(OrderCreated(aggregate_id=uuid4(), tenant_id=None)) # processed
await projection.handle(LegacyImported(aggregate_id=uuid4())) # processed
This is deliberate. Events written before multi-tenancy existed, and system events that belong to no tenant (schema markers, imports, maintenance events), would otherwise be invisible to every tenant-scoped projection — each per-tenant instance would skip them and the checkpoint would move past them, so nothing would ever see them. Passing them through means each instance sees them once, which is the right default for events that are global by nature.
The consequence to plan around: the filter is not a security boundary. An event with a missing or null tenant reaches the handlers of a projection scoped to any tenant. Two ways to close that:
Reject untenanted events inside the handler, where you can decide per event type:
@handles(OrderCreated)
async def _handle_order_created(self, event: OrderCreated) -> None:
if event.tenant_id is None:
return
...
Or remove the case at the source by deriving your events from
TenantDomainEvent (eventsource.domain.tenant_events, re-exported from
eventsource), which redeclares tenant_id
as a required UUID; construction without a tenant then fails validation
rather than producing an unfiltered event. Note that the guard only helps for
events you control — replayed history written before the switch still carries
tenant_id=None.
tenant_filter=None (default) -> no filtering, all events processed¶
_should_process_event returns True immediately when the resolved value is
None, without touching the event. Omitting tenant_filter therefore preserves
pre-tenant behaviour exactly — and, as noted earlier, a callable that returns
None lands in this same branch, so an unscoped get_current_tenant processes
everything rather than nothing.
| Filter value | Event tenant_id |
Result |
|---|---|---|
None |
anything | processed |
UUID |
equal UUID |
processed |
UUID |
different UUID |
skipped, DEBUG log, checkpoint advances |
UUID |
None / attribute absent |
processed |