Define & Enqueue a Job

Write a job message and handler, register them, and enqueue work to run now or at a future instant.


Write a job message and handler, register them, and enqueue work to run now or at a future instant.

This guide walks the full define-and-enqueue path: declaring a Job, writing its Handler, wiring both into dependency injection, and calling the client to put work on a Queue. Everything here is the base BackWave surface, no Pro package required. By the end you will know what the source generator does on your behalf, what the enqueue call actually returns, and which sharp edges to watch.

If you have not read Jobs & Handlers yet, skim it first. It defines the vocabulary this guide leans on: Job, Handler, Wire Name, Job Registry, and Job Context.

Declare a Job with [Job]#

A Job type carries a single mandatory piece of identity: its Wire Name. You declare it with the [Job] attribute, and that string, not the CLR type name, is what the store records. Refactor the class, rename the namespace, move the file: the Wire Name stays put, so jobs already sitting in storage still route to the right Handler.

There are two shapes. The first attributes a payload record directly, and pairs it with a Handler in the same compilation.

OrderCharged.cs
[Job("order-charged")]
public sealed record OrderCharged(Guid OrderId);
 
public sealed class OrderChargedHandler : IJobHandler<OrderCharged>
{
    public Task HandleAsync(OrderCharged job, JobContext context, CancellationToken cancellationToken)
        => /* charge the order */;
}

The second shape, method form, attributes a Handler method. The generator reads the signature and writes both the payload record and the Handler for you.

SampleJobs.cs
[Job("send-receipt")]
public Task SendReceipt(Guid orderId, CancellationToken cancellationToken)
    => /* email the receipt */;

Both produce the same runtime artifacts. Method form is less to type when the payload is a flat set of values; class form gives you a named record you can reference elsewhere. Pick per Job.

Attribute options#

[Job] takes the Wire Name positionally and exposes two settable properties.

  • Queue defaults to "default". It names the Queue every job of this type lands on unless an enqueue call overrides it.
  • Labels defaults to an empty array. These are default Tag Labels (bare strings) that every job of this type starts with. They are additive: each label unions into whatever Tags you pass at enqueue, and there is no way to subtract one later.
SampleJobs.cs
[Job("tagged-report", Queue = "bulk", Labels = ["billing", "report"])]
public async Task TaggedReportAsync(string tenant, decimal amount, JobContext context, CancellationToken cancellationToken)
    => /* ... */;

Because Labels is a compile-time constant array, it can only hold bare Labels. A Keyed Tag (a key with a value) cannot be encoded on the attribute. If you need one, attach it at enqueue time through the tags argument. See Tags for the distinction.

What method form requires#

A method-form Handler must be public and return Task. Anything else is a build error (BW0005). Order the data parameters first; the generator recognizes JobContext and CancellationToken by type and excludes them from the payload, so place them last. The generated record name drops a trailing Async, so GreetAsync(string name, int delayMs, ...) becomes a record Greet, enqueued as new Greet(name, 0).

Supported payload types#

The generator emits hand-written Utf8JsonWriter and Utf8JsonReader code, no reflection, so the set of payload member types it understands is deliberate: string, bool, the integer and floating-point and decimal numerics, DateTime, DateTimeOffset, Guid, any enum, and the Nullable<T> value forms of those. Note that string? (a nullable reference string) is rejected.

Collections, nested objects, and other complex types are not supported and produce a build error (BW0004). When you hit that, register the Job by hand with JobRegistration.Create<TJob, THandler> and supply your own JsonTypeInfo<TJob>. That is the same path the generator uses internally, so nothing is lost except the convenience.

Write the Handler#

Implement IJobHandler<TJob>. Its one method, HandleAsync, receives the job payload, a JobContext, and a CancellationToken.

Returning normally signals success. Throwing signals failure, which schedules a retry until the Job's Attempt ceiling is reached; after that the Job is Dead-Lettered. The CancellationToken is signaled when the Attempt is torn down, on shutdown or when the Lease is lost, so honor it to stop promptly.

BackWave runs Handlers At-Least-Once. A crash or a lapsed Lease after the work is done but before the outcome is recorded leads to a retry, which means the body can run more than once. Making the work idempotent is your responsibility. The recorded outcome and its downstream effects are fenced to be Effect-Once, but the Handler body itself carries no such guarantee. Execution Guarantee covers the model in full.

The Job Context#

The second parameter, JobContext, is the per-Attempt execution context. It is a class, not a record, because it holds mutable buffers. The two members you reach for first:

  • JobId is the running Job's id.
  • Attempt is the current Attempt number. It starts at 1 on the first try, not 0.

The context also exposes runtime tagging (AddLabel, AddTag) and a single opaque output value (SetOutput), plus GetDependencyOutputAsync for reading an ancestor's output. Those buffer on the context and flush atomically with the Attempt's outcome write, so they belong to Tags, Job Output, and Dependencies rather than the minimal enqueue path. Reach for them when you need them.

What the source generator emits#

A Roslyn source generator reads every [Job] in the assembly at build time and writes, per Job, a JSON wire-format class, (for method form) the payload record and the Handler, and a JobRegistration. The output is the straight-line serialization code you would write by hand: no reflection, no expression trees, trim and NativeAOT clean.

Once per assembly it also emits a static entry point, BackWave.Generated.BackWaveJobs, with three members: a CreateRegistrations() list, a CreateRegistry() factory, and the Module property.

Module is what you hand to DI. It carries the registrations, one Handler mapping per Job, and the distinct declaring classes of non-static method-form Handlers (so they can be resolved from the container). All three lists are ordered by Wire Name, which keeps the Job Manifest diff stable across builds.

There is no file on disk to open: the generator runs in memory during compilation. You consume BackWaveJobs as if it were ordinary code, because to the compiler it is.

Build-time diagnostics#

The generator fails the build rather than letting a malformed Job slip through. Each diagnostic is an error in the BackWave category:

  • BW0001 Wire Name missing. [Job] needs a non-empty Wire Name.
  • BW0002 Duplicate Wire Name. Two types declared the same one.
  • BW0003 No Handler. A class-form [Job] type has no IJobHandler<T> in the compilation.
  • BW0004 Unsupported payload member type. Register by hand with JobRegistration.Create and your own JsonTypeInfo.
  • BW0005 Invalid [Job] method shape. Must be public and return Task.
  • BW0006 Duplicate generated job type. Two [Job]s resolve to the same payload type.

Wire it into DI#

Registration happens in one AddBackWave call. The callback must do three things: name a Storage Adapter with UseStore, supply your Jobs with UseJobs, and add at least one Worker Group. Skip the store or the worker group and registration throws.

Program.cs
builder.Services.AddBackWave(backwave =>
{
    backwave
        .UseStore(new PostgresJobStore(connectionString))
        .UseJobs(BackWaveJobs.Module);
 
    backwave.AddWorkerGroup(new WorkerGroupOptions
    {
        Name = "strict-priority",
        Policy = new DispatchPolicy.Strict(["critical", "bulk", "limited"]),
        PollInterval = TimeSpan.FromMilliseconds(250),
        RetryPolicy = new RetryPolicy { MaxAttempts = 5, Backoff = _ => TimeSpan.FromMilliseconds(500) },
    });
});

UseJobs(BackWaveJobs.Module) registers the Job Registry, one Handler per mapping, and each declaring class. Handlers and declaring classes register scoped, resolved once per Attempt, so a Handler may depend on scoped services such as a DbContext. Declaring classes register through TryAddScoped, so a registration you place yourself wins over the generated one. The BackWaveClient, the store, the registry, and the monitor are singletons.

A Worker Group is what actually claims and runs work. Each group spins up one or more Pumps, independent claim-and-execute loops, and at least one group is required or nothing ever leaves the Queue. The Worker Groups page covers DispatchPolicy, RetryPolicy, pool sizing, and Lease tuning.

Enqueue the work#

You enqueue through BackWaveClient, resolved from DI. There is no separate "schedule" call: enqueuing now and scheduling for later are the same code path, differing only in the instant you pass. An enqueued Job is one whose due time is now.

Program.cs
app.MapPost("/greet/{name}", async (string name, BackWaveClient client) =>
{
    var id = await client.EnqueueAsync(new Greet(name, 0), dueTime: DateTimeOffset.UtcNow);
    return Results.Ok(new { id });
});

Beyond the payload and dueTime, EnqueueAsync takes optional queue, tags, transaction, and cancellationToken arguments, each defaulting so the two-argument call above is the common case.

dueTime is a required positional argument. There is no parameterless "now" overload: to run as soon as possible, pass DateTimeOffset.UtcNow; to defer, pass a future instant. A future due time always defers the Job and is never treated as due now.

Program.cs
// run now
await client.EnqueueAsync(new Greet(name, 0), dueTime: DateTimeOffset.UtcNow);
 
// run in 30 seconds, same method, future instant
await client.EnqueueAsync(new Greet(name, 0), dueTime: DateTimeOffset.UtcNow.AddSeconds(30));

The call returns the new Job's Guid, minted client-side before any Worker runs. It identifies the Job, not a completed result, and you can use it as the parent id when chaining dependent work.

Overriding Queue and Tags per call#

queue is null by default, which uses the Queue declared on the type. Pass a name to override it for this one enqueue.

Program.cs
await client.EnqueueAsync(new WeightedWork($"high-{i}", delayMs), dueTime: DateTimeOffset.UtcNow, queue: "high");

tags are merged on top of the type's default Tags, never instead of them. Build them fluently from JobTags.Empty. This is where a Keyed Tag goes, since the attribute cannot express one.

Program.cs
var tags = JobTags.Empty.WithTag("tenant", tenant);
await client.EnqueueAsync(new TaggedReport(tenant, amount), dueTime: DateTimeOffset.UtcNow, tags: tags);

Failure modes#

EnqueueAsync translates store results into exceptions you can catch:

  • A serialized payload that exceeds the store's limit throws ArgumentException. The fix is to store a reference (an id or a blob key) and have the Handler fetch the data, rather than carrying the bytes through the Job.
  • Any other non-Ok result throws InvalidOperationException with the result name.
  • Passing a transaction to a store whose SupportsTransactionalEnqueue is false throws NotSupportedException.
  • Enqueuing a type that was never registered throws InvalidOperationException from the registry lookup.

One related gotcha: if a Job with an unknown Wire Name reaches the store some other way, the Pump that claims it cannot route it and Quarantines it instead of retrying. A payload that no longer decodes Quarantines the same way. The decoder is otherwise tolerant, skipping unknown JSON properties and defaulting missing ones, but it stays strict on type mismatches: an unparseable enum or a wrong-shaped non-null scalar throws. That tolerance is what lets you evolve payloads without breaking jobs already in flight.

Enqueue inside your own transaction#

When the store supports it, you can write the Job in the same database transaction as your own data. The Job commits or rolls back with your writes, so there is no outbox to maintain and no window where the row exists but the Job does not. With EF Core, use the extension overload that reads the open transaction off the DbContext.

Program.cs
await using var transaction = await db.Database.BeginTransactionAsync();
db.BusinessRows.Add(new SampleBusinessRow { Id = rowId, /* ... */ });
await db.SaveChangesAsync();
 
var jobId = await client.EnqueueAsync(new TxFinalize(rowId), db, dueTime: DateTimeOffset.UtcNow);
 
await transaction.CommitAsync();

This overload requires an explicitly begun transaction. If none is open on the DbContext it throws InvalidOperationException; it does not quietly fall back to a non-transactional enqueue. Guard the call with store.SupportsTransactionalEnqueue if your code runs against adapters that may not support it. The transactional enqueue guide goes deeper, including the raw DbTransaction form.

Where to go next#