Skip to main content

EF Core Outbox Storage

UnambitiousFx.Synapse.Outbox.EntityFrameworkCore is a transactional IEventOutboxStorage implementation backed by EF Core. It writes outbox entries through whatever DbContext it is given, so a stored entry lands in that context's ambient transaction — the same guarantee EmitMode.Outbox exists for.

Install​

dotnet add package UnambitiousFx.Synapse.Outbox.EntityFrameworkCore

Choose a schema story​

Standalone OutboxDbContext (the common case)​

services.AddDbContext<OutboxDbContext>(o => o.UseNpgsql(connectionString));
services.AddEfCoreEventOutbox<OutboxDbContext>();
services.AddSynapse(cfg =>
{
cfg.SetEventOutboxStorage<EfCoreEventOutboxStorage<OutboxDbContext>>();
});

Generate its migrations like any other context, in your own project:

dotnet ef migrations add InitialOutbox --context OutboxDbContext

Embed the table in your own DbContext​

If you'd rather keep the outbox table under your existing context's migration history instead of a separate one, apply OutboxEntityTypeConfiguration yourself:

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new OutboxEntityTypeConfiguration());
// ... your own entities
}
}
services.AddEfCoreEventOutbox<AppDbContext>();
services.AddSynapse(cfg =>
{
cfg.SetEventOutboxStorage<EfCoreEventOutboxStorage<AppDbContext>>();
});

OutboxEntityTypeConfiguration's constructor takes an optional schema parameter (default "outbox"), so the table never collides with your own tables regardless of which context it lives in.

:::note Call AddEfCoreEventOutbox<TContext>() before AddSynapse

SetEventOutboxStorage<EfCoreEventOutboxStorage<TContext>>() forwards IEventOutboxStorage to whatever AddEfCoreEventOutbox<TContext>() already registered, so both resolve to the same instance per scope — but only if AddEfCoreEventOutbox<TContext>() runs first, as shown above. Call it after AddSynapse and the forwarding has nothing to find, so IEventOutboxStorage falls back to constructing its own independent instance instead.

:::

Multiple instances​

EfCoreEventOutboxStorage implements IClaimableOutboxStorage, so several instances of your application can run the background dispatcher (or call CommitAsync) against one outbox table without dispatching an entry twice.

A claim is two statements and needs no provider-specific SQL. The storage first selects candidate rows. It then runs one conditional UPDATE that stamps a lease (ClaimedUntil) and a per-call ClaimToken only on candidates that are still unclaimed, and reads back the rows carrying its token. The database re-checks that condition against each row as it updates it, so when two instances race for the same row, exactly one wins it. Unlike FOR UPDATE SKIP LOCKED, instances under heavy contention can pick overlapping candidates and end up with smaller batches, but no row is ever claimed twice.

:::note Upgrading

Claiming adds two nullable columns, ClaimedUntil (UTC ticks, like the other timestamps) and ClaimToken. Run dotnet ef migrations add for the context that owns the outbox table. The change is additive, so existing rows need no conversion.

:::

Timestamp storage​

OutboxEntityTypeConfiguration stores every DateTimeOffset column (CreatedAt, ProcessedAt, NextAttemptAt) as a 64-bit integer of UTC ticks (DateTimeOffset.UtcTicks), not as the provider's native date/time type. Two things follow from that:

  • The pending-entry query (the NextAttemptAt back-off filter and the CreatedAt ordering) and the oldest-pending-age lookup run entirely in SQL on every provider, SQLite included, so entries still backing off never leave the database and ix_outbox_events_pending can serve the filter.
  • Values read back through EF Core come back with a zero offset. The instant is preserved; the original offset is not. If you query the table with raw SQL, the columns hold ticks, not dates.

:::caution Upgrading from an earlier version

Earlier versions mapped those columns to the provider's native type (TEXT on SQLite, datetimeoffset on SQL Server, timestamp with time zone on PostgreSQL). After upgrading, run dotnet ef migrations add for the context that owns the outbox table. EF Core generates a column type change, but it does not convert the values already stored. The simplest path is to drain the outbox first, so no pending rows are left. Otherwise, edit the migration to convert the data, as shown below. Test the migration against a copy of your data first.

:::

Converting existing rows​

A stored value is .NET ticks: 100-nanosecond intervals since 0001-01-01T00:00:00Z, which is not the Unix epoch. The conversion is unix_seconds × 10,000,000 + 621,355,968,000,000,000. The snippets below assume the default outbox schema and EF Core's default column names. Repeat them for ProcessedAt and NextAttemptAt.

PostgreSQL: the generated AlterColumn fails, because PostgreSQL cannot cast timestamptz to bigint on its own. Replace each call with a USING clause. PostgreSQL rebuilds the indexes on the column itself.

migrationBuilder.Sql("""
ALTER TABLE outbox.outbox_events
ALTER COLUMN "CreatedAt" TYPE bigint
USING (EXTRACT(EPOCH FROM "CreatedAt") * 10000000)::bigint + 621355968000000000;
""");

SQL Server: datetimeoffset cannot be altered to bigint in place. Add a bigint column, fill it, drop ix_outbox_events_pending (it covers NextAttemptAt) and the old column, rename the new column, then recreate the index. The value to fill it with is:

DATEDIFF_BIG(microsecond, CAST('0001-01-01' AS datetime2(7)),
CAST(SWITCHOFFSET([CreatedAt], '+00:00') AS datetime2(7))) * 10
+ DATEPART(nanosecond, [CreatedAt]) / 100 % 10

SQLite: the generated migration rebuilds the table and copies the old text values across unchanged. Add an UPDATE after the rebuild. SQLite's date functions keep millisecond precision, so converted values are rounded to the nearest millisecond. That is enough to keep the drain order.

migrationBuilder.Sql("""
UPDATE "outbox_events"
SET "CreatedAt" = CAST(ROUND((julianday("CreatedAt") - julianday('0001-01-01')) * 86400000) AS INTEGER) * 10000
WHERE typeof("CreatedAt") = 'text';
""");

Multi-DbContext (modular monolith)​

When a business DbContext and the outbox context must commit or roll back together — the point of the outbox pattern — share one DbTransaction across them with Database.UseTransactionAsync:

await using var tx = await businessContext.Database.BeginTransactionAsync(ct);
await outboxContext.Database.UseTransactionAsync(tx.GetDbTransaction(), ct);

businessContext.Tasks.Add(task);
await businessContext.SaveChangesAsync(ct); // business save

await emitter.EmitAsync(new TaskCreatedEvent(task.Id), EmitMode.Outbox, ct); // outbox write,
// same DbTransaction

await tx.CommitAsync(ct); // both commit together; either rolls back together

See also Modular Monolith for the broader module-boundary picture this fits into.

Limitations​

Outbox rows store their event's assembly-qualified CLR type name, and GetPendingEventsAsync, ClaimPendingEventsAsync and GetDeadLetterEventsAsync resolve it back with Type.GetType(..., throwOnError: true). If you rename, move or delete an event type while rows referencing it are still pending, that resolution throws and the whole call fails — blocking dispatch of every pending entry, not just the offending one, and there is no in-library recovery: an operator has to fix the stored EventType value or delete the row by hand. Keep a type-forwarding shim, or migrate the stored type names, before renaming or removing an event type that may still have outbox rows.

See also​

  • Outbox Pattern — the general outbox contract, retry/dead-letter behavior, health check.
  • Modular Monolith — module boundaries this storage's multi-DbContext support is designed for.