# Migrating from Hangfire

Port a Hangfire app: the delegation recipe, a concept-by-concept mapping, and a side-by-side cutover playbook.

You will not find `BackgroundJob.Enqueue` anywhere in BackWave. There is no static facade, no expression-tree capture of a method call, no implicit job identity derived from a type and a method name. Instead you inject a client, enqueue a typed payload, and a separately-declared Handler runs it. That single shift, from capturing a call to routing a message, drives almost every difference in this guide. The rest is mechanical once you have internalized it.

This page is in three parts. First the delegation recipe: the smallest BackWave equivalent of a fire-and-forget Hangfire job. Then a concept-by-concept mapping table for everything else you used in Hangfire. Finally a phased cutover playbook for running both systems side by side and switching over without downtime or double execution.

## Three shifts to front-load

Before any code, hold these three differences in your head. They explain why the port is a re-shaping and not a line-for-line translation.

- **No static enqueue API.** Hangfire's signature move is `BackgroundJob.Enqueue(() => svc.Method(arg))`, a static call that captures a lambda. BackWave has you inject a `BackWaveClient` from dependency injection and call an instance method. You enqueue a typed payload object, not a captured method call.
- **Jobs are messages plus Handlers, not captured lambdas.** Hangfire serializes a method call: the type, the method, and the arguments. BackWave serializes a payload record keyed by an explicit [Wire Name](/docs/core-concepts/jobs-and-handlers) string and routes it to an `IJobHandler<TJob>`. The Wire Name is the job's identity in storage, stable across refactors, never tied to a CLR name.
- **Storage lives in your own database.** Hangfire owns its own schema and defaults to SQL Server. BackWave runs over [Postgres](/docs/storage/postgres), [SQL Server](/docs/storage/sql-server), or [SQLite](/docs/storage/sqlite), and the job tables can live in your application's own database, so a Job can even commit inside your own transaction.

The design rationale behind these choices lives in [How It Works](/docs/introduction/how-it-works). Here we stay practical.

## Part 1: the delegation recipe

Start from a typical Hangfire fire-and-forget. You have a service with a method, and you hand a lambda calling it to the static enqueuer.

```csharp title="Hangfire (before)"
public class EmailService
{
    public void SendReceipt(Guid orderId) { /* ... */ }
}

// fire-and-forget
BackgroundJob.Enqueue<EmailService>(svc => svc.SendReceipt(orderId));

// delayed
BackgroundJob.Schedule<EmailService>(svc => svc.SendReceipt(orderId), TimeSpan.FromMinutes(30));
```

The smallest BackWave translation reuses the method body almost verbatim. Method form, where you attribute a Handler method and let the generator write the payload record and Handler for you, is the closest ergonomic match to Hangfire's "just call my method."

```csharp title="EmailJobs.cs"
public sealed class EmailJobs(IEmailSender sender)
{
    [Job("send-receipt")]
    public async Task SendReceipt(Guid orderId, JobContext context, CancellationToken cancellationToken)
    {
        await sender.SendReceiptAsync(orderId, cancellationToken);
    }
}
```

A few things to note against Hangfire's conventions. The method must be `public` and return `Task`; Hangfire allowed `void`, BackWave does not, and a wrong shape is a build error. The `[Job]` attribute's Wire Name, `"send-receipt"`, replaces Hangfire's implicit `EmailService.SendReceipt` identity, so you can rename the class freely later. The declaring class `EmailJobs` is registered scoped automatically, so constructor-injected dependencies like `IEmailSender` resolve per Attempt, exactly as a Hangfire job activated from DI would. The generator builds the payload record from the data parameters only; it recognizes `JobContext` and `CancellationToken` by type and excludes them, so `SendReceipt(Guid orderId, ...)` is enqueued as `new SendReceipt(orderId)`.

### Replace the static facade with an injected client

At the call site, you inject `BackWaveClient` instead of reaching for a static.

```csharp title="Program.cs"
app.MapPost("/orders/{id}/receipt", async (Guid id, BackWaveClient client) =>
{
    // Hangfire: BackgroundJob.Enqueue<EmailService>(s => s.SendReceipt(id));
    var jobId = await client.EnqueueAsync(new SendReceipt(id), dueTime: DateTimeOffset.UtcNow);

    // Hangfire: BackgroundJob.Schedule<EmailService>(s => s.SendReceipt(id), TimeSpan.FromMinutes(30));
    await client.EnqueueAsync(new SendReceipt(id), dueTime: DateTimeOffset.UtcNow.AddMinutes(30));

    return Results.Ok(new { jobId });
});
```

Three translations are happening here. `BackgroundJob.Enqueue` becomes `client.EnqueueAsync(payload, DateTimeOffset.UtcNow)`. `BackgroundJob.Schedule(..., delay)` becomes the same call with a future `dueTime`; there is no `delay` parameter, you compute the instant yourself, and enqueuing now versus scheduling later is one code path that differs only by the time you pass. And where Hangfire returns a `string` job id, BackWave returns a `Guid`, minted client-side before any Worker runs, which you can use later as a dependency parent id.

### Wire it up once at startup

`services.AddHangfire(...)` plus `app.UseHangfireServer()` collapse into one `AddBackWave` call.

```csharp title="Program.cs" {2-6,8}
builder.Services.AddBackWave(backwave => backwave
    .UseStore(new PostgresJobStore(new PostgresStoreOptions
    {
        ConnectionString = builder.Configuration.GetConnectionString("App")!,
        AutoMigrate = true,
    }))
    .UseJobs(BackWaveJobs.Module)
    .AddWorkerGroup(new WorkerGroupOptions
    {
        Name = "default",
        Policy = new DispatchPolicy.Strict(["default"]),
    }));
```

`UseStore` names the [Storage Adapter](/docs/storage/overview); the Postgres, SQL Server, and SQLite stores all take an options object, not a bare connection string. `AutoMigrate = true` is a dev convenience that creates the tables on startup; in production apply the schema through your migration pipeline instead and leave it `false`. `UseJobs(BackWaveJobs.Module)` registers the generated Job Registry and Handlers, replacing Hangfire's reflection-based activation. `AddWorkerGroup` is the analogue of `AddHangfireServer`: it stands up the [Worker Group](/docs/core-concepts/worker-groups) that claims and runs work. At least one group is required or nothing ever leaves the Queue, and the hosted pumps register automatically, so there is no separate worker host to start.

### When the payload deserves a name

If the work is data-shaped, class form maps a Hangfire job class onto a payload record plus a Handler. This is the same surface as method form, just spelled out.

```csharp title="OrderCharged.cs"
[Job("order-charged", Queue = "payments")]
public sealed record OrderCharged(Guid OrderId);

public sealed class OrderChargedHandler(IPaymentGateway gateway) : IJobHandler<OrderCharged>
{
    public Task HandleAsync(OrderCharged job, JobContext context, CancellationToken cancellationToken)
        => gateway.ChargeAsync(job.OrderId, cancellationToken);
}
```

One gotcha that bites Hangfire porters specifically: Hangfire serialized arbitrary method arguments as JSON, so passing a `List<T>` or a DTO just worked. BackWave's generator understands a deliberate set of payload member types, the scalars and their `Nullable<T>` forms, and a `string?` or a collection or a nested object is a build error. When you hit that, store a reference such as an id and have the Handler fetch the data, or hand-register the Job with your own JSON type info. The full define-and-enqueue path, including this list and the source generator's diagnostics, lives in [Define & Enqueue a Job](/docs/guides/define-and-enqueue-a-job).

## Part 2: concept-by-concept mapping

Once the delegation recipe clicks, the rest of your Hangfire surface translates row by row. Every BackWave cell below has a guide that goes deeper.

| Hangfire | BackWave equivalent |
|---|---|
| `BackgroundJob.Enqueue(() => svc.M(x))` | `client.EnqueueAsync(new MPayload(x), DateTimeOffset.UtcNow)` |
| `BackgroundJob.Schedule(() => ..., delay)` | `client.EnqueueAsync(payload, DateTimeOffset.UtcNow + delay)` |
| Job method captured via lambda | `[Job("wire-name")]` method (method form) or payload record plus `IJobHandler<T>` (class form) |
| Implicit `Type.Method` identity | Explicit, mandatory Wire Name string, stable across refactors |
| `IBackgroundJobClient` | `BackWaveClient`, injected from DI |
| Returned `string` job id | Returned `Guid`, minted client-side |
| `BackgroundJob.ContinueJobWith(parentId, () => ...)` | `client.EnqueueDependencyAsync(payload, parentId, mode: ...)` |
| Continuation on parent success (default) | `DependencyMode.OnSuccess` (the default) |
| Continuation regardless of outcome | `DependencyMode.OnAnyTerminal` |
| `IBatchClient` / `Batch.ContinueBatchWith` (paid) | [Workflow](/docs/guides/build-a-workflow) (Pro): `client.Workflow(...).Then(step).Then(next, after: [typeof(Step)]).EnqueueAsync()` |
| `RecurringJob.AddOrUpdate("id", () => ..., cron)` | `client.UpsertRecurringAsync("id", Cron.Daily(...), template)` |
| `RecurringJob.RemoveIfExists("id")` | `client.RemoveRecurringAsync("id")` |
| `RecurringJob.TriggerJob("id")` | `op.TriggerScheduleNowAsync("id", actor)` |
| `Cron.Daily()` helpers / cron strings | `Cron.Daily(hour, minute)` and friends, or `CronExpression.Parse(...)` |
| `TimeZoneInfo` on a recurring job | `timeZone:` IANA id argument, null for UTC |
| `MisfireHandlingMode` | `CatchUpPolicy.Skip` (default) or `CatchUpPolicy.Coalesce` |
| `[DisableConcurrentExecution]` on recurring | `noOverlap: true` per schedule |
| `[Queue("x")]` / `EnqueuedState` | `[Job(Queue = "x")]` type default plus `queue:` enqueue override |
| Queue priority via server queue order | Worker Group `DispatchPolicy.Strict` or `Weighted` |
| `BackgroundJobServer` and server options | [Worker Group](/docs/core-concepts/worker-groups): `AddWorkerGroup` plus `WorkerGroupOptions` |
| `[AutomaticRetry(Attempts = n)]` | `[Retry(n, seconds...)]` on the Job, or `WorkerGroupOptions.RetryPolicy { MaxAttempts = n, Backoff = ... }` for the group baseline |
| Exhausted retries to "Failed" | Dead-Lettered terminal state |
| Undeserializable / no handler to "Failed" | Quarantined terminal state (routing or decode failure) |
| `[Tag]` / job parameters for search | [Tags](/docs/core-concepts/tags): type-default Labels, enqueue-time `tags:`, runtime `context.AddTag` |
| `IJobFilter` / `IElectStateFilter` | [Transition Observer](/docs/guides/react-to-job-outcomes) (egress-only, cannot veto) |
| SQL Server / Redis storage | [Storage Adapter](/docs/storage/choosing-an-adapter) over your own database |
| Hangfire outbox or opt-in `TransactionScope` enlistment | Native [transactional enqueue](/docs/guides/transactional-enqueue-with-ef-core): `client.EnqueueAsync(job, dbContext, dueTime)` on your own transaction, no `TransactionScope` |

A handful of caveats do not fit in a table cell and matter enough to spell out.

**Priority is not a job property.** Hangfire users sometimes simulate priority with per-job queues and server queue ordering. In BackWave priority lives entirely in a Worker Group's `DispatchPolicy`: a `Strict` ordered list that always drains higher queues first, or a `Weighted` smooth round-robin. A Job never carries a priority of its own.

**Retries have a group baseline and a per-job override.** Hangfire's `[AutomaticRetry(Attempts = ...)]` decorates the job class. In BackWave the group baseline is `WorkerGroupOptions.RetryPolicy`, and one Job type overrides it with `[Retry(3, 1, 5)]` beside its `[Job]`: three attempts, one second, then five. The override is a fixed list of intervals rather than a function, and it applies when the handler throws. After a lapsed Lease, the store schedules the next Attempt, so the group policy governs it. [Retries & Error Handling](/docs/guides/configure-retries-and-error-handling) covers both levels in full.

**A lapsed Lease counts as an Attempt.** A stalled or crashed Worker burns one unit of retry budget exactly like a thrown exception, then the Job becomes claimable again. Hangfire has the analogous invisibility timeout, but the numbers differ: BackWave's Lease defaults to 60 seconds. Size it above your worst-case Handler runtime and make long Handlers heartbeat.

**Two terminal failure states, not one.** Hangfire lumps everything into "Failed." BackWave separates Dead-Lettered, a Job that ran and kept failing, from Quarantined, a Job that could not be routed or decoded at all. Renaming a job class is safe because the Wire Name is decoupled from the CLR name. Changing a payload shape incompatibly is not: the stored bytes may stop decoding and the Job Quarantines. [Evolve Job Payloads](/docs/guides/evolve-job-payloads) covers how to avoid that.

**Delivery is At-Least-Once, the same class as Hangfire.** A correctly-written Hangfire handler's idempotency assumptions carry over unchanged. If your Hangfire handlers were already safe to repeat, they stay safe here.

**Workflows are a Pro feature.** The typed workflow builder, `client.Workflow(...)` through `.EnqueueAsync()`, lights up only when the `BackWave.Pro` package is referenced. Hangfire's Batches are also a paid feature, so the comparison is fair. The base dependency surface, `EnqueueDependencyAsync`, needs no Pro package and covers most continuation chains.

## Part 3: the cutover playbook

There is no migration tool to run and no codemod. What makes a cutover safe is that BackWave and Hangfire share nothing: separate schemas, separate clients, separate workers, optionally separate databases. They can run in the same process, so you can port one job at a time and switch producers incrementally. The recommended phasing:

**Phase 0, install alongside.** Add the BackWave packages and call `AddBackWave(...)` next to your existing `AddHangfire(...)`. Both run in the same host. Use a separate `AddWorkerGroup` so BackWave's Workers and Hangfire's `BackgroundJobServer` coexist. Point BackWave at the same database if you like; the adapter creates its own tables, with `AutoMigrate` for dev or your migration pipeline for production.

**Phase 1, dual-declare one job.** For each Hangfire job you port, write the BackWave `[Job]` and Handler from Part 1, and leave the Hangfire job intact. The Handler can call the same underlying service method, so the business logic is shared, not duplicated.

**Phase 2, switch the producer, drain the old.** At the call site, flip `BackgroundJob.Enqueue(...)` to `client.EnqueueAsync(...)`. New work now flows through BackWave while Hangfire's queue drains whatever was already enqueued. Because only one producer enqueues each item, there is no double-execution risk for new work. The producer switch is the cutover point for that job.

**Phase 3, migrate recurring schedules.** Schedules cannot drain, they re-fire, so swap them cleanly: in the same deploy, call `RecurringJob.RemoveIfExists("id")` on the Hangfire side and `client.UpsertRecurringAsync("id", ...)` on the BackWave side. Mind the timing. BackWave mints only future ticks and never back-fills, and `CatchUpPolicy.Skip` is the default, so a gap between removing the old schedule and the first new tick simply means missed runs, not a catch-up storm. If you need a guaranteed make-up run, pass `catchUp: CatchUpPolicy.Coalesce`. To force a first run at switch time, call `op.TriggerScheduleNowAsync("id", actor)`. [Recurring Schedules](/docs/guides/recurring-schedules) covers the options in detail.

**Phase 4, port continuations and batches last.** Move `ContinueJobWith` chains to `EnqueueDependencyAsync` and Batches to Workflows once their producers already run on BackWave. A dependency's parent id must be a BackWave Job id; you cannot make a BackWave Job depend on a Hangfire job, so the parents have to be ported first. [Chain Jobs with Dependencies](/docs/guides/chain-jobs-with-dependencies) walks the dependency surface.

**Phase 5, decommission.** Once every producer is switched and Hangfire's queues are empty, watched through both dashboards, remove `AddHangfire` and `AddHangfireServer` and drop its schema.

During the overlap window, lean on idempotency. If the same logical work is ever live in both systems at once (a recurring schedule briefly defined on both sides, say), it runs twice. Both systems are At-Least-Once, so your Handlers should already tolerate that. The two dashboards mount at different paths, `app.UseHangfireDashboard("/hangfire")` and `app.UseBackWaveDashboard("/backwave", options)`, so operators can watch both through the transition. One posture difference: both dashboards are interactive, but BackWave's operator actions are default-deny, individually audited, and delegated to your host's authorization, where Hangfire gates the whole dashboard with a single all-or-nothing authorization filter.

## Gotchas to plan for

A short list of edges Hangfire users hit first.

- **Payload size is capped and rejected, not truncated.** A serialized payload over the store's limit throws `ArgumentException` at enqueue. Hangfire users who passed large arguments hit this first. Carry a reference, not the bytes.
- **`string?` payload members are rejected** by the generator, along with collections and nested objects. Use a non-nullable `string` with a sentinel, or hand-register the Job.
- **Dead-Lettered and Quarantined are different fixes.** Dead-Lettered means your code failed, so fix the code and requeue. Quarantined means a deploy problem, an unknown Wire Name or an undecodable payload, so deploy the fix before you requeue or it re-quarantines.
- **A job can declare at most 16 parents.** Relevant if you are fanning a large Hangfire batch into a single dependency join; split wide fan-ins across stages.
- **Cancellation is cooperative.** Like Hangfire's cancellation token, BackWave's `CancellationToken` fires through the heartbeat and never kills a thread. Honor it to stop promptly. See [Cancel a Running Job](/docs/guides/cancel-a-running-job).

## Where to go next

- [Define & Enqueue a Job](/docs/guides/define-and-enqueue-a-job): the full base surface behind the delegation recipe, including the generator and its diagnostics.
- [Recurring Schedules](/docs/guides/recurring-schedules): `UpsertRecurringAsync`, cron helpers, catch-up, and no-overlap for the `RecurringJob` row.
- [Chain Jobs with Dependencies](/docs/guides/chain-jobs-with-dependencies): `EnqueueDependencyAsync` and `DependencyMode`, the `ContinueJobWith` analogue.
- [Configure Retries & Error Handling](/docs/guides/configure-retries-and-error-handling): the Worker Group retry policy and the Dead-Lettered versus Quarantined split.
- [Transactional Enqueue with EF Core](/docs/guides/transactional-enqueue-with-ef-core): commit a Job atomically with your own writes, using your existing transaction directly rather than an outbox or opt-in `TransactionScope` enlistment.
- [Build a Workflow](/docs/guides/build-a-workflow): the Pro DAG surface that replaces Hangfire Batches.
