Entity fields and invariant
- Owner in this walkthrough
- The Load definition in Cohesive.Entities.
Cohesive.Storage Guide
Persist one entity, read only the fields an operation needs, protect an update with optimistic concurrency, and expose the repository as a Relations source.
This guide persists a Load in the in-memory reference repository. It writes the initial state, reads the fields needed to assign a carrier, and commits a new state using the concurrency token returned by that read.
The same repository is then registered as a bounded source for Cohesive.Relations. The repository remains responsible for physical acquisition, while Relations remains responsible for filters, joins, projections, and results.
Add the current prerelease package to a .NET 10 project:
dotnet add package Cohesive.Storage --prereleaseImport the namespaces used by this walkthrough:
using Cohesive.Prelude;
using Cohesive.Storage;
using Cohesive.Transitions.Authoring;Keep Storage and the semantic packages it references on compatible prerelease versions.
Start with the same style of entity used by Cohesive.Entities:
public enum LoadStatus { Draft, Assigned }
public sealed record LoadState(
string TenantId,
LoadStatus Status,
string? CarrierId);
public sealed class Load : Entity<Load>
{
public Load()
{
TenantId = WriteOnceField<string>(nameof(TenantId));
Status = Field(nameof(Status), LoadStatus.Draft);
CarrierId = Field<string?>(
nameof(CarrierId),
initialValue: null,
configure: field => field.Optional());
Invariant(
name: "AssignedLoadsHaveACarrier",
predicate: load => load.Status != LoadStatus.Assigned ||
load.CarrierId != null);
}
public Field<string> TenantId { get; }
public Field<LoadStatus> Status { get; }
public Field<string?> CarrierId { get; }
}Load.Define() supplies the semantic entity definition that the repository persists. The physical provider does not infer or redefine these fields and invariants.
Create the in-memory entity and outbox repository. TenantId supplies its partition policy:
var repository = new InMemoryEntityOutboxRepository(
entityDefinition: Load.Define(),
partitionKeyFieldName: nameof(Load.TenantId));
var context = OperationContext.Create();Application hosts normally resolve repositories through RegisterEntityRepository and the dependency-injection helpers. Constructing one directly keeps the first example focused on its behavior.
Create a valid entity state and persist its underlying observation:
var initial = Load.Instance.CreateState(
entityId: "load-42",
stateObject: new LoadState(
TenantId: "tenant-7",
Status: LoadStatus.Draft,
CarrierId: null));
var created = await repository.Upsert(
context: context,
write: new EntityWriteRequest(
Entity: initial.Observation));The returned EntitySnapshot includes the committed observation, resolved partition key, and an opaque provider concurrency token.
An assignment decision needs the current status and carrier. Ask for those fields and provide the known partition:
var current = await repository.TryGet(
context: context,
id: "load-42",
options: EntityReadOptions
.ForFields(nameof(Load.Status), nameof(Load.CarrierId))
.WithPartitionKey("tenant-7"));
if (current is null)
throw new InvalidOperationException("Load not found.");The snapshot still retains entity identity, version, partition, and concurrency evidence. Its observation contains only the requested fields, and LoadedFields records the projection.
In a Transition execution environment, compilation supplies the possible field requirements and the evaluated decision records the fields its selected path actually read. An adapter can use those facts to choose a selective read safely.
For a compact storage-only example, construct the accepted candidate state directly. A normal application obtains it by applying an accepted Transition decision:
var assigned = Load.Instance.CreateState(
entityId: "load-42",
stateObject: new LoadState(
TenantId: "tenant-7",
Status: LoadStatus.Assigned,
CarrierId: "carrier-9"),
version: current.Entity.Version + 1);
var committed = await repository.Upsert(
context: context,
write: new EntityWriteRequest(
Entity: assigned.Observation,
ExpectedConcurrencyToken: current.ConcurrencyToken));If another writer changed the entity after the read, the repository throws ObservationConcurrencyConflictException. Storage does not silently overwrite state from a stale decision.
Production Transition integrations can also validate actual semantic reads and request stronger atomic boundaries. IEntityOutboxRepository commits state with outgoing envelopes, while IEntityTransitionOperationRepository commits state with a replayable Process operation receipt.
The repository can provide physical facts to a canonical Relation or Query. Register its graph-qualified shape, reader, and hard limits:
var source = EntityRelationQuerySourceRegistration.InMemory(
shape: loadShape,
repository: repository,
limits: new(
maximumBatchSize: 100,
maximumBufferedRows: 10_000,
maximumFanOut: 100,
maximumConcurrency: 4));
var catalog = new EntityRelationQuerySourceCatalog([source]);
var evaluator = catalog.CreateEvaluator(physicalPlanningPolicy);loadShape is the graph-qualified shape used by the Relation definition. The source reader supplies bounded enumeration, identity batches, relationship-reference batches, selected fields, and completeness evidence. The evaluator continues to use the canonical Relations model for query meaning.