# Transactional Enqueue with EF Core

Enqueue a job inside your own EF Core transaction so the business write and the job commit or roll back together.

The problem this solves shows up the moment a job depends on data you just wrote. You insert an order, then enqueue a job to send the receipt. If the insert commits but the enqueue fails, the customer never hears from you. If the enqueue lands but the insert rolls back, a Worker picks up a job that points at an order that does not exist. The usual fix is an outbox table and a relay process. Transactional Enqueue removes the need for one: BackWave writes the Job row on the transaction you already own, so the business write and the Job commit or roll back as a unit.

## What "rides along on your transaction" actually means

BackWave never opens the business transaction. You begin it on your `DbContext`, you commit it, you roll it back. The only thing BackWave does is read the open transaction off the context and write its Job row on the same connection inside it. When you commit, the Job becomes durable and visible to the cluster in the same atomic step as your own rows. When you roll back, the Job never existed.

This is a [Storage Adapter](/docs/storage/overview) capability, not a universal guarantee. The adapter has to be able to enlist in an ADO.NET transaction you hand it, and your `DbContext` and the BackWave store have to target the same database. Get those two things lined up and the atomicity is real; miss them and BackWave tells you loudly rather than degrading in silence.

One thing worth stating up front, because it shapes everything below: there is no EF Core specific job store, no `DbSet` for jobs, no `SaveChanges` interceptor, and no change-tracked Job entity. The `BackWave.EntityFrameworkCore` package is one static extension method. It reads the transaction off your context and forwards to the regular client. The Job is written by a raw `INSERT` on the connection, not through EF's change tracker. Keep that picture in mind and the sharp edges later make sense.

## The shape of the call

The whole EF Core surface is a single `EnqueueAsync` extension method on `BackWaveClient`. Its arguments, in order:

- `job` is the payload, serialized through its registration. Its [registration](/docs/core-concepts/jobs-and-handlers) decides the Wire Name, the serializer, and the default Queue.
- `unitOfWork` is the `DbContext` whose open transaction the Job joins. You begin a transaction on it first.
- `dueTime` is required. There is no implicit "now" on this overload. Pass `DateTimeOffset.UtcNow` to run the Job as soon as a Worker is free, or a future instant to defer it.
- `queue` defaults to `null`, which uses the Job type's registered Queue. Pass a string to override it for this one enqueue.

It returns the new Job's id, which you can hold onto and use later as a dependency parent.

There is no `tags` parameter on this overload. If you need [tags](/docs/core-concepts/tags), set them on the Job type's registration so the default carries through.

## End to end

Here is the canonical pattern: open a transaction, write your row, enqueue the Job, commit.

```csharp title="OrderEndpoint.cs" {3-8}
await using var transaction = await context.Database.BeginTransactionAsync();

context.Orders.Add(order);
await context.SaveChangesAsync();

// The job rides on the EF transaction: it commits or rolls back atomically with the order.
var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);

await transaction.CommitAsync(); // the order and the job commit together
```

The `DbContext` here is a plain EF Core context over your business database. Nothing in it is BackWave-specific:

```csharp title="SampleDbContext.cs"
public sealed class SampleDbContext(DbContextOptions<SampleDbContext> options) : DbContext(options)
{
    public DbSet<SampleBusinessRow> BusinessRows => Set<SampleBusinessRow>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<SampleBusinessRow>().ToTable("sample_business");
}
```

The one rule that makes the atomicity work: register that context against the **same connection string** as your BackWave store.

```csharp title="Program.cs"
services.AddDbContext<SampleDbContext>(options => options.UseNpgsql(connectionString));
```

BackWave's tables live alongside your business tables in the same database. The context and the store share a connection target, so a single transaction can span both. That is the whole arrangement. You do not register anything from `BackWave.EntityFrameworkCore` in DI; the package's entire public surface is the extension method you call. Its only dependency is `Microsoft.EntityFrameworkCore.Relational`, so the provider comes from your application, not from BackWave.

## When the rollback path matters

The reason to reach for this is the failure case, so it is worth seeing it work. Roll the transaction back and the Job is gone with the row:

```csharp title="OrderEndpoint.cs"
await using var transaction = await context.Database.BeginTransactionAsync();

context.Orders.Add(order);
await context.SaveChangesAsync();

var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);

if (somethingWentWrong)
{
    await transaction.RollbackAsync();
    // Both the order and the job were rolled back. No Worker will ever see this job.
}
```

A rollback, or disposing the transaction without committing, means the Job never existed. No Worker can claim it because it was never durable.

## When the Job actually becomes visible

This is the part that surprises people, so it is worth being precise. The `INSERT` for the Job happens **during** the `EnqueueAsync` await, synchronously, on your open transaction. It does not wait for `SaveChangesAsync` and it does not wait for commit. What waits for commit is visibility and durability.

That means right after `EnqueueAsync` returns, the Job is written into your transaction but invisible to everyone else. A read on a separate connection, including BackWave's own Monitor reads and any `GetJobAsync`, returns nothing until you commit:

```csharp title="OrderEndpoint.cs"
var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);

// Invisible to the rest of the cluster until the unit of work commits.
// A read on a fresh connection returns null here.

await transaction.CommitAsync();
// After commit: the job is Scheduled and claimable.
```

Once you commit, the Job enters the [Scheduled](/docs/core-concepts/job-lifecycle) state and a Worker can claim it. Every enqueued Job is a Scheduled Job; "enqueue now" is the same code path as a future schedule, with a due time of now.

### You do not need a second SaveChanges

Because BackWave writes the Job with a raw `INSERT` rather than through the change tracker, `SaveChangesAsync` and `EnqueueAsync` are two independent writes on the same transaction. The Job is already inserted by the time `EnqueueAsync` returns. You do not need a later `SaveChangesAsync` to persist it. All of it, your entities and the Job row, is bound by the single `CommitAsync`. The order in the samples, `Add` then `SaveChangesAsync` then `EnqueueAsync` then `CommitAsync`, is the one to follow.

### Multiple jobs in one transaction

Enqueueing several Jobs through the same unit of work before a single commit works as you would expect. All of them are written on the transaction and all become Scheduled together when you commit. The one caveat: a duplicate Job id inside the same transaction is rejected at the call site as an `InvalidOperationException`. You will not hit that with freshly minted ids, which is the normal case.

## The sharp edges

### An open transaction is required, and its absence throws

EF Core wraps each `SaveChanges` in its own implicit transaction, but that transaction is not ambient and BackWave will not use it. You must call `BeginTransaction` or `BeginTransactionAsync` yourself. If there is no open transaction on the context when you call `EnqueueAsync`, it throws an `InvalidOperationException` whose message names `BeginTransaction` as the fix.

This is deliberate. The alternative would be silently falling back to a plain, non-transactional enqueue, which would defeat the entire point and hide a correctness bug. BackWave throws instead. If you genuinely want a non-transactional enqueue, that is an explicit choice: call the base `client.EnqueueAsync(job, dueTime)` overload with no `DbContext` at all.

### The capability is not universal

If you hand a transaction to a store whose adapter does not support Transactional Enqueue, the client throws a `NotSupportedException` before it touches the store. The message names `SupportsTransactionalEnqueue` so the cause is unmistakable.

Where the capability stands across the production adapters:

- [Postgres](/docs/storage/postgres) supports it. It enlists in `NpgsqlTransaction`.
- [SQL Server](/docs/storage/sql-server) supports it. It enlists in `SqlTransaction`.
- [SQLite](/docs/storage/sqlite) supports it, but only the co-resident deployment can exploit it (see below). It enlists in `SqliteTransaction`.

You can guard for this at runtime when a deployment might vary:

```csharp title="OrderEndpoint.cs"
if (!store.SupportsTransactionalEnqueue)
{
    // This store deployment cannot enlist in your transaction.
    // Fall back, or refuse the operation, rather than enqueue non-atomically.
}
```

### The DbContext provider must match the store provider

Each adapter casts your transaction to its provider's native type and throws an `ArgumentException` if it gets the wrong one. With the EF overload this can only go wrong if your `DbContext` uses a different provider than your BackWave store. They have to be the same provider against the same database. A Postgres context and a SQL Server store cannot share a transaction, and BackWave will not pretend otherwise.

This also rules out cross-database and cross-engine atomicity. There is no distributed transaction, no `TransactionScope` across two engines, no MSDTC. Atomicity requires one database (one file, for SQLite).

### SQLite: co-resident only

SQLite has two deployments, and the difference matters here. In the **co-resident** deployment, BackWave's tables live in your application's own SQLite file. The EF transaction and the Job commit in one file, in one transaction. That is the tightest Transactional Enqueue there is. In the **dedicated** deployment, BackWave runs on its own file with your business data elsewhere, and no single transaction can span two files, so it forgoes Transactional Enqueue even though the underlying adapter reports the capability.

The SQLite adapter also runs a same-file guard. On the first transactional enqueue per connection it checks that your `SqliteConnection` is attached to the same on-disk file BackWave is configured for, comparing canonical paths. If they differ, it throws rather than commit a Job invisibly into the wrong file. In-memory and temporary databases are skipped by the check.

### A note on latency, not correctness

Under transactional enqueue some adapters hold back the Wake-Up Hint, the latency-only nudge that tells the Worker pump a new Job has arrived. ADO.NET exposes no commit callback, so firing the hint before commit would wake the pump to a row it cannot see. Postgres is the exception: it signals through `pg_notify`, which is itself transactional, so its hint fires on commit. Either way this only affects how quickly a Worker notices the Job. The Wake-Up Hint is never correctness-bearing; polling picks the Job up regardless.

## Where to go next

- [Define and enqueue a job](/docs/guides/define-and-enqueue-a-job) for the plain, non-transactional enqueue path.
- [Jobs and Handlers](/docs/core-concepts/jobs-and-handlers) for how registration sets the Wire Name, serializer, and default Queue.
- [Job lifecycle](/docs/core-concepts/job-lifecycle) for what happens after a Job becomes Scheduled.
- [EF Core storage](/docs/storage/ef-core) for wiring your context and the store against one database.
- [Storage overview](/docs/storage/overview) for which adapters support Transactional Enqueue.
- [Client API reference](/docs/reference/client-api) for every enqueue overload and its parameters.
