Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/guid-empty-is-a-mode-flag && read --section="top" 0%
Architecture

Guid.Empty Is a Mode Flag

Trill's monolith puts the envelope fields on the message itself, then uses one sentinel value to mean this-is-a-synchronous-call in three unrelated subsystems that never mention each other.

By Kumar Chandrachooda 02 Jan 2026 6 min read
One blank token being read by three separate readers

Guid.Empty is the emptiest value in .NET. It is what an uninitialised Guid field holds, what a failed TryParse leaves behind, and what a JSON body without an id deserialises to. It means nothing, which is exactly why it is so tempting to make it mean something.

In the Trill monolith it means “this is a synchronous request,” and three unrelated files agree on that without any of them saying so. Part 5 looked at a naming convention that stops carrying information; this is a value convention that carries too much.

The decision that causes it

The framework's message contract is two properties (Abstractions/Messaging/IMessage.cs):

public interface IMessage
{
    Guid Id { get; set; }
    Guid CorrelationId { get; set; }
}

ICommand : IMessage and IEvent : IMessage, so every command and every event in the application carries mutable envelope fields on the payload itself. There is no separate envelope type, no Message<T> wrapper, no metadata dictionary. A ChargeFunds command is simultaneously the business intent and the transport header.

Compare the microservices build, where Convey.CQRS.Commands.ICommand is a bare marker and the envelope lives in RabbitMQ's own headers plus a CorrelationContext object propagated separately — copied into eight repositories, but structurally separate from the payload. The monolith merged the two, and merging them is defensible: it means one object crosses the module boundary, MessagePack serialises it in one pass, and correlation survives without a AsyncLocal or an ambient context.

The cost arrives the moment something needs to distinguish between two kinds of traffic on the same object.

Three files, one sentinel

Reader one sets it. ModuleClient.SendAsync handles the request/response path — one module calling another and awaiting a result (Modules/ModuleClient.cs:36-40):

if (request is IMessage message)
{
    // A synchronous request
    message.Id = Guid.Empty;
}

The comment is the only documentation the convention has. Note what it is doing: it is destroying the message's identity on purpose, because identity is what makes something a candidate for deduplication and retry, and a synchronous call is neither.

Reader two reads it for idempotency. MongoInbox.HandleAsync is the inbox decorator's core (Messaging/Inbox/MongoInbox.cs:46-51):

if (message.Id == Guid.Empty)
{
    // A synchronous request
    await handler();
    return;
}

Above this line is a disabled check; below it is the real inbox — look the id up in {module}-module.inbox, skip if seen, otherwise handle inside a transaction and record it. A synchronous call skips all of that, which is correct: the caller is holding an open await, so a duplicate is impossible and a stored row would be pure noise.

Reader three reads it for error routing. UnitOfWorkCommandHandlerDecorator.TryHandleAsync catches everything a handler throws (Commands/UnitOfWorkCommandHandlerDecorator.cs:62-76):

// Not a background processing
if (command.Id == Guid.Empty)
{
    throw;
}

var rejectedEvent = _exceptionToMessageMapperResolver.Map(exception);
if (rejectedEvent is {})
{
    _logger.LogInformation("Publishing the rejected event...");
    await _messageBroker.PublishAsync(rejectedEvent);
}

throw;

This is the subtlest of the three and the one worth reading twice. Both branches throw. The sentinel does not decide whether the caller sees the failure; it decides whether the failure is also announced to the rest of the application as an ActionRejected event. A synchronous caller gets the exception and nothing else, because it is standing right there. An asynchronous caller is long gone, so the failure has to be broadcast to reach the originating user's screen.

That is a genuinely good piece of design — asynchronous failures need a return path and synchronous ones do not — and it is being expressed entirely through the absence of a GUID.

Why this is a smell and not just a shortcut

Three properties make it worth naming rather than shrugging at.

The three readers have no compile-time relationship. ModuleClient lives in Modules/, MongoInbox in Messaging/Inbox/, UnitOfWorkCommandHandlerDecorator in Commands/. Nothing imports anything from the other two. Delete the assignment in ModuleClient and both readers silently change behaviour: every synchronous call would start writing inbox rows and broadcasting rejection events for exceptions the caller is about to receive anyway.

The comment is the specification. // A synchronous request appears twice, // Not a background processing once. Three comments in three files are the entire definition of a system-wide protocol. There is no IsSynchronous extension method to grep for, no enum, no test.

The value is reachable by accident. Every message in this application is deserialised by MessagePack using ContractlessStandardResolverAllowPrivate, which populates properties through private-member access rather than constructors. A message that arrives without an Id — a hand-written .rest request, a new integration, a serialiser change — gets Guid.Empty, and is therefore silently classified as synchronous by two subsystems that will quietly change their behaviour for it.

The other field on the same interface, done right

The instructive contrast is sitting two lines away. CorrelationId has exactly the same type, the same mutability and the same sentinel-shaped default, and it does not go wrong — because it has one meaning and one owner.

Both dispatchers back-fill it the same way (Commands/CommandDispatcher.cs:26-30, Events/EventDispatcher.cs:30-34):

if (command.CorrelationId == Guid.Empty)
{
    var context = scope.ServiceProvider.GetRequiredService<IContext>();
    command.CorrelationId = context.CorrelationId;
}

InMemoryMessageBroker does the same for every message it publishes (:49-52). Three writers, all doing the identical thing: if it is unset, inherit it from the ambient request context. Then three Serilog decorators push it into LogContext so every log line inside a handler carries it.

Guid.Empty is being used as a sentinel here too — and it is fine, because the only inference anyone draws from it is "nobody has set this yet." That is what a default value legitimately means. Nothing branches on it; something fills it in. The difference between this field and Id is not the value or the type; it is that CorrelationId's sentinel means absence and Id's sentinel means a mode of operation.

That single implementation is also a quiet win over the other column, where CorrelationIdFactory.cs and CorrelationContext.cs exist as eight byte-near-identical copies, one per repository, propagated over RabbitMQ headers and an x-correlation-id HTTP header. One implementation, eight fewer files, and a correlation id that crosses module boundaries without any transport agreeing to carry it.

What the alternative looks like

The fix is not clever, which is part of what makes the smell worth writing up. Two lines on the interface:

public interface IMessage
{
    Guid Id { get; set; }
    Guid CorrelationId { get; set; }
    MessageMode Mode { get; set; }
}

public enum MessageMode
{
    Asynchronous = 0,
    Synchronous = 1
}

ModuleClient sets Mode = MessageMode.Synchronous and leaves the id alone. The inbox and the unit-of-work decorator test message.Mode and read as prose. Grep for MessageMode and you find all three call sites in one search. Messages keep their identity, so a synchronous call is still traceable in logs — which it currently is not, because InMemoryMessageBroker logs "Publishing a message: '{name}' with ID: '{message.Id:N}'" and every synchronous message logs as thirty-two zeroes.

The deeper fix is the envelope type that was declined at the start: Envelope<TMessage> carrying Id, CorrelationId, Mode, SentAt and Module, with the payload as a field. That is more invasive, it costs a generic parameter everywhere, and — to be fair to the authors — it is genuinely harder to serialise cleanly across a TranslateType boundary that already deals in object and Type. Choosing the flat shape was reasonable. Choosing to encode a second dimension of meaning into an existing field was the step that should have prompted a new field instead.

The distilled version

If a value's default has been given a meaning, you no longer have a default. Any code path that fails to set the field is now making a decision, and the decision is invisible at the point where it is taken.

The distributed build never faces this particular choice, because its two integration styles are physically different transports — RabbitMQ for events, Convey.HTTP for request/response — and a message can tell which world it is in by which pipeline it arrived on. The monolith collapsed both onto one IModuleClient with one message type, which is a real simplification, and the sentinel is the bill for it. That is the recurring shape of this whole comparison: consolidation removes a distinction the system was relying on, and the distinction comes back as a convention.

Sometimes, though, the monolith answers a lost distinction with a mechanism rather than a convention. Next, the best idea in either build: the contract checker they built after the drift.