Architecture Decision Record
ADR-035: Optimistic Concurrency via RowVersion Round-Trip
Status
Accepted (2026-07-02).
Revised 2026-09-07 (a conditional write always stamps the aggregate root, so If-Match is enforced
even when only child rows changed, and a concurrency-conflict detector joins the infrastructure
contracts).
Revised 2026-10-01 (current-state corrections and refreshed citations, no decision changed).
Revised 2026-10-06: the RowVersion mapping and the PostgreSQL/SQLite re-stamp are selected by the engine's declared RowVersionStrategy capability rather than by provider, and stale citations were refreshed.
Revised 2026-10-07: MMCA.Helpdesk also subclasses ConcurrencyConventionTestsBase (three consumers), MMCA.Common's own subclass is recorded as vacuous (its map declares no module layer), and stale anchors were refreshed against current source.
Context
Every mutable aggregate in the framework is edited through a load-modify-save handler: the update use
case 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 the same row and
save in turn silently overwrite each other. The second write wins, the first editor's change
vanishes, and nothing surfaces the collision. That last-write-wins default is fine for a single-user
admin tool and wrong for a multi-actor system where an organizer and a speaker can both be editing
the same session.
This is a different concern from the two idempotency mechanisms the framework already records. ADR-017 (request idempotency) dedups a client retrying the same request, and ADR-021 (consumer inbox) dedups the broker redelivering the same integration 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?" We wanted a first-class, framework-wide mechanism that turns a conflicting concurrent edit into a status the caller can react to, rather than a silent overwrite.
HTTP has described that exchange since long before this framework: a response ETag naming the
version the client holds, and an If-Match request header making the next write conditional on it.
A design that invents its own carrier for the same value leaves a generic REST tool, a mobile HTTP
stack, anything not generated from our DTOs, unable to participate in a check the protocol already
standardizes.
Decision
Give every auditable entity a database-managed RowVersion concurrency token, round-trip it through
the client as an HTTP entity tag, and stamp the client's last-seen value as EF's original value so a
stale update fails inside the UPDATE statement.
A
RowVersiontoken on the audit base.AuditableBaseEntity<TIdentifierType>carries abyte[] RowVersionproperty with a private setter (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:53), so every aggregate root and child entity inherits it, and the base implementsIRowVersioned(:13, declared inMMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IRowVersioned.cs) so a child row can be reached without a second generic parameter. EF configures the property on every non-ownedIAuditableEntityinConfigureConcurrencyTokens(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:589, called fromOnModelCreatingat:421). The mapping keys on the engine's declared capability (Engine.Capabilities.RowVersion == RowVersionStrategy.StoreGenerated,:592) rather than on the provider: SQL Server, the one engine declaringStoreGenerated, maps it to a server-generatedrowversion(IsRowVersion,:602), and the other relational engines map it as a plain application-managed token (IsConcurrencyToken,:606). EF then includes the token in every UPDATE/DELETEWHEREclause and raisesDbUpdateConcurrencyExceptionwhen it matches no row.A read carries the token; a write does not.
IConcurrencyAware(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:15) declares a non-nullablebyte[] RowVersion(:19) and is implemented by read DTOs, so a row served to a client can be rendered with its version. An update request implements nothing of the kind: the precondition travels in the header alone (:9-14).The read emits a weak
ETag.EntityControllerBase.GetByIdAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:361) callsSetConcurrencyETag(result.Value)(:382), which (:417) renders the served row's token asW/"<base64>"(formatted byConcurrencyETag.Format,MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:40, and written to the response at:425). Weak is the honest strength: the tag identifies the row's version, not a byte-exact representation, and the same row legitimately serializes differently under afields=projection. Those shaped responses are seen through rather than skipped:ReadRowVersion(:434) reads the typed property when the payload is the DTO and falls back to a dictionary lookup keyed by JSON name when it is a projection (:439-442). The property is resolved once per closed controller type (:391), and a DTO type with no token makes the method a no-op (:419-420). It isprotectedso a hand-written read action can emit the same header instead of re-implementing the format, which is where the two would drift and a precondition would quietly stop working.[SupportsIfMatch]decodes the header, and nothing else carries the token.SupportsIfMatchAttribute(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:49) is a sealedAttributeimplementingIAsyncActionFilterdirectly, so unlike[Idempotent](aServiceFilterAttributeresolving from DI, ADR-017) it is self-contained and needs no registration by a host. Before the action runs it parsesIf-MatchwithConcurrencyETag.TryParse(ConcurrencyETag.cs:62) and places the decoded bytes inHttpContext.ItemsunderTokenItemKey(:57, written at:122). The action reads them back throughSupportsIfMatchAttribute.RequiredToken(HttpContext)(:68) and passes the token to its command, as the generic PUT does (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:90for the attribute,:103for the read). No bound argument is written to, so no request model has to loosen its immutability to receive a token.Three status codes, one problem-details shape.
- A request that states no precondition is
428 Precondition Requiredand the action never runs (SupportsIfMatchAttribute.cs:109-114). A blank header and*are both no precondition: the wildcard names no particular version, so honouring it would be a last-write-wins write wearing a conditional request's clothes. - A malformed tag is
400 Bad Request(:116-120): a precondition the server cannot read is a client error, and running the write would ignore a precondition the caller did state. - A stale token is
412 Precondition Failed, produced after the action byRewriteConflictToPreconditionFailed(:130). ADbUpdateConcurrencyExceptionis answered in the filter rather than left to the global handler (:132-138), and an already-produced 409 result has its status and itsProblemDetails.Statusrewritten in place with the body otherwise untouched (:141-158).
All three are RFC 9457 problem details built by one helper (
:204) through the registeredProblemDetailsFactory, so they carry the sametraceId/requestIddiagnostics as every other problem response on the surface, and each carries the standarderrorsextension with a stable code:Concurrency.PreconditionRequired(:169),Concurrency.MalformedIfMatch(:181),Concurrency.PreconditionFailed(:193).- A request that states no precondition is
SetOriginalRowVersionis the persistence extension point.IWriteRepository.SetOriginalRowVersion(TEntity, byte[])(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:408) applies the caller's token as the tracked entity's originalRowVersion; theEFRepositoryimplementation writes it toEntry(entity).Property(nameof(RowVersion)).OriginalValue(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:81-83). The shared write workflow calls it right after loading the aggregate and before the mutation runs (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:300), so EF compares the client's token against the row's current value inside the UPDATE statement, atomically, with no read-then-check race.Child entities are reached by the
IRowVersionedoverload. The first overload is typed to the repository's aggregate root (TEntity), so a child edit (aProductVariantunder aProduct) cannot receive a token through it. A second overload,SetOriginalRowVersion(IRowVersioned childEntity, byte[] rowVersion)(IRepository.cs:419, implemented atEFRepository.cs:87), accepts any tracked auditable entity instead, and an update handler that mutates children through the aggregate's repository stamps each child itself. Both overloads reject a null token (EFRepository.cs:79,:90): there is no value that means "skip the check".A fitness function keeps the token out of the body.
ArchitectureRules.UpdateRequestsAreNotConcurrencyAware(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Governance.cs:24) scans module Application assemblies for types whose simple name ends inUpdateRequestand flags any that does implementIConcurrencyAware, because a token in the body would give the same check a second, competing source.ConcurrencyConventionTestsBaseexposes it as a single[Fact](MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/ConcurrencyConventionTestsBase.cs:14), and all three consumers subclass it (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/ConcurrencyConventionTests.cs:3,MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Domain/ConcurrencyConventionTests.cs:3,MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:56, whereTicketUpdateRequestis the request it inspects), each supplying its ownIArchitectureMap. A module with no mutable aggregate is legitimately vacuous. MMCA.Common subclasses it as well (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/ConcurrencyConventionTests.cs:12, overCommonArchitectureMap,:14), but that run checks nothing: the rule reads onlyModuleApplication(), which keeps layers with a non-empty module (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureMapBase.cs:42,:102), andCommonArchitectureMapdeclares only framework layers (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:21-27). A framework*UpdateRequestcarrying a body token would not fail it.The UI speaks the same format.
ConcurrencyETaglives inMMCA.Common.Shared.Httprather than in the API package precisely so both ends of the exchange can use it: the API reads anIf-Matchvalue with it and the UI writes one.EntityServiceBase.UpdateAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:188) passesConcurrencyTagOf(entity)(:209) as the write'sIf-Match(:196), and the request client (CreateRequestClientAsync,:403) sets the header (:424), so a Blazor page conditions its writes with no per-page code.Every auditable table carries the column. The token exists in the database or it does not exist at all: each database's migrations add the
RowVersioncolumn to every table mapped from an auditable entity, typedrowversionin SQL Server, alongside the audit columns of the same base (MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Catalog/Migrations/20260621192800_InitialCreate.cs:34,MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260606053146_InitialCreate.cs). Framework tables such asInboxMessagesandOutboxMessagescarry none (Store:47,:62).
Rationale
- Database-managed token over a hand-maintained version field. A SQL Server
rowversionauto-increments on the server on every write; no domain code sets or reads it (the setter is private, populated by EF). The token stays invisible to the aggregate's behavior, so concurrency is a persistence concern, not a domain one. - Round-trip over a server-side reload. Reloading and re-saving on the server hides the collision (both writes succeed against the freshest row). Forcing the client to echo the version it last read is what makes a competing edit a real conflict instead of a silent overwrite.
- One extension point over a per-handler compare.
SetOriginalRowVersionplus EF'sWHERE-clause check does the comparison in the database, atomically with the UPDATE. A hand-rolled "read the current token, compare, then save" would reintroduce the exact race it is meant to close. - The header is the one transport. HTTP already defines
ETagandIf-Matchfor precisely this exchange, so a generic REST client participates without knowing a framework DTO, and the precondition stays out of the write contract. A token in the request body would be a second, competing source for the same check: two places to populate, a precedence rule to define, and a silent divergence whenever a caller fills one and not the other. One carrier means one answer. - Required rather than optional. A conditional write whose precondition is missing is refused, not quietly downgraded to last-write-wins. A token the caller may omit makes the guarantee a property of the caller rather than of the endpoint: the type says the field is there, and whether the row is actually protected depends on whether someone remembered to fill it.
- 412 rather than 409. RFC 9110 reserves 412 for a precondition the client stated in a
conditional request header, which is exactly what
If-Matchis. 409 stays the answer for a conflict the client did not condition on, whichDbUpdateExceptionHandlerstill produces for any otherDbUpdateException(MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DbUpdateExceptionHandler.cs:33, with a generic detail message so the schema is not leaked and the full exception logged rather than returned). Answering 409 for a failed precondition would makeIf-Matcha decorative alias for an ordinary state conflict. - Invariant over discipline (ADR-015). The
*UpdateRequestnaming convention is checked mechanically in all three consumers (ADC, Store, Helpdesk) over their module Application assemblies, so a newly added mutable request cannot reintroduce a body token by an author simply reaching for the interface. This is the same posture the framework prefers elsewhere.
Trade-offs
- The rewrite keys on the conflict outcome, not on its cause. The filter turns any 409 result
the action returns under an
If-Matchrequest into a 412 (SupportsIfMatchAttribute.cs:141-158), including a non-concurrency conflict a handler reports through theResultchannel. A thrownDbUpdateExceptionother thanDbUpdateConcurrencyException(:132) is not touched: it leaves the filter unhandled and the globalDbUpdateExceptionHandler(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:156) still answers 409 (DbUpdateExceptionHandler.cs:33). The response keeps its original problem details and error codes, so the cause is still readable in the body, but one class of conflict wears a status code naming a precondition the client did not actually violate. The alternative, a dedicated concurrency error code threaded through theResultchannel, was not worth a parallel failure path. - The attribute cannot be configured. Being its own filter with no DI means no host can adjust
its behavior, and a future need for a service (a policy, a logger, a metric) forces either a
rewrite to
ServiceFilterAttributeor a static dependency. - An absent
ETagcarries no signal. A DTO without aRowVersionproperty produces no header at all, so a client cannot distinguish "this resource has no concurrency control" from "this deployment does not emit one". The no-op is the correct behavior and it says nothing. - Nothing here decides conditional GET. The
ETagexists to be echoed back on the next write: there is noIf-None-Matchhandling and no 304 path, and the read-side caching answer remains ADR-040's output cache. AnETagon a response does invite a client to try, and the attempt is simply ignored. - Opt-in per action.
[SupportsIfMatch]has to be applied, and no fitness rule requires it anywhere, so a conditional write exists exactly where someone annotated one. The inverse rule covers what an update request must not carry, not which actions are guarded. - Enforcement is bound to a naming convention. The rule keys on the
UpdateRequestsuffix. A mutable request that does not follow that suffix is outside its scope. - Cross-engine asymmetry. SQL Server gets a server-generated
rowversion; SQLite and other relational providers get an application-managedIsConcurrencyTokenover the samebyte[], and on PostgreSQL and SQLite (the engines declaringRowVersionStrategy.ClientStamped)AuditSaveChangesInterceptorwrites a fresh random value (Guid.NewGuid().ToByteArray()) on every insert and every update (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:67for the capability gate,:88on insert,:97on update,StampRowVersionat:113-117); the update re-stamp is what lets the next stale writer'sWHEREclause miss. Cosmos gets no framework concurrency token at all:CosmosDbContext.OnModelCreating(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:119-146) calls neither the base norConfigureConcurrencyTokens, and no ETag concurrency is configured anywhere in the framework, so[SupportsIfMatch]over a Cosmos-mapped entity stamps an original value on a property EF does not compare. - Adoption is a schema step per database. Every auditable table needs the
RowVersioncolumn for the token to exist there, so a new database, or a table introduced outside the migrations that carry it, has no version to condition on.
Revision (2026-09-07)
Two additions from the 2026-09-07 security review.
If-Matchis enforced even when the root row is otherwise unchanged (SEC-Common-77).IWriteRepository.TouchConcurrencyToken(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:440) is a default no-op on the interface, implemented byEFRepository(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:97), and called byMutateEntityHandlerBaseunder a conditional write (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:314). It marks an otherwise-Unchangedaggregate root as modified, so the save always emits a root UPDATE carrying the caller's token. Without it, a mutation that touched only child rows produced no root UPDATE, EF Core had nothing to compare theRowVersionagainst, and a stale precondition was silently accepted: the caller got 200 while overwriting a concurrent edit it had never seen. With it, the same request answers 412.- A conflict detector, so the Application layer does not catch provider exceptions.
IConcurrencyConflictDetector(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IConcurrencyConflictDetector.cs:23) andEfCoreConcurrencyConflictDetector(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/EfCoreConcurrencyConflictDetector.cs:18) sit besideIUniqueConstraintViolationDetectorand are registered byAddInfrastructurewithTryAdd. A handler that claims work by writing a row can now recognise losing that claim without referencingDbUpdateConcurrencyExceptionfrom the Application layer, which the layer rules forbid. ADC's Sessionize refresh throttle is the first consumer: it turns a lost claim race into the same throttled answer a within-cooldown request gets (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:87-92).
Revision (2026-10-01)
No decision changed; three current-state statements were corrected and stale citations refreshed.
(1) Only tables mapped from an auditable entity carry RowVersion; the framework's inbox and
outbox tables do not
(MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Catalog/Migrations/20260621192800_InitialCreate.cs:47,
:62). (2) The 409-to-412 rewrite applies to a 409 result returned by the action
(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:141-158);
a thrown unique-constraint or foreign-key DbUpdateException is not caught by the filter (:132
catches only DbUpdateConcurrencyException) and stays a 409 from DbUpdateExceptionHandler, which
matches the Rationale. (3) PostgreSQL and SQLite tokens are re-stamped on update as well as insert
(AuditSaveChangesInterceptor.cs:59, :83), and Cosmos receives no concurrency token, ETag-based
or otherwise (CosmosDbContext.cs:124-151). Refreshed anchors: ApplicationDbContext.cs (:588,
:421, :601, :605), EntityControllerBase.cs (:352, :373, :408, :416, :425,
:430-433, :382, :410-411), IRepository.cs (:408, :419), MutateEntityHandlerBase.cs:294
and EntityServiceBase.cs:398. Anchors inside the 2026-09-07 Revision are left as recorded.
Revision (2026-10-06)
No decision changed.
- The concurrency-token mapping is selected by the engine's declared capability, not by a provider
check:
ConfigureConcurrencyTokensmapsIsRowVersionwhenEngine.Capabilities.RowVersion == RowVersionStrategy.StoreGenerated(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:591), which only the SQL Server engine declares, andAuditSaveChangesInterceptorre-stamps only when the engine declaresRowVersionStrategy.ClientStamped(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:67), which PostgreSQL and SQLite declare. Decision and Trade-offs now say so. - Facts recorded only in earlier Revisions whose anchors moved:
TouchConcurrencyTokenis now atIRepository.cs:442,EFRepository.cs:98andMutateEntityHandlerBase.cs:320(insideif (conditionalWrite)at:319); ADC's lost-claim handling inRefreshFromSessionizeHandler.csis theIsConcurrencyConflictcatch at:90returning the throttled answer at:95; the interceptor re-stamps on insert atAuditSaveChangesInterceptor.cs:88and on update at:97. - Anchors in Decision and Trade-offs (
ApplicationDbContext.cs,EntityControllerBase.cs,EFRepository.cs,MutateEntityHandlerBase.cs,EntityServiceBase.cs,AuditSaveChangesInterceptor.cs) were re-verified against current source and refreshed.
Revision (2026-10-07)
Re-verified against current source. No decision changed: the token mapping, the header-only transport, the three status codes, the original-value stamp and the Cosmos gap all hold as written. One adoption statement was incomplete and several anchors had moved.
- The convention test has three consumers, and the framework's own run is vacuous. Besides
ADC and Store, MMCA.Helpdesk subclasses
ConcurrencyConventionTestsBaseoverTicketUpdateRequest(MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:56). MMCA.Common's own architecture tests subclass it too (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/ConcurrencyConventionTests.cs:12, overCommonArchitectureMapat:14), but the rule reads only module Application assemblies (ArchitectureMapBase.cs:102) and that map declares none (CommonArchitectureMap.cs:21-27), so the run passes over an empty set. Decision and Rationale now say so. - Anchors re-verified against current source:
ApplicationDbContext.csConfigureConcurrencyTokensat:589, called fromOnModelCreatingat:421, theStoreGeneratedcheck at:592,IsRowVersionat:602,IsConcurrencyTokenat:606(the values the 2026-10-06 Revision recorded at:591and the 2026-10-01 Revision at:588/:601/:605are one line behind);SupportsIfMatchAttribute.csConcurrency.PreconditionFailedat:193(one line on);CosmosDbContext.OnModelCreatingatCosmosDbContext.cs:119-146(five lines earlier than the 2026-10-01 Revision's:124-151), still with nobase.OnModelCreatingcall (the explanatory comment is at:139) and noConfigureConcurrencyTokenscall. The 2026-10-01 Revision'sEntityControllerBase.cs:352,MutateEntityHandlerBase.cs:294andEntityServiceBase.cs:398are now:361,:300and:403, the values the Decision already cites.
Related
ADR-017 (HTTP request idempotency, which dedups retries of the same request, the mirror-image
concern to two distinct edits racing here, and whose declared-intent gate sits at this same
edge), ADR-021 (consumer-side inbox, which dedups broker redeliveries of the same event,
likewise distinct from concurrency), ADR-006 (the one-shared-context-per-engine model over which the
RowVersion token is configured uniformly), ADR-015 (the fitness-function-over-discipline
enforcement this reuses), ADR-005 (soft-delete and audit fields live on the same
AuditableBaseEntity base that carries RowVersion),
ADR-034 (the generic entity controller whose GetByIdAsync
emits the ETag, and whose fields= shaping the emitter has to see through),
ADR-099 (the generic PUT that carries
[SupportsIfMatch] and hands the decoded token to UpdateEntityCommand),
ADR-013 (the ProblemDetails edge the 428, the 400 and the 412 all speak).