Skip to content
Kumar Chandrachooda
Microservices

A Saga With a Memory

A stateful DShop saga remembers when a customer signed up and grants a discount only if their first order lands within 24 hours - a genuinely stateful process manager whose reward turns out to be a Console.WriteLine. Part 7 of Nine Services and a Message Bus.

By Kumar Chandrachooda 01 Sep 2025 5 min read
A saga holding a remembered timestamp between two distant events

The checkout saga was stateless: every message it needed carried the order id, so it never had to remember anything between steps. Most sagas you meet are like that. But some rules span events that arrive minutes or days apart and depend on when the earlier one happened — and for those you need a saga that remembers. DShop has exactly one, and it encodes a marketing rule: give a customer a discount if they place their first order within 24 hours of signing up. Reading it is the clearest small lesson in the estate on what “stateful saga” actually means.

State is a class, and the saga carries it

A stateless Chronicle saga derives from Saga. A stateful one derives from Saga<TState>, and TState is a plain class the framework persists between messages:

public class FirstOrderDiscountSagaState
{
    public DateTime CustomerCreatedAt { get; set; }
}

public class FirstOrderDiscountSaga : Saga<FirstOrderDiscountSagaState>,
    ISagaStartAction<CustomerCreated>,
    ISagaAction<OrderCreated>
{
    private const int CreationHoursLimit = 24;

Two messages, potentially far apart in time: CustomerCreated starts the saga, OrderCreated continues it. Between them, the saga has to hold onto one fact — when the customer was created — and Data.CustomerCreatedAt is where it lives. Data is the framework-managed state; whatever the saga writes to it on the first message is there, rehydrated, when the second message arrives, however long later.

Correlating two events that don't share an id

Here is the subtle part, and it is genuinely clever. The two events do not share a field name for the customer. CustomerCreated has an Id (the customer's). OrderCreated has a CustomerId. For both to route to the same saga instance, ResolveId has to normalise them:

public override Guid ResolveId(object message, ISagaContext context)
{
    switch (message)
    {
        case CustomerCreated cc: return cc.Id;
        case OrderCreated oc: return oc.CustomerId;
        default: return base.ResolveId(message, context);
    }
}

The saga's identity is the customer, not the order. A CustomerCreated for customer X and an OrderCreated whose CustomerId is X resolve to the same GUID, so they land in the same saga instance even though they were produced by different services about different aggregates, possibly a day apart. The saga's id is the thing the rule is about — the customer's lifetime — not the thing that triggered any single step. Choosing the right correlation identity is most of the design of a stateful saga, and this one gets it exactly right.

The rule, in two handlers

The signup handler records the moment:

public async Task HandleAsync(CustomerCreated message, ISagaContext context)
{
    Data.CustomerCreatedAt = DateTime.UtcNow;
    await Task.CompletedTask;
}

The order handler measures the gap and decides:

public async Task HandleAsync(OrderCreated message, ISagaContext context)
{
    var diff = DateTime.UtcNow.Subtract(Data.CustomerCreatedAt);
    if (diff.TotalHours <= CreationHoursLimit)
    {
        await _busPublisher.SendAsync(new CreateOrderDiscount(
            message.Id, message.CustomerId, 10), CorrelationContext.Empty);
        Complete();
    }
    else
    {
        Reject();
    }
}

First order within 24 hours: send CreateOrderDiscount with a value of 10, and Complete(). Too late: Reject(). The logic is right, the shape is textbook, and the correlation threads two services' events into one decision. As a demonstration of a stateful process manager, it is the best small example in the estate.

Notice what Data buys you that a stateless saga cannot. Between the two handlers, the saga instance may sit dormant for hours or days — a customer signs up on Monday and orders on Tuesday. Chronicle persists FirstOrderDiscountSagaState to its saga store when the first handler returns and rehydrates it when the second message arrives, so the timestamp survives across process restarts, deploys, and the entire gap between the two events. That persistence is the whole point of a Saga<TState>: it turns “two events far apart” into “one object that remembers.” A stateless saga would have to reconstruct the signup time from somewhere else on every order — a call back to Identity, a lookup in a projection — reintroducing exactly the cross-service dependency the remembered state removes. The state is not an optimisation; it is what makes the rule expressible at all.

Where the memory is slightly wrong

Now read Data.CustomerCreatedAt = DateTime.UtcNow again. It records the moment the saga processed the signup event, not the moment the customer was actually created. Those are usually close — the event is handled seconds after it's published — but they are not the same clock. If the CustomerCreated event sat in the queue during a Operations outage or a redelivery storm, CustomerCreatedAt captures the recovery time, not the signup time, and the 24-hour window silently starts late. The right source of truth is a CreatedAt on the event itself; but the CustomerCreated contract does not carry one, so the saga stamps the clock it has. The bug isn't the code; it's the contract — the event that starts a time-based rule doesn't carry the time the rule is about. It is a quiet argument for putting domain timestamps in the event, not reconstructing them at the consumer.

Where the reward evaporates

And then there is the ending, which is the honest-ledger heart of this one. The saga does all of this — the stateful persistence, the cross-service correlation, the windowed decision — and its reward, CreateOrderDiscount, is handled in Orders like this:

public sealed class CreateOrderDiscountHandler : ICommandHandler<CreateOrderDiscount>
{
    public Task HandleAsync(CreateOrderDiscount command, ICorrelationContext context)
    {
        Console.WriteLine($"Creating an order discount, value: '{command.Percentage}%'");
        return Task.CompletedTask;
    }
}

The discount is a Console.WriteLine. Nothing is persisted, nothing is applied to an order, no customer ever pays less. The entire machinery culminates in a line printed to stdout. And recall from Part 2 that this is the one command Orders subscribes to with no rejection path — so even the “it failed” signal was never wired. The compensations are stubs too: both CompensateAsync methods are // TOOD: Implement compensation (the typo is in the source) over Task.CompletedTask.

To be fair, this is a teaching estate, and the saga is clearly the lesson — the discount effect is a placeholder the author left honest rather than faking. That is the right instinct. But it makes the saga a beautifully-built pipe with nothing flowing through it, and it is worth sitting with why: the coordination was the hard, interesting problem the authors wanted to show, and the effect was the boring part they stubbed. Real systems fail the same way in reverse — teams lavish attention on the effect and bolt on the coordination — and DShop is a useful mirror precisely because it shows the coordination done well and the effect left as a print statement. The full inventory of what works and what is stubbed is Rich Domain, Honest Stubs.

The rule of thumb

  • A stateful saga's identity is the thing the rule is about, not the thing that triggered a step. This one is keyed on the customer's lifetime, which is why two unrelated events converge correctly.
  • Put the timestamp a time-based rule needs into the event. Reconstructing “when it happened” from “when I processed it” works until the day processing lags, and then the rule fires on the wrong clock.

Both sagas live in Operations, which has a stranger trick still: it subscribes to every message in the estate, by reflection, to feed them in. Next: One Orchestrator Subscribes to Everything.