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.
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.
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.
// 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.
| Class | Use When | Gets You |
|---|---|---|
| IAutoRegisteredJob (interface) | Job is simple, minimal boilerplate needed | Just the contract — full control, zero extras |
| RecurringJobBase (abstract) | You want timing, error logging, structured output | Auto start/complete/error logging with elapsed time |
| SoftDeleteCleanupJob<TContext> (abstract) | Module needs a soft-delete permanent-cleanup job | Auto entity discovery, FK-ordered deletion, batch processing |
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.
| Feature | Native | Hangfire | Quartz |
|---|---|---|---|
| Job Persistence | In-memory only — lost on restart | SQL-backed — survives restarts | In-memory (DB store optional) |
| Dashboard | None | /hangfire (SuperAdmin only) | None (Quartz.UI available separately) |
| Auto Retry | No | Yes (configurable attempts) | Yes (via misfire policy) |
| Best For | Local development, unit testing | Production with SQL Server | Production with Oracle or PostgreSQL |
{
"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 Class | JobId | Default Cron | Purpose |
|---|---|---|---|
| OutboxCleanupJob | core-outbox-cleanup | 0 5 * * * | Deletes processed outbox messages older than 7 days |
Identity Module (4 jobs)
| Job Class | JobId | Default Cron | Purpose |
|---|---|---|---|
| IdentitySoftDeleteCleanupJob | identity-soft-delete-cleanup | 0 3 * * * | Permanently deletes soft-deleted Identity entities after retention period |
| EmailProcessingJob | core-email-processing | * * * * * | Polls and dispatches deferred emails via EmailJobProcessor |
| WebhookRetryJob | webhook-retry | * * * * * | Processes persistent webhook retry queue in batches of 50 |
| WebhookLogCleanupJob | webhook-log-cleanup | 0 4 * * * | Deletes webhook delivery logs older than 90 days |
Entitlements Module (12 jobs)
| Job Class | JobId | Default Cron | Purpose |
|---|---|---|---|
| EntitlementsSoftDeleteCleanupJob | entitlements-soft-delete-cleanup | 0 3 * * * | Permanently deletes soft-deleted Entitlements entities after retention period |
| SubscriptionReconciliationJob | subscription-reconciliation | 0 2 * * * | Expires trials, renews active subscriptions, handles grace periods |
| TrialNotificationJob | trial-notification | 0 9 * * * | Sends reminders for trials expiring in 7, 3, or 1 day |
| DunningNotificationJob | dunning-notification | 0 10 * * * | Payment failure notices with escalating urgency |
| EditionRolloutJob | edition-rollout | 0 1 * * * | Applies scheduled edition upgrades and downgrades |
| UserSubscriptionReconciliationJob | user-subscription-reconciliation | 0 3 * * * | Tier 2 user-level subscription reconciliation |
| AnalyticsSnapshotJob | analytics-snapshot | 0 0 * * * | Daily revenue/MRR/ARR snapshot aggregation |
| TenantHealthScoreJob | tenant-health-score | 0 6 * * * | Recalculates health score for all active tenants |
| AnalyticsReportJob | analytics-report | 0 7 * * 1 | Weekly analytics report generation |
| CommissionInvoicingJob | commission-invoicing | 0 2 1 * * | Monthly commission invoice rollup |
| CommissionAutoChargeJob | commission-auto-charge | 0 6 * * * | Retries failed commission auto-charges |
| PaymobRecurringBillingJob | paymob-recurring-billing | 0 4 * * * | Paymob card-on-file recurring charges |
Compliance Module (7 jobs)
| Job Class | JobId | Default Cron | Purpose |
|---|---|---|---|
| ComplianceSoftDeleteCleanupJob | compliance-soft-delete-cleanup | 0 3 * * * | Permanently deletes soft-deleted Compliance entities after retention period |
| DsrExecutionJob | compliance-dsr-execution | */5 * * * * | Executes pending Data Subject Requests every 5 minutes |
| DsrEscalationJob | compliance-dsr-escalation | 0 8 * * * | Escalates DSRs approaching their SLA deadline |
| DsrExportCleanupJob | compliance-dsr-export-cleanup | 0 4 * * * | Deletes expired DSR export files |
| RetentionEnforcementJob | compliance-retention-enforcement | 0 1 * * 0 | Enforces data retention policies (runs every Sunday at 1:00 AM) |
| ConsentExpiryJob | compliance-consent-expiry | 0 0 * * * | Expires lapsed user consents (runs at midnight daily) |
| ReportGenerationJob | compliance-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.
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); } }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>());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 * * *" } } } }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.TerminalShellscripe 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.
# 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// 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