ETags and Optimistic Concurrency
OhData supports HTTP ETags for optimistic concurrency control. Opt in per entity set by calling UseETag inside the profile constructor.
Setup
public class ProductProfile : EntitySetProfile<int, Product>
{
public ProductProfile() : base(x => x.Id)
{
// Hash one or more properties into the ETag
UseETag(x => x.RowVersion); // byte[] row-version column
GetById = (id, ct) => ...;
Put = (id, product, ct) => ...; // If-Match checked before proceeding
Patch = (id, product, ct) => ...; // same
Delete = (id, ct) => ...; // same
}
}
UseETag accepts one or more property selectors. The framework SHA-256 hashes their values and Base64-encodes the result. Binary buffers are hashed directly (ideal for SQL row-version columns) - byte[], ImmutableArray<byte>, ReadOnlyMemory<byte>, Memory<byte> and ArraySegment<byte> are all treated identically. Every other value is hashed as its UTF-8 string representation, formatted as described below.
Hash multiple fields together - the ETag changes if any of them changes:
UseETag(x => x.Name, x => x.Price, x => x.UpdatedAt);
Which selector types are allowed
MapOhData() throws InvalidOperationException if a UseETag selector returns a type the hash
cannot faithfully represent. Supported types are:
- a binary buffer -
byte[],ImmutableArray<byte>,ReadOnlyMemory<byte>,Memory<byte>,ArraySegment<byte> string,bool, an enum, or any type implementingIFormattable(which covers every numeric type,DateTime,DateTimeOffset,DateOnly,TimeOnly,TimeSpan,Guidandchar)- a
Nullable<T>of any of the above
Anything else - a navigation property, an entity reference, a List<T>, a POCO, object - is
rejected. The reason is that such a type usually has no ToString() override, so it would format
to its own type name: the same string for every row, giving every entity in the set one shared
ETag and turning If-Match into a check that always passes. Nothing in any response reveals that;
the only symptom is a lost update, so it fails at startup instead. The fix is always to select a
scalar projection - x => x.Related.Id, x => x.Related.RowVersion.
The check sees only the declared type. A selector declared as IFormattable (or any base type)
is accepted, and it is then the type's responsibility to render culture-independently.
How values are formatted
Non-binary values are formatted round-trippably and under InvariantCulture before hashing,
so the ETag is a faithful function of the entity state and nothing else:
| Value type | Formatting | Why |
|---|---|---|
DateTime (Utc, Unspecified) |
"O" (ISO-8601 round-trip) |
Keeps all seven fractional-second digits, plus the Z/no-suffix that discriminates the two Kinds. |
DateTime (Local) |
"O" with the offset suppressed, plus a Kind marker |
See the note below - "O" would append the server's UTC offset. |
DateTimeOffset |
"O" |
Full sub-second precision plus the value's own offset. A DateTimeOffset.UtcNow timestamp changes the ETag even when two writes land in the same second. |
DateOnly, TimeOnly |
"O" |
TimeOnly's general format drops seconds as well as the fraction. |
TimeSpan |
"c" |
Full tick precision. "O" is not a valid TimeSpan specifier - it throws. |
Guid |
"D" |
Canonical hyphenated form. "O" is not a valid Guid specifier - it throws. |
float, double |
invariant, default | The shortest round-trippable form - two values that differ by one bit hash differently. |
decimal |
invariant, default | Exact, and preserves scale (1.50m differs from 1.5m). |
integers, char, enums |
invariant, default | Exact by construction; invariant culture pins the sign character, which differs in some locales. |
string |
as-is | |
bool |
ToString() |
bool does not implement IFormattable; its ToString() ignores any format provider and always yields True/False. |
| anything else | IFormattable under invariant culture, else ToString() |
Reachable only for a selector declared as a base type or interface - see the allowlist above. |
Invariance is what lets a de-DE and an en-US server behind the same load balancer agree on the
ETag for identical entity state. Values are additionally length-prefixed and tagged with their CLR
type before hashing, so adjacent properties cannot be reinterpreted across the boundary
(("ab","c") vs ("a","bc")), null never hashes the same as "", and the string "1" never
collides with the integer 1.
Two consequences worth knowing about:
DateTimewithKind == Localis hashed by its wall-clock reading, not its instant. The round-trip"O"format appendsTimeZoneInfo.Local's offset for aLocalvalue, which would make the ETag a function of the server's timezone configuration: a client reading from aTZ=UTCnode and writing to aTZ=America/Chicagonode would get412forever, and a tzdata update that changes a future DST rule would rotate every outstanding ETag. So the offset is suppressed and theDateTimeKindis recorded instead - the value stays lossless and machine-independent. (Storing UTC, asDateTimeOffset.UtcNoworDateTime.UtcNowdoes, sidesteps the question entirely and remains the recommendation.)- Two
DateTimeOffsetvalues that are==can have different ETags.DateTimeOffset.Equalscompares instants, so10:00Zequals12:00+02:00- but they are different representations and serialize differently, so they hash differently. Normalize (.ToUniversalTime()) in your model if you need offset-insensitive comparison.
The type discriminator is derived from type names only, never assembly identity, so the net8.0
and net10.0 builds of the package produce the same ETag for the same data and an application
version bump does not rotate anything. Renaming or moving a type that appears in an ETag selector
does change that entity set's ETags - treat it like any other representation change.
Upgrading: these rules changed in the release noted in the CHANGELOG, and every previously-issued ETag value changes with them. Clients holding an older ETag get a
412on a conditional write (or a full200instead of304on a conditional read) and re-fetch - the safe direction. No configuration is involved and no ETag is comparable across the upgrade.
Response headers
When UseETag is configured, the ETag response header is added to:
| Operation | Status | Header |
|---|---|---|
GET /{EntitySet}({key}) |
200 | ETag: "dGVzdA==" |
POST /{EntitySet} |
201 | ETag: "..." |
PUT /{EntitySet}({key}) |
200 | ETag: "..." |
PATCH /{EntitySet}({key}) |
200 | ETag: "..." |
The ETag value is double-quoted per the HTTP spec: "<base64-sha256>".
The @odata.etag annotation is also included in the response body for each entity.
Conditional write operations
On PUT, PATCH, and DELETE, if the request includes an If-Match header:
- The framework fetches the current entity via
GetById - If no entity exists at that key, returns
412 Precondition Failedimmediately (RFC 7232 §3.1 / Protocol §11.4.1.1 - a missing resource never satisfiesIf-Match, not even*) - it does not fall through to whatever404the operation would otherwise produce for a missing key - Computes the current ETag
- Checks whether it appears in the
If-Matchlist (comma-separated ETags per RFC 7232) - Returns
412 Precondition Failedif no match; proceeds if matched
If-Match: * matches any existing representation - it still fails with 412 (not 404) when
the resource does not exist.
If-None-Match: * as a create-guard on PUT
When AllowUpsert is enabled, PUT also honors If-None-Match: * as a create-guard (§11.4.4):
if the entity already exists at the target key, the request fails with 412 Precondition Failed
instead of overwriting it; otherwise the PUT proceeds as an insert. This is a no-op when the
header is absent, and is independent of the If-Match handling above.
PUT /odata/Products(1)
If-Match: "dGVzdA=="
Content-Type: application/json
{ "id": 1, "name": "Updated Widget", "price": 12.99 }
GetById must be configured for If-Match checking to work on write operations.
Conditional reads
On GET /{EntitySet}({key}), if the request includes an If-None-Match header:
- If the current ETag matches any value in
If-None-Match, returns304 Not Modified(no body) - Otherwise proceeds normally and returns the full entity
This lets clients avoid re-downloading unchanged data.
If-None-Match is also honored on individual property reads
(GET /{EntitySet}({key})/{Property}) when UseETag is configured - a match returns
304 Not Modified with the same ETag header the entity-level GET would produce.
GET .../{Property}/$value does not set or check an ETag.
Client-side ETag support
OhData.Client exposes ETag-aware methods on KeyedEntitySetClient<T>.
Fetch entity with ETag
var (product, etag) = await client.For<Product>().Key(42).GetWithETagAsync();
Returns a (T? Entity, string? ETag) tuple. ETag is the raw header value (double-quoted, e.g. "dGVzdA=="), or null if the server did not send an ETag header.
Conditional GET with If-None-Match
GetIfChangedAsync sends a previously-observed ETag as If-None-Match and tells you whether the
server confirmed 304 Not Modified or returned a fresh representation - useful for cache
invalidation without re-fetching and re-deserializing data you already have:
var (product, etag, _) = await client.For<Product>().Key(42).GetIfChangedAsync();
// ... later, using the cached etag ...
var (fresh, currentEtag, notModified) = await client.For<Product>().Key(42).GetIfChangedAsync(etag);
if (!notModified)
{
product = fresh; // server sent a new representation; currentEtag is its ETag
}
See the client guide for the full return-tuple semantics.
Conditional write operations
Pass the ETag as ifMatch to PutAsync, PatchAsync, or DeleteAsync. The server returns 412 Precondition Failed if the entity has been modified since the ETag was fetched:
// Fetch with ETag
var (product, etag) = await client.For<Product>().Key(42).GetWithETagAsync();
// Replace — fails with 412 if another client modified the entity
Product? updated = await client.For<Product>().Key(42)
.PutAsync(new Product { Id = product!.Id, Name = product.Name, Price = 9.99m }, ifMatch: etag);
// Partial update
Product? patched = await client.For<Product>().Key(42)
.PatchAsync(new { Price = 9.99m }, ifMatch: etag);
// Delete
await client.For<Product>().Key(42).DeleteAsync(ifMatch: etag);
Pass "*" as ifMatch to skip the ETag check (match any current entity):
await client.For<Product>().Key(42).DeleteAsync(ifMatch: "\"*\"");
Concurrency note
The ETag check is a best-effort conflict signal, not an atomic operation. The framework fetches the entity in one database call, then the caller performs the write in a separate operation - another request may modify the entity between those two steps. For true atomic optimistic concurrency, use a database-level mechanism (e.g. SQL WHERE RowVersion = @expected) inside the handler itself and return null / throw on conflict.
Example: SQL row-version column
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
[Timestamp]
public byte[] RowVersion { get; set; } = [];
}
// Profile:
UseETag(x => x.RowVersion);
EF Core updates RowVersion automatically on every SaveChanges. The ETag changes on every write.