In module 4.3 you built the quote as an aggregate: a root that guards its own rules, and the rule “one aggregate per transaction.” In lesson 4.5.1 you saw that a grain handles one call after the other. The two fit together exactly. An aggregate is the boundary within which the rules must always hold; a grain is a place where two commands never run at the same time. Put each aggregate in a grain of its own, and Orleans holds the boundary for you, without a lock in the database.

In this lesson you put the quote in a QuoteGrain and the appointments in an AppointmentGrain. You prove with a test that of two simultaneous approvals only one goes through, and you get to know the two places where this guarantee is lost by accident: reentrancy and badly chosen keys.

One grain per aggregate

Each quote gets a grain of its own. The key is the aggregate’s identity, and since module 4.3 that includes the tenant:

quote/office-north/0199a1bd-2f0b-7dd3-b443-2fce002520f8
└─┬─┘ └─────┬────┘ └─────────────────┬────────────────┘
 Type   Tenant                       Quote

In front comes the grain type, behind it the key {tenant}/{quote}. Microsoft recommends printable characters for keys: letters, digits and a few characters such as -, _, @ and =. The slash as a separator works because Orleans splits type and key at the first slash; the Kladde uses it because it appears in no ID. A fixed form matters more, because you know from lesson 4.5.1: two spellings of the same quote would be two grains. That is why exactly one class builds the key, from the typed IDs of the domain model, and reads it back:

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

namespace Kladde.Quotes.Grains;

public static class QuoteKey
{
    public static string Of(TenantId tenant, QuoteId quote) => $"{tenant.Value}/{quote}";

    public static (TenantId Tenant, QuoteId Quote) Parse(string key)
    {
        var slash = key.IndexOf('/', StringComparison.Ordinal);
        if (slash <= 0 || !Guid.TryParse(key.AsSpan(slash + 1), out var quote))
        {
            throw new FormatException($"'{key}' is not a quote key (tenant/quote).");
        }

        return (new TenantId(key[..slash]), new QuoteId(quote));
    }
}

Since 4.3.1, TenantId allows no slash, so the first / is always the boundary. QuoteId writes its Guid with ToString() in format D, that is, with hyphens and in lowercase. If your types from 4.3 have other names or formats, adapt QuoteKey; what matters is only that it remains the one place.