Skip to content

5.7.1. Fields

Mikołaj Milewski edited this page Nov 12, 2026 · 1 revision

Entity Fields

An Entity field is a registered property in its template. A stored field owns a value in the Entity's persisted context; a computed field derives its value from other fields. Both appear in the template as properties marked [Field].

Stored fields

A stored field has a getter and setter. Set initial values in a default or event-specific initializer, then change them through registered mutations. A default value can be provided through the template's field metadata or the builder.

using Stateflows.Entities.Attributes;

public interface ICart
{
    [Field]
    int Quantity { get; set; }

    [Field]
    decimal UnitPrice { get; set; }
}

Within an Entity mutation or initializer, the template proxy reads and writes these properties against the Entity's current state. The property setter does not itself register a mutation; it is the internal field operation used by the mutation/initializer implementation.

Computed fields

A computed field is a read-only [Field] property with a default interface getter. Its getter calculates a value from other fields:

using Stateflows.Entities;
using Stateflows.Entities.Attributes;

public interface ICart
{
    [Field]
    int Quantity { get; set; }

    [Field]
    decimal UnitPrice { get; set; }

    [Field]
    decimal Total => Quantity * UnitPrice;

    [Field]
    bool HasItems => Quantity > 0;
}

During initialization, Stateflows evaluates computed fields and records which fields their getters read. After an initializer, mutation, or permitted direct field write changes stored values, dependent computed fields are recalculated; cascaded dependencies are stabilized before dependent projections are refreshed. A computed field can itself depend on another computed field.

Computed fields are read-only in the template: do not give them a setter or write them from mutations. Treat their getter as a pure derivation of Entity fields, not as a place for side effects. To make a computed value available outside the Entity, enable direct Get access or expose it through a projection.

You can register a computed field explicitly with the builder instead of using a default interface getter. This is useful when the template property is abstract:

using Stateflows.Entities;
using Stateflows.Entities.Attributes;

public interface ICart
{
    int Quantity { get; set; }
    decimal UnitPrice { get; set; }
    decimal Total { get; }
}

public sealed class CartEntity : IEntity<ICart>
{
    public static void Build(IEntityBuilder<ICart> entity)
    {
        entity
            .AddField(cart => cart.Quantity)
            .AddField(cart => cart.UnitPrice)
            .AddField(cart => cart.Total, field =>
            {
                field.AddComputation(cart => cart.Quantity * cart.UnitPrice);
                field.SetAccess(FieldAccess.Get);
            });
    }
}

Use either attribute-based registration or builder-based registration for a given field, not both.

Direct field access and access flags

[Field] defaults to FieldAccess.None, so a field is not externally readable or writable. Enable only the permissions needed:

Flag Allows
FieldAccess.Get External direct reads
FieldAccess.Set External direct writes
`FieldAccess.Get FieldAccess.Set`
FieldAccess.None Neither (default)
using Stateflows.Entities.Attributes;

public interface ICart
{
    [Field(FieldAccess.Get)]
    decimal Total => Quantity * UnitPrice;

    [Field(FieldAccess.Get | FieldAccess.Set)]
    string Note { get; set; }

    [Field]
    int Quantity { get; set; }

    [Field]
    decimal UnitPrice { get; set; }
}

Use an Entity behavior handle for direct operations:

var (read, total) = await cart.TryGetAsync<decimal>(nameof(ICart.Total));
var written = await cart.TrySetAsync(nameof(ICart.Note), "Gift wrap");

These helpers send typed field-state requests/events to the Entity; they do not expose or return the Entity's in-memory template object. TryGetAsync<T> succeeds only when the field name, exact value type, and Get permission match. TrySetAsync<T> requires a matching field name/type and Set permission. A failed read returns Success == false; an unsuccessful write returns false. Direct field writes do not invoke mutation handlers or domain rules.

A successful direct write is still processed by the Entity: field validation runs, dependent computed fields are recalculated, and affected projections are refreshed and may be published. Prefer a mutation event when a write requires domain validation, invariants, or side effects.

Computed fields should remain read-only. The runtime uses FieldAccess.Set as the external write gate; do not grant it to a computed field, because a direct write could replace its derived value until one of its dependencies changes and triggers recalculation.

Direct field injection into model elements

Stateflows can also resolve a field into a model element's constructor parameter with [FromField<TTemplate>]. This performs an Entity field read during model-element activation and follows the same direct-read access requirement:

using Stateflows.Common.Attributes;

public sealed class PriceGuard(
    [FromField<ICart>(nameof(ICart.Total))] decimal total)
{
    public bool IsAffordable(decimal limit) => total <= limit;
}

The generic template form identifies the Entity; EntityScope.Self is the default. Use EntityScope.Parent or EntityScope.Owner to resolve the field from a related Entity in a behavioral group. The field name can be omitted to use the constructor parameter name. A required value that cannot be read causes model-element activation to fail; set required: false or use a nullable/optional parameter when absence is valid.

Do not confuse [FromField] with direct access through IEntityBehavior.TryGetAsync: both are reads governed by FieldAccess.Get, but one injects a snapshot into a model element's constructor while the other requests a value each time the caller invokes the API.

Clone this wiki locally