Skip to content
Kumar Chandrachooda
.NET

How a Message Finds Its Saga

Correlation is the part of the saga pattern that fails silently. SagaId, SagaContext, the Empty-context trap that starts a new saga per message, and the one-method override that routes messages by their own content.

By Kumar Chandrachooda 27 Sep 2025 5 min read
Many messages, one id, one saga instance that remembers them all

The first Chronicle bug I ever chased looked like this: the order saga handled OrderPlaced, handled PaymentCompleted, and never completed. No errors, no rejection. Both handlers demonstrably ran — the console said so. But the completion condition Data.Paid && Data.StockReserved never saw both flags true at the same time, because the two messages had been handled by two different saga instances, each with its own fresh Data.

That's a correlation failure, and it's the signature failure mode of the whole pattern. A saga is only useful because messages arriving minutes apart land on the same accumulated state. This part is about how Chronicle — the open-source .NET saga library this series is reading — decides which state that is, and the two or three lines of source that make its behaviour obvious once you've seen them.

SagaId: a string in a nice coat

Chronicle's saga identity type is charmingly minimal — a struct wrapping a string, with implicit conversions in both directions and a factory that mints a GUID:

public struct SagaId
{
    public string Id { get; }

    public static implicit operator string(SagaId sagaId) => sagaId.Id;
    public static implicit operator SagaId(string sagaId) => new SagaId(sagaId);
    public static SagaId NewSagaId() => new SagaId(Guid.NewGuid().ToString());
}

The implicit conversions mean you rarely write SagaId in your own code — you pass order.Id.ToString() and it just works. The choice of string rather than Guid is worth noticing: it means your correlation id can be anything with business meaning — an order number, an invoice-2026-01173, a tenant-plus-order composite. Persisted saga state is keyed by this id (together with the saga type), so pick something you can find again in a database when you're debugging at 2 a.m.

SagaContext: the envelope you build yourself

Since Chronicle has no bus integration, there are no message headers for correlation ids to ride on. Instead you build a context and pass it alongside the message:

var context = SagaContext.Create()
    .WithSagaId(order.Id.ToString())
    .WithOriginator("CheckoutApi")
    .WithMetadata("tenant", tenantId)
    .WithMetadata("trace", Activity.Current?.Id)
    .Build();

await _coordinator.ProcessAsync(new OrderPlaced { OrderId = order.Id }, context);

Three things live in a context. The saga id is the correlation key. The originator is a free-text “who sent this” — Chronicle never interprets it, but it flows into your handlers for logging. Metadata is a key-value bag for cross-cutting baggage (tenant, trace id); keys must be unique — the SagaContext constructor actually checks and throws a ChronicleException if you add the same key twice, one of those small validations that tells you the authors got bitten. The context also carries a mutable SagaContextError slot, which the pipeline fills with the exception if your handler blows up — that's how your onRejected hook finds out why.

One caveat from the trenches: the context is not persisted. It exists for the duration of one ProcessAsync call. Metadata you attach when processing OrderPlaced is not magically available when PaymentCompleted arrives tomorrow — if the saga needs it later, copy it into Data.

The trap: SagaContext.Empty

Now the bug from the top of this post. The context parameter on ProcessAsync is optional, and the coordinator's source handles the null with one innocuous line:

context ??= SagaContext.Empty;

And SagaContext.Empty, in SagaContext.cs:

public static ISagaContext Empty =>
    new SagaContext(SagaId.NewSagaId(), string.Empty,
        Enumerable.Empty<ISagaContextMetadata>());

NewSagaId(). Every call to ProcessAsync without a context gets a brand-new random saga id. Nothing correlates with anything, ever. Each start message spawns a pristine saga; each non-start message is checked against a state that cannot exist and is silently dropped. No exception, no log — the library cannot know you meant those messages to be related.

I understand the design: Empty makes the quick-start sample work and keeps the API friendly. But in a real system I'd treat a context-less ProcessAsync call as a code smell bordering on a bug, and it's worth an analyzer rule or at least a code-review reflex. If you learn one thing from this article: always pass a context, or override ResolveId — which brings us to the good part.

ResolveId: correlation by message content

The context is only the default source of the saga id. The actual decision goes through a virtual method on the Saga base class — here it is, in its entirety:

public virtual SagaId ResolveId(object message, ISagaContext context)
    => context.SagaId;

The coordinator calls this per saga, per message, before touching any state: var id = saga.ResolveId(message, context);. Because it's virtual and receives the message, you can move correlation out of the caller's hands and into the saga itself:

public class OrderSaga : Saga<OrderSagaData>,
    ISagaStartAction<OrderPlaced>,
    ISagaAction<PaymentCompleted>,
    ISagaAction<StockReserved>
{
    public override SagaId ResolveId(object message, ISagaContext context)
        => message switch
        {
            OrderPlaced m      => m.OrderId.ToString(),
            PaymentCompleted m => m.OrderId.ToString(),
            StockReserved m    => m.OrderId.ToString(),
            _ => base.ResolveId(message, context)
        };
}

This is Chronicle's equivalent of NServiceBus's ConfigureHowToFindSaga mapping or MassTransit's CorrelateById, compressed into one ordinary method. Once every message type maps its own business key to the saga id, callers can stop threading contexts through their code entirely — a bus consumer just does await _coordinator.ProcessAsync(message) and correlation still works, because the resolved id comes from the payload, not from SagaContext.Empty's random GUID.

In every non-trivial Chronicle system I've worked on, this override ends up being the only correlation mechanism, and the context reverts to what it's genuinely good at: carrying originator and metadata for observability. My advice is to standardise on it from day one, and to make the message-to-key mapping total — the switch with a base fallback ensures a newly added message type fails loudly in testing (uncorrelated saga never completes) rather than corrupting an unrelated saga.

What the id actually keys

Two source-level details complete the picture.

First, persisted state is keyed by the pair of saga id and saga type — the repository contract is ReadAsync(SagaId id, Type type). So an OrderSaga and a ShippingSaga can both correlate on the same order id without colliding; each gets its own state row. That's a genuinely useful property: one business identifier can drive several independent processes.

Second, the lock Chronicle takes while processing is keyed by the id alone, not the (id, type) pair. Two different saga types sharing a correlation id will serialize behind the same semaphore — harmless for correctness, mildly surprising for throughput if you fan one hot id out across many saga types. The locking machinery (and the bigger caveat that it's process-local) is next part's subject, because it lives exactly where correlation hands over to dispatch.

The checklist

Correlation in Chronicle, compressed:

  • Pick saga ids with business meaning; they're strings, use that.
  • Never call ProcessAsync without a context unless the saga overrides ResolveId for every message it handles.
  • Prefer the ResolveId override; it puts correlation knowledge where the saga lives.
  • Context metadata is per-call, not per-saga — copy what you need into Data.
  • Same id across different saga types is safe and useful; state is keyed by both.

Next: what happens after the id is resolved — how AddChronicle() found your saga classes in the first place, how one message fans out to several sagas at once, and the little ref-counted lock that keeps concurrent messages from trampling the same state.