Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/two-flags-four-systems-one-that-stalls && read --section="top" 0%
Architecture

Two Flags, Four Systems, One That Stalls

Two booleans govern Inflow's messaging spine. Taking their product gives four behaviours - one shipped and working, two working differently, and one that throws a NullReferenceException every second forever because the component that would prevent it only exists in another cell.

By Kumar Chandrachooda 25 Jan 2026 8 min read
A two-by-two grid with one cell blacked out

Configuration flags are a product type, and almost nobody enumerates the product. You test the shipped combination, you test the one you use in CI, and the rest of the grid is a set of states your system can reach and your test suite has never visited.

Inflow's messaging spine is governed by exactly two booleans, which makes the grid small enough to walk in one article:

  • outbox:enabled — shipped false in both appsettings.json and appsettings.test.json.
  • messaging:useAsyncDispatcher — shipped true in both.

Four cells. One is the shipped configuration. One is a permanent stall.

The grid

outbox asyncDispatcher What happens
off on (shipped) Commit, then write to an in-memory channel, then a background drain. At-most-once. Correlation pinned after the first message. Queue discarded on shutdown. Works.
off off Commit, then ModuleClient.PublishAsync inline on the caller's thread. Correlation correct throughout. A receiver's exception propagates back into the command handler after its commit, so the HTTP response is a 500 while the state change is durable and receivers have partially applied. Works, with a nastier failure mode.
on on Two independent commits (part 11), then a one-second poll drain into the same volatile channel, with SentAt stamped on enqueue. All of the cost, none of the guarantee. Also the inbox decorator resolves from the root provider and throws under scope validation (part 12).
on off Permanently stalled. Every outbox tick throws NullReferenceException, is caught, is logged, and retries one second later. Forever.

The first two cells are design choices with honest trade-offs. The third is the outbox's own defects. The fourth is the one worth an article, because its failure has nothing to do with either component's own code.

Walking the fourth cell

Set outbox:enabled: true and messaging:useAsyncDispatcher: false. The application starts cleanly, migrations run, controllers respond, and the first cross-module event never arrives. Here is why, in five hops.

Hop one. A command handler commits and calls _messageBroker.PublishAsync(...). InMemoryMessageBroker sees _outboxBroker.Enabled is true, calls SendAsync, and a row lands in the module's OutboxMessages table.

Hop two. OutboxProcessor — registered because the outbox is enabled — wakes on its one-second tick, resolves GetServices<IOutbox>(), and calls PublishUnsentAsync on each.

Hop three. EfOutbox.PublishUnsentAsync reaches its dispatch branch:

if (_messagingOptions.UseAsyncDispatcher)
{
    await _asyncMessageDispatcher.PublishAsync(message);
}
else
{
    await _moduleClient.PublishAsync(message);
}

The flag is off, so it publishes inline, on the OutboxProcessor's background thread.

Hop four. ModuleClient.PublishAsync finds the receiver registrations, translates the message, and invokes the broadcast action, which reaches EventDispatcher.PublishAsync and resolves IEventHandler<T> from a fresh scope. That resolution constructs the decorator chain, and LoggingEventHandlerDecorator<T>'s constructor takes an IContext:

services.AddTransient(sp => sp.GetRequiredService<ContextAccessor>().Context);

Which is ContextAccessor.Context, which is Holder.Value?.Context, which on a background thread that has never had a context assigned is null. The factory returns null, the container injects null — there is no null guard on the parameter — and the decorator is constructed with _context == null.

Hop five. LoggingEventHandlerDecorator.HandleAsync line 34:

var requestId = _context.RequestId;

NullReferenceException. It propagates out of EventDispatcher, out of ModuleClient, out of PublishUnsentAsync, into OutboxProcessor's catch block:

catch (Exception exception)
{
    _logger.LogError("There was an error when processing outbox.");
    _logger.LogError(exception, exception.Message);
}

Logged. The SentAt stamp never happens, because the exception aborted the loop before SaveChangesAsync. One second later, the same row is selected again, and the same exception is thrown, and logged. Every message in the outbox is stuck permanently, and the process emits two error lines per second per module until somebody looks.

Why this is the most interesting thing in the shard

Look at where the fix would live. Nothing is wrong with OutboxProcessor. Nothing is wrong with EfOutbox. Nothing is wrong with LoggingEventHandlerDecorator, which is entitled to expect a context in a framework that provides one everywhere else. Nothing is wrong with ContextAccessor.

The missing piece is this, in AsyncDispatcherJob:

_contextAccessor.Context ??= envelope.MessageContext.Context;

That is the only writer of ContextAccessor.Context outside the HTTP pipeline in the entire repository. And AsyncDispatcherJob is registered conditionally:

if (messagingOptions.UseAsyncDispatcher)
{
    services.AddHostedService<AsyncDispatcherJob>();
}

Only when the other flag is true.

So the correctness of the outbox: on cell depends on a hosted service that only exists in the asyncDispatcher: on cells. The coupling is real, it spans four files, and no unit test could find it: every component in isolation behaves correctly, and every integration test of the shipped configuration passes. A cell's correctness depends on a component that only exists in a different cell.

EfOutbox even builds the right context and then does not install it:

_messageContextRegistry.Set(message, new MessageContext(messageId, new Context(correlationId,
    outboxMessage.TraceId, new IdentityContext(outboxMessage.UserId))));

A fully populated Context — correlation id, trace id, identity, all recovered from the persisted row — is constructed one line before the dispatch and registered into the message-context side table. It is never assigned to ContextAccessor.Context. One statement, _contextAccessor.Context = context;, would make the fourth cell work. The information was in hand; the assignment was not made, because on the shipped path something else makes it.

That is also the small compliance defect worth flagging while we are here: new IdentityContext(outboxMessage.UserId) takes a Guid?, and UserId is stored as context.Context.Identity.Id, which is Guid.Empty for an anonymous request — stored as empty, not null. The constructor is:

public IdentityContext(Guid? id)
{
    Id = id ?? Guid.Empty;
    IsAuthenticated = id.HasValue;
}

HasValue is true for Guid.Empty. So replaying any outbox message that originated from an unauthenticated request manufactures an authenticated identity with an all-zeros user id. Guid.Empty is not null, and an auth predicate built on HasValue will tell you so at the worst possible moment.

How each cell announces itself

One more asymmetry, because it is the operational half of the same lesson. When the outbox is disabled, OutboxProcessor is still registered and still starts:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    if (!_enabled)
    {
        _logger.LogWarning("Outbox is disabled");
        return;
    }
    // ...

A warning at boot. You know. When the async dispatcher is disabled, AsyncDispatcherJob is simply never registered — no log line, no warning, no evidence in GET /modules or anywhere else. The two flags in the same subsystem have opposite conventions for announcing “off”, and the one that announces nothing is the one whose absence breaks another cell.

If a setting turns off a component, the log line saying so is worth more than the component's own start-up message. An operator reading a boot log should be able to enumerate what is not running, and in this configuration space that is precisely the information needed to diagnose the stall.

The third cell deserves a word too

I labelled the outbox: on, asyncDispatcher: on cell “all of the cost, none of the guarantee”, which is accurate but terse. It is worth spelling out, because it is the cell most people would reach for if they read the README and decided they wanted durability.

Turning the outbox on adds: a table per module, a poller running every second per module, two cleanup background services, an extra DbContext resolution and an extra transaction per publish, and an inbox decorator wrapped around every event handler. That is real operational surface — five background services in a four-module estate, four tables to migrate and monitor, and a poll loop that will hold a connection open on an idle system indefinitely.

What you get for it, given part 11 and part 12: the outbox row commits in a separate transaction from the aggregate, so the atomicity you paid for is absent; SentAt is stamped when the message enters the volatile channel, so a crash after the stamp loses it exactly as before; and the inbox decorator resolves a transient with a scoped dependency from the root provider, which throws under the scope validation that is on by default in Development.

So in Development the third cell fails immediately and loudly — a resolution exception on the first handled event. In Production, where scope validation is off by default, it runs, and the resolution succeeds against the root provider with whatever DbContext lifetime the container is willing to hand back. The same configuration crashes in Development and silently misbehaves in Production, and the difference is a hosting default nobody set.

That inversion is worth sitting with. The usual worry is that Development is more permissive than Production. Here it is stricter, which means the environment where you would notice is the one where you would dismiss the crash as a local setup problem.

What to do with this

The practical move is not to fix Inflow's fourth cell. It is to run the exercise on your own system: list the settings that gate a registration rather than a value, take their product, and for each cell ask three questions.

  1. Is it reachable? Can an operator set this combination, in any environment, without editing code?
  2. Is it exercised? Does any test, in any suite, run with these values?
  3. What couples it? Does any component in this cell depend on something registered only in another cell?

Inflow's grid has four cells, one exercised, and one where the answer to question three is a hosted service four files away. Most estates I have worked on have twelve or twenty gating flags and a grid too large to enumerate — which is itself the finding, and an argument for having fewer of them.

Next, the container you build to read a setting — twenty-two throwaway service providers built during registration, two of which are load-bearing in a way one character would break.