Skip to main content
Version: 1.2

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 IBackgroundJobExecutionPipelineBackgroundJobExecutionPipeline (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 next delegate, classify and re-throw on exception (never on cancellation).
  • Dependencies: IBackgroundJobFailureClassifier only.
  • 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 IBackgroundJobSchedulerBackgroundJobScheduler (internal)

  • Purpose: the intended single frozen entry point for all job submission (enqueue, schedule, recurring registration), replacing direct Hangfire.BackgroundJob/RecurringJob static calls.
  • Responsibilities: forwards every call (EnqueueAsync, ScheduleAsync, AddOrUpdateRecurringAsync, RemoveRecurringAsync, TriggerRecurringAsync) to IHangfireBackgroundJobAdapter — 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 IBackgroundJobTenantContextBackgroundJobTenantContext (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 to IBackgroundJobTenantResolverAdapter.
  • 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 IBackgroundJobDistributedLockBackgroundJobsDistributedLockService (internal)

  • Purpose: exposes a job-level (method + optional tenant) mutual-exclusion primitive.
  • Responsibilities: TryAcquireAsync(key, timeout, ct) delegating to IBackgroundJobDistributedLockAdapter, returning an IBackgroundJobLockHandle (or null on 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 IBackgroundJobFailureClassifierBackgroundJobFailureClassifier (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 — Cancelled for OperationCanceledException/TaskCanceledException; Permanent for ArgumentException-family, InvalidOperationException, NotSupportedException, NotImplementedException, OutOfMemoryException, StackOverflowException (matched via IsInstanceOfType, so subclasses correctly classify); Transient for 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 IBackgroundJob invocation and turns its outcome into a BackgroundJobExecutionResult.
  • Responsibilities: null-guard the context, await job.RunAsync(context, ct), and on normal return, wrap as BackgroundJobExecutionResult.Succeeded(). Zero exception handling of its own — everything propagates to the pipeline.
  • Dependencies: the one IBackgroundJob instance passed to its constructor.
  • Lifetime: deliberately not DI-registered — instantiated with new explicitly, once per execution, by the bridge. This is intentional: IBackgroundJob is 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 RunAsync call.
  • Forbidden responsibilities: currently write-only — its Elapsed value 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

ServiceLifetime
IBackgroundJobSchedulerTransient
IBackgroundJobExecutionPipelineTransient
IBackgroundJobTenantContextScoped
IBackgroundJobDistributedLockTransient
IBackgroundJobFailureClassifierTransient
The 5 framework infrastructure adaptersTransient (all five)
BackgroundJobExecutorNot DI-registered — new'd per execution
Job-specific IBackgroundJob adaptersNot DI-registered as IBackgroundJobnew'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.