6. Runtime Services
Every service the framework registers in DI, its purpose, dependencies, DI lifetime, and what it must never be asked to do.
6.1 IBackgroundJobExecutionPipeline → BackgroundJobExecutionPipeline (internal)
- Purpose: the single orchestration entry point every Hangfire bridge calls into.
- Responsibilities: cancellation fast-path, start an execution-lifetime timer, invoke the caller-supplied
nextdelegate, classify and re-throw on exception (never on cancellation). - Dependencies:
IBackgroundJobFailureClassifieronly. - Lifetime: Transient.
- Forbidden responsibilities: must never resolve or call
IBackgroundJobTenantContext,IBackgroundJobDistributedLock, or any adapter directly — those remain the caller's decision to compose. Must never swallow an exception. Must never open a database transaction. See §4 — Runtime Pipeline for exactly what it does and does not do today.
6.2 IBackgroundJobScheduler → BackgroundJobScheduler (internal)
- Purpose: the intended single frozen entry point for all job submission (enqueue, schedule, recurring
registration), replacing direct
Hangfire.BackgroundJob/RecurringJobstatic calls. - Responsibilities: forwards every call (
EnqueueAsync,ScheduleAsync,AddOrUpdateRecurringAsync,RemoveRecurringAsync,TriggerRecurringAsync) toIHangfireBackgroundJobAdapter— zero logic of its own. - Dependencies:
IHangfireBackgroundJobAdapter. - Lifetime: Transient.
- Forbidden responsibilities: must never reference
Hangfire.*types directly — that's the adapter's job. See §10 — Scheduler for the important caveat that no production job currently calls this service — all six use raw Hangfire registration directly.
6.3 IBackgroundJobTenantContext → BackgroundJobTenantContext (internal)
- Purpose: answers "which tenant is this execution scoped to?" without any dependency on an HTTP request or claims principal.
- Responsibilities: exposes
CurrentTenant,CurrentTenantId,TryGetCurrentTenant(out tenant), delegating entirely toIBackgroundJobTenantResolverAdapter. - Dependencies:
IBackgroundJobTenantResolverAdapter. - Lifetime: Scoped — the one deliberately non-Transient runtime service, so tenant identity is consistent for the lifetime of one job's DI scope. See §8 — Tenant Context.
- Forbidden responsibilities: must never read
ClaimsPrincipal/ICurrentUser/HttpContext— this is a tested architectural invariant, not a style preference, since the whole point of this service is to work correctly where no HTTP context exists.
6.4 IBackgroundJobDistributedLock → BackgroundJobsDistributedLockService (internal)
- Purpose: exposes a job-level (method + optional tenant) mutual-exclusion primitive.
- Responsibilities:
TryAcquireAsync(key, timeout, ct)delegating toIBackgroundJobDistributedLockAdapter, returning anIBackgroundJobLockHandle(ornullon timeout). - Dependencies:
IBackgroundJobDistributedLockAdapter. - Lifetime: Transient (the acquired lock's state lives in the returned handle, not this service).
- Forbidden responsibilities: must never be used to protect a specific business row/entity from double-processing — see §9.4. As of today, zero production jobs actually invoke this service — only the framework's own E2E test suite exercises it.
6.5 IBackgroundJobFailureClassifier → BackgroundJobFailureClassifier (internal)
- Purpose: categorizes an exception so a caller could make a retry/notification decision informed by whether a failure looks transient or permanent.
- Responsibilities: a pure function —
CancelledforOperationCanceledException/TaskCanceledException;PermanentforArgumentException-family,InvalidOperationException,NotSupportedException,NotImplementedException,OutOfMemoryException,StackOverflowException(matched viaIsInstanceOfType, so subclasses correctly classify);Transientfor everything else (network/IO/timeout/database errors). - Dependencies: none.
- Lifetime: Transient.
- Forbidden responsibilities: must never itself decide whether to retry, log, or notify — it only
classifies. The ERP host may replace this registration (
services.Replace(...)) with domain-specific classification rules; no current job does.
6.6 BackgroundJobExecutor (public, not DI-registered)
- Purpose: wraps exactly one
IBackgroundJobinvocation and turns its outcome into aBackgroundJobExecutionResult. - Responsibilities: null-guard the context,
await job.RunAsync(context, ct), and on normal return, wrap asBackgroundJobExecutionResult.Succeeded(). Zero exception handling of its own — everything propagates to the pipeline. - Dependencies: the one
IBackgroundJobinstance passed to its constructor. - Lifetime: deliberately not DI-registered — instantiated with
newexplicitly, once per execution, by the bridge. This is intentional:IBackgroundJobis always job-specific, so registering the executor generically would require the framework to know about a concrete job type it cannot know about. - Forbidden responsibilities: must never catch or classify exceptions — that belongs entirely to the pipeline layer above it.
6.7 BackgroundJobExecutionLifetime (internal, not directly exposed)
- Purpose: a pure timing helper —
Start(),StartedAt,Elapsed. - Responsibilities: records when an execution began.
- Dependencies: none.
- Lifetime: instantiated inline by the pipeline, once per
RunAsynccall. - Forbidden responsibilities: currently write-only — its
Elapsedvalue is computed but has no consumer, since there is no tracking adapter registered to hand it to. This is part of the same gap described in AP-01.
6.8 Summary — lifetime table
| Service | Lifetime |
|---|---|
IBackgroundJobScheduler | Transient |
IBackgroundJobExecutionPipeline | Transient |
IBackgroundJobTenantContext | Scoped |
IBackgroundJobDistributedLock | Transient |
IBackgroundJobFailureClassifier | Transient |
| The 5 framework infrastructure adapters | Transient (all five) |
BackgroundJobExecutor | Not DI-registered — new'd per execution |
Job-specific IBackgroundJob adapters | Not DI-registered as IBackgroundJob — new'd per execution |
*PipelineJob classes (host-owned) | Transient, via the ERP host's own ITransientService marker-interface auto-registration |
How the one Scoped service resolves correctly: IBackgroundJobTenantContext is resolved correctly as
long as it's resolved from within the same DI scope Hangfire creates for the job — that mechanism is
unchanged legacy Hangfire job-activation scoping; the framework adds services into whatever scope already
exists rather than replacing it. Since every *PipelineJob and its dependencies are constructor-injected
(never manually resolved via IServiceProvider.GetRequiredService), everything resolved transitively is
naturally scoped correctly — though this property is, as of today, demonstrated only by the E2E test suite,
since no production job currently resolves IBackgroundJobTenantContext at all.