SolTechnology.Core.Hangfire 1.0.0

dotnet add package SolTechnology.Core.Hangfire --version 1.0.0
                    
NuGet\Install-Package SolTechnology.Core.Hangfire -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SolTechnology.Core.Hangfire" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SolTechnology.Core.Hangfire" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SolTechnology.Core.Hangfire" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SolTechnology.Core.Hangfire --version 1.0.0
                    
#r "nuget: SolTechnology.Core.Hangfire, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SolTechnology.Core.Hangfire@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SolTechnology.Core.Hangfire&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SolTechnology.Core.Hangfire&version=1.0.0
                    
Install as a Cake Tool

SolTechnology.Core.Hangfire

Hangfire-backed persistent event dispatch and recurring jobs for SolTechnology.Core.CQRS. Events survive process restarts via Hangfire storage; recurring jobs run on cron schedules with typed, DI-resolved handlers. Intentionally minimal public surface — new knobs require an ADR.

Features

  • Persistent eventsIEvent instances are enqueued as Hangfire background jobs. A crash between publish and dispatch does not lose the event.
  • Recurring jobsIJob implementations run on cron schedules, registered with a one-liner and resolved from DI at execution time.
  • Plugin model — opt in with AddPersistentEvents() after AddCQRS(). Does not replace in-memory dispatch unless explicitly installed.

Registration

The plugin offers two independent features — use either or both:

// Persistent events only (requires AddCQRS first)
builder.Services.AddCQRS(assemblies: typeof(Program).Assembly);
builder.Services.AddPersistentEvents();

// Recurring jobs only (no AddCQRS dependency)
builder.Services.AddRecurringJob<MyDailyJob>(Cron.Daily);

// Both together
builder.Services.AddCQRS(assemblies: typeof(Program).Assembly);
builder.Services.AddPersistentEvents();
builder.Services.AddRecurringJob<MyDailyJob>(Cron.Daily);
Method Requires AddCQRS() What it does
AddPersistentEvents() ✅ yes Replaces in-memory event publisher with Hangfire-backed durable dispatch
AddRecurringJob<TJob>(cron) ❌ no Registers a typed job on a cron schedule via Hangfire

Configuration

builder.Services.AddPersistentEvents(o => o.QueueName = "events");
Option Default Description
QueueName "default" Hangfire queue used for event dispatch jobs.

Usage — Persistent Events

Define an event and its handler(s) in your CQRS layer:

public class CitySearched : IEvent
{
    public City City { get; set; }
}

public class SaveCitySearchHandler(ICityDomainService cityDomainService) : IEventHandler<CitySearched>
{
    public async Task Handle(CitySearched @event, CancellationToken cancellationToken)
    {
        await cityDomainService.Save(@event.City);
    }
}

Publish from any handler via IMediator:

mediator.Publish(new CitySearched { City = result });

With AddPersistentEvents() installed, the event is enqueued as a Hangfire job and dispatched in a fresh DI scope when the server picks it up.

Usage — Recurring Jobs

public class FetchTrafficJob(ITrafficClient client) : IJob
{
    public async Task Execute(CancellationToken cancellationToken)
    {
        await client.FetchLatest(cancellationToken);
    }
}

// Registration (Program.cs)
builder.Services.AddRecurringJob<FetchTrafficJob>("0 */6 * * *"); // every 6 hours

The job id is stable (typeof(TJob).Name) — re-registration on deploy updates rather than duplicates the schedule.

App-Side Requirements

The plugin depends on Hangfire.Core only. The app must provide:

  1. A DI-aware Hangfire JobActivator — supplied by Hangfire.AspNetCore (via AddHangfireServer()) or GlobalConfiguration.UseActivator(...). Without it, Hangfire cannot resolve the plugin's internal dispatcher/runner types.

  2. Type-aware job-argument serialisationIEvent is serialised through an interface-typed parameter. Configure:

    builder.Services.AddHangfire(config => config
        .UseSqlServerStorage(connectionString)
        .UseRecommendedSerializerSettings()           // TypeNameHandling.Auto
        .UseSimpleAssemblyNameTypeSerializer());
    

    Without this, the event cannot be deserialised when the job runs.

  3. Hangfire serverbuilder.Services.AddHangfireServer() so jobs are actually processed. Without it, jobs are persisted but never fire.

The Newtonsoft.Json 13.0.4 pin (CVE-2024-21907) is satisfied transitively by the plugin's own package reference.

Retry & Resilience

Persistent events dispatch once ([AutomaticRetry(Attempts = 0)]). A failed handler surfaces as a Failed job in the Hangfire dashboard — visible and manually re-queueable.

Resilience is a handler-level concern (e.g. Polly inside the handler). The plugin does not add automatic retries.

Persistence buys durability (a job enqueued before a crash runs after restart) and at-least-once delivery (crash-recovery or manual re-queue). Handlers must be idempotent.

Rate Limiting

Out of scope for this Hangfire.Core-only plugin. Concurrency is bounded app-side via AddHangfireServer(o => o.WorkerCount = N) and named queues. True jobs-per-interval rate limiting requires Hangfire.Throttling or infrastructure-level controls.

Event Payload Guideline

Persisted events may be re-dispatched (at-least-once). Prefer small, immutable payloads carrying IDs — reload current state inside the handler. CitySearched carries its full payload as a deliberate exception (freshly fetched city not yet in the store, no id to reload by).

Public API Surface

Intentionally minimal:

Symbol Kind
AddPersistentEvents() Extension method
AddRecurringJob<TJob>(cron) Extension method
IJob Interface
PersistentEventsOptions Options class

IEvent, IEventHandler<T>, IEventPublisher, IEventDispatcher live in SolTechnology.Core.CQRS — see CQRS docs.

Filters

The plugin ships three Hangfire job filters. Register them globally via UseSolTechnologyFilters() in the app's AddHangfire callback:

builder.Services.AddHangfire((sp, config) => config
    .UseRecommendedSerializerSettings()
    .UseSimpleAssemblyNameTypeSerializer()
    .UseSqlServerStorage(connectionString)
    .UseSolTechnologyFilters(sp));   // registers correlation + smart-retry filters

Correlation-id propagation

Preserves the X-Correlation-Id across the enqueue→execute boundary. On enqueue the current correlation id is saved as a job parameter; on execute it is restored into ICorrelationIdService and pushed as a log scope — so logs from background handlers appear under the same correlation as the original request.

Smart retry (Result-aware)

Bridges the Result pattern with Hangfire's exception-only retry model. If a job returns Result.Fail(new Error { Recoverable = true }), the filter forces the job into FailedState so Hangfire retries it. Non-recoverable failures are left as succeeded — no pointless retries, no silent swallowing.

Prevent overlap

Cancels a job execution if another instance with the same method + arguments is already scheduled or processing. Prevents pile-up when a recurring job is mid-retry and the next cron trigger fires. Apply as an attribute on a job method:

[PreventOverlapJobFilter]
public async Task RunAsync(CancellationToken cancellationToken) { ... }

Or register for a specific recurring job:

builder.Services.AddRecurringJob<MyJob>(Cron.Hourly, preventOverlap: true);

Testing

In component/integration tests, do not call AddPersistentEvents(). The default in-memory IEventPublisher from AddCQRS() dispatches events in-process (fire-and-forget) — no Hangfire infrastructure needed, no test doubles required.

// Test fixture — just use AddCQRS(), no publisher swap needed
builder.Services.AddCQRS(assemblies: typeof(Program).Assembly);
// Events dispatch in-memory; handlers run in background Task.Run with a fresh scope.

If your tests assert on handler side-effects, the in-memory publisher's Task.Run dispatch is fast enough for most scenarios. For strict synchronous guarantees, assert with a short poll/retry — not a custom publisher.

Retry backoff

SmartRetryJobFilter applies this 10-attempt schedule by default:

10s → 30s → 1m → 5m → 15m → 30m → 1h → 2h → 4h → 8h

After the 10th failure, the job is moved to the Failed state (visible in the dashboard). Override per-job with [SmartRetry(attempts: 5)] if you need fewer attempts.

Worker count

Hangfire defaults to Environment.ProcessorCount * 2 workers. This is a good starting point for IO-bound handlers (events, notifications). Do not override unless you have measured contention — more workers on CPU-bound work causes context-switch overhead.

Database migration

Hangfire requires its own schema tables (HangfireJob, HangfireState, HangfireServer, etc.). The storage provider creates them automatically on first use via SqlServerStorage.Install(...), which is idempotent — safe to call on every startup.

// Program.cs — call before UseHangfireServer/Dashboard
var connectionString = builder.Configuration.GetConnectionString("Hangfire");
Hangfire.SqlServer.SqlServerObjectsInstaller.Install(
    new System.Data.SqlClient.SqlConnection(connectionString));

Why no framework helper? The storage (connection string, database, schema) is owned by the application, not by SolTechnology.Core.Hangfire. The framework registers jobs and event dispatch; the application owns infrastructure. This keeps the library independent of any specific ADO.NET provider or migration tool.

See Also


Decision note: Delay-queue vs Hangfire scheduled jobs

TL;DR: use Hangfire scheduled jobs. Don't build a bespoke delay-queue.

The pattern: "hold a message until time T, then process it." Examples: send a reminder email in 24h, retry a failed webhook in 5 minutes, expire a draft after 7 days.

Hangfire already does this:

// Fire-and-forget in 5 minutes
BackgroundJob.Schedule(() => handler.ProcessAsync(payload), TimeSpan.FromMinutes(5));

// Or via the typed IJob + cron for recurring
services.AddRecurringJob<ExpireDraftsJob>("0 */6 * * *"); // every 6 hours

When NOT to use Hangfire: if you need sub-second delay precision or millions of pending messages (Hangfire polls SQL storage — latency floor ~15s, throughput ceiling ~100/s per server). In that case, use Azure Service Bus scheduled messages or a Redis sorted set.

Why not build a library primitive:

  • Hangfire gives you dashboard visibility, retry, dead-letter, correlation (via CorrelationIdJobFilter), and persistence for free.
  • A bespoke delay-queue duplicates that stack without the observability.
  • See also: FI-002 — Priority background jobs.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 61 7/6/2026