DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

A Row Version Is a Cross-Layer Contract

A concurrency column can exist in the database and still be wrong.

The name may match. The migration may have run. Inserts may appear healthy. Yet optimistic concurrency can still be weaker than the application assumes because the runtime model, the physical schema, and the test environment disagree about what that column means.

The lesson is broader than one ORM feature: database-generated values are cross-layer contracts. Treating them as a property of only the entity class or only the table leaves a gap exactly where correctness matters.

What the contract actually contains

For a database-generated row version, at least five facts need to agree:

  • the physical type has true row-version semantics;
  • the value is non-nullable;
  • the database generates it on insert and update;
  • the ORM marks it as a concurrency token;
  • existing values remain intact during alignment or deployment.

A binary column of the expected length is not automatically equivalent. A nullable token changes the invariant. A model that knows about concurrency but not database generation can try to write a value the database owns. Conversely, a correctly generated database column provides little application protection if the runtime model never includes it in optimistic-concurrency checks.

The useful mental model is not “there is a column.” It is “every layer agrees who owns this value and how a stale write is detected.”

Prefer verification over destructive repair

Suppose the shipped database already has the correct non-null generated token, but the current EF Core model describes it incompletely. A tempting fix is to alter or recreate the column until the migration history looks tidy.

That can be the riskiest part of the change. Replacing a generated token can touch every row, invalidate values held by active writers, or disguise an unexpected schema. The safer move is often smaller:

  1. align the runtime mapping with the physical contract;
  2. add a forward-only migration that asserts the exact expected shape;
  3. refuse to proceed when the real schema differs.

This kind of assertion migration changes no business data. It asks the database whether the table, column, type, length, nullability, and generation semantics match the known contract. Running it twice should be safe. Encountering a missing, nullable, or look-alike column should fail clearly.

Failing a rollout is inconvenient. Quietly weakening stale-write protection is worse. A fail-closed migration turns unknown drift into an explicit operational decision instead of an accidental data-integrity change.

Test at three different levels

No single test provider can prove the whole contract efficiently. A layered suite gives each test one honest job.

1. Model metadata

A cheap unit test can inspect EF Core metadata. It should verify that the property is required, marked as a concurrency token, and generated on add or update. This catches accidental mapping changes quickly, without starting a database.

Metadata tests do not prove the database behaves that way. They prove the application describes its expectation correctly.

2. Lightweight application flows

In-memory providers are useful for service and orchestration tests, but they do not automatically behave like a relational engine with generated row versions. Once a token becomes required, fixtures may fail because no value is produced.

A narrowly scoped, test-only save interceptor can generate a fresh fixed-length token for inserted or modified entities. That keeps fast tests representative of the required-value lifecycle. It should be described honestly as an emulator, not provider parity.

The emulator proves that application code can carry changing tokens through its flows. It cannot prove the SQL type, migration assertion, or actual stale-write mechanics.

3. A focused real-database proof

The decisive test uses the real provider. Create the shipped shape, insert a synthetic row, and retain its generated token. Apply the alignment migration—ideally twice—and verify that both the row and original token survive.

Then load the same row through two contexts. Save through the first and confirm its token changes. Attempt to save the stale copy through the second. The expected concurrency exception is the behavioural proof that the contract works end to end.

Negative cases matter too. A missing table, missing column, nullable token, or ordinary binary column should be refused. These cases prevent a permissive assertion from becoming ceremonial.

Put the expensive proof on the required path

A real-database test that developers can skip forever is documentation, not a gate. Register the small provider-faithful suite in the required CI job and include the relevant file scopes in change detection.

This does not mean every application test should use a full database. That would make feedback slower and fixtures harder to maintain. The trade-off is deliberate separation:

  • metadata tests are cheap and broad;
  • in-memory tests exercise application flows;
  • real-database tests prove database-owned behaviour.

The suite costs more than one happy-path unit test. In return, failures become easier to interpret because each layer states what it can and cannot guarantee.

A practical review checklist

When reviewing a database-generated concurrency token, ask:

  • Does the runtime model match the shipped type, nullability, and generation rules?
  • Does the migration preserve rows and existing token values?
  • Does unexpected schema fail closed?
  • Do fast fixtures represent the required token lifecycle without claiming provider equivalence?
  • Does a real-provider test prove token change and stale-write rejection?
  • Is that proof part of required CI?

The broader engineering habit is simple: when a value is owned by the database, verify the contract where each promise is made. Model tests describe intent. Fast tests protect application flow. Only the real provider can prove the final integrity boundary.

Top comments (1)

Collapse
 
dhruv_malaviya_cdcc71e595 profile image
Dhruv Malaviya •

The nullable-token case deserves more space than it usually gets, because it fails in a provider-dependent direction. A WHERE rowversion = @old predicate with a NULL never matches, so the update touches zero rows and you get a concurrency exception — that fails closed. But a provider that omits the predicate when the value is null fails open, and the stale write succeeds silently. Which one you get is an implementation detail you shouldn't have to know.

That's an argument for the assertion migration being stricter than the schema: assert non-nullability explicitly, not just the type.

On the three test levels I'd add a fourth. Metadata tests prove the model describes intent. Provider tests prove the mapping round-trips. But only two real transactions racing on the same row prove a stale write is actually detected , and that's the only one of the four that catches a silently weakened contract after the fact.

"Failing a rollout is inconvenient; quietly weakening stale-write protection is worse" is the line that generalises furthest past EF Core. Most schema drift gets repaired because the repair is visible and the risk isn't.