Skip to main content
Version: Next

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):

  1. Reads contextAccessor.MultiTenantContext.TenantInfo.
  2. Parses the tenant identifier into a Guid.
  3. Compares the tenant's Identifier against BackgroundJobAdapterOptions.RootTenantIdentifier (default "000111", ordinal case-insensitive comparison) to set IsRootTenant.

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.