Background Jobs

Provider-agnostic auto-discovered recurring jobs (Native, Hangfire, Quartz.NET) — 24 jobs across 4 modules, zero manual wiring.

SCRIPE's background job system is built on one principle: write once, run on any provider. Every job implements IAutoRegisteredJob and is discovered automatically at startup. Switching between Native, Hangfire, or Quartz is a single config change in appsettings.json — no code modifications required.

Architecture Overview

At startup, BackgroundJobsConfiguration reads the active provider from appsettings.json and calls GetServices<IAutoRegisteredJob>() to discover every registered job from the DI container. For each job, it checks for a per-job appsettings override, resolves Enabled and CronExpression, then schedules the job with the provider API. The job itself has zero provider-specific code.

appsettings.json Provider + Per-Job Overrides — Provider: Native | Hangfire | Quartz Jobs: { id: { Enabled, CronExpression } }
drives
BackgroundJobsConfiguration AddBackgroundJobsConfiguration() — Reads provider, discovers all jobs, schedules them
triggers
Auto-Discovery Loop GetServices<IAutoRegisteredJob>() — Scans DI container for every registered IAutoRegisteredJob
for each job
Schedule Each Job If Enabled -> Register with provider API — Uses CronExpression from appsettings override or job default
on cron tick
job.ExecuteAsync(ct) At every cron tick — Provider-agnostic - job has zero knowledge of which provider runs it

IAutoRegisteredJob Contract

Every recurring background job in SCRIPE implements one interface: IAutoRegisteredJob. This is the complete contract — three properties and one method. The interface deliberately excludes any provider-specific concepts (no Hangfire attributes, no Quartz annotations). The job has zero knowledge of which provider executes it.

IAutoRegisteredJob.cs — The Only Interface You NeedC#
public interface IAutoRegisteredJob
{
    // Unique job identifier — kebab-case: "{module}-{purpose}"
    // Examples: "identity-soft-delete-cleanup", "compliance-dsr-execution"
    string JobId { get; }

    // Standard 5-field CRON expression
    // Can be overridden per-job in appsettings.json at runtime
    string CronExpression { get; }

    // Default true — can be disabled in appsettings.json
    bool Enabled => true;

    // MUST be idempotent — safe to call multiple times
    // Provider calls this on every cron tick
    Task ExecuteAsync(CancellationToken ct = default);
}

DI Registration — The Critical Two-Line Pattern

Every job requires exactly two DI registrations in its module's DependencyInjection.cs. Skipping the second line renders the job completely invisible to all providers — it will never be discovered or scheduled, with no error or warning.

DependencyInjection.cs — Critical Two-Line PatternC#
// Line 1: Concrete type (enables constructor injection)
services.AddScoped<MyJob>();

// Line 2: Factory delegate (enables auto-discovery) — NEVER SKIP THIS
services.AddScoped<IAutoRegisteredJob>(sp =>
    sp.GetRequiredService<MyJob>());

// WHY TWO LINES?
// GetServices<IAutoRegisteredJob>() scans for everything registered AS IAutoRegisteredJob.
// Without line 2, the scan returns nothing for MyJob — it is invisible to all providers.

Class Hierarchy — Choose Your Base

Three options exist depending on how much structure you need. Lightweight jobs implement IAutoRegisteredJob directly. Jobs that need structured timing logs extend RecurringJobBase. Jobs that clean up soft-deleted entities extend SoftDeleteCleanupJob<TContext>, which auto-discovers ISoftDeletable entities and sorts them topologically by FK order.

ClassUse WhenGets You
IAutoRegisteredJob (interface)Job is simple, minimal boilerplate neededJust the contract — full control, zero extras
RecurringJobBase (abstract)You want timing, error logging, structured outputAuto start/complete/error logging with elapsed time
SoftDeleteCleanupJob<TContext> (abstract)Module needs a soft-delete permanent-cleanup jobAuto entity discovery, FK-ordered deletion, batch processing
RecurringJobBase.cs — Timing + Structured LoggingC#
public abstract class RecurringJobBase : IAutoRegisteredJob
{
    protected ILogger Logger { get; }

    public abstract string JobId { get; }
    public abstract string CronExpression { get; }

    public async Task ExecuteAsync(CancellationToken ct = default)
    {
        var sw = Stopwatch.StartNew();
        Logger.LogInformation("[{JobId}] Starting", JobId);
        try
        {
            await ExecuteJobAsync(ct);
            Logger.LogInformation("[{JobId}] Completed in {Elapsed}ms", JobId, sw.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "[{JobId}] Failed after {Elapsed}ms", JobId, sw.ElapsedMilliseconds);
            throw; // Let the provider handle retry
        }
    }

    protected abstract Task ExecuteJobAsync(CancellationToken ct);
}

Provider Comparison

All three providers use the exact same IAutoRegisteredJob interface. The only difference is how they schedule and persist jobs. Configure the provider in appsettings.json — no code changes required to switch.

FeatureNativeHangfireQuartz
Job PersistenceIn-memory only — lost on restartSQL-backed — survives restartsIn-memory (DB store optional)
DashboardNone/hangfire (SuperAdmin only)None (Quartz.UI available separately)
Auto RetryNoYes (configurable attempts)Yes (via misfire policy)
Best ForLocal development, unit testingProduction with SQL ServerProduction with Oracle or PostgreSQL
appsettings.json — Switching Providers (no code change needed)JSON
{
  "BackgroundJobs": {
    "Provider": "Hangfire",          // "Native" | "Hangfire" | "Quartz"
    "PollingIntervalSeconds": 60,    // Native only
    "Jobs": {
      "identity-soft-delete-cleanup": {
        "Enabled": true,
        "CronExpression": "0 3 * * *"  // Override default cron
      },
      "analytics-report": {
        "Enabled": false               // Disable a job in this environment
      },
      "compliance-dsr-execution": {
        "CronExpression": "*/2 * * * *"  // Override cron only
      }
    }
  }
}

Complete Jobs Inventory — 24 Jobs

All 24 recurring background jobs across the four modules. Every job implements IAutoRegisteredJob. The Default Cron can be overridden per-environment in appsettings.json under BackgroundJobs.Jobs.

Core Module (1 job)

Job ClassJobIdDefault CronPurpose
OutboxCleanupJobcore-outbox-cleanup0 5 * * *Deletes processed outbox messages older than 7 days

Identity Module (4 jobs)

Job ClassJobIdDefault CronPurpose
IdentitySoftDeleteCleanupJobidentity-soft-delete-cleanup0 3 * * *Permanently deletes soft-deleted Identity entities after retention period
EmailProcessingJobcore-email-processing* * * * *Polls and dispatches deferred emails via EmailJobProcessor
WebhookRetryJobwebhook-retry* * * * *Processes persistent webhook retry queue in batches of 50
WebhookLogCleanupJobwebhook-log-cleanup0 4 * * *Deletes webhook delivery logs older than 90 days

Entitlements Module (12 jobs)

Job ClassJobIdDefault CronPurpose
EntitlementsSoftDeleteCleanupJobentitlements-soft-delete-cleanup0 3 * * *Permanently deletes soft-deleted Entitlements entities after retention period
SubscriptionReconciliationJobsubscription-reconciliation0 2 * * *Expires trials, renews active subscriptions, handles grace periods
TrialNotificationJobtrial-notification0 9 * * *Sends reminders for trials expiring in 7, 3, or 1 day
DunningNotificationJobdunning-notification0 10 * * *Payment failure notices with escalating urgency
EditionRolloutJobedition-rollout0 1 * * *Applies scheduled edition upgrades and downgrades
UserSubscriptionReconciliationJobuser-subscription-reconciliation0 3 * * *Tier 2 user-level subscription reconciliation
AnalyticsSnapshotJobanalytics-snapshot0 0 * * *Daily revenue/MRR/ARR snapshot aggregation
TenantHealthScoreJobtenant-health-score0 6 * * *Recalculates health score for all active tenants
AnalyticsReportJobanalytics-report0 7 * * 1Weekly analytics report generation
CommissionInvoicingJobcommission-invoicing0 2 1 * *Monthly commission invoice rollup
CommissionAutoChargeJobcommission-auto-charge0 6 * * *Retries failed commission auto-charges
PaymobRecurringBillingJobpaymob-recurring-billing0 4 * * *Paymob card-on-file recurring charges

Compliance Module (7 jobs)

Job ClassJobIdDefault CronPurpose
ComplianceSoftDeleteCleanupJobcompliance-soft-delete-cleanup0 3 * * *Permanently deletes soft-deleted Compliance entities after retention period
DsrExecutionJobcompliance-dsr-execution*/5 * * * *Executes pending Data Subject Requests every 5 minutes
DsrEscalationJobcompliance-dsr-escalation0 8 * * *Escalates DSRs approaching their SLA deadline
DsrExportCleanupJobcompliance-dsr-export-cleanup0 4 * * *Deletes expired DSR export files
RetentionEnforcementJobcompliance-retention-enforcement0 1 * * 0Enforces data retention policies (runs every Sunday at 1:00 AM)
ConsentExpiryJobcompliance-consent-expiry0 0 * * *Expires lapsed user consents (runs at midnight daily)
ReportGenerationJobcompliance-report-generation*/2 * * * *Polls and generates pending compliance reports every 2 minutes

Creating a New Background Job

Follow these four steps exactly. The only mandatory files are the job class itself and the two DI registration lines. Everything else is auto-wired by the discovery engine.

  1. Step 1 — Create the Job Class

    Create a new file in {Module}.Infrastructure/BackgroundJobs/. One class per file. Use JobId convention: '{module}-{purpose}' in kebab-case. Implement ExecuteAsync as an idempotent operation — it must be safe to call multiple times.
    ProductExpiryJob.csC#
    // {Module}.Infrastructure/BackgroundJobs/ProductExpiryJob.cs
    public class ProductExpiryJob : IAutoRegisteredJob
    {
        private readonly IProductRepository _repo;
        private readonly IUnitOfWork _uow;
        private readonly ILogger<ProductExpiryJob> _logger;
    
        public ProductExpiryJob(
            IProductRepository repo,
            IUnitOfWork uow,
            ILogger<ProductExpiryJob> logger)
        {
            _repo = repo;
            _uow = uow;
            _logger = logger;
        }
    
        // Convention: "{module}-{purpose}" in kebab-case
        public string JobId => "products-expiry-check";
        public string CronExpression => "0 6 * * *"; // Daily at 6 AM
    
        public async Task ExecuteAsync(CancellationToken ct = default)
        {
            var expired = await _repo.GetExpiredProductsAsync(ct);
            foreach (var product in expired)
                product.MarkExpired();
            await _uow.SaveChangesAsync(ct);
            _logger.LogInformation("[{JobId}] Marked {Count} expired", JobId, expired.Count);
        }
    }
  2. Step 2 — Register BOTH DI Lines

    In the module's DependencyInjection.cs, add exactly two registrations. Line 1 enables constructor injection. Line 2 enables auto-discovery via GetServices<IAutoRegisteredJob>(). Never skip line 2 — missing it means the job is invisible to all providers.
    DependencyInjection.csC#
    // Products.Infrastructure/DependencyInjection.cs
    // BOTH lines are mandatory — never skip line 2
    services.AddScoped<ProductExpiryJob>();
    services.AddScoped<IAutoRegisteredJob>(sp =>
        sp.GetRequiredService<ProductExpiryJob>());
  3. Step 3 — Add appsettings Override (Optional)

    If you want environment-specific schedule or want to disable the job in certain environments, add an override under BackgroundJobs.Jobs using the JobId as the key. This is completely optional — the job's default CronExpression and Enabled=true will be used if no override exists.
    appsettings.json (optional override)JSON
    {
      "BackgroundJobs": {
        "Jobs": {
          "products-expiry-check": {
            "Enabled": true,
            "CronExpression": "0 6 * * *"
          }
        }
      }
    }
  4. Step 4 — Build and Verify

    Run the backend build. Zero errors means the job is ready. Auto-discovery handles the rest — no changes to BackgroundJobsConfiguration.cs, no changes to any host extension, no manual registration anywhere.
    TerminalShell
    scripe build backend
    # Must output: Build succeeded. 0 Error(s)
    # Auto-discovery handles the rest — no other wiring needed

SoftDeleteCleanupJob — Auto FK-Ordered Deletion

The SoftDeleteCleanupJob<TContext> base class is the most sophisticated option. It automatically discovers all ISoftDeletable entity types in the DbContext, sorts them topologically based on FK relationships (children before parents), and batch-deletes records that have passed the retention period. Override PreCleanupAsync to unlock guardian-protected entities before deletion, or GetCustomCleanupOrder() to specify explicit entity ordering.

Cron Tick (3:00 AM) — Default cron for Soft Delete jobs
triggers
SoftDeleteCleanupJob<TContext> — Instantiated by DI container
starts
Discover ISoftDeletable — Reflection scan on DbContext for entities implementing ISoftDeletable
builds query
Filter Expired Entities — Find records where IsDeleted = true AND DeletedAt < DateTime.UtcNow.AddDays(-30)
orders
FK-Aware Cascading — Handles foreign key constraints in correct deletion order
removes
Hard Delete — Execute native SQL for bulk deletion, bypassing EF change tracking
CLI — One Command to Add SoftDeleteCleanupJobShell
# Add soft-delete cleanup job to a module
scripe add-bg-service Products

# This generates:
# 1. Products.Infrastructure/BackgroundJobs/ProductsSoftDeleteCleanupJob.cs
# 2. Registers BOTH DI lines in Products.Infrastructure/DependencyInjection.cs
ProductsSoftDeleteCleanupJob.cs — Auto-GeneratedC#
// Minimum implementation — inherits FK-aware deletion logic
// Auto-discovers all ISoftDeletable entities in ProductsDbContext
public class ProductsSoftDeleteCleanupJob
    : SoftDeleteCleanupJob<ProductsDbContext>
{
    public ProductsSoftDeleteCleanupJob(
        ProductsDbContext context,
        IOptions<SoftDeleteCleanupOptions> options,
        ILogger<ProductsSoftDeleteCleanupJob> logger)
        : base(context, options, logger) { }

    public override string JobId => "products-soft-delete-cleanup";
    // CronExpression defaults to "0 3 * * *" from base class
}

Rules

  • ✅ Must Do

    • One class per file in BackgroundJobs/ folder
    • Register BOTH DI lines (concrete + factory delegate)
    • Use 5-field CRON (not 6-field Quartz format)
    • Make ExecuteAsync idempotent
    • Build after every change — scripe build backend
  • ❌ Never Do

    • Never import Hangfire or Quartz namespaces in job classes
    • Never use [AutomaticRetry] — global retry is in BackgroundJobsConfiguration
    • Never call RecurringJob.AddOrUpdate<T>() in module code
    • Never put jobs in Services/ or any other folder
    • Never register as Singleton — always AddScoped