Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-version-that-counts-to-two && read --section="top" 0%
Architecture

The Version That Counts to Two

Inflow's aggregate root increments its Version at most once per instance lifetime. As a concurrency token that is correct; as a version number it is a lie - and the one unit test that touches it documents the latch instead of catching it.

By Kumar Chandrachooda 11 Jan 2026 7 min read
A counter with a latch that only lets it move once

There are two different things people mean by a version column on an aggregate. One is a concurrency token: a value that must change on every write so the database can reject a stale update. The other is a version number: a count of how many times this thing has changed, which people read, log, put in support tickets and compare between environments. They are usually implemented with the same integer, and the requirements are not the same — a token has to differ, a number has to be right.

Part 6 noticed that the wallet's public collection setter bypasses the version bump. This part is about the bump itself.

The latch

public abstract class AggregateRoot<T>
{
    public T Id { get; protected set; }
    public int Version { get; protected set; } = 1;
    public IEnumerable<IDomainEvent> Events => _events;

    private readonly List<IDomainEvent> _events = new();
    private bool _versionIncremented;

    protected void AddEvent(IDomainEvent @event)
    {
        if (!_events.Any() && !_versionIncremented)
        {
            Version++;
            _versionIncremented = true;
        }

        _events.Add(@event);
    }

    public void ClearEvents() => _events.Clear();

    protected void IncrementVersion()
    {
        if (_versionIncremented)
        {
            return;
        }

        Version++;
        _versionIncremented = true;
    }
}

(src\Shared\Inflow.Shared.Abstractions\Kernel\Types\AggregateRoot.cs, branch master.)

_versionIncremented is a plain instance field, not persisted anywhere, so it resets to false every time the aggregate is materialised. IncrementVersion() therefore does something at most once per in-memory instance, no matter how many times it is called. Wallet.AddFunds and Wallet.DeductFunds both call it after appending a transfer.

Give it its due: the token works

It would be easy to write this up as a bug and move on. It is not, and the distinction is the whole point of the article.

Trace a normal request. WalletRepository.GetAsync materialises a fresh Wallet with Version read from the row — say 4 — and _versionIncremented at false. AddFunds appends a transfer and calls IncrementVersion(), so the in-memory value becomes 5. WalletConfiguration has marked the property as a token:

builder.Property(x => x.Version).IsConcurrencyToken();

so SaveChangesAsync emits UPDATE "Wallets" SET ..., "Version" = 5 WHERE "Id" = @id AND "Version" = 4. A second request that loaded the same row at version 4 and got there second matches zero rows and gets a DbUpdateConcurrencyException. That is textbook optimistic concurrency, and it is correct.

A token only has to change. It does not have to count. Across a sequence of requests that each mutate a wallet once — which is every handler in the module — the latch never fires, because each request has its own instance. The mechanism does its job.

What is not true is the word “Version”

The latch bites when one loaded instance is mutated more than once, and the module's own unit test does exactly that:

[Fact]
public void given_incoming_and_outgoing_transfers_wallet_amount_should_be_properly_calculated()
{
    var incomingAmount = new Amount(1000);
    var outgoingAmount = new Amount(200);
    var expectedAmount = incomingAmount - outgoingAmount;

    var now = DateTime.UtcNow;
    var wallet = CreateWallet();

    wallet.AddFunds(new TransferId(Guid.NewGuid()), incomingAmount, now);
    wallet.DeductFunds(new TransferId(Guid.NewGuid()), outgoingAmount, now);

    wallet.CurrentAmount().ShouldBe(expectedAmount);

    wallet.Transfers.Count().ShouldBe(2);
    wallet.Version.ShouldBe(2);
}

(Wallets.Tests.Unit\Entities\WalletTests.cs.)

Two mutations. Two transfers. Version 2. The sibling fact in the same file performs a single AddFunds and asserts wallet.Version.ShouldBe(2) as well.

Read those two assertions next to each other, because that is the finding: the test suite does not catch the latch, it records it. Somebody wrote the second test, saw 2 where they might have expected 3, and wrote ShouldBe(2). The value became the specification. There is nothing in the file marking it as surprising, and nothing anywhere in the estate defining what Version is supposed to count.

So what does the column mean in production? It counts loads that mutated, not mutations. For this module that is very nearly the same number, because every handler mutates once — which is exactly why the discrepancy is safe today and will not be safe the moment a handler does two things to one wallet. Write a SplitFunds command that deducts once and credits twice on the same instance and the version advances by one, silently.

There is a latent hazard on the write path too. Suppose a handler called UpdateAsync twice on the same instance. The first SaveChangesAsync writes SET Version = 5 WHERE Version = 4; EF then adopts 5 as the original value. The second mutation is latched, so the property stays 5, and the second statement is SET Version = 5 WHERE Version = 5 — a token that matches the row it just wrote, and therefore no concurrency protection at all between the two writes. No handler in the estate does this today. It is one refactor away, and nothing would fail.

The base class also carries the same latch logic twice, with different conditions: AddEvent re-implements it inline as if (!_events.Any() && !_versionIncremented), guarding on both the event list and the flag. AddEvent is never called anywhere in either module — neither Wallets nor Payments raises a domain event, and WalletConfiguration says builder.Ignore(x => x.Events) — so the duplication is dormant. Two code paths writing one flag with two different rules is the kind of thing that is fine until the day one of them wakes up.

What the token cannot protect

There is a limit to what any optimistic token buys here, and it is worth stating because it is easy to over-trust the mechanism once you have seen it work.

TransferFunds writes two wallets, each with its own token, through two separate UpdateAsync calls that each flush independently — and, as part 4 established, nothing in this estate runs inside a database transaction. So the two concurrency checks are two independent gambles. The first can pass and the second fail: the sender's debit is committed, the receiver's UPDATE ... WHERE "Version" = @loaded matches zero rows, and a DbUpdateConcurrencyException propagates out of a handler that has already moved money out of one wallet and not into the other.

Optimistic concurrency protects a row. It does not protect an operation. The exception it throws is a signal to retry the whole unit of work, and there is no unit of work here to retry — the failure surfaces at the controller as an unhandled exception mapped to a 500, with the estate in a state no compensating code exists to repair. The token is doing its job correctly and the surrounding code cannot use the answer.

There is also a small missed opportunity in the other direction. Version is on the entity and on the table and appears in no DTO — WalletDto and WalletDetailsDto expose WalletId, OwnerId, Currency, CreatedAt, Amount and Transfers, and no version. A concurrency token that reached the client would be an ETag, and the transfer endpoints could take an If-Match and reject stale writes from the customer's own session. The estate builds a perfectly good token and never lets anyone outside the process see it.

Payments solved it better, in one line, without a base class

The estate contains a second answer to the same problem, four projects away, and it is the better one:

// For PostgreSQL UseXminAsConcurrencyToken() can be used instead
builder.Property(x => x.ProcessedAt).IsConcurrencyToken();

(Payments.Core\DAL\Configurations\DepositConfiguration.cs; WithdrawalConfiguration is identical.)

ProcessedAt is a business field. It is null while a deposit is Started and gets stamped by Complete or Reject. Making that the token buys three things at once:

  • It cannot be latched, because it is not a counter and there is no flag. Every state transition sets a new value by definition.
  • It needs no base class. Deposit is a plain class with private setters and two guarded transition methods. There is no AggregateRoot<T>, no Version property, no shared abstraction to keep in sync.
  • It is an idempotency scheme for free. Two concurrent deliveries of the same completion webhook both read ProcessedAt = null, both pass the in-memory Status != Started guard, and the second UPDATE ... WHERE "ProcessedAt" IS NULL matches zero rows. The duplicate is rejected by the database, not by a check the handler forgot.

Two optimistic-concurrency strategies, same estate, same author, same week — and, as with everything else in this series, nothing anywhere records that a choice was made. The only comment on either is the one above, which is about a Npgsql feature, not about the decision. The Wallets side gets a synthetic counter with a latch and a name that overstates it; the Payments side gets a natural timestamp that is self-describing in the table. Given that Payments is the module this series is otherwise harder on, it is worth saying plainly: on concurrency, the unlayered module wins.

The rule of thumb: if your token is also a number people read, it has two specifications, and you will only test one of them. Either give the token a name that promises nothing (RowVersion, Etag, xmin) or make it a field whose business meaning already changes on every write.

Next, the type that has no idea which currency it is denominated in: money without a currency.