Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-outbox-that-commits-somewhere-else && read --section="top" 0%
Architecture

The Outbox That Commits Somewhere Else

Inflow ships a complete transactional outbox - table, poller, cleanup jobs, type registry - and one CreateScope() call means the outbox row commits on a different DbContext from the state change it is supposed to be atomic with.

By Kumar Chandrachooda 24 Jan 2026 6 min read
Two transactions that were supposed to be one

The transactional outbox exists to close exactly one gap. Your handler commits a state change and then publishes an event. If the process dies in between, the state change is durable and the event is lost — the at-most-once failure part 8 traced through Inflow's shipped path. The outbox closes it by writing the event into the same transaction as the state change, so the two facts become one fact, and a separate poller publishes from the table afterwards.

The whole pattern reduces to a single requirement: the outbox row and the aggregate change must commit together. Everything else — the poller, the retry, the cleanup, the deduplication downstream — is machinery around that one invariant.

Inflow builds all of the machinery. Fifteen files under Messaging/Outbox/, roughly seven hundred lines: EfOutbox<T>, EfInbox<T>, OutboxBroker, OutboxMessage, InboxMessage, OutboxProcessor, two cleanup processors, two type registries, an inbox decorator, and an options class with four TimeSpan? knobs. It is the largest subsystem in the shared framework.

And then OutboxBroker.SendAsync is this:

public async Task SendAsync(params IMessage[] messages)
{
    var message = messages[0]; // Not possible to send messages from different modules at once
    var outboxType = _registry.Resolve(message);
    if (outboxType is null)
    {
        throw new InvalidOperationException($"Outbox is not registered for module: '{message.GetModuleName()}'.");
    }

    using var scope = _serviceProvider.CreateScope();
    var outbox = (IOutbox)scope.ServiceProvider.GetRequiredService(outboxType);
    await outbox.SaveAsync(messages);
}

using var scope = _serviceProvider.CreateScope();

Why that line is the whole story

EfOutbox<T> takes a T dbContext in its constructor, where T is the module's DbContext. DbContext is registered scoped by AddDbContext<T>. So the EfOutbox<CustomersDbContext> resolved from a new scope receives a new CustomersDbContext — a different instance, with a different change tracker, on a different connection, from the one the command handler has been using.

EfOutbox.SaveAsync then does exactly what you would want, on entirely the wrong object:

await _set.AddRangeAsync(outboxMessages);
await _dbContext.SaveChangesAsync();

That SaveChangesAsync opens its own transaction, writes the outbox rows, and commits. The aggregate change is committed separately, by the handler's own DbContext, in its own transaction. Two transactions on two connections, in an unspecified order, with a window between them. Which is precisely the failure the outbox was installed to remove.

The failure is also worse than the no-outbox case in one direction. Without the outbox, a crash after the commit loses an event. With this outbox, a crash between the two commits can lose the event or — if the outbox row commits first and the aggregate change fails — publish an event describing a state change that never happened. You have converted a lost-update problem into a phantom-event problem and paid a table, a poller and two background services for the privilege.

The fix is deleting one line and using the ambient scope's IOutbox. OutboxBroker is constructed by InMemoryMessageBroker, which is registered transient and resolved inside whatever scope the command handler is running in — the scope CommandDispatcher created. Resolving outboxType from that provider rather than a fresh one would put the outbox on the same DbContext the handler is using, and SaveChangesAsync would then flush the aggregate change and the outbox row in the same transaction. That is the pattern.

To be fair, and this matters: outbox:enabled is false in both appsettings.json and appsettings.test.json, so this code has never run. More than that — AddOutbox<T>() returns early when the flag is off, before registering IOutbox at all, so EfOutbox<T> is never even in the container. Seven hundred lines of the shared framework's largest subsystem are compiled, referenced by four modules' AddCore() calls, and never instantiated. A defect in code that has never executed is a defect nobody had the opportunity to notice, and in a teaching repository with no CI and no deployment there is no mechanism by which anybody would have.

What the read side would do if you switched it on

Suppose you flip the flag. OutboxProcessor starts, waits its five-second start delay, and then every second resolves GetServices<IOutbox>() — one EfOutbox<T> per module, since each module's AddOutbox<T>() adds its own transient registration — and runs them all under Task.WhenAll. That part is sound: each outbox has a different DbContext type, so the parallelism is safe.

Inside, EfOutbox.PublishUnsentAsync has five properties worth naming, because each has a name in the literature.

No ordering. _set.Where(x => x.SentAt == null).ToListAsync() — no OrderBy(x => x.CreatedAt). Messages replay in whatever order Postgres returns rows. There is no Resequencer and no per-aggregate ordering key. For a payments domain, “the deposit completed event arrived before the deposit started event” is not a nuance.

No locking. No FOR UPDATE SKIP LOCKED, no owner column, no lease. Two instances of the application are Competing Consumers on a channel with no competition semantics: both select the same rows, both publish, both stamp SentAt. Horizontal scaling multiplies every integration event by the instance count. This is, in my experience, the single most-copied snippet in .NET outbox implementations and the single most-copied without its locking clause.

A poison pill with no exit. The type is stored as an assembly-qualified name and resolved with Type.GetType(outboxMessage.Type). An assembly-qualified name embeds assembly version, culture and public key token. Rename the type, move it to another assembly, or bump the version, and Type.GetType returns null. Then:

var type = Type.GetType(outboxMessage.Type);
var message = _jsonSerializer.Deserialize(outboxMessage.Data, type) as IMessage;
if (message is null)
{
    _logger.LogError($"Invalid message type in outbox ('{module}'): '{type.Name}', ...");
    continue;
}

Deserialize(data, null) throws before the message is null guard is reached — and the guard's own log line dereferences type.Name, so even arriving there would throw. PublishUnsentAsync faults, OutboxProcessor catches and logs, and one second later it tries the same row again. There is no retry counter, no Attempts column, no Error column, no Dead Letter Channel and no Invalid Message Channel. One bad row stops that module's entire outbox permanently while emitting one error line per second, forever. A Format Indicator chosen from the CLR type system is bound to your build number.

SentAt is stamped on enqueue, not on delivery. With useAsyncDispatcher: true — the shipped value — the “publish” step is _asyncMessageDispatcher.PublishAsync(message), which writes to the in-memory channel and returns. The row is marked delivered while the message is still volatile in a queue that is discarded on shutdown. Guaranteed Delivery, defeated by the transport it hands off to. The two features are individually sensible and jointly incoherent, which is the honest way to describe most feature interactions nobody enumerated.

And one thing that is exactly right. SaveChangesAsync() is called after the loop, not inside it. If the process dies mid-batch, already-dispatched messages have no SentAt and will be re-dispatched. That is correct at-least-once behaviour, and it is precisely what the inbox exists to absorb. Two components fitting together as designed, in a subsystem where that is rare enough to be worth pointing at.

The comment that is doing real work

Back at the top of SendAsync:

var message = messages[0]; // Not possible to send messages from different modules at once

The array's first element decides which module's outbox handles the whole batch. The comment is honest about the constraint and nothing enforces it. IMessageBroker.PublishAsync(IMessage[] messages) is public, module code can pass whatever it likes, and if a handler ever publishes two messages from two modules in one call, the second module's messages are silently written to the first module's outbox table — where they will be published from the wrong module's poller with the wrong module label in the logs.

I like this comment. It is the clearest example in the repository of a stated simplification rather than an accidental one, and the discrimination matters: a teaching simplification is deliberate, self-consistent and usually announced, while a bug contradicts the code's own stated intent. messages[0] with a comment is the first kind. The CreateScope() two lines below it — a method that creates a resource specifically to defeat the purpose of the class it lives in — is the second.

The signal I trust most when reading unfamiliar code is internal contradiction. When a method builds a subsystem whose entire reason for existing is atomicity, and then opens a second transaction to write into it, nobody chose that.

Next, an inbox that is really a ledger — the other half of this subsystem, which absorbs the at-least-once redelivery correctly and is named after a pattern it does not implement.