The Reference That Points at Itself
Each half of an Inflow transfer stores a referenceId in its metadata. On both legs that reference is the leg's own id - so the two halves of a transfer cannot be joined from the data they persist.
The first question anyone asks a ledger is “what is my balance”. The second question, always, is “what was this”. A customer sees a 100 EUR debit on a Tuesday and wants to know where it went; a support engineer sees an unexplained credit and wants to know where it came from. Answering the second question is what a correlation id is for, and it is the field most likely to be added late, by someone who has just spent an afternoon answering it manually.
Inflow's Wallet adds it early. Part 4 showed the transfer method that produces both legs of a movement in one call; this part is about the two strings it writes while doing so.
The local function
Here is the whole correlation mechanism, a static local function at the bottom of TransferFunds:
var outTransfer = DeductFunds(outTransferId, amount, createdAt,
metadata: GetMetadata(outTransferId, receiver.Id));
var inTransfer = receiver.AddFunds(inTransferId, amount, createdAt,
metadata: GetMetadata(inTransferId, Id));
return new List<Transfer> { outTransfer, inTransfer };
static TransferMetadata GetMetadata(TransferId referenceId, WalletId walletId)
=> new($"{{\"referenceId\": \"{referenceId}\", \"walletId\": \"{walletId}\"}}");
(src\Modules\Wallets\Inflow.Modules.Wallets.Core\Wallets\Entities\Wallet.cs, branch master.)
The intent is legible and correct. Each transfer row should carry two facts a support engineer would want: which wallet was on the other end, and which transfer was the other half. The parameter is even named referenceId, which is exactly the right name for the second fact.
Now read the two call sites against the parameter list.
The outgoing leg is created with GetMetadata(outTransferId, receiver.Id). Its referenceId is outTransferId — its own id. The incoming leg is created with GetMetadata(inTransferId, Id). Its referenceId is inTransferId — its own id. Each half of the transfer stores a pointer to itself.
The walletId half is right: the outgoing row records the receiver's wallet and the incoming row records the sender's, so “who was on the other end” is answerable. It is the transfer-level link that is missing, and it is missing in the most invisible way possible, because the field is present, populated, well-named and always exactly equal to the row's primary key.
What the rows look like
Two transfers written by one call, as they land in wallets.Transfers:
// row 1 — Type = 'OutgoingTransfer', Id = 6e2d...a41
{ "referenceId": "6e2d...a41", "walletId": "b17c...9f0" }
// row 2 — Type = 'IncomingTransfer', Id = 91ab...cc7
{ "referenceId": "91ab...cc7", "walletId": "4d55...31e" }
Row 1's Id column already says 6e2d...a41. The metadata restates it in JSON and calls it a reference. Nothing in either row names the other.
The fix is one substitution on each line: pass inTransferId when building the outgoing leg's metadata, and outTransferId when building the incoming leg's. Both variables are in scope, three lines above. Written that way, row 1 points at row 2 and row 2 points at row 1, and every question a ledger gets asked becomes answerable with a single lookup.
Why it matters more than it looks
The consequence is not “a field is redundant”. It is that a transfer, as a business object, does not exist in the database. There are two independent rows that happen to share an amount and a timestamp.
Consider a customer who pays the same landlord 800 EUR on the first of the month and again on the second, and then disputes one of them. In the ledger there are two debits of 800 from wallet A and two credits of 800 into wallet B. To decide which credit answers which debit you have CreatedAt — set from _clock.CurrentDate() and identical on both legs of a single transfer, which is genuinely useful, and nearly identical across the two transfers if they were made close together. Under load, two transfers of equal value between the same pair of wallets inside the clock's resolution are indistinguishable. You cannot reverse one of them with confidence, because you cannot say which two rows constitute it.
It also forecloses the obvious query. “Show me this transfer” ought to be:
-- What the shape of the data should permit.
SELECT *
FROM wallets."Transfers"
WHERE "Id" = @transferId
OR "Metadata"::jsonb ->> 'referenceId' = @transferId::text;
Against the data the estate actually writes, that returns one row for any input, forever. The OR branch matches only the row the first branch already found.
And there is no compensating structure anywhere else. There is no Transfers.CounterpartTransferId column, no Transactions parent table, no correlation id on the two integration events the handler publishes — FundsDeducted and FundsAdded carry (WalletId, OwnerId, Currency, Amount, TransferName, TransferMetadata), so the only join key they could offer downstream is the same self-referential string. A repository-wide search for referenceId on master returns two hits: the parameter name and the interpolation. Nothing in the estate ever reads the field it writes.
That last fact is the reason the defect survived. A value nobody consumes cannot fail a test, cannot break a screen, and cannot be caught by review unless the reviewer happens to compare two argument lists three lines apart that differ by one identifier. WalletTests never touches TransferFunds; TransferFundsHandlerTests asserts only that the repository and broker were called.
The wrong home for a join key
Even fixed, the correlation would be living in the wrong place, and that is the more transferable half of the criticism.
Metadata is character varying(1000), mapped from a TransferMetadata value object whose only rule is a length cap. It is a free-text column that happens to contain JSON, and the module treats it as one: the producer writes with string interpolation, and — in the withdrawal flow, part 13 — a consumer in another module reads it with a real deserialiser. Nothing declares the schema, nothing validates it, and nothing indexes it.
Compare what a column would have bought. Transfers.CounterpartTransferId uuid NULL with a self-referencing foreign key gives you referential integrity (the counterpart must exist), an index (the join is cheap), a NOT NULL constraint on the rows where it applies, and — the part that matters most — a name that appears in every schema diagram anyone ever generates. The JSON blob gives you none of those and one advantage: you can add fields without a migration. That is a real advantage for genuinely open-ended annotation. It is not what a join key wants.
The value object itself is worth a glance while we are here, because it has a quirk that makes the blob even softer than it looks:
public TransferMetadata(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (value.Length > 1000)
{
throw new InvalidTransferMetadataException(value);
}
Value = value.Trim();
}
A blank input returns early without assigning Value, producing a perfectly legal TransferMetadata instance whose Value is null. Since the EF converter is x => new TransferMetadata(x), every row with an empty metadata column materialises as a non-null object wrapping a null string. TransferName does the same thing. A caller checking transfer.Metadata is not null learns nothing.
The pattern worth naming
To be fair to the design: writing correlation into a metadata blob at all is more than most first-draft ledgers do, and the walletId half is correct and useful. This is not an absent feature, it is a feature with a transposition in it.
But the shape is worth naming, because I have now seen it three times in three unrelated estates. A correlation field whose value is derived from the row it lives on is not a correlation field. The tell is always the same: the argument passed at the call site is the same variable that produced the entity's own identity. If GetMetadata's first parameter had been named counterpartTransferId instead of the pleasantly vague referenceId, GetMetadata(outTransferId, receiver.Id) would have read as wrong on the line it was written.
Two rules fall out of it, both cheap:
- Name reference parameters after the relationship, not after the mechanism.
referenceIddescribes a column type.counterpartTransferIddescribes a fact, and facts can be checked against the argument you are about to pass. - A correlation id needs one assertion, once. A single integration test that performs a transfer and asserts
outgoing.Metadatanamesincoming.Idwould have caught this in the same afternoon it was written. The estate has an integration test forTransferFundsHandlerthat already loads both wallets back out of PostgreSQL and checks their balances — the assertion would have been two more lines in a test that already existed.
Metadata is going to come back. In part 13 the same hand-built JSON pattern crosses a module boundary and becomes the correlation key for the withdrawal saga, where a missing field does not merely make a support query hard — it silently abandons the workflow.
Next, though, the one place in the module where persistence reaches into the domain model and takes something away: a setter on the ledger.