A Saga That Cannot Compensate
Trill's ad-publication saga implements Compensating Action in five overrides and can reach none of them - the rejection events it subscribes to carry no correlating id, their handlers return Task.CompletedTask, and no handler ever throws.
Compensation is the half of the saga pattern you pay for. The happy path is just handlers; the unhappy path is where the pattern earns its keep. So when a saga class declares five CompensateAsync overrides, the natural reading is that somebody thought hard about failure — five undo paths is more than most production sagas I have read.
Trill's does. And it can reach none of them.
Part 11 finished the push half of the estate. This part reads Trill.Saga, twenty-four source files that orchestrate exactly one business flow.
The flow
PublishAdSaga is built on Chronicle, the DevMentors saga library, and it is a Process Manager in the enterprise-integration sense: a central coordinator that receives events and issues commands, holding the flow's state between steps.
public class PublishAdSaga : Saga<PublishAdSagaData>,
ISagaStartAction<AdApproved>,
ISagaAction<AdPaid>,
ISagaAction<AdPublished>,
ISagaAction<AdActionRejected>,
ISagaAction<StoryActionRejected>
{
public override SagaId ResolveId(object message, ISagaContext context)
=> message switch
{
AdApproved m => (SagaId) m.AdId.ToString(),
AdPaid m => (SagaId) m.AdId.ToString(),
AdPublished m => (SagaId) m.AdId.ToString(),
_ => base.ResolveId(message, context)
};
Trill.Saga/src/Trill.Saga/Sagas/PublishAdSaga.cs:10-33.
The happy path is three steps and reads cleanly: AdApproved arrives and the saga sends PayAd; AdPaid arrives and it sends PublishAd; AdPublished arrives and it calls CompleteAsync(). The Ads service is the counterparty on both ends — it publishes the three events and subscribes to the two commands — so the whole conversation is one service and one coordinator passing a Guid back and forth over the ads exchange.
ResolveId is how Chronicle finds the right saga instance for an incoming message; how a message finds its saga covers the mechanism properly. The important thing here is which messages are in that switch and which are not.
Three defects, stacked
One: the rejection events are missing from ResolveId.
AdActionRejected and StoryActionRejected fall through to base.ResolveId. They have to, because they cannot do anything else — here is the entire contract:
[Message("ads")]
public class AdActionRejected : IRejectedEvent
{
public string Reason { get; }
public string Code { get; }
public AdActionRejected(string reason, string code)
Events/External/AdActionRejected.cs:6-12. A reason and a code. No AdId, no correlation id, nothing that identifies which ad failed. A rejection is structurally uncorrelatable to the saga instance it should abort. Even if everything downstream of this were perfect, the coordinator could not know whose flow to unwind.
Two: the rejection handlers do nothing.
public Task HandleAsync(AdActionRejected message, ISagaContext context)
{
return Task.CompletedTask;
}
...
public Task HandleAsync(StoryActionRejected message, ISagaContext context)
{
return Task.CompletedTask;
}
PublishAdSaga.cs:68-81. The saga subscribes to both rejection events at Extensions.cs:77-78, receives them, and returns successfully without touching Data, without rejecting, and without logging. Received and ignored.
Three: CompensateAsync is only invoked when a handler rejects or throws.
Chronicle's compensation is a replay of the saga log in reverse, triggered when a saga lands in Rejected — a mechanism compensation is a replay in reverse traces through the library's source. It is triggered by a handler calling Reject() or throwing. Now look at all five handlers in this saga: three of them await a SendAsync or return CompleteAsync(), and two return Task.CompletedTask. Not one of them can throw a domain failure, and not one calls Reject().
So the five overrides — every one of which is this:
public Task CompensateAsync(AdPaid message, ISagaContext context)
{
return RejectAsync();
}
— are unreachable. The saga models Compensating Action in five places and implements it in zero. The apparatus is complete, correctly typed, and dead.
What is not modelled at all
Alongside the unreachable compensation there is no timeout, which matters more than it looks. A saga waiting for an AdPaid that never arrives — because the Ads service crashed mid-payment, or the message was dropped, or the payment rejected and produced an AdActionRejected nobody could correlate — waits forever. Nothing sweeps it. Nothing alerts.
Part of why is that there is nothing to sweep on:
public class PublishAdSagaData
{
public Guid AdId { get; set; }
}
Sagas/PublishAdSagaData.cs:5-8. One field. No state enum, no timestamps, no attempt counter, no idempotency key, no last-error. The saga's entire memory of a multi-step distributed transaction is the identifier it was started with. You cannot write a “find sagas stuck for more than an hour” query against that, because nothing records when anything happened.
Chronicle's default saga persistence is in-memory unless a store is configured, and nothing in Extensions.cs configures one — so two Saga replicas would hold disjoint saga state, and an AdPaid arriving at the wrong replica finds no saga at all. Where a saga keeps its memory is the fuller treatment; the short version is that this saga is single-instance by construction and nothing says so.
There is a cross-domain oddity too: an Ads saga subscribes to StoryActionRejected on the stories exchange for a flow that never touches stories. That is a real coupling — the Saga now has a queue bound to another bounded context's exchange and a copy of its contract class — bought for a handler that returns Task.CompletedTask.
The corpse of the previous design
The most interesting four lines in the repository are comments:
public async Task HandleAsync(AdApproved message, ISagaContext context)
{
Data.AdId = message.AdId;
await _messageBroker.SendAsync(new PayAd(message.AdId));
// await _adApiClient.PayAsync(message.AdId);
}
public async Task HandleAsync(AdPaid message, ISagaContext context)
{
await _messageBroker.SendAsync(new PublishAd(message.AdId));
// await _adApiClient.PublishAsync(message.AdId);
}
PublishAdSaga.cs:35-51.
Both integration styles sit side by side in the same method. Remote Procedure Invocation — AdApiHttpClient issuing PUT /ads/{id}/pay — was replaced by Messaging, and the old version was commented rather than deleted. This is the single clearest piece of design-evolution evidence in the estate, and it exists nowhere in git history: the init commit already contains it. Part 14 shows it is not an isolated case.
The abandoned client is still fully wired. IAdApiClient and AdApiHttpClient live in Clients/, the implementation is registered at Extensions.cs:38, and the saga's constructor still takes it:
public PublishAdSaga(IAdApiClient adApiClient, IMessageBroker messageBroker)
Which produces a genuinely nasty consequence, because of how that client is built:
public AdApiHttpClient(IHttpClient client, HttpClientOptions options)
{
_client = client;
_url = options.Services["ads"];
}
Clients/AdApiHttpClient.cs:12-16. An unguarded indexer on a configuration-supplied dictionary, evaluated at construction. The base appsettings.json:9-11 supplies "services": { "ads": "http://localhost:5030" } and the docker profile supplies "ads": "http://ads-service" — but appsettings.development.json:2-6, the profile every launchSettings.json selects, re-declares the section as "services": {}.
Whether that empty object actually clears the inherited key depends on how the JSON configuration provider treats empty objects, and I would not stake a claim on it either way without running it. What is not ambiguous is the shape: a dependency that exists only to serve two commented-out lines is constructed on every saga resolution, and it reads a configuration key with an indexer that has no fallback. The correct form is options.Services.TryGetValue("ads", out var url) with a clear exception naming the missing key — or, better, deleting a client whose only two call sites are comments.
Two more things worth naming
The package has a trailing underscore.
<PackageReference Include="Chronicle_" Version="3.2.1" />
Trill.Saga.csproj:8. Not Chronicle — Chronicle_. The canonical DevMentors saga library is published as Chronicle; Chronicle_ is a separate NuGet identifier. Whether this is the author's own republish or an accident, an underscore-suffixed package id in a public sample is worth flagging out loud, because it is exactly the shape a typosquat takes and nothing in the repository explains it. With every Convey reference in the estate floating on Version="0.5.*" and no packages.lock.json, no Directory.Build.props, no nuget.config and no global.json anywhere, a consumer has no way to verify what they resolved.
In Docker, the Saga reports itself as the Ads service. appsettings.docker.json is a copy of the Ads service's file with the names left in: "jaeger": { "serviceName": "ads" } on line 30, "redis": { "instance": "ads:" } on line 51. The base appsettings.json correctly says "serviceName": "saga" and "instance": "saga:"; only the container profile is wrong. Every distributed trace involving the saga in a containerised run is attributed to Ads — which is the precise failure mode that makes tracing untrustworthy, because the data is present, plausible and wrong.
The honest ledger
What this saga gets right deserves saying. The flow is modelled as a Process Manager rather than a chain of choreographed handlers, which is the correct choice for a transaction with a terminal state. The command and event vocabulary is clean — ICommand for PayAd/PublishAd, IEvent for the three ad lifecycle events, enforced by Convey's type split. The outbox and inbox decorators are wired around every handler (the outbox that keeps your events honest covers what they buy). And the intent to compensate is there, in five methods, correctly typed against five message types.
The gap between that intent and the running behaviour comes down to one design decision made early and never revisited: the rejection events carry no correlating identifier. Everything else follows. Without an AdId there is nothing to resolve, so ResolveId cannot include them; without a resolvable saga the handlers have nothing to do, so they became no-ops; with no handler able to fail, nothing rejects, and compensation is unreachable.
One field. AdActionRejected(string reason, string code, Guid adId) would have made the whole apparatus live — and the same field would have to be added in three repositories at once, by hand, because this estate has no shared contracts package.
Which is exactly the subject of the next part: Two Contracts Drifted, Twelve Did Not.