An Inbox That Is Really a Ledger
Inflow's inbox writes a row only after the handler succeeds, which makes its own duplicate-check predicate dead code and its name wrong. It is a good Idempotent Receiver with a one-hour memory, a decorator that resolves from the wrong provider, and an orphaned brace pair.
Part 11 ended on the one thing the outbox gets right: it commits SentAt after the batch, so a crash mid-loop causes redelivery. At-least-once needs a receiving half, and Inflow has one — EfInbox<T>, InboxMessage, InboxTypeRegistry, InboxEventHandlerDecorator<T> and InboxCleanupProcessor.
It is a good implementation of the wrong pattern's name, and that turns out to be the interesting part.
What the code does
EfInbox<T>.HandleAsync receives a message id, a name, and the wrapped handler as a delegate:
if (await _set.AnyAsync(m => m.Id == messageId && m.ProcessedAt != null))
{
_logger.LogTrace($"Message with ID: '{messageId}' was already processed ('{module}').");
return;
}
var inboxMessage = new InboxMessage
{
Id = messageId,
Name = name,
ReceivedAt = _clock.CurrentDate()
};
var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
await handler();
inboxMessage.ProcessedAt = _clock.CurrentDate();
await _set.AddAsync(inboxMessage);
await _dbContext.SaveChangesAsync();
// ... commit
}
Check for a row, run the handler, write the row, commit both in one transaction. As an idempotency guard that is correct and neatly done: the handler's own writes and the ledger row are in the same transaction on the same DbContext, so there is no window in which the work is committed and the record of it is not. Compare that to the outbox two files away, which puts its row on a different DbContext entirely, and the contrast is stark enough to suggest the two were written at different times with different care.
Why the name is wrong
An inbox, in the store-and-forward sense the pattern usually means, receives a message off the wire, persists it before processing, and then processes it — possibly much later, possibly after a restart, possibly several times. The durable row is what makes the message survivable independently of the handler.
Here, the row is written after the handler succeeds, and ReceivedAt and ProcessedAt are set in the same operation moments apart. There is no state in which a row exists with ProcessedAt == null. Which means:
The dedupe query's own predicate is dead. m.Id == messageId && m.ProcessedAt != null — the second clause can never be false for a row that exists. It reads as defensive and is decorative.
There is no store-and-forward. A message that arrives and fails leaves no trace in the inbox at all. The transaction rolls back, the exception propagates, and nothing is persisted. There is no replay-from-inbox, no durable subscription, no recovery path.
What it actually is, precisely, is an Idempotent Receiver: a ledger of “I have already done this one, do not do it again”. That is a genuinely useful component and exactly the right partner for the outbox's at-least-once redelivery. It is simply not an inbox, and calling it one sets an expectation the code does not meet. If your operations team reads “inbox” and concludes that a failed message is sitting in a table waiting to be retried, they will be looking for rows that were never written.
Naming a component after the pattern you meant to build rather than the one you built is the most expensive documentation error there is, because it survives every code review — the reviewer checks whether the code does what the name says at the level of the method, not at the level of the pattern.
The scope that is created and not used
InboxEventHandlerDecorator<T> is what wires the inbox into the handler chain. Its HandleAsync:
using var scope = _serviceProvider.CreateScope();
var inbox = (IInbox) _serviceProvider.GetRequiredService(inboxType);
var context = _messageContextProvider.Get(@event);
var name = @event.GetType().Name.Underscore();
await inbox.HandleAsync(context.MessageId, name, () => _handler.HandleAsync(@event, cancellationToken));
Line one creates a scope. Line two resolves from _serviceProvider — the root provider the decorator was injected with — not from scope.ServiceProvider. The scope is created, never used, and disposed at the end of the method.
That is not merely redundant. EfInbox<T> is registered Transient and depends on a scoped DbContext. Resolving a transient with a scoped dependency from the root provider throws InvalidOperationException when scope validation is on, which it is by default in the Development environment:
Cannot resolve scoped service 'CustomersDbContext' from root provider.
So with the outbox enabled in Development, the first event to reach a decorated handler throws before the inbox does anything. That this has never been observed by anyone is direct evidence that the inbox path has never executed — which is consistent with outbox:enabled: false in every settings file in the repository.
Changing _serviceProvider.GetRequiredService(inboxType) to scope.ServiceProvider.GetRequiredService(inboxType) fixes it, and the presence of the unused scope on the line above tells you that was the intent. A method that creates a resource and then does not use it is the clearest signal available that something was edited and not re-read.
Which brings me to the other artefact in the same file's neighbourhood. EfInbox.HandleAsync's finally block:
finally
{
{
await transaction.DisposeAsync();
}
}
An orphaned brace pair — the remains of a deleted if (transaction is not null). The two surviving if (transaction is not null) guards in the try and catch are themselves dead, because BeginTransactionAsync() never returns null; it either returns a transaction or throws. Somebody removed one null check, left the braces, and left the other two. It is cosmetic and it is the single most legible sign in the shard of code edited under time pressure and never read again.
The retention window is the sweep period
InboxCleanupProcessor is a BackgroundService with the same shape as the other two processors — start delay, Interlocked guard, scope, try/catch/finally, Stopwatch, trailing Task.Delay. Its one interesting line:
_interval = outboxOptions.InboxCleanupInterval ?? TimeSpan.FromHours(1);
// ...
var tasks = inboxes.Select(inbox => inbox.CleanupAsync(_clock.CurrentDate().Subtract(_interval)));
The same field is both “how often do I sweep” and “how far back do I keep”. Default one hour, so:
- The deduplication window is one hour. A duplicate arriving sixty-one minutes after the original is reprocessed, because its ledger row has been deleted.
- Shortening the sweep shortens the guarantee. Set
inboxCleanupIntervalto five minutes because you want the table tidier, and you have silently reduced your idempotency window to five minutes. Nothing in the option name, the type or the surrounding code suggests that.
Those are two different concerns wearing one setting. Retention should be derived from your redelivery horizon — how long after the fact could a duplicate plausibly arrive — while sweep frequency is an operational convenience. Splitting them is one extra TimeSpan? property:
// Fresh illustrative code, not from the repository.
public class OutboxOptions
{
public bool Enabled { get; set; }
public TimeSpan? StartDelay { get; set; }
public TimeSpan? Interval { get; set; }
public TimeSpan? InboxCleanupInterval { get; set; } // how often we sweep
public TimeSpan? InboxRetention { get; set; } // how far back we keep
public TimeSpan? OutboxCleanupInterval { get; set; }
}
And one changed argument at the call site. The point is not the code; it is that a value used for two purposes will eventually be tuned for one of them.
Where the message id comes from, and why that matters
Deduplication is only as good as the identity it deduplicates on, and this one has a longer chain than it looks.
InboxEventHandlerDecorator gets the id from the message-context side table:
var context = _messageContextProvider.Get(@event);
// ...
await inbox.HandleAsync(context.MessageId, name, () => _handler.HandleAsync(@event, cancellationToken));
MessageContextProvider.Get is _cache.Get<IMessageContext>(message) — an IMemoryCache lookup keyed on the message object, with a one-minute sliding expiration. Three things follow.
A cache miss is a NullReferenceException. context.MessageId has no null guard. If more than a minute elapses between the broker minting the context and the handler running — which the outbox path makes entirely possible, since a row can sit unsent while a poller retries something else — the deduplication step throws instead of deduplicating.
The key is a record. As part 8 noted, IMemoryCache uses EqualityComparer<object>.Default, and Inflow's events are positional records with value equality. Two structurally identical events published within a minute share a cache entry and therefore share a MessageId — which means the inbox will treat the second as a duplicate of the first and skip it. That is arguably the right behaviour for genuinely identical events and definitely the wrong behaviour for, say, two FundsAdded events of the same amount to the same wallet, which is a completely legitimate pair of business facts.
The id is minted per publish, not per business event. InMemoryMessageBroker creates new MessageContext(Guid.NewGuid(), _context) at publish time. So the same domain fact republished after a failure — by a retrying caller, not by the outbox — gets a fresh id and is not deduplicated. Idempotency here protects against transport duplication, not against application duplication, and only the outbox's replay produces the former.
The version that survives contact is a deterministic message id derived from the business fact — a hash of aggregate id, event type and version, or an explicit id the producer assigns and persists. Then redelivery, republication and a retrying caller all collapse to the same key, and the ledger means what its name suggests.
What I would keep
I have spent two parts being unkind to this subsystem, so let me be specific about what is worth stealing.
The type registries are excellent. InboxTypeRegistry and OutboxTypeRegistry are nineteen lines each and key on a module name derived from a namespace, with the same expression serving registration (from EfInbox<CustomersDbContext>, generic, keyed on the argument) and resolution (from a CustomerCompleted record, non-generic, keyed on itself). That is a Format Indicator implemented as a naming convention, and it works because the module name is recoverable from any type in the module — the property part 3 traced.
The transactional pairing of handler and ledger row is right. One DbContext, one transaction, no window.
And the decorator placement — wrapping IEventHandler<> rather than putting idempotency inside handlers — is the correct level. Idempotency is infrastructure, and this framework treats it as such, which is more than most codebases manage.
The subsystem's problem is not craftsmanship. It is that it has two flags, four reachable configurations, and only one of them was ever run. That configuration space is the next part, and one of its four cells never recovers.