# Define & Enqueue a Job

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](/docs/core-concepts/jobs-and-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.

```csharp title="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.

```csharp title="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](/docs/core-concepts/queues) 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.

```csharp title="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](/docs/core-concepts/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](/docs/core-concepts/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](/docs/core-concepts/tags), [Job Output](/docs/guides/read-another-jobs-output), and [Dependencies](/docs/core-concepts/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](/docs/guides/guard-wire-compatibility) 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.
- **BW0007** Workflow or output type missing from your `JsonSerializerContext`. A one-click fix adds it.
- **BW0008** Invalid `[Retry]` attempt ceiling. The ceiling must be from 1 to 1000.
- **BW0009** Invalid `[Retry]` backoff list. Give 1 to 20 intervals, none of them negative.
- **BW0010** `[Retry]` on a type with no `[Job]`. The attribute needs a Job to override.

## 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](/docs/core-concepts/worker-groups). Skip the store or the worker group and registration throws.

```csharp title="Program.cs" {4-5}
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](/docs/core-concepts/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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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](/docs/guides/evolve-job-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`.

```csharp title="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](/docs/guides/transactional-enqueue-with-ef-core) goes deeper, including the raw `DbTransaction` form.

## Where to go next

- [Jobs & Handlers](/docs/core-concepts/jobs-and-handlers): the concepts behind the Job, Handler, and Wire Name.
- [Job Lifecycle](/docs/core-concepts/job-lifecycle): what happens to a Job between enqueue and terminal state.
- [Transactional Enqueue with EF Core](/docs/guides/transactional-enqueue-with-ef-core): commit a Job atomically with your own writes.
- [Configure Retries & Error Handling](/docs/guides/configure-retries-and-error-handling): tune the Attempt ceiling and backoff.
- [Chain Jobs with Dependencies](/docs/guides/chain-jobs-with-dependencies): run a Job only after another reaches a terminal state.
- [Evolve Job Payloads](/docs/guides/evolve-job-payloads): change a payload shape without breaking in-flight jobs.
