Skip to content
Kumar Chandrachooda
.NET

Two Outboxes, One Interface

The parent series covered the outbox pattern; it never compared the implementations. Entity Framework gets a real transaction, Mongo gets sessions you can switch off, expiry is three different mechanisms — and the processor that drains them all is a fire-and-forget timer.

By Kumar Chandrachooda 26 Jun 2026 6 min read
One interface, two very different drawers

Part 12 of the parent series told the outbox story: the dual-write problem, IMessageOutbox, the inbox that deduplicates, the background processor that drains. What it did not do — and I only saw this on the audit re-read — is open the two storage implementations side by side. Convey.MessageBrokers.Outbox.Mongo got most of the airtime because the samples use it; Convey.MessageBrokers.Outbox.EntityFramework got a single passing mention. That is backwards from how most teams meet this decision, because the EF variant is the one you reach for when your service already has a relational database — which, in the .NET world, is most services.

So this chapter is the comparison the parent skipped: both implementations, line against line, plus a harder look at the shared OutboxProcessor than I gave it the first time. The differences are not cosmetic. They are differences in what is actually guaranteed, and two of them changed how I configure this thing.

The contract both sides implement

Quick recap of the seam. IMessageOutbox is the application-facing pair — SendAsync (store an outgoing message) and HandleAsync(messageId, handler) (process an incoming message exactly once). IMessageOutboxAccessor is the processor-facing triple — GetUnsentAsync, and ProcessAsync for one message or many. Both implementations are one class wearing both interfaces, registered by an AddMongo() or AddEntityFramework<TContext>() call on the outbox configurator. Same interface, same options section, interchangeable in Program.cs. The interchangeability is real; the equivalence is not.

HandleAsync: where the transactions live

The inbox side is where the money is, because HandleAsync is the exactly-once machine: check if the message id was seen, run your handler, record the id — atomically, or the whole exercise is decorative.

The EF implementation does this the way a relational database wants you to (source: Convey, MIT-licensed, trimmed):

await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
    await handler();
    await _inboxMessagesSet.AddAsync(new InboxMessage { Id = messageId, ProcessedAt = DateTime.UtcNow });
    await _dbContext.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch (Exception ex)
{
    await transaction.RollbackAsync();
    throw;
}

One real database transaction wraps your handler's writes and the inbox insert — provided your handler writes through the same TContext. That proviso is the whole design. The generic parameter is not a convenience; it is the mechanism that puts your business rows and the inbox row in the same commit. Use a different context, or Dapper on the side, and the guarantee quietly narrows from “handler effects and dedup record are atomic” to “dedup record is written if the handler didn't throw.”

The Mongo implementation wants to make the same promise and has to negotiate with its database first:

IClientSessionHandle session = null;
if (_transactionsEnabled)
{
    session = await _sessionFactory.CreateAsync();
    session.StartTransaction();
}

Mongo only supports multi-document transactions on replica sets — a single standalone mongo container, which is exactly what the sample compose file runs, will throw at StartTransaction. Hence the options flag the parent series never mentioned: outbox.DisableTransactions. Flip it and the inbox becomes check-then-insert with no atomicity: your handler can complete, the process can die before the inbox insert, and the message replays. At-least-once, not exactly-once — controlled by one boolean whose default (false, transactions on) is correct for production and wrong for the dev compose file. I have watched this exact mismatch burn an afternoon: works locally with the flag set, then someone copies the flag to an environment where it actually mattered.

There is a subtler wrinkle: even with sessions enabled, the repository calls inside the handler have to participate in the session for the atomicity to cover them — and the generic IMongoRepository from part 13 doesn't take a session handle. In practice the Mongo outbox's transaction reliably covers its own inbox bookkeeping; whether it covers your writes depends on plumbing the session yourself. The EF version gets this for free from DbContext change tracking. That, in one sentence, is the strongest argument for the EF variant.

Serialization: the same clever trap twice

Both implementations store outgoing messages the same way: the payload JSON-serialized into SerializedMessage, and the CLR type recorded as MessageType = message.GetType().AssemblyQualifiedName. On the way out, the processor round-trips it:

var messageType = Type.GetType(om.MessageType);
om.Message = JsonSerializer.Deserialize(om.SerializedMessage, messageType, SerializerOptions);

Type.GetType on an assembly-qualified name means your outbox rows are coupled to an assembly name and version. Rename the assembly, move OrderCreated to a shared contracts project, or in some configurations even bump versions between enqueue and drain (a deploy landing while messages sit unsent — precisely the crash window the outbox exists for), and Type.GetType returns null and the drain dies on that row. The pattern is common — several big frameworks did the same before switching to type-name maps — but it is worth knowing that your outbox table has opinions about your refactoring.

Expiry: three mechanisms, one setting

The outbox.expiry option (seconds to retain processed messages) is implemented three different ways across the three stores, and the differences are a nice little systems lesson:

  • In-memory sweeps expired entries inline, during ProcessAsync — bookkeeping as a side effect of publishing.
  • Mongo does it properly: a startup IInitializer creates TTL indexes on ProcessedAt for both collections, and the database deletes old rows itself, forever, with no application involvement. This is the single most production-grade detail in either package — and note the shape: expiry configured in your appsettings becomes an index in the database, created by the initializer chain from part 2.
  • Entity Framework: nothing. I read the package twice looking for the cleanup job; there isn't one. Processed rows accumulate until you delete them. Every relational outbox I have run eventually met this as a “why is this table 40 GB” ticket, so consider this paragraph the scheduled-job requirement your story pointing at the EF variant should include.

One setting, three semantics: immediate, database-enforced, unimplemented. The options class is the same; the operational behavior is not.

The processor: honest about its timer

The shared OutboxProcessor also deserved harder scrutiny than the parent series gave it. It is an IHostedService around a System.Threading.Timer, and its tick handler is this:

private void SendOutboxMessages(object state)
{
    _ = SendOutboxMessagesAsync();
}

Fire-and-forget, discarded task. Two consequences follow directly. First, ticks can overlap: if a drain takes longer than intervalMilliseconds (default configs run this at a few hundred ms), the next tick starts a second drain concurrently, both read the same unsent rows, and both publish them — duplicates manufactured by your own delivery mechanism. Second, an unobserved exception in the drain is simply lost; there is no catch in the async path, no backoff, just the next tick. The inbox on the consumer side will absorb the duplicates — that is what it is for — but “the outbox occasionally double-publishes by design under load” belongs in your runbook, not in your incident retro.

And scale it out: the processor runs in every instance of your service. Three replicas, three timers, all draining one collection with no lease or row lock (GetUnsentAsync is a plain unfiltered-by-owner query in both stores). Same effect, more of it. Sequential versus Parallel — the outbox.type setting — only changes when ProcessAsync marks rows (after each publish, or once after all), trading fewer round-trips for a bigger duplicate window on a crash mid-batch. Neither mode changes the multi-instance story.

None of this makes the outbox wrong. It makes it an at-least-once outbox with inbox-based dedup, which is exactly what part 12 said the pattern honestly is. The missing chapter was that the strength of the “least” differs by store: EF gives you the tightest handler atomicity and no expiry; Mongo gives you real expiry and transactions that depend on your topology and your session plumbing; in-memory gives you a demo. Same interface. Read the implementation before you pick the drawer.

Next chapter zooms all the way out: the docker-compose file it takes to run three toy services — ten containers of infrastructure, and what that bill says about the chassis.