Skip to content
Kumar Chandrachooda
.NET

The Saga That Rides a Header

OrderMaker drives a whole order through a Chronicle saga and reports its progress in a single RabbitMQ header - stamped on every publish, forwarded by seven copied MessageBrokers, read back by Operations, and backed by a saga store that forgets everything on restart.

By Kumar Chandrachooda 17 Feb 2026 7 min read
One header carried across every hop of the estate

Part 8 ended on the one component that has to consume every event style the estate produces at once. Pacco.Services.OrderMaker is the estate's only orchestrator: a single-project service on port 5015 whose root endpoint greets you with "Welcome to Pacco uber AI order maker Service!", and whose job is to take one POST /orders and drive an entire order — create it, add its parcels, find a vehicle, reserve a date, wait for approval — across five other services. It does this with a Chronicle saga, and with one of the most quietly interesting mechanisms in the estate: the saga's state does not travel in any message payload. It rides a header.

Before the saga, the package reference, because it is a story in one line of csproj:

<PackageReference Include="Chronicle_" Version="3.2.1" />

That trailing underscore is not a typo. The Chronicle package id was squatted on NuGet, so the library ships as Chronicle_ — the same 3.2.1 whose rejection machinery I once traced source-first in another series. Pacco is the library's home estate: same authors, consumed here the way it was designed to be. Note also what the reference sits beside: Convey.MessageBrokers.Outbox is in the csproj too. Keep that in mind for the ledger.

Five steps and a header

AIOrderMakingSaga is a Saga<AIMakingOrderData> that starts on MakeOrder and acts on four external events — Orders' OrderCreated, ParcelAddedToOrder, VehicleAssignedToOrder and OrderApproved, all copied contract classes, all correlated back to the saga by ResolveId switching on OrderId. The first step shows the whole pattern:

// Pacco.Services.OrderMaker/Sagas/AIOrderMakingSaga.cs (trimmed)
private const string SagaHeader = "Saga";

public async Task HandleAsync(MakeOrder message, ISagaContext context)
{
    Data.ParcelIds.Add(message.ParcelId);
    Data.OrderId = message.OrderId;
    Data.CustomerId = message.CustomerId;

    await _publisher.PublishAsync(new CreateOrder(Data.OrderId, message.CustomerId),
        messageContext: _accessor.CorrelationContext,
        headers: new Dictionary<string, object>
        {
            [SagaHeader] = SagaStates.Pending.ToString()
        });
}

Every publish in the saga carries that dictionary. Mid-flight commands are stamped Saga: Pending; the final MakeOrderCompleted goes out with Saga: Completed; the compensation path stamps Saga: Rejected. The saga narrates its own lifecycle into AMQP headers, one hop at a time.

On its own that would be a private convention. What makes it estate infrastructure is the other half, which lives in every other service. Each of the seven domain services' copied MessageBroker.cs files — the 87 lines part 2 hashed — calls a copied helper before publishing anything:

// Pacco.Services.Orders.Infrastructure/Extensions.cs (trimmed)
internal static IDictionary<string, object> GetHeadersToForward(this IMessageProperties messageProperties)
{
    const string sagaHeader = "Saga";
    if (messageProperties?.Headers is null || !messageProperties.Headers.TryGetValue(sagaHeader, out var saga))
    {
        return null;
    }

    return saga is null ? null : new Dictionary<string, object> {[sagaHeader] = saga};
}

Read what that is: a forwarding rule for exactly one header, by name, hard-coded identically in seven repositories. When Orders handles the saga's CreateOrder and publishes OrderCreated, the Saga: Pending sticker peels off the incoming message and onto the outgoing one. The header survives every hop of the choreography, service after service, so that by the time any event reaches Operations, it still carries the saga's verdict. And Operations — whose generic handlers, as part 7 showed, never read a payload — reads the sticker: its GetSagaState() extension decodes the header's bytes and overrides the operation state it would otherwise infer, so a mid-saga event that would normally mark an operation Completed keeps it Pending until the saga itself says otherwise. The feedback loop that surfaces this to a polling client is the estate arc's story of the 202 that names the future; the header is how the saga steers it.

This is data movement by header: the saga's progress is invisible to every payload schema in the estate, and fully visible to the one service that knows where to look. As a decoupling trick it is genuinely elegant — Operations gained saga-awareness without a single shared type. As governance it is part 7's lesson again, worse: the estate's most important cross-cutting contract is a magic string, agreed by copy-paste, validated by nothing.

The honest ledger

Now the other side of the book, because this saga keeps one of the richest defect ledgers in the estate. Five entries, each verified in source, in ascending severity.

The constructor stomps the correlation context. The saga's constructor ends with _accessor.CorrelationContext = new CorrelationContext { User = new UserContext() } — every time Chronicle instantiates the saga, the ambient context is overwritten with a fresh, empty, anonymous one. Whatever identity or correlation the triggering message carried, the saga's outbound publishes describe an order made by nobody.

One leg is dead. OrderMaker subscribes SubscribeEvent<ResourceReserved>(), keeps a copied ResourceReserved contract class, and its AIOrderMakingHandler dutifully forwards the event into the saga coordinator. But the saga does not implement ISagaAction<ResourceReserved>, and its ResolveId switch has no arm for it — the message arrives, the coordinator finds no action to run, and it evaporates. The estate evolved past this leg: it is Orders that reacts to ResourceReserved by approving the order, and the saga now waits for OrderApproved instead. The subscription, the class and the handler survive as a vestigial organ — glue nobody deleted because no test would notice.

The “all parcels added” test is inverted. The saga waits to hunt for a vehicle until every parcel is on the order. Here is the property that decides:

public bool AllPackagesAddedToOrder => AddedParcelIds.Any() && AddedParcelIds.All(ParcelIds.Contains);

Read it twice. It checks that everything added was wanted — that AddedParcelIds is a subset of ParcelIds. The intended condition is the mirror image: everything wanted has been added. As written, the flag goes true the moment the first parcel confirmation arrives, since one added-and-wanted parcel is trivially a subset. The saga only ever handles single-parcel orders today — MakeOrder carries exactly one ParcelId — so the inversion is invisible; the day someone extends it to real multi-parcel orders, the saga will go vehicle-hunting after parcel one of five, and the guard designed for precisely that moment will wave it through.

Compensation is one message and a shrug. Of the saga's five CompensateAsync methods, four are Task.CompletedTask. The one that acts is worth quoting in full:

public Task CompensateAsync(ParcelAddedToOrder message, ISagaContext context)
    => _publisher.PublishAsync(new CancelOrder(message.OrderId, "Because I'm saga"),
        messageContext: _accessor.CorrelationContext,
        headers: new Dictionary<string, object>
        {
            [SagaHeader] = SagaStates.Rejected.ToString()
        });

"Because I'm saga" is a real string, in a real compensation, and it travels: CancelOrder carries a reason, Orders records it, and the estate's naming grammar happily propagates it to anything that asks why the order died. Funnier still is what the compensations don't do. If the saga rejects after ReserveResource succeeded, nothing releases the reservation — the vehicle's date stays booked by a cancelled order. And part 7's registry foreshadowed the depth of that gap: the release command's very name is mis-recorded in messages.json, so even the estate's paperwork does not know how to undo a booking.

The state is a dictionary in RAM, and the publishes skip the outbox. AddInfrastructure calls builder.Services.AddChronicle() bare — which in Chronicle 3.2.1 means InMemorySagaStateRepository and InMemorySagaLog. Redis is registered a few lines up in the same chain and never used for saga state (it is the graveyard's problem, part 12). Restart OrderMaker mid-order and the saga simply forgets: in-flight orders never complete, never compensate, never reject — they cease to have ever been orchestrated, while their Saga: Pending stickers keep circulating through an estate that no longer contains anyone who remembers issuing them. And unlike the seven domain services, whose MessageBroker glue at least offers an outbox, the saga publishes through raw IBusPublisher — the Convey.MessageBrokers.Outbox package referenced in the csproj is wired to nothing. A crash between saga-state mutation and publish loses the message; there is no retry ledger to replay. The umbrella repo completes the picture: the PM2 process manifests omit OrderMaker entirely, so in that run mode the estate's orchestrator does not run at all.

What the pattern promised and what this one waives

To be fair to both libraries: none of this is Chronicle's fault, and little of it is Convey's. Chronicle's in-memory store is an explicit default for getting started, not a deception; its contracts (ISagaAction per message, ResolveId, CompensateAsync) are honest about what a durable orchestration needs, and the estate's bridging handler — six one-line methods pumping bus messages into the coordinator — is exactly the right glue. The header trick is a legitimately good idea I have stolen for real systems, with the string promoted to a shared constant and a schema note. But the saga pattern earns its keep through two promises — the process survives, and the process undoes — and this saga waives both while keeping the vocabulary. My rule from reading it: the moment your saga's memory is a dictionary in RAM, your business process is one deploy away from amnesia — everything else about the orchestration is decoration on that fact.

There is one part of OrderMaker this article has deliberately skated past: the two “intelligent” lookups the saga delegates when it goes shopping — the best vehicle and the best reservation date. Their implementation is eleven lines, one of which is a comment that names the whole genre. Next: AI in a startup, an honest article about the estate's honestly absent machine learning.