DEV Community

Cover image for How RunWay solves the outbox pattern in .NET without the boilerplate
kododo
kododo

Posted on

How RunWay solves the outbox pattern in .NET without the boilerplate

Here's a scenario you've probably written before:

public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);

    await emailQueue.EnqueueAsync(new SendConfirmationEmail { To = order.Email }, ct);
}
Enter fullscreen mode Exit fullscreen mode

The order is saved. The email is queued. Looks fine.

Now imagine the process crashes between those two lines. Or the message broker is temporarily unavailable. Or there's a network blip.

The order exists in your database. The email never gets queued. Your customer placed an order and never received a confirmation. You'll find out from a support ticket.

This is the problem the outbox pattern solves — and it's more common than it looks.


Why it keeps happening

The root cause is that you're writing to two different systems in what feels like one operation. Your database and your job queue are independent — they don't share a transaction. Even a tiny gap between the two writes is enough for things to go out of sync.

The naive fix is to swap the order:

await emailQueue.EnqueueAsync(new SendConfirmationEmail { To = order.Email }, ct);

db.Orders.Add(order);
await db.SaveChangesAsync(ct);
Enter fullscreen mode Exit fullscreen mode

Now you have the opposite problem: the email job is queued before the order is saved. If SaveChangesAsync fails, the job runs against data that doesn't exist yet.

You can add retry logic, deduplication keys, idempotency checks — all of which move complexity to the job handler. It's still fighting the symptom.


What the outbox pattern actually is

The outbox pattern is simple in concept: write the job to the same database as your business data, in the same transaction. A separate process (the "runner") reads from that outbox table and dispatches jobs.

Because everything goes into one transaction, you either commit both the order and the job, or you commit neither. The gap disappears.

The tricky part is the implementation. You need an outbox table, a runner that polls it reliably, logic to handle in-progress jobs after a crash, deduplication to avoid double-processing — and all of this needs to share a database connection with your application code.

Most teams end up building a partial version of this and calling it done.


How RunWay handles it

RunWay is a background job queue for .NET that stores jobs in your database. Because jobs live in the same database as your application, participating in an existing transaction is straightforward:

await using var transaction = await db.Database.BeginTransactionAsync(ct);

db.Orders.Add(new Order { ... });
await db.SaveChangesAsync(ct);

await scheduler
    .Job(new SendConfirmationEmail { To = order.Email })
    .AsTransactional(false)   // reuse the ambient transaction — don't open a new one
    .ScheduleAsync(ct);

await transaction.CommitAsync(ct);
Enter fullscreen mode Exit fullscreen mode

AsTransactional(false) tells RunWay to enlist in the open transaction rather than managing its own. The job row is written to the runway.jobs table inside the same BeginTransactionAsync scope.

If the commit fails — for any reason — the job is rolled back along with the order. If the commit succeeds, the runner will pick up the job. There's no window where one side commits and the other doesn't.

For this to work, RunWay needs to share the same database connection as your DbContext:

builder.Services.AddRunWay(x =>
{
    x.UsePostgreSQL(p => p.GetRequiredService<AppDbContext>().Database.GetDbConnection())
     .AddRunner(opts => opts.AddHandlersFromAssembly(typeof(Program).Assembly));
});
Enter fullscreen mode Exit fullscreen mode

That single line — passing the connection from the existing DbContext — is what makes the transaction sharing work.


What happens after the commit

Once the transaction commits, RunWay's runner (a background IHostedService) polls the jobs table and picks up the work. It handles:

  • Retries with backoff — configurable per job, not globally
  • Timeouts — mark a job as failed if it runs too long
  • Heartbeats — detect stuck runners and recover stalled jobs
await scheduler
    .Job(new SendConfirmationEmail { To = order.Email })
    .AsTransactional(false)
    .WithRetryDelaysInSeconds(30, 120, 600)
    .WithTimeout(TimeSpan.FromMinutes(2))
    .ScheduleAsync(ct);
Enter fullscreen mode Exit fullscreen mode

The job handler itself stays clean — no awareness of retries or transactions:

public class SendConfirmationEmailHandler : IJobHandler<SendConfirmationEmail>
{
    public async Task HandleAsync(SendConfirmationEmail data, CancellationToken ct)
    {
        await emailService.SendAsync(data.To, "Order confirmed", ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

When AsTransactional(true) makes sense

AsTransactional(false) reuses an open transaction. AsTransactional(true) (the default when you call .AsTransactional() without arguments) opens a new transaction scoped to the job insertion itself.

Use true when you want the job write to be atomic but you don't have — or don't want to share — an ambient transaction. It won't protect you from the two-write problem, but it does guarantee the job row is written consistently on its own.

Most outbox use cases want false.


The pattern without the boilerplate

The outbox pattern is well understood, but most implementations require you to own the outbox table schema, the polling logic, the retry semantics, and the connection sharing setup. That's a lot of infrastructure for what should be a solved problem.

RunWay gives you that infrastructure as a library — one transaction flag instead of a custom outbox table.

If you keep writing the same two-lines-of-risk pattern, it's worth a look:

If you've solved this differently in your team — a custom outbox table, MassTransit's outbox, something else — I'd be curious to hear how it's holding up.

Top comments (0)