Skip to content

5.7.2. Mutations

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

Entity Mutations

A mutation changes an Entity in response to a typed event. Register mutation events deliberately: they are the Entity's domain operations and the normal way to enforce invariants around stored fields.

sequenceDiagram
    participant Caller
    participant Entity
    participant Fields as Fields and computations
    participant Views as Dependent projections
    Caller->>Entity: Send mutation event
    Entity->>Entity: Run matching mutation
    Entity->>Fields: Detect changed fields and recompute
    Fields->>Views: Refresh dependent projections
    Entity-->>Caller: Consumed / Failed
Loading

Define a mutation

In an attribute-based interface template, mark a default interface method with [Mutation]. It must accept exactly one event argument, return void, and provide a default implementation:

using System;
using Stateflows.Entities.Attributes;

public sealed record SetQuantity(int Quantity);

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

    [Mutation]
    void ChangeQuantity(SetQuantity mutation)
    {
        if (mutation.Quantity < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(mutation.Quantity));
        }

        Quantity = mutation.Quantity;
    }
}

The event type identifies the mutation handler. Register at most one mutation for a given event type in an Entity definition. A method marked [Mutation] is invoked by the Entity engine, not by calling it directly on the template proxy.

An equivalent builder-based registration is:

builder.AddMutation<SetQuantity>(context =>
{
    if (context.MutationEvent.Quantity < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(context.MutationEvent.Quantity));
    }

    context.Entity.Quantity = context.MutationEvent.Quantity;
});

Invoke a mutation

Send the event through the Entity behavior handle:

var result = await cart.SendAsync(new SetQuantity(3));
if (result.Status != EventStatus.Consumed)
{
    // Handle rejection, failure, or an undelivered mutation.
}

For a boolean result, TryMutateAsync<TEvent> returns true only when the event was consumed:

var consumed = await cart.TryMutateAsync(new SetQuantity(3));

Mutation events are exact typed event registrations. If no handler matches, it is not consumed as a mutation; use the full SendResult when the distinction between event statuses matters.

Related-entity operations

Behavior contexts provide TryMutateAsync and TrySetFieldAsync for updating related entities. Direct field updates require the field's external write permission. These operations commit independently of the calling behavior by default; a later failure of the caller does not roll back an entity update. In the Orleans runtime, pass a JoinTransaction header to opt into the sender transaction. The same opt-in applies to TryGetProjectionAsync and TryGetFieldAsync: joined reads see tentative writes from that transaction. Staged entity mutations/field writes are no longer provided; use an explicit joined operation when atomicity is required.

See staged sends for the separate post-commit self-send operation.

Reactive updates and failures

The Entity runtime snapshots field values around a mutation. If stored fields changed, it recalculates dependent computed fields and refreshes projections that depend on the changed values. See Fields and Projections.

If mutation logic or a dependent computation/projection fails, the event returns EventStatus.Failed and the Entity operation is rolled back; a partially applied field update is not committed. Register an Entity exception handler to observe failures without treating them as successful mutations. Acknowledging an exception in a handler does not suppress the failure or commit partial state.

Mutation versus direct field writes

TrySetAsync is a separate, permission-controlled direct field operation. It recalculates dependent values, but bypasses mutation handlers and their domain rules. Use mutations for business commands and direct field writes only when the exposed field contract intentionally allows an external caller to set that value. See Direct field access.

Clone this wiki locally