A Saga Held Together by a String
Inflow's withdrawal flow is a three-module round trip with a compensating branch and no saga class. The correlation between the halves is a magic string declared three times and a JSON blob written by hand on one side and deserialised on the other.
Not every distributed workflow needs a saga library. A two-step flow with one compensating branch is often better as two event handlers than as a state machine with a persistence provider — less machinery, fewer concepts, and the whole thing readable in one sitting. Inflow's withdrawal flow takes that bet, and it is a reasonable one.
Part 12 followed an instruction that half the estate ignores. This part follows one that works, and looks at what is holding it together.
The round trip
Three hops, two modules, no saga class.
Payments starts it. StartWithdrawalHandler loads the customer, refuses if inactive or unverified, finds the account by (CustomerId, Currency), creates a Withdrawal in status Started, and publishes WithdrawalStarted(WithdrawalId, CustomerId, Currency, Amount).
Wallets does the money, in a try/catch/finally:
try
{
var transfer = wallet.DeductFunds(Guid.NewGuid(), @event.Amount, _clock.CurrentDate(),
TransferName, GetMetadata(@event.WithdrawalId));
await _messageBroker.PublishAsync(new FundsDeducted(wallet.Id, wallet.OwnerId, wallet.Currency,
@event.Amount, transfer.Name, transfer.Metadata), cancellationToken);
// log
}
catch (Exception exception)
{
_logger.LogError(exception, exception.Message);
// log
await _messageBroker.PublishAsync(new DeductFundsRejected(wallet.Id, wallet.OwnerId, wallet.Currency,
@event.Amount, TransferName, GetMetadata(@event.WithdrawalId)), cancellationToken);
}
finally
{
await _walletRepository.UpdateAsync(wallet);
}
private static string GetMetadata(Guid withdrawalId) => $"{{\"withdrawalId\": \"{withdrawalId}\"}}";
(Wallets.Application\Wallets\Events\External\Handlers\WithdrawalStartedHandler.cs, branch master.)
Payments closes it. FundsDeductedHandler completes the withdrawal; DeductFundsRejectedHandler rejects it. Both start the same way:
if (@event.TransferName != TransferName)
{
return;
}
var withdrawalId = _metadataResolver.TryResolveWithdrawalId(@event.TransferMetadata);
if (!withdrawalId.HasValue)
{
return;
}
There is the whole correlation mechanism: a string literal comparison and a Guid fished out of a text column.
Three copies of one literal
private const string TransferName = "withdrawal"; is declared, independently, in three classes:
Wallets.Application\...\Handlers\WithdrawalStartedHandler— the producerPayments.Core\Withdrawals\Events\External\Handlers\FundsDeductedHandler— the success consumerPayments.Core\Withdrawals\Events\External\Handlers\DeductFundsRejectedHandler— the failure consumer
Three const fields, no shared definition, no contract, no test. The filter is necessary — FundsDeducted is also published by DeductFundsHandler and by TransferFundsHandler for ordinary customer transfers, and Payments must ignore those — so the mechanism has a job. It is the coupling that is unmanaged.
Change the producer's literal to "withdrawals" and here is what happens. FundsDeducted still fires. Both Payments handlers return at their first line. The withdrawal sits in status Started forever. Nothing logs anything. There is no else, no warning, no metric — the early return is the same statement the handler uses for the legitimate case of “this is somebody's ordinary transfer, not mine”, and the two are indistinguishable from outside.
That is the sharpest edge in the seam: a silent early return that is correct nine times out of ten is a filter, and the tenth time it is a dropped workflow, and they are the same line of code.
Hand-written JSON meets a deserialiser
The other half of the key is worse in an interesting way, because the two ends are written with different tools.
The producer builds JSON by string interpolation:
private static string GetMetadata(Guid withdrawalId) => $"{{\"withdrawalId\": \"{withdrawalId}\"}}";
The consumer parses it with a real serialiser into a private class:
public Guid? TryResolveWithdrawalId(string metadata)
{
if (string.IsNullOrWhiteSpace(metadata))
{
return null;
}
try
{
return _jsonSerializer.Deserialize<Metadata>(metadata).WithdrawalId;
}
catch (Exception e)
{
_logger.LogError(e, e.Message);
_logger.LogError($"Couldn't resolve withdrawal metadata for value{Environment.NewLine}{metadata}");
return null;
}
}
private class Metadata
{
public Guid WithdrawalId { get; set; }
}
(Payments.Core\Withdrawals\Services\WithdrawalMetadataResolver.cs.)
Credit where it is due: the resolver is defensive and it does log on failure — twice, including the offending value. Of the two silent-failure paths in this seam, this is the one that leaves a trace. And because the interpolated value is always a Guid, the hand-built JSON cannot currently be malformed or injected into.
But look at what the pairing means. The producer's contract is a format string in a private static method in another module. The consumer's contract is a class with a property name, matched case-insensitively by the serialiser. The schema of the message between two modules exists in two incompatible notations and neither can validate the other. Add a second field on the producing side and the consuming class silently ignores it; rename the key and the consumer gets Guid.Empty back — because Deserialize<Metadata> on {"foo": "..."} succeeds and leaves WithdrawalId at its default. That path throws no exception, logs nothing, and takes the TryResolveWithdrawalId result down the “found it” branch with an all-zeros id, ending in a WithdrawalNotFoundException a line later. Three different drift scenarios, three different failure modes, none of them the same.
The estate has a mechanism designed for exactly this. Local contracts let a consuming module re-declare a message and assert at boot that the producer's type still matches. WalletsModule.Use registers two of them. PaymentsModule.Use has an empty body, so neither FundsDeducted nor DeductFundsRejected — the two most fragile messages in the estate — is validated at all. And even if they were, contract validation compares property names and CLR types; both sides here agree that TransferMetadata is a string. The governance mechanism could not have caught this even if it had been switched on, because what drifts is a value, not a shape.
Three more things in the same handler
The compensating handler has a lot going on for twenty lines.
The catch is unfiltered. catch (Exception exception) turns anything into “the deduction was rejected”. An InvalidAmountException from the one-million cap in part 3, a DbUpdateConcurrencyException, an OperationCanceledException on shutdown, a NullReferenceException — all of them reach Payments as DeductFundsRejected and mark the withdrawal rejected. The one exception the branch actually exists for, InsufficientWalletFundsException, is never named. The intent — turn a business failure into a compensating event rather than an unhandled exception — is right; the aperture is a catch clause wide enough to swallow infrastructure faults and report them as customer-facing business outcomes.
The finally persists on both paths. On the failure path wallet is unmodified, so UpdateAsync issues a no-op UPDATE plus, thanks to DbSet.Update marking the whole loaded graph modified, one redundant UPDATE per historic transfer row. The failure path costs the same as the success path.
And the ordering is inverted relative to its siblings. Here, PublishAsync is inside the try and UpdateAsync is in the finally — the event announcing the deduction is published before the deduction has been sent to the database. AddFundsHandler and DeductFundsHandler, two files away, both do UpdateAsync and then PublishAsync. Three handlers in one module, two orderings, no comment: the tell that the ordering was never a decision. With the outbox disabled and no transaction anywhere (part 4), that window is real rather than theoretical.
One last piece of copy-paste damage, small and telling: DeductFundsRejectedHandler injects ILogger<FundsDeductedHandler> — its sibling's generic argument. Every log line from the failure path of a money-moving workflow is attributed to the wrong class. The one place you would go looking when a withdrawal misbehaves is the one place the logs do not point.
The endpoint that is not there
The last oddity is structural, and it makes the deposit and withdrawal sides mirror images with the mirror cracked.
Deposits complete via a webhook: PUT /deposits/{depositId}/complete reaches CompleteDepositHandler, which decides using the documented stub return secret == "secret" ? ... : ... above the comment // This could be refactored to an application service with checksum validation etc. That is a recorded simplification, and one of only seven intent comments in these two modules.
Withdrawals have the same handler. CompleteWithdrawal, CompleteWithdrawalHandler and the identical secret == "secret" stub all exist, fully written. WithdrawalsController has no completion route, and a repository-wide search for CompleteWithdrawal finds only the record, the handler, and the handler's own logger generic. Withdrawal completion actually happens in FundsDeductedHandler, driven by the event, never by a call.
So: the deposit side has an endpoint and no saga; the withdrawal side has a saga and a dead endpoint handler. Reading either module in isolation, both look complete. Reading them together is the only way to notice that one of the two symmetric halves is unreachable — which is the recurring cost of a repository where nothing distinguishes a staged simplification from an abandoned one.
To be fair to the design as a whole: this flow works. Start a withdrawal with funds and it completes; start one without and it is rejected, cleanly, with the wallet untouched. Two handlers and one filter is genuinely less machinery than a saga class, and for a two-step flow that is the right trade. The problem is not the absence of a framework. It is that the two ends of the correlation are held by conventions — a literal, a JSON key, an argument position — and the estate has no way to assert any of them.
The rule of thumb: a saga without a saga class is fine; a correlation without a shared definition is not. Put the literal and the metadata shape in one place both modules can see — even a shared constants file in the abstractions assembly — or accept that your workflow's join key is a comment.
There is a second saga in the estate, and this one does have a class, a library and a state machine. It also has a compensation path that cannot fire. Next, compensation that cannot fire.