Data & persistence · No. 13
Optimistic concurrency you cannot opt out of: RowVersion from the database to a required If-Match

Two admins open the same session, both edit it, both hit Save. Without a precondition the
second write silently wins and the first admin's change is gone, with no error, no log, no trace. Here is
the concurrency token that travels from the database out as a weak ETag, back in a required If-Match
header, and into EF's WHERE clause, so a stale edit is a 412 Precondition Failed and a write that states
no precondition at all is a 428 Precondition Required, formalized in ADR-035.
An organizer opens a conference session to fix the room. A speaker opens the same session, at the same time, to fix the abstract. Both read the row. The organizer saves first. Then the speaker saves, over the top, and the room change vanishes. Nobody typed anything wrong. Nobody saw an error. The system did exactly what a load-modify-save handler does by default: it loaded the freshest row, applied the request, and wrote it back. Last write wins.
That default is fine for a single-user admin tool and quietly wrong for a multi-actor system. Every
mutable aggregate in the framework is edited through the same shape: the update handler fetches the
tracked entity, applies the request, and calls SaveChangesAsync. With one shared context per engine
(ADR-006) and no concurrency token, two editors who both read a row and save in turn overwrite each other
and nothing surfaces the collision.
Why it matters, and why it is not idempotency
The lost update is a data-integrity bug that never announces itself. It does not throw. It does not log. It leaves a perfectly valid-looking row. You find out when the organizer reopens the session a day later and their room assignment is simply not there, and by then the winning write is indistinguishable from a correct one.
It is worth being precise about what this is, because the framework already carries two things that sound adjacent. Request idempotency (ADR-017) dedups a client that retries the same request. The consumer inbox (ADR-021) dedups a broker that redelivers the same event. Both answer "I saw this one action more than once." Optimistic concurrency answers the opposite question: "two distinct actions targeted the same row, which one is stale?" Same neighborhood, different problem. Idempotency collapses duplicates into one effect; concurrency control keeps two genuine edits from silently clobbering each other.
The MMCA answer: a database token, handed out as an ETag, demanded back as a precondition
The mechanism is a concurrency token that the database manages, the read model exposes, the client states
back in the If-Match header, and the persistence layer plants as EF's original value so a stale update
fails inside the UPDATE statement itself. There is exactly one transport for it, and no way past it: a
conditional write that states no precondition never reaches the action.
It rests on four members.
AuditableBaseEntity<TId> carries a byte[] RowVersion property with a private setter, so every
aggregate root and every child entity inherits it and no domain code ever sets it. On SQL Server it maps
to a server-generated rowversion that auto-increments on every write; the value is populated by EF, and
the aggregate's behavior never sees it. Concurrency stays a persistence concern, not a domain one.
IConcurrencyAware is the read contract, a single non-nullable byte[] RowVersion { get; init; }. Read
DTOs implement it so the API can render the current token as the response ETag. Update requests do not
implement it: the precondition travels in the header alone, and the token is the header's whole content,
so it is never optional.
IWriteRepository.SetOriginalRowVersion is the persistence extension point. MutateEntityHandlerBase
calls it right after loading the entity and before applying the request, with the token the concrete
handler reports through its RowVersion(command) override. The EF implementation writes that token to
Entry(entity).Property(nameof(RowVersion)).OriginalValue, and it rejects null: there is no value that
means skip the check. A second overload takes any tracked IRowVersioned child, because the
aggregate-typed one can only reach the root.
IWriteRepository.TouchConcurrencyToken closes the hole that stamping alone leaves. Setting the original
value does not make the entry dirty, so an applier that changed only child rows would leave the root
Unchanged, EF would emit no root UPDATE, and there would be nothing for the database to compare the
token against. Under a conditional write the handler base marks the root as modified, so the save always
emits a root UPDATE carrying the caller's token.
// The read DTO exposes the token; the API renders it as the response ETag.
public record class SessionDTO : IBaseDTO<SessionIdentifierType>, IConcurrencyAware
{
public byte[] RowVersion { get; init; } = []; // the version the client last read
}
// The update request carries no token: the precondition travels in If-Match.
public record class SessionUpdateRequest : ISessionFieldsRequest
{
public required string Title { get; init; }
}
// The action reads the decoded header token and puts it on the command.
var rowVersion = SupportsIfMatchAttribute.RequiredToken(HttpContext);
var result = await UpdateHandler.HandleAsync(
new UpdateSessionCommand(id, request, rowVersion), cancellationToken);
// The handler reports the token; the shared base does the stamping.
protected override byte[]? RowVersion(UpdateSessionCommand command) => command.RowVersion;
// MutateEntityHandlerBase: SetOriginalRowVersion(entity, rowVersion), then
// TouchConcurrencyToken(entity) so the save always emits a root UPDATE.
// A stale token matches no row -> DbUpdateConcurrencyException -> 412 Precondition Failed.
That is the client contract: the read hands you an entity tag, and the edit is refused unless you state that tag back.
The detail that actually makes it a conflict
The interesting part is not "store a version number." It is where the comparison happens. A hand-rolled "read the current token, compare it to the client's, then save if they match" reintroduces the exact race it is meant to close: another writer can slip in between the compare and the save.
SetOriginalRowVersion sidesteps that entirely. Once the client's token is EF's original value for the
row, EF includes it in the UPDATE's WHERE clause. The comparison runs in the database, atomically,
as part of the write. If the row's current token no longer matches (someone else wrote since the client
read), the UPDATE affects zero rows and EF raises DbUpdateConcurrencyException. There is no window. The
check and the write are the same statement.
ConfigureConcurrencyTokens wires this for every non-owned IAuditableEntity in the model, uniformly.
On SQL Server the property is configured with IsRowVersion (server-generated). On other relational
providers it falls back to IsConcurrencyToken over the same byte[], application-managed. One
configuration, every table, no per-entity boilerplate.
From exception to a status at the edge
DbUpdateConcurrencyException is a DbUpdateException, and the framework already has one handler for
that whole family. DbUpdateExceptionHandler translates any DbUpdateException into an RFC 9457
409 Conflict ProblemDetails, logs the full exception, and returns a deliberately generic detail message
("A data conflict occurred. Please retry or contact support.") so the database schema never leaks to the
client. This is the same edge that already returns 409 for unique-constraint and foreign-key violations,
so a concurrency conflict inherits its translation, logging, and schema-safe message with no new
middleware. On a guarded action the conflict never reaches the client as a 409: the filter rewrites the
outcome to 412 after the action, keeping the problem-details body exactly as it was built. Either way the
caller gets a status it can act on: reload, show the fresh values, let the human decide.
The transport: a weak ETag out, a required If-Match back
The same RowVersion, the same SetOriginalRowVersion extension point, the same comparison inside the
UPDATE. The header is where the token lives on the wire, which means anything that speaks HTTP can
participate: a generic REST tool, a mobile stack, curl.
The read emits it. EntityControllerBase calls SetConcurrencyETag on the DTO it is about to return,
which writes a weak ETag (W/"<base64 of the token>"). Weak is the honest strength: the tag
identifies the row's version, not a byte-exact representation, and the same row serializes differently
under a fields= projection (the emitter reads the token out of the shaped dictionary in that case). A
DTO with no RowVersion property, or an empty token, gets no header at all.
The write consumes it. [SupportsIfMatch] is a sealed attribute that implements IAsyncActionFilter
directly, so unlike the DI-resolved [Idempotent] filter it needs no host registration. Before the action
runs it decodes If-Match and places the token in HttpContext.Items under TokenItemKey, where the
action reads it back with SupportsIfMatchAttribute.RequiredToken. No bound argument is written to, so no
request model has to loosen its immutability to receive a token. Three decisions matter: a request that
states no precondition is refused with 428 Precondition Required and the action never runs (a blank
header and * both count, because the wildcard names no particular version), a malformed tag is a
400 short-circuit because the server cannot tell what the caller meant, and only a decodable tag lets
the action run.
Then the status changes. After the action, RewriteConflictToPreconditionFailed turns the conflict
outcome, both a DbUpdateConcurrencyException and an already-built 409 result, into
412 Precondition Failed. It does so unconditionally, because every request that reached the action
stated a precondition. RFC 9110 reserves 412 for a precondition the client stated in a conditional request
header, which is exactly what If-Match is; 409 stays the answer on the unconditional endpoints, for a
conflict the client never conditioned on.
PUT /orders/42/pay
(no If-Match header)
428 Precondition Required
GET /orders/42
200 OK
ETag: W/"AAAAAAAAB9E="
PUT /orders/42/pay
If-Match: W/"AAAAAAAAB9E="
412 Precondition Failed
The generic UpdateAsync on CrudEntityControllerBase carries the attribute, so every CRUD PUT
inherits the precondition without anyone remembering it. Beyond that it is applied by hand, on 40 actions
across 18 controllers today: 24 across seven controllers in Store (five in Sales' OrdersController,
five in ProductsController, four in ProductVariantsController, three each in CustomersController
and ReviewsController, two each in CategoriesController and InventoryItemsController) and 16 across
eleven in ADC (three in Conference's EventsController, three in Engagement's SessionQuestionsController,
two in its LivePollsController, and one each in eight more Conference controllers). Nothing here decides
conditional GET. The tag exists to be stated back on the next write, and there is no If-None-Match
handling and no 304 path.
The invariant: one source for the precondition
A convention that lives only in a code-review checklist rots. So the one-transport rule is enforced
mechanically. ArchitectureRules.UpdateRequestsAreNotConcurrencyAware scans each module's Application
assemblies for every type whose simple name ends in UpdateRequest and flags any that does implement
IConcurrencyAware, because a token in the body would give the same check a second, competing source.
ConcurrencyConventionTestsBase exposes it as a single [Fact],
UpdateRequests_ShouldNotImplement_IConcurrencyAware, and both consumers subclass it: ADC and Store each
supply their own IArchitectureMap. A module with no mutable aggregate is legitimately vacuous. This is
invariant-over-discipline (ADR-015), and it is the type-level half of the rule; the 428 is the caller-level
half, so neither a request model nor a client can quietly reintroduce last-write-wins.
Both apps have adopted the pattern end to end. In ADC, UpdateSessionHandler overrides RowVersion to
report the token that arrived on UpdateSessionCommand, SessionDTO implements IConcurrencyAware, and
SessionUpdateRequest implements only its field contract. In Store, the same shape runs across all three
modules: Catalog and Identity edits are appliers over the shared handler base (changing a customer's
email, changing a product's brand), and the Sales order transitions (PayOrderHandler,
DeliverOrderHandler, CancelOrderHandler, ShipOrderHandler, UpdateShipmentHandler) each report the
command's token the same way. Both apps run one database per service (ADR-006), so each per-service
database is created with the RowVersion column on every table by its own module's InitialCreate
migration, and the token is present from the first row.
Trade-offs, honestly
- The precondition is mandatory, and that is a real constraint. A caller that has not read the
resource cannot write it. There is no null token, no wildcard escape, no legacy-client path: a write
with no
If-Matchis a 428 and stops there. Scripts and generic REST tools have to do a GET first, and a client that drops the header sees an immediate error instead of a silent overwrite. That is the trade the framework makes, deliberately. - The 409 is coarse, and the 412 inherits it. All
DbUpdateExceptions map to one 409 with a generic message, so from the status and body alone a client cannot tell a concurrency conflict from a unique-constraint or foreign-key violation. That is deliberate (no schema leak), but it means retry logic treats the three the same. The rewrite keys on the conflict outcome, not its cause, so on a guarded action all three come back as 412, and one of them then wears a status naming a precondition the client did not actually violate. - It does not merge. Optimistic concurrency detects the collision and refuses the stale write. It does not reconcile the two edits for you. What to do on a 412 (reload and retry, or surface a diff to the human) is the caller's decision, not the framework's.
- Cross-engine asymmetry. SQL Server gets a server-generated
rowversion; SQLite and other relational providers get an application-managedIsConcurrencyTokenover the samebyte[]. Cosmos has its own ETag mechanism that is not routed through this property. - A child-level precondition costs a second field.
If-Matchnames exactly one version, and that slot belongs to the aggregate root, so a write that needs to condition on a child row states that second precondition in the body: Store'sProductVariantChangePriceRequestcarries a requiredVariantRowVersion(and no product token at all), which the child-typedSetOriginalRowVersionoverload stamps on the tracked variant. A conflicting edit to the same variant then fails the precondition even when the product row was untouched. Two preconditions on one write is the price of keeping the aggregate boundary intact. - Every conditional write touches the root.
TouchConcurrencyTokenmarks the aggregate root modified so the precondition is actually evaluated, which means a conditional write always emits a root UPDATE and always advances the root's token, even when only a child row changed. Correctness over a spared statement: the cost is that every other editor holding that aggregate's tag now has a stale one. - Adoption is a per-database migration. A new database, or a table added later, must carry the
RowVersioncolumn for the token to exist there.
None of these are reasons to keep last-write-wins. They are the reasons to state the precondition explicitly and to decide, per endpoint, what a conflict means to your user.
Apply this even without MMCA
The pattern ports to any load-modify-save stack:
- Put a database-managed concurrency token on your rows (SQL Server
rowversion, a Postgresxmin, or an application-managed version column). Do not hand-maintain it in domain code. - Hand it out on the read, demand it back on the write. Expose the token as a weak
ETagon the read and require the client to state it inIf-Matchon the update. Pick one transport and keep it: a second copy in the payload gives the same check a competing source, and nothing at runtime tells you which one was honored. - Do the comparison in the write, not before it. Set the client's token as the row's expected
value so it lands in the UPDATE's
WHEREclause. A read-then-compare-then-save reopens the race. Make sure the write actually happens: if only child rows changed, the parent row needs touching or the comparison never runs. - Answer with a status the caller can act on:
428 Precondition Requiredwhen no precondition was stated,400 Bad Requestwhen it cannot be decoded,412 Precondition Failedwhen it is stale. Then let the caller decide how to recover. - Make the rule a build failure, not a review comment. A naming-convention fitness test (here: no
*UpdateRequesttype carries the token) keeps a new mutable endpoint from growing a second source of the same precondition.
The takeaway: a lost update is the write you never see fail. Give the row a database-managed token, hand
it out as an ETag, require it back as an If-Match precondition, and compare it inside the UPDATE, so a
stale save is a 412 the caller can handle and a save that states no precondition is a 428, instead of
an overwrite nobody notices.
What we covered: why a load-modify-save handler silently loses the first of two concurrent edits, how
MMCA.Common gives every auditable entity a database-managed RowVersion, renders it on the read as a weak
ETag through IConcurrencyAware, requires it back in If-Match so a write with no precondition is a
428 Precondition Required, plants it as EF's original value via SetOriginalRowVersion (and touches the
root so the comparison always runs) to detect the conflict atomically inside the UPDATE, rewrites that
conflict to 412 Precondition Failed at the edge, and keeps the token out of every request body with a
build-wide fitness function.
Next in the series: self-ordering modules, discovered and Kahn-sorted so a dependency is always registered before the modules that need it.
MMCA.Common is Apache-2.0 licensed and open source. Star the repo, read ADR-035 for the decision record, or
dotnet add package MMCA.Common.API and try it.
- ⭐ Repo: https://github.com/ivanball/MMCA.Common
- 📚 Full series index: https://ivanball.github.io/writing.html
- 📄 ADR-035 (optimistic concurrency) in the docs site.
Tags: .NET, C Sharp, Software Architecture, Entity Framework, Concurrency