Optimistic Concurrency That Never Says No
Availability guards every save with a version-filtered ReplaceOneAsync - and discards the result, so a lost update is indistinguishable from a successful one and the integration event ships either way. Three folders away, Orders and Customers inherit the same AggregateRoot name with the versioning quietly deleted.
Part 12 showed that every Pacco command has two front doors. This part asks what happens when two commands walk through them at once — because the estate does have an answer, in one service, and it is the most instructive two lines of persistence code in all thirteen repos. Pacco.Services.Availability/src/Pacco.Services.Availability.Infrastructure/Mongo/Repositories/ResourcesMongoRepository.cs:
public Task UpdateAsync(Resource resource)
=> _repository.Collection.ReplaceOneAsync(r => r.Id == resource.Id && r.Version < resource.Version,
resource.AsDocument());
That filter is a real optimistic concurrency check — the write only lands if the stored document's version is older than the one being saved. And the result of the operation — the ReplaceOneResult that says whether anything was actually replaced — is discarded. The method returns the driver's Task, the handler awaits it, and nobody, anywhere, ever reads MatchedCount. Availability has optimistic concurrency that can detect every conflict and is incapable of reporting one.
The version that counts to one
Start with where the version comes from, because the mechanism is genuinely nice. Availability's AggregateRoot base class ties versioning to domain events:
protected void AddEvent(IDomainEvent @event)
{
if (!_events.Any())
{
Version++;
}
_events.Add(@event);
}
The version bumps on the first event of a unit of work, and only the first — one increment per save, however many events the operation raises. An expropriation from part 3, which removes one reservation and adds another (ReservationCanceled + ReservationAdded), still moves the aggregate exactly one version forward. The mapping layer carries it faithfully: AsDocument copies entity.Version into the Mongo document, and rehydration passes document.Version back through the constructor. Change tracking by event-raising — if the aggregate did something worth announcing, it is worth a new version; if a handler loads an aggregate and raises nothing, the version stays put and the replace filter (Version < Version) makes the save a deliberate no-op. There is design intent here, and it holds together.
Right up until someone actually races.
Two reservations, one van, no referee
Set the scene with the estate's own flagship flow. A Resource is a vehicle; a Reservation is date-granular; and thanks to part 12 we know ReserveResource arrives over both HTTP and RabbitMQ — where the queue is durable and non-exclusive, so a scaled consumer means competing consumers by default. Two commands for the same vehicle on different dates are not even a conflict in the domain's eyes; they should both succeed. Watch what happens instead:
- Handler A loads the resource at version 3. Handler B loads the same resource at version 3.
- A adds its reservation;
AddEventbumps A's copy to version 4. B does the same to its copy — also version 4. - A's
ReplaceOneAsync(... r.Version < 4)matches the stored version 3 and lands. The document is now version 4, holding A's reservation. - B's
ReplaceOneAsync(... r.Version < 4)matches nothing — the stored version is no longer less than 4.MatchedCount: 0. The driver dutifully reports this to aTasknobody inspects. - Both handlers return success. B's reservation does not exist.
No exception, no retry, no log line. The filter did exactly its job — it refused to let B clobber A — and then the silence threw the second half of the pattern away. Optimistic concurrency is a two-act play: detect the conflict, then react — reload, merge, retry, or fail loudly. Availability performs act one and cancels act two.
It gets sharper, because of what the handler does next. ReserveResourceHandler, straight from the source:
resource.AddReservation(reservation);
await _repository.UpdateAsync(resource);
await _eventProcessor.ProcessAsync(resource.Events);
The event processing is unconditional. B's write vanished, but B's ReservationAdded domain event is still sitting on the aggregate copy, and the EventProcessor maps it and publishes ResourceReserved to the bus regardless. Now recall part 6: Orders reacts to ResourceReserved by looking up the order for that vehicle and date and calling Approve(). So the lost update does not merely lose a reservation — it approves an order against a reservation that is not in the ledger. The customer is told their delivery date is secured; the vehicle's document has never heard of them; and the next ReserveResource for that date will find the slot free and hand it to someone else without so much as an expropriation event. The integration event outran the write it was supposed to describe — the same “events must be facts” failure as part 8, reached through infrastructure instead of a missing Remove.
Three folders away, the version evaporates
Now open the same file in Orders. Pacco.Services.Orders.Core/Entities/AggregateRoot.cs has the identical name, the identical properties — Id, Version, Events — and this AddEvent:
protected void AddEvent(IDomainEvent @event)
{
_events.Add(@event);
}
No bump. Version is declared, initialised to zero, and never touched again. OrderDocument has no version field at all, so even the vestige is dropped at the mapping layer; OrderMongoRepository.UpdateAsync delegates to Convey's plain UpdateAsync, whose filter is e.Id.Equals(entity.Id) — an unconditional last-writer-wins replace (and, for symmetry, the framework discards that ReplaceOneAsync result too). Customers is the same story: the copied AggregateRoot without the bump, a CustomerDocument without a Version, a plain replace. Two commands racing on one order — an Approve from the bus against a Cancel from HTTP, say — resolve by timestamp roulette, one write silently overwriting the other's document wholesale.
So the estate contains, under a single class name, three different concurrency semantics: Availability (versioned, checked, silent), Orders and Customers (versioned in name only, unchecked), and Parcels (no aggregate at all, as part 2 established). Nothing enforces the difference; nothing even records it. Each service's AggregateRoot.cs is a copy-paste of a copy-paste, edited in one place and not the others, and the compiler is satisfied because the files never meet. The name promises a shared contract; the copies deliver three. What that copying discipline costs across the whole estate, measured file by file, is the closing argument of the companion series in the price of no shared kernel.
To be fair — and what it would take
Two fairness notes before the verdict. First, the silent filter is still better than no filter: when it drops a write, the stored data regresses nothing — Availability can lose updates but can never overwrite newer state with older, which is more than Orders can say. Second, in the estate's default habitat — one instance per service, a single demo user clicking through a .rest file — these races essentially cannot happen, and a teaching estate can defensibly leave the reaction out of scope. But the code teaches the pattern, and the pattern as shipped is a trap for the reader who scales it: the moment you run two replicas (which the durable, non-exclusive queues invite you to do), the failure mode is not an error rate you can alarm on — it is quiet data loss wearing a success status.
The repair is almost embarrassingly small, which is why the omission is worth an article:
public async Task UpdateAsync(Resource resource)
{
var result = await _repository.Collection.ReplaceOneAsync(
r => r.Id == resource.Id && r.Version < resource.Version,
resource.AsDocument());
if (result.MatchedCount == 0)
{
throw new ResourceConcurrentUpdateException(resource.Id);
}
}
Five lines, and every consequence above inverts: the losing handler throws, the outbox decorator abandons the message, RabbitMQ redelivers, the handler reloads the resource at its true version and re-runs the domain logic — the expropriation rule from part 3 gets to arbitrate the collision it was designed for, and the integration event once again describes a write that happened. Everything the pattern needed was already in place except the sentence at the end. If you do not observe the result of a conditional write, you do not have optimistic concurrency — you have optimistic hope. Check the count, or delete the filter; the honest versions of this repository are the ones that pick a side.
The version filter is a story about a service protecting itself from its own replicas. The next part widens the lens to the opposite failure: a single command in Orders that cannot finish without three other services answering the phone, in sequence, right now — the estate's one true synchronous chain, and the loyalty ladder waiting at the end of it. Next: one request, four services.