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
NextAttemptAtback-off filter and theCreatedAtordering) and the oldest-pending-age lookup run entirely in SQL on every provider, SQLite included, so entries still backing off never leave the database andix_outbox_events_pendingcan 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-
DbContextsupport is designed for.