Generated values, from Screenplay to running app
Open a handler in an average codebase and you find Guid.NewGuid() next to an invite code built from System.Random, and an order number that only that one handler knows how to make. Each call site decides how a value is created. The model says one thing, the code does another, and a test cannot choose the value the handler just invented.
Cratis moves that decision to the concept that owns the value. You declare how a value is generated once, in the model. Stage renders it into code, Arc creates it before your handler runs, and specs pin it by name. This tutorial follows one small shop through all of those steps.
The shop has four server-owned values:
| Concept | Strategy | Random bits | Role |
|---|---|---|---|
CustomerId | cus_ plus a lowercase ULID | 80 | generated identifier |
OrderId | TypeID order_… (a UUIDv7 inside) | 74 | generated identifier, time taken from the command |
InviteCode | NanoID, 10 characters without look-alikes | 50 | generated property, too short for an identifier |
ApiKey | 32 random bytes, URL-safe base64 | 256 | generated property, kept off the causation chain |
The same shop is built three times, and each build is checked:
Before you start
Section titled “Before you start”Every snippet on this page is an excerpt of a file in the GeneratedValues/Shop sample, and every printed output comes from running it. Values such as ids are sample values.
- Clone Cratis/Samples and work from
GeneratedValues/Shop. - Install the .NET SDK that the repository’s
global.jsonasks for (10.0.100 or a later feature band). - Run
dotnet tool restoreinGeneratedValues/Shop. It installs the pinned Screenplay compiler (4.151.0) and Cratis CLI (3.59.0). Use these pinned tools: an older Cratis CLI, such as one installed with Homebrew, bundles a Screenplay that does not knowgenerate. - You need no Docker, Chronicle or MongoDB. Every step below runs in memory. The sample’s package versions are pinned in the repository’s
Directory.Packages.props; you need Cratis.Fundamentals 7.24.0 or later, and Arc 22.95.0 or later for step 4.
dotnet tool restore1. Model the shop in Screenplay
Section titled “1. Model the shop in Screenplay”A Screenplay concept can say how its values are created. The model for the shop declares four concepts, each with a generate line:
concept CustomerName : String
// A prefixed, lowercase ULID: 80 random bits, time-ordered.concept CustomerId : String generate ulid prefix "cus_" case lower
// A TypeID: lowercase prefix plus a UUIDv7 suffix, 74 random bits, time-ordered.concept OrderId : String generate typeid "order"
// A short code without look-alike characters: 10 characters x 5 bits = 50 random bits.// Too few bits for an identifier, so commands generate it as a plain property.concept InviteCode : String generate nanoid 10 alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
// 32 random bytes as URL-safe base64 without padding: 256 random bits.// Screenplay cannot mark it as a secret yet; the hand-written Shop adds [NotAudited].concept ApiKey : String generate bytes 32 base64urlA command then says which of its properties the server generates. RegisterCustomer generates the id of the stream it writes to, so the caller sends only a name:
slice StateChange RegisterCustomer command RegisterCustomer customerId CustomerId generated identifier name CustomerName produces event CustomerRegistered name CustomerName = name returns customerIdgenerated identifier marks the value that selects the event stream. A bare generated marks any other server-owned property, like the invite code:
command IssueInvite customerId CustomerId identifier inviteCode InviteCode generated produces event InviteIssued inviteCode InviteCode = inviteCode returns inviteCodeA model specification pins a generated value so the result is deterministic. For an identifier, for "…" names the stream, and generated inviteCode = "…" pins any other generated property:
specification IssuingAnInvite when IssueInvite customerId = "cus_01m4t7txm0hrn5rwemy2dkwtgq" generated inviteCode = "K7MPQ2XRT9" then InviteIssued for "cus_01m4t7txm0hrn5rwemy2dkwtgq" inviteCode = "K7MPQ2XRT9" then returns "K7MPQ2XRT9"Choose a strategy
Section titled “Choose a strategy”Pick by what the value is for. The full guide, with a flowchart and a collision table, is Choosing a strategy in Fundamentals.
| The value is… | Choose | Why |
|---|---|---|
| an id that people read in logs or URLs, sorted by age | ulid, optionally with prefix and case | 80 random bits, sorts by creation time |
| an id that names its type | typeid "order" | the prefix names the type; 74 random bits |
| a short code people type | nanoid with an alphabet | readable, but few bits, so never an identifier |
| a token or an API key | bytes 32 base64url | 256 random bits |
The Screenplay pages Choose a generation strategy and strategy reference list every strategy and its encoding.
Run the model
Section titled “Run the model”Compile with warnings as errors, then run the model specifications:
dotnet screenplay shop.play --warnaserror1 file(s) compiled - 0 error(s), 0 warning(s)dotnet screenplay test shop.playshop.play(6,1): Information PLAY0270: A domain declaration is authoring metadata and is not part of ESM v1 behavior.shop.play(50,9): Warning PLAY0683: Generated property 'inviteCode' of 'IssueInvite' has 50 random bits; 1% collision probability after about 4.757E+06 values.passed: Shop.Customers.IssueApiKey.IssuingAnApiKeypassed: Shop.Customers.IssueInvite.IssuingAnInvitepassed: Shop.Customers.RegisterCustomer.RegisteringACustomerpassed: Shop.Orders.PlaceOrder.PlacingAnOrderpassed: 4 discovered, 4 selected, 4 executed; 4 passed, 0 failed, 0 unsupportedRead the warning
Section titled “Read the warning”PLAY0683 is the model telling you the truth about InviteCode. Ten characters from a 32-character alphabet carry 50 random bits, and 50 bits collide after a few million codes. Compilation alone does not report it; binding and the evaluator do.
That is why InviteCode is a generated property and not a generated identifier. An identifier needs at least 64 random bits, because a collision would write two customers’ facts to one stream. A property can tolerate a rare collision if something else catches it. Screenplay’s own fix is a unique constraint on the event property. Stage cannot render that constraint on a generated value yet (see the limits in the next step), so the sample leaves the warning in view instead of hiding it.
When not to generate
Section titled “When not to generate”Do not mark a value generated when:
- The client supplied it. A client-supplied identity is input. Keep it and enforce uniqueness with a constraint.
- The same request must give the same id. Random generation does not provide idempotency: a retry draws a different value.
- The number must be gapless. Invoice numbers need an allocator, not randomness.
- It would authorize something. A generated id identifies; it never proves who is calling.
- You expect the model to protect a secret.
ApiKeyis generated like any other value, and its 256 random bits make it a good token. What the model cannot do yet is mark it as a secret: Screenplay has nosecretclassification for generated values, so neither the model nor the rendered code keeps the key out of the causation chain. Step 3 adds that by hand with[NotAudited].
The choosing guide has the full list and the reasoning.
2. Render the application with Stage
Section titled “2. Render the application with Stage”Stage turns the model into a working Arc backend. Run the render command from GeneratedValues/Shop, into a directory that does not exist yet:
dotnet cratis render shop.play --target cratis --name Shop --project-name Shop.Rendered --destination ./Rendered╭─Rendered─────────────────────────────────────────────────────────────────────╮│ …/GeneratedValues/Shop/Rendered ││ Target: cratis ││ Documents: 1 ││ Artifacts: 57 ││ Written: 57 ││ Removed: 0 ││ Unchanged: 0 ││ Recovered: False │╰──────────────────────────────────────────────────────────────────────────────╯The sample commits the result in Rendered/. To check that it still matches the model, add --check. The command exits 0 when the output is current and 6 when the model has changes that are not rendered yet:
dotnet cratis render shop.play --target cratis --name Shop --project-name Shop.Rendered --destination ./Rendered --checkWhat Stage produced
Section titled “What Stage produced”Each generated concept implements IGeneratable<T>, and its New() makes one call to GenerateValue:
public record OrderId(string Value) : global::Cratis.Chronicle.Events.EventSourceId<string>(Value), global::Cratis.Concepts.IGeneratable<OrderId>{ public static readonly OrderId NotSet = new(string.Empty);
public static OrderId New() => new(global::Cratis.Concepts.GenerateValue.TypeId("order"));
public static implicit operator OrderId(string value) => new(value);}The command’s Handle receives the generated id as a [GeneratedValue] parameter, so the id is not part of the request:
public (global::Shop.Common.OrderId, global::Cratis.Chronicle.EventSequences.EventsWithConcurrencyScopes) Handle([global::Cratis.Arc.Commands.ModelBound.GeneratedValueAttribute("orderId")] global::Shop.Common.OrderId orderId)The generated TypeScript proxy for RegisterCustomer ends at that boundary too. It has a name property and no id:
export interface IRegisterCustomer { name?: string;}Stage also turns each model specification into an Arc command scenario that pins the generated value by name. Build and run them:
dotnet test Rendered/Shop.Rendered.csproj --configuration DebugPassed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12Do not edit files under Rendered/. Change shop.play and render again. The rendered project pins Arc 22.83.0 and Chronicle 19.54.0, the versions Stage 4.92.0 targets, and building it in Debug also regenerates the TypeScript proxies beside each command.
Limits of the rendered application
Section titled “Limits of the rendered application”- Time-ordered values use the wall clock. Stage renders
IGeneratable<T>.New(), not the context form, so the time part of a renderedOrderIdorCustomerIdis not the command’s received time. The next step writesOrderIdby hand to fix that. - Unique constraints on generated values are refused. Stage will not render the constraint that would answer
PLAY0683, soInviteCodekeeps its collision warning. - The frontend ends at the typed proxy. The proxy sends a command without an id, and the response reaches the proxy, but this sample builds no screen on it.
The Stage reference for generated values lists the full mapping, the diagnostics Stage raises, and how it renders fixtures.
3. Write it by hand in Arc
Section titled “3. Write it by hand in Arc”The Shop/ project is the same shop written with Arc, the way you would if you did not render it. Start with the simplest case.
Keep the id out of the request
Section titled “Keep the id out of the request”CustomerId implements IGeneratable<CustomerId>, and its strategy is the same one Stage rendered for generate ulid prefix "cus_" case lower:
public record CustomerId(string Value) : EventSourceId<string>(Value), IGeneratable<CustomerId>{ /// <summary> /// Converts text to a <see cref="CustomerId"/>. /// </summary> /// <param name="value">The text to convert.</param> public static implicit operator CustomerId(string value) => new(value);
/// <summary> /// Generates a new customer identity. /// </summary> /// <returns>A new <see cref="CustomerId"/>.</returns> public static CustomerId New() => new($"cus_{GenerateValue.Ulid().ToLowerInvariant()}");}RegisterCustomer receives only a name. Arc creates the CustomerId before it calls Handle, and the handler returns the id as the response and as the event source:
[Command]public record RegisterCustomer(CustomerName Name){ /// <summary> /// Handles the command. /// </summary> /// <param name="customerId">The generated identity of the new customer.</param> /// <returns>The new identity, and the event that records the registration on it.</returns> public (CustomerId, EventForEventSourceId) Handle([GeneratedValue("customerId")] CustomerId customerId) => (customerId, new EventForEventSourceId(customerId, new CustomerRegistered(Name)));}The parameter is not a command property, so it is not in the request body or in the generated client inputs. The name in [GeneratedValue("customerId")] stays stable if you rename the C# parameter, and it is the name specs pin by. See Generated values in the Arc commands documentation for naming, validation and captures.
Let time follow the command
Section titled “Let time follow the command”A rendered OrderId reads the wall clock when it is created. In hand-written code you can do better. OrderId implements IGeneratableInContext<OrderId>, so Arc passes a GenerationContext carrying the command’s received time:
public record OrderId(string Value) : EventSourceId<string>(Value), IGeneratableInContext<OrderId>{ /// <summary> /// Generates a new order identity for the given context. /// </summary> /// <param name="context">The time and tenant of the operation generating the identity.</param> /// <returns>A new <see cref="OrderId"/>.</returns> public static OrderId New(GenerationContext context) => new(GenerateValue.TypeId("order", context));}The command is declared the same way as before:
[Command]public record PlaceOrder(CustomerId CustomerId){ /// <summary> /// Handles the command. /// </summary> /// <param name="orderId">The generated identity of the new order.</param> /// <returns>The new identity, and the event that records the order on it.</returns> public (OrderId, EventForEventSourceId) Handle([GeneratedValue("orderId")] OrderId orderId) => (orderId, new EventForEventSourceId(orderId, new OrderPlaced(CustomerId)));}Arc supplies the command’s received time as GenerationContext.Now, so the time inside the TypeID is the moment the command arrived. It does not read a second clock. A TypeID has no tenant part: Arc also provides the tenant in the context, but this id does not use it. A value that must carry the tenant is a separate concept that builds its text from context.Tenant; see Context-dependent values.
Keep a secret off the causation chain
Section titled “Keep a secret off the causation chain”ApiKey is the only concept in the shop that is a secret. It is marked [NotAudited]:
[NotAudited]public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>{ /// <summary> /// Generates a new API key. /// </summary> /// <returns>A new <see cref="ApiKey"/>.</returns> public static ApiKey New() => new(GenerateValue.Bytes(32, ValueEncoding.Base64Url));}NotAuditedAttribute comes from Cratis.Arc.Chronicle.Commands. Wherever a command carries an ApiKey, Arc leaves its value out of the causation recorded with the command’s events.
That is all it does. It does not keep the key out of event payloads, command responses or logs, so you keep it out of those yourself. IssueApiKey shows both sides: the event carries no key and is appended to the existing CustomerId, not to an ApiKey event source, and the response returns the key once.
public (ApiKey, EventForEventSourceId) Handle([GeneratedValue("apiKey")] ApiKey apiKey) => (apiKey, new EventForEventSourceId(CustomerId, new ApiKeyIssued()));RevokeApiKey takes the key as request input instead. The concept’s marking keeps it off the causation of ApiKeyRevoked, and the spec when_revoking_an_api_key checks it. See Secrets and tokens for how to generate and handle secrets.
Migrate X.New() out of Handle
Section titled “Migrate X.New() out of Handle”Code that already generates ids inline looks like this. The handler creates its identity itself, so a spec cannot choose it:
[Command]public record StartCartWithInlineGeneration{ /// <summary> /// Handles the command. /// </summary> /// <returns>The identity of the new cart.</returns> public CartId Handle() => CartId.New();}Building the project reports one diagnostic, on purpose:
Shop/Carts/Starting/StartCartWithInlineGeneration.cs(22,31): warning ARC0028: Move inline generation of 'CartId' to a [GeneratedValue] Handle parameter so scenarios can pin itThe code fix, “Move to a [GeneratedValue] parameter”, produces StartCart:
[Command]public record StartCart{ /// <summary> /// Handles the command. /// </summary> /// <param name="cartId">The generated identity of the new cart.</param> /// <returns>The identity of the new cart.</returns> public CartId Handle([GeneratedValue("cart")] CartId cartId) => cartId;}The rule is documented on ARC0028. For a larger migration, including specs that used an identity-source queue, see Migrate generated identifiers.
4. Keep new ids new
Section titled “4. Keep new ids new”A generated identifier is meant to open a new event source. If it lands on one that already has events, nothing fails: the new command appends its facts to the old stream, and two orders share one history. With 74 or 80 random bits that is very unlikely. It is not impossible, and a spec that pins an id can reuse one on purpose. “Fresh” means the event source has no events yet.
Two layers guard this:
- Chronicle checks a concurrency scope that says “no event may exist for this id yet”. Stage already renders a
BeforeFirstscope for generated event sources. Chronicle checks the scopes of a batch once, against the state before the batch, and appends all of it or none; since kernel 19.40.1 it checks them again whenever it renumbers an append after a storage collision. See Concurrency. - Arc adds that scope for you in a hand-written command. You write nothing.
Step 3’s PlaceOrder already has the guard. A [GeneratedValue] parameter is guarded by default, so when Handle returns EventForEventSourceId for that id, Arc requires the whole event source to be empty before it appends:
public (OrderId, EventForEventSourceId) Handle([GeneratedValue("orderId")] OrderId orderId) => (orderId, new EventForEventSourceId(orderId, new OrderPlaced(CustomerId)));The scope is the same one Stage renders, new ConcurrencyScope(EventSequenceNumber.BeforeFirst, EventSourceId: id). It has no stream, source-type or event-type narrowing, so an event of any type in any stream occupies the id. A value used only in a payload or response adds no scope: the ApiKey that IssueApiKey returns does not guard the customer’s stream.
What the caller sees
Section titled “What the caller sees”If the id is taken, Arc does not run Handle again and appends nothing. The command result carries one validation result:
| Field | Value |
|---|---|
Reason | ValidationResultReason.ConcurrencyViolation |
ReasonDetail | "GeneratedIdentifierCollision" |
The message does not contain the id. To retry, send a new command: Arc generates a new id for it. Do not loop on the same value, and do not treat this as idempotency. If an earlier call failed after the event store accepted the write, check whether that call committed before you create a second stream.
Where the guard does not reach
Section titled “Where the guard does not reach”The guard covers events that Handle returns: a single event, a collection, EventForEventSourceId wrappers, EventsWithConcurrencyScopes, and aggregates acquired with a guarded id. It does not cover events that you add yourself with IUnitOfWork.AddEvent or eventLog.Transactional.Append inside Handle. Return the events instead, or supply a whole-source BeforeFirst scope.
A client-supplied or natural id is not a generated value. Protect its uniqueness with a Chronicle constraint.
Opt out on purpose
Section titled “Opt out on purpose”Appending to an existing stream is sometimes the point. Set the attribute’s Freshness property to GeneratedValueFreshness.Unguarded, for example [GeneratedValue("orderId", Freshness = GeneratedValueFreshness.Unguarded)], and Arc still generates, validates and captures the value, but leaves concurrency to your command. The shop does not do this; see Fresh identifiers for when it fits.
GeneratedValueAttribute is in Cratis.Arc.Commands.ModelBound and GeneratedValueFreshness is in Cratis.Arc.Commands. Unguarded is not a way to survive collisions.
The Arc pages Fresh identifiers and Concurrency cover explicit scopes, aggregates and the supported Chronicle versions.
Pin a reused id in a spec
Section titled “Pin a reused id in a spec”A pinned value is the same on every execution, so a pin is also how you reuse an id. Seed an event for it with Given.ForEventSource(...).Events(...), then run the command and expect the rejection and an unchanged log:
public class with_an_occupied_order_id : Specification{ readonly CommandScenario<PlaceOrder> _scenario = new CommandScenario<PlaceOrder>().UseDecisionReads(); readonly CustomerId _customerId = new("cus_01m4t7txm0hrn5rwemy2dkwtgq"); readonly OrderId _orderId = new("order_01m4t7txm0fgx9p7tehmp6mprg"); CommandResult _result = null!; EventSequenceNumber _tailBefore; EventSequenceNumber _tailAfter;
async Task Establish() { _scenario.PinGeneratedValue("orderId", _orderId); _scenario.Given.ForEventSource(_orderId).Events(new OrderPlaced(_customerId)); _tailBefore = await _scenario.EventLog.GetTailSequenceNumber(); }
async Task Because() { _result = await _scenario.Execute(new PlaceOrder(_customerId)); _tailAfter = await _scenario.EventLog.GetTailSequenceNumber(); }
void Destroy() => _scenario.Dispose();
[Fact] void should_reject_the_command() => _result.IsSuccess.ShouldBeFalse(); [Fact] void should_report_a_concurrency_violation() => _result.ValidationResults.Single().Reason.ShouldEqual(ValidationResultReason.ConcurrencyViolation); [Fact] void should_name_the_collision() => _result.ValidationResults.Single().ReasonDetail.ShouldEqual("GeneratedIdentifierCollision"); [Fact] void should_append_nothing() => _tailAfter.ShouldEqual(_tailBefore);}UseDecisionReads() makes Given append the seeded event to the scenario’s event log, which is what the guard checks; without it, Given only seeds read-model state, the log stays empty and the command succeeds. Given and UseDecisionReads come from Cratis.Arc.Chronicle.Testing.Commands, and ValidationResultReason from Cratis.Arc.Validation. Run the same command twice with one pin and the second run collides with the first, instead of silently continuing it.
5. Test it
Section titled “5. Test it”Three levels of spec check the shop, and all three pin values by name.
| Level | What runs | Where |
|---|---|---|
| Model specifications | Screenplay’s in-memory evaluator, with for "…" and generated x = "…" | shop.play |
| Rendered specifications | Arc command scenarios that Stage wrote from the model | Rendered/ |
| Hand-written specifications | Arc command scenarios you write | Shop.Specs/ |
The first two come free with the model. The rest of this step is the third level, in Shop.Specs/:
dotnet test Shop.Specs/Shop.Specs.csproj --configuration DebugPassed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32Pin a value by name
Section titled “Pin a value by name”PinGeneratedValue takes the name from [GeneratedValue("orderId")] and the value to use. The pinned id is the same literal the model’s PlacingAnOrder specification uses:
public class with_a_pinned_order_id : Specification{ readonly CommandScenario<PlaceOrder> _scenario = new(); readonly CustomerId _customerId = new("cus_01m4t7txm0hrn5rwemy2dkwtgq"); readonly OrderId _orderId = new("order_01m4t7txm0fgx9p7tehmp6mprg"); CommandResult _result = null!;
void Establish() => _scenario.PinGeneratedValue("orderId", _orderId); async Task Because() => _result = await _scenario.Execute(new PlaceOrder(_customerId)); void Destroy() => _scenario.Dispose();
[Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] void should_return_the_pinned_order_id() => ((CommandResult<OrderId>)_result).Response.ShouldEqual(_orderId); [Fact] Task should_place_the_order_on_the_pinned_id() => _scenario.ShouldHaveAppendedEvent<PlaceOrder, OrderPlaced>(_orderId, @event => @event.CustomerId == _customerId);}The rendered specification pins the same name the same way:
_scenario.PinGeneratedValue("orderId", new global::Shop.Common.OrderId("order_01m4t7txm0fgx9p7tehmp6mprg"));An unpinned generated value fails the command, and the failure tells you how to pin it. This is the strict default, so a spec never passes by accident on a random id:
[Fact] void should_fail_the_command() => _result.ShouldNotBeSuccessful(); [Fact] void should_report_an_exception_not_a_validation_failure() => _result.ShouldHaveExceptions(); [Fact] void should_explain_how_to_pin_it() => _result.ExceptionMessages.Any(message => message.Contains("PinGeneratedValue(\"orderId\",", StringComparison.Ordinal)).ShouldBeTrue();Fix the clock and the tenant
Section titled “Fix the clock and the tenant”To check that OrderId really follows the command’s time, let the scenario generate the value and fix its inputs. Clock sets the received time, Tenant sets the tenant, and AllowGeneration() lets the scenario call the real strategy:
void Establish() => _scenario = new CommandScenario<PlaceOrder>() .Clock(_receivedAt) .Tenant(new TenantId("north")) .AllowGeneration();The spec then reads the time back out of the TypeID and compares it with the fixed clock:
[Fact] void should_use_the_received_time_as_the_time_part() => TypeIdTime.Of(_orderId.Value).ShouldEqual(_receivedAt); [Fact] void should_capture_the_generated_order_id() => _scenario.GeneratedValues.Last<OrderId>("orderId").ShouldEqual(_orderId);TypeIdTime is a small helper in the sample that decodes the 48-bit timestamp at the start of a TypeID’s UUIDv7 suffix. A TypeID has no tenant part, so the fixed tenant only scopes the execution; the spec asserts the time, not the tenant.
Stage does not support clock fixtures for generated values yet, so time assertions belong in hand-written scenarios. See Pin generated values in command specs for the full scenario API, and Testing generated values for specs outside Arc.
6. Coming next
Section titled “6. Coming next”One chapter waits on work that has not shipped, so it is not part of this tutorial yet.
- Studio. Editing the generation strategy in Studio’s concept editor, with a preview of the generated code, waits on StudioIssues#588.
Go deeper
Section titled “Go deeper”- Fundamentals: Generating values, with a tutorial, choosing a strategy, context-dependent values, secrets and tokens, testing and a reference.
- Screenplay: Generated values, choosing a strategy and strategies.
- Stage: the generated values rendering reference.
- Arc: Generated values, pinning generated values in specs and migrating generated identifiers.
- The sample: GeneratedValues/Shop, including its README with every command and output.
- Related: Build a full-stack feature and Testing with Cratis.