8. Tenant Context
8.1 The contract
public interface IBackgroundJobTenantContext
{
BackgroundJobTenantInfo? CurrentTenant { get; }
Guid? CurrentTenantId { get; }
bool TryGetCurrentTenant(out BackgroundJobTenantInfo? tenant);
}
BackgroundJobTenantInfo is a sealed record: (Guid TenantId, string? Identifier, bool IsRootTenant).
Registered as Scoped in DI (AddBackgroundJobsFrameworkCore) — one instance per execution DI scope, not
per process and not per request (there is no HTTP request during a background job execution).
8.2 Why claims independence
An HTTP-request-scoped tenant resolver — the mechanism the ERP uses for ordinary web requests — would be
meaningless inside a Hangfire worker, because there is no HTTP request, no JWT, and no claims principal to
read from. The framework's tenant resolution is therefore built on
Shumoul.Framework.MultiTenancy's IMultiTenantContextAccessor, an AsyncLocal-backed accessor that
survives across the async continuations of a background job execution without needing any
request-pipeline concept at all.
Concretely, BackgroundJobTenantResolverAdapter (in the Adapters package):
- Reads
contextAccessor.MultiTenantContext.TenantInfo. - Parses the tenant identifier into a
Guid. - Compares the tenant's
IdentifieragainstBackgroundJobAdapterOptions.RootTenantIdentifier(default"000111", ordinal case-insensitive comparison) to setIsRootTenant.
This is a materially different mechanism from the ERP's JWT-claims-based ICurrentUser.GetTenant() used in
ordinary controller code — the two are not interchangeable, and code written for one context cannot assume
the other is available.
8.3 Isolation guarantees
Tenant identity flows into a job execution exactly once, at BackgroundJobExecutionContext construction
time (tenantId constructor parameter, nullable). Nothing inside the pipeline, executor, or any adapter
re-resolves or overrides it mid-execution — the context is immutable for the lifetime of the execution.
All 6 currently migrated jobs pass tenantId: null — a deliberate business-semantic choice, since every
one of them operates across all tenants (e.g. "clean up dead letters for every tenant," not "for tenant X").
Per-tenant scoping is architecturally proven — the framework has dedicated end-to-end tenant-context tests —
but has not yet been exercised by a real, single-tenant production job. This is a completeness gap, not a
readiness gap: the mechanism works, it simply hasn't been asked to do per-tenant work yet in production.
8.4 Why it exists as a first-class concept at all
Any future job that does need to act on a single tenant's data — for example, a per-tenant maintenance
task — needs a reliable way to know which tenant it's currently scoped to, and needs that answer to survive
async continuations without an HTTP request in scope. Without IBackgroundJobTenantContext, every such job
would have to invent its own ad hoc tenant-threading mechanism — exactly the kind of repeated cost the
framework exists to eliminate. See §1.2.
8.5 What this is not
IBackgroundJobTenantContext does not enforce EF Core query filters, does not stamp TenantId on new
entities, and does not apply any of the ERP's own multi-tenancy machinery (ApplicationDbContext's global
query filter, IDataFilter). It only answers "which tenant is this execution scoped to?" — enforcement of
tenant isolation at the data layer remains entirely the ERP host's responsibility, using its own existing
multi-tenancy mechanisms once the job's business logic runs.