The QuoteGrain from lesson 4.5.2 loads the quote from the database for every command and writes it back. That is safe, but it wastes what a grain can do: it is the only object that changes this quote anyway. It could load the state once, keep it in memory and write only when something changes.

That is exactly what Orleans’ grain storage does. In this lesson you put the quote’s state into the grain, store it through the ADO.NET storage in SQL Server, and learn the two questions every grain with state has to answer: when do I write? And what do I do when writing fails?

State with IPersistentState

A grain gets its stored state through the constructor, as IPersistentState<T>. An attribute says what the state is called and which storage it lives in:

C#
public sealed class QuoteGrain(
    [PersistentState("quote", "quotes")] IPersistentState<QuoteState> state) : Grain, IQuoteGrain
  • "quote" is the name of the state. A grain can have several states, each with a name of its own.
  • "quotes" is the name of the storage, under which a storage provider is registered in the host (more on that in a moment).

IPersistentState<T> has few members:

Member Meaning
State the state, an object of type T
RecordExists true if there is already an entry in storage
Etag the version of the entry that was read, for checking conflicts
ReadStateAsync() reads again; happens by itself before activation
WriteStateAsync() writes State
ClearStateAsync() deletes the entry

Orleans reads the state before OnActivateAsync runs. If there is no entry yet, State is a new object from T’s parameterless constructor, and RecordExists is false. Writing never happens by itself: only WriteStateAsync writes.

A type of its own for the stored state

The state is not the aggregate. The aggregate from 4.3 has private fields, rules in the constructor and no reason to know a storage format. The stored state is a simple type in the grain project, with public properties, and the grain translates in both directions. That keeps the domain model pure, and you decide deliberately which field is stored.

C#
// src/Kladde.Quotes.Grains/QuoteState.cs
using Kladde.Quotes.Domain;

namespace Kladde.Quotes.Grains;

[GenerateSerializer, Alias("kladde.quotes.QuoteState")]
public sealed class QuoteState
{
    [Id(0)]
    public string CreatedBy { get; set; } = "";

    [Id(1)]
    public DateOnly ValidUntil { get; set; }

    [Id(2)]
    public QuoteStatus Status { get; set; }

    [Id(3)]
    public string? ApprovedBy { get; set; }

    [Id(4)]
    public List<QuoteLineState> Lines { get; set; } = [];

    public static QuoteState From(Quote quote) => new()
    {
        CreatedBy = quote.CreatedBy,
        ValidUntil = quote.ValidUntil,
        Status = quote.Status,
        ApprovedBy = quote.ApprovedBy,
        Lines = [.. quote.Lines.Select(l => new QuoteLineState { Text = l.Text, Quantity = l.Quantity, UnitPrice = l.UnitPrice })],
    };

    public Quote ToQuote(TenantId tenant, QuoteId id) => Quote.Restore(new QuoteSnapshot(
        tenant, id, CreatedBy, ValidUntil, Status, ApprovedBy,
        [.. Lines.Select(l => new QuoteLine(l.Text, l.Quantity, l.UnitPrice))]));
}

[GenerateSerializer, Alias("kladde.quotes.QuoteLineState")]
public sealed class QuoteLineState
{
    [Id(0)]
    public string Text { get; set; } = "";

    [Id(1)]
    public int Quantity { get; set; }

    [Id(2)]
    public decimal UnitPrice { get; set; }
}

Tenant and quote ID are not in the state: they are the grain’s key and come from QuoteKey.Parse. QuoteSnapshot and Quote.Restore are the way your aggregate comes back from stored values without going through the rules of a new draft; your SQL adapter from 4.3.5 already needed one for that. The attributes [GenerateSerializer], [Id] and [Alias] are here because Orleans may also copy the state across silo boundaries. What happens when this type is stored is a separate story, and lesson 4.5.4 is about it.

The grain with state

C#
// src/Kladde.Quotes.Grains/QuoteGrain.cs
using Kladde.Quotes.Domain;

namespace Kladde.Quotes.Grains;

[GrainType("quote")]
public sealed class QuoteGrain(
    [PersistentState("quote", "quotes")] IPersistentState<QuoteState> state) : Grain, IQuoteGrain
{
    public async Task<QuoteResult> CreateAsync(string createdBy, DateOnly validUntil)
    {
        if (state.RecordExists)
        {
            return new QuoteResult("quote.exists");
        }

        var (tenant, id) = QuoteKey.Parse(this.GetPrimaryKeyString());
        await SaveAsync(Quote.Draft(tenant, id, createdBy, validUntil));
        return QuoteResult.Done;
    }

    public Task<QuoteResult> AddLineAsync(string text, int quantity, decimal unitPrice) =>
        ChangeAsync(quote => quote.AddLine(new QuoteLine(text, quantity, unitPrice)));

    public Task<QuoteResult> ApproveAsync(string approver) =>
        ChangeAsync(quote => quote.Approve(approver));

    public Task<QuoteView?> GetAsync()
    {
        if (!state.RecordExists)
        {
            return Task.FromResult<QuoteView?>(null);
        }

        var quote = Load();
        return Task.FromResult<QuoteView?>(new QuoteView(quote.Status.ToString(), quote.Total, quote.ApprovedBy, quote.Lines.Count));
    }

    private async Task<QuoteResult> ChangeAsync(Func<Quote, Result> command)
    {
        if (!state.RecordExists)
        {
            return QuoteResult.NotFound;
        }

        var quote = Load();
        var result = command(quote);
        if (result.IsSuccess)
        {
            await SaveAsync(quote);
        }

        return QuoteResult.From(result);
    }

    private Quote Load()
    {
        var (tenant, id) = QuoteKey.Parse(this.GetPrimaryKeyString());
        return state.State.ToQuote(tenant, id);
    }

    private async Task SaveAsync(Quote quote)
    {
        var before = state.State;
        state.State = QuoteState.From(quote);
        try
        {
            await state.WriteStateAsync();
        }
        catch
        {
            state.State = before;
            this.DeactivateOnIdle();
            throw;
        }
    }
}

Compared with lesson 4.5.2, only loading and storing have changed; the interface, the contracts and the division of labor with the model stay. Three places deserve a closer look:

  • RecordExists instead of “not found.” A grain always exists (lesson 4.5.1). Whether the quote exists as far as the business is concerned is told only by storage: without an entry it is not a quote, and every command except CreateAsync answers quote.not-found.
  • The aggregate is rebuilt from the state for every command. That costs a few microseconds and has an advantage: if a command fails in the model, the state stays untouched, because only a copy was changed.
  • Store first, then answer. SaveAsync returns when storage has confirmed. Only then does the grain report QuoteResult.Done. If it reported success earlier, a crash could erase an approval that the person has already seen.