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

The Snapshot With No Version

ADR 04 names one risk in prose - changing a reservation on stale information - and the table built to mitigate it has no version, no timestamp and no concurrency check.

By Kumar Chandrachooda 22 Nov 2025 6 min read
A copy of a record that has no way of knowing the original moved

Every distributed decision is made on a photograph. You read some state, you think about it, and by the time you act the state has moved — a minute later, an hour later, a fortnight later. The question is never whether your copy is stale; it is whether the system can tell. A copy that carries a version can be checked. A copy that carries nothing is indistinguishable from the truth until the moment it is wrong.

Part 10 scored ADR 04's ten steps and found nine of them implemented. This part is about the risk that ADR names in prose, mitigates halfway, and leaves open — and it is the sharpest item in this series' honest ledger, precisely because it was written down.

The risk, in the author's words

ADR 04 weighs two options for the reservation-change process. Option 2 — extract it into a Postsale module — wins, and the ADR lists its two costs. The second, translated:

the necessity of ensuring the minimisation of conflicts between a reservation and a change (I change a reservation on the basis of stale information)

Read that parenthetical again, because it is a complete statement of the problem in nine words. Once the change process lives in a different bounded context from the reservation, the change is always proposed against a copy. The client declares a wish on Monday; a cashier prices it on Wednesday; the client pays on Friday; the change is applied to Sales on Friday evening. Four days in which the reservation itself can gain a payment, meet a deadline, miss a deadline, have passengers named, or be changed by a second request.

This is not a subtle risk that emerged during implementation. It is a named, understood, deliberately accepted cost of the split, sitting in the decision record with an approver's name on it.

The mitigation that exists

Step 2 of the ADR's flow is the answer: [Postsale → Sales] fetch a simplified reservation state as at the moment of declaration. And it is genuinely built. ReservationChangeRequestDomainService.cs:32 calls ISalesApi.GetReservationForChange, and lines 51-65 map the returned DTO into a domain type:

private static ReservationToChange MapToReservationToChange(ReservationToChangeDto reservationDto)
{
    return new ReservationToChange(
        reservationDto.ReservationId,
        reservationDto.AirlineType,
        reservationDto.IsCompleted,
        new ReservationCost(reservationDto.CurrentCost.TotalCost, reservationDto.CurrentCost.RefundableCost, Guid.NewGuid()),
        MapSegments(reservationDto.CurrentTravel),
        reservationDto.CurrentPayments
            .Select(p =>
                new RequiredPayment(p.PaymentId, new Deadline(p.Deadline.DeadlineId.Value, p.Deadline.DueDate)))
            .ToList(),
        new Deadline(reservationDto.PassengerNamesDeadline.DeadlineId.Value,
            reservationDto.PassengerNamesDeadline.DueDate));
}

Postsale.Domain/Changes/DomainService/ReservationChangeRequestDomainService.cs:51-65, GroupFlights at commit a19b337.

That ReservationToChange is persisted. ReservationToChangeConfig.cs maps it across five tables in the postsale schema — ReservationSnapshots plus owned collections for travel segments and payments — and the change request holds it by foreign key. Postsale takes a real, durable snapshot of another context's aggregate and reasons about it locally. That is the right architectural move. It means the change process never has to call back into Sales while it is thinking, and Sales never has to hold anything open on Postsale's behalf.

The mitigation that does not

Now look at what the snapshot table actually contains. From the initial migration:

CREATE TABLE postsale."ReservationSnapshots" (
    "ReservationId"                     uuid    NOT NULL,
    "AirlineType"                       integer NOT NULL,
    "IsCompleted"                       boolean NOT NULL,
    "CurrentCost_TotalCost_Amount"      numeric NOT NULL,
    "CurrentCost_TotalCost_Currency"    integer NOT NULL,
    "CurrentCost_RefundableCost_Amount" numeric NOT NULL,
    "CurrentCost_RefundableCost_Currency" integer NOT NULL,
    "CurrentCost_Id"                    uuid    NOT NULL,
    "PassengerNamesDeadline_Id"         uuid,
    "PassengerNamesDeadline_DueDate"    timestamp without time zone,
    "PassengerNamesDeadline_Fulfilled"  boolean,
    CONSTRAINT "PK_ReservationSnapshots" PRIMARY KEY ("ReservationId")
);

Reconstructed from Postsale_Initial.cs:36-55. Twelve columns. Count what is not among them.

  • No version. Nothing records which revision of the Sales reservation this is a photograph of.
  • No timestamp. Nothing records when the photograph was taken. PassengerNamesDeadline_DueDate is a date in the domain, not a capture time.
  • No ETag, no row-version, no [ConcurrencyCheck]. The type has no such property and the configuration declares no concurrency token.
  • No source revision on the Sales side either. Reservation in Sales.Domain has nothing to version against; ReservationToChangeDto carries no revision field to copy.

The snapshot is a copy with no provenance. It knows what the reservation looked like; it cannot know whether that is still true, and neither can anything downstream of it.

Where the check would have gone

Follow the change to its end. Postsale finishes its process and hands Sales a command; Sales applies it:

public async Task HandleAsync(ChangeReservationCommand command, CancellationToken cancellationToken = default)
{
    var reservation = await _reservationRepository.GetReservationById(
        new ReservationId(command.ReservationId), cancellationToken);
    
    reservation.ApplyReservationChange(MapReservationChange(command), _clock);

    await _reservationRepository.UpdateReservation(reservation, cancellationToken);

    await _eventDispatcher.PublishMultiple(reservation.DomainEvents
        .Select(@event => @event.RemapToPublicEvent())
        .SelectMany(e => e), cancellationToken);
}

Sales.Application/Commands/ChangeReservation/ChangeReservationCommandHandler.cs:28-40. Read it for what it does not do.

  • It re-reads the reservation. Good — the aggregate applied to is the current one, not the snapshot.
  • It does not compare anything. ChangeReservationCommand carries the new itinerary, the new cost and the deadline shifts. It does not carry the version the change was priced against, so there is nothing to compare to.
  • It has no ambient transaction. There are zero BeginTransaction or TransactionScope calls in all thirty-five projects, so the reservation update and the deadline updates that preceded it in Postsale's handler are independent writes.
  • ApplyReservationChange is where a domain guard could live, and does not. Nothing in the aggregate rejects a change whose premises no longer hold.

The failure mode is concrete rather than theoretical. A change is priced on Wednesday against a reservation with a total of 12,000 PLN. On Thursday the client makes their second instalment, or a passenger-names deadline is missed and the reservation's state moves. On Friday the change lands and overwrites the total with a number computed from Wednesday's figures. Nothing throws. Nothing logs. The reservation is quietly wrong, and the only record of the premise is a snapshot row nobody will look at.

To be fair to the estate, the window is small in the walkthrough. The README's fourteen-step scenario runs the whole thing in one sitting, and the fake payment gateway auto-pays after five seconds, so a reader following the happy path will never see it. That is precisely why it survives: a race whose window is measured in days does not show up in a demo whose window is measured in seconds.

What closing it would take

Two lines and a guard, and it is worth being specific because the cheapness is the point.

// Illustrative - fresh code, not the estate's
public record ReservationToChangeDto(
    Guid ReservationId,
    int Version,                 // <- added
    AirlineType AirlineType,
    bool IsCompleted,
    ReservationCostDto CurrentCost,
    IReadOnlyCollection<FlightSegmentDto> CurrentTravel,
    IReadOnlyCollection<RequiredPaymentDto> CurrentPayments,
    DeadlineDto PassengerNamesDeadline);

Sales gains a Version on Reservation, incremented on every state transition, and copies it into the DTO. Postsale stores it on the snapshot. ChangeReservationCommand carries it back. And then the aggregate enforces its own premise:

public void ApplyReservationChange(ReservationChange change, IClock clock)
{
    if (change.BasedOnVersion != Version)
    {
        throw new ChangePricedOnStaleReservationException(change.BasedOnVersion, Version);
    }
    // ...
}

The exception belongs in the domain, not the handler, because “a change may only be applied to the reservation it was priced against” is a business rule about reservations. A cashier gets a re-price request instead of a silently wrong total. The estate already has the machinery — HumanPresentableException with a category enum that the host maps to an HTTP status — so the failure would surface as a 409 with a Polish message like every other business rejection.

That is perhaps fifteen lines across four files. EF Core will even do most of it for you with IsRowVersion() if you would rather not own the counter.

The lesson is about records, not concurrency

Optimistic concurrency across a context boundary is a solved problem and I have taught nothing new about it. What is worth taking away is the shape of the failure.

This risk was identified during design, written into a decision record, weighed against an alternative, and approved — and it still shipped unmitigated. Writing it down was necessary and not sufficient. Nothing carried the sentence from the ADR to a ticket, to a test, or to a line of code, and the artefact that was built in response — a real, well-mapped snapshot table — looks like the mitigation while implementing only its first half.

That is the trap in named risks. A half-built mitigation is more dangerous than none, because it retires the concern. Somebody looking for staleness handling finds ReservationSnapshots, sees five tables of careful mapping, and stops looking.

The habit I would take from it: when an ADR names a risk, the ADR should also name the artefact that will prove the risk is handled — a test, an assertion, a column. “We take a snapshot” is a design. “There is a version column and a test that fails when the change is stale” is a mitigation.

Next, a pattern this estate solves three separate ways in one repository, because the message header had nowhere to put it: correlation identifier, invented three times.