DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

Tenantless by Design: A Safer Pattern for Multi-Tenant Scheduled Jobs

A recurring job can be registered correctly, appear in the scheduler, and still be unable to do useful work.

The failure often hides at the boundary between scheduling and tenancy. In an ASP.NET Core request, middleware usually resolves the tenant before application services run. A scheduler has no request. It wakes up in a host scope with no tenant, yet the job may depend on an EF Core context whose connection, filters, or model configuration require one.

If that dependency is resolved too early, the best outcome is a clear “tenant not resolved” exception. A more dangerous design may quietly choose a default tenant.

The safer model is to admit that the job is not tenant-aware work. It is a cross-tenant orchestrator.

Registration Is Not Execution

There are at least three separate questions behind a recurring job:

  1. Did this host schedule the job?
  2. Can this host map the scheduled key to an executable job?
  3. Can the job establish the context required by its dependencies?

A repository-wide source scan can answer a rough version of the first two. It may count one scheduler and one binding and report balance. But those registrations can live in different deployable hosts. The repository is balanced while one running application still has only half the contract.

Likewise, a dependency-injection container may construct the job even though execution fails later when a tenant-bound factory tries to resolve ambient tenant state.

The deployable host—not the repository—is the unit that must satisfy the contract.

Make Cross-Tenant Ownership Explicit

The module that owns the operation should not need to know how every host stores or discovers tenants. One host may use a platform catalogue; another may use a local registry.

Introduce a small tenant-source abstraction owned by the operation, then let each host provide the implementation. The host says which tenants it owns. The module says what must happen for one tenant.

Conceptually, the orchestration looks like this:

var tenants = await tenantSource.ListAsync(cancellationToken);

foreach (var tenant in tenants)
{
    await using var scope = scopeFactory.CreateAsyncScope();

    var tenantContext =
        scope.ServiceProvider.GetRequiredService<ITenantExecutionContext>();
    tenantContext.Select(tenant);

    var operation =
        scope.ServiceProvider.GetRequiredService<ITenantMaintenanceOperation>();
    await operation.RunAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

The important detail is ordering. Create the scope, establish the tenant, and only then resolve the EF Core context factory or service that depends on it.

Pass a tenant descriptor or identity into the scope, not a connection string or secret. Connection resolution remains behind the tenant boundary.

Use a Fresh Scope Per Tenant

A DbContext is a unit-of-work object, not a cross-tenant cache. Sharing one scope across the sweep risks stale state, incorrect query filters, accidental data mixing, and difficult recovery after a failure.

A fresh scope provides:

  • a clean tenant context;
  • a separate DbContext lifetime;
  • deterministic disposal of connections and tracked entities;
  • a natural boundary for logging, metrics, and retries.

This is also why simply injecting a tenant-bound factory into the scheduler-created job is suspect. Even if the factory defers creating the DbContext, it may already be tied to the wrong scope.

Resolve tenant-bound dependencies inside the tenant scope, as late as practical.

Choose Failure Semantics Deliberately

Should one tenant failure stop the entire sweep? Sometimes, but not automatically.

If each tenant operation is independent, catch the failure at the tenant boundary, record a structured result, continue with the remaining tenants, and emit one aggregate outcome. That prevents a single malformed configuration from starving everyone else.

Fail fast when ordering matters, when a shared mutation makes later work unsafe, or when continuing would violate a cross-tenant invariant. “Continue” is not resilience if it spreads inconsistent state.

Whichever policy you choose, make the operation idempotent where possible. Retries then repeat a bounded tenant operation rather than an ambiguous portion of a global sweep.

Boot-Test the Real Composition Root

Unit tests can prove that an orchestrator fans out, creates scopes, honours cancellation, and aggregates failures. They cannot prove that a particular host registered everything required to run it.

Add a boot test that composes the real host service graph with safe boundary substitutes. From a tenantless root scope:

  • activate every recurring job that the host claims to own;
  • start scheduler services against a recording scheduler;
  • assert that at least one schedule was captured, avoiding a vacuous pass;
  • verify every captured key maps to an executable registration in that host;
  • ensure tenant-bound services are resolved only after a tenant scope exists.

This test is intentionally coupled to the composition root. That coupling is the point: production also runs the composition root.

A useful mutation check is to remove one binding temporarily. The test should fail and name the missing host-local contract.

The Trade-Off

Per-tenant scopes create more objects, database connections, log entries, and operational decisions. Large estates may need bounded concurrency rather than a simple sequential loop. Time budgets, cancellation, back pressure, retry policy, and aggregate reporting all become explicit.

That is additional engineering. It is also honest engineering. The complexity already exists in multi-tenant background work; implicit ambient state merely hides it until runtime.

My practical checklist is short: host-owned tenant discovery, one scope per tenant, tenant context before EF Core resolution, explicit failure semantics, idempotent tenant operations, and a boot test of the real host graph.

When one of your scheduled jobs wakes up without an HTTP request, what proves that it can establish the right tenant boundary before touching data?

Top comments (0)