Skip to content

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:

ConceptStrategyRandom bitsRole
CustomerIdcus_ plus a lowercase ULID80generated identifier
OrderIdTypeID order_… (a UUIDv7 inside)74generated identifier, time taken from the command
InviteCodeNanoID, 10 characters without look-alikes50generated property, too short for an identifier
ApiKey32 random bytes, URL-safe base64256generated property, kept off the causation chain

The same shop is built three times, and each build is checked:

screenplay test

cratis render

written by hand

shop.play

4 model specifications

Rendered/ · 12 specs

Shop/ and Shop.Specs/ · 32 specs

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.json asks for (10.0.100 or a later feature band).
  • Run dotnet tool restore in GeneratedValues/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 know generate.
  • 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.
Terminal window
dotnet tool restore

A Screenplay concept can say how its values are created. The model for the shop declares four concepts, each with a generate line:

shop.play
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 base64url

A 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:

shop.play
slice StateChange RegisterCustomer
command RegisterCustomer
customerId CustomerId generated identifier
name CustomerName
produces event CustomerRegistered
name CustomerName = name
returns customerId

generated identifier marks the value that selects the event stream. A bare generated marks any other server-owned property, like the invite code:

shop.play
command IssueInvite
customerId CustomerId identifier
inviteCode InviteCode generated
produces event InviteIssued
inviteCode InviteCode = inviteCode
returns inviteCode

A 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:

shop.play
specification IssuingAnInvite
when IssueInvite
customerId = "cus_01m4t7txm0hrn5rwemy2dkwtgq"
generated inviteCode = "K7MPQ2XRT9"
then InviteIssued
for "cus_01m4t7txm0hrn5rwemy2dkwtgq"
inviteCode = "K7MPQ2XRT9"
then returns "K7MPQ2XRT9"

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…ChooseWhy
an id that people read in logs or URLs, sorted by ageulid, optionally with prefix and case80 random bits, sorts by creation time
an id that names its typetypeid "order"the prefix names the type; 74 random bits
a short code people typenanoid with an alphabetreadable, but few bits, so never an identifier
a token or an API keybytes 32 base64url256 random bits

The Screenplay pages Choose a generation strategy and strategy reference list every strategy and its encoding.

Compile with warnings as errors, then run the model specifications:

Terminal window
dotnet screenplay shop.play --warnaserror
1 file(s) compiled - 0 error(s), 0 warning(s)
Terminal window
dotnet screenplay test shop.play
shop.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.IssuingAnApiKey
passed: Shop.Customers.IssueInvite.IssuingAnInvite
passed: Shop.Customers.RegisterCustomer.RegisteringACustomer
passed: Shop.Orders.PlaceOrder.PlacingAnOrder
passed: 4 discovered, 4 selected, 4 executed; 4 passed, 0 failed, 0 unsupported

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.

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. ApiKey is 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 no secret classification 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.

Stage turns the model into a working Arc backend. Run the render command from GeneratedValues/Shop, into a directory that does not exist yet:

Terminal window
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:

Terminal window
dotnet cratis render shop.play --target cratis --name Shop --project-name Shop.Rendered --destination ./Rendered --check

Each generated concept implements IGeneratable<T>, and its New() makes one call to GenerateValue:

Rendered/Common/OrderId.cs
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:

Rendered/Shop/Orders/PlaceOrder/PlaceOrder.cs
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:

Rendered/Shop/Customers/RegisterCustomer/RegisterCustomer.ts
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:

Terminal window
dotnet test Rendered/Shop.Rendered.csproj --configuration Debug
Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12

Do 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.

  • Time-ordered values use the wall clock. Stage renders IGeneratable<T>.New(), not the context form, so the time part of a rendered OrderId or CustomerId is not the command’s received time. The next step writes OrderId by hand to fix that.
  • Unique constraints on generated values are refused. Stage will not render the constraint that would answer PLAY0683, so InviteCode keeps 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.

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.

CustomerId implements IGeneratable<CustomerId>, and its strategy is the same one Stage rendered for generate ulid prefix "cus_" case lower:

Shop/Common/CustomerId.cs
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:

Shop/Customers/Registration/RegisterCustomer.cs
[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.

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:

Shop/Orders/Placing/PlaceOrder.cs
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:

Shop/Orders/Placing/PlaceOrder.cs
[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.

ApiKey is the only concept in the shop that is a secret. It is marked [NotAudited]:

Shop/Customers/ApiKeys/ApiKey.cs
[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.

Shop/Customers/ApiKeys/IssueApiKey.cs
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.

Code that already generates ids inline looks like this. The handler creates its identity itself, so a spec cannot choose it:

Shop/Carts/Starting/StartCartWithInlineGeneration.cs
[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 it

The code fix, “Move to a [GeneratedValue] parameter”, produces StartCart:

Shop/Carts/Starting/StartCart.cs
[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.

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 BeforeFirst scope 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:

Shop/Orders/Placing/PlaceOrder.cs
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.

If the id is taken, Arc does not run Handle again and appends nothing. The command result carries one validation result:

FieldValue
ReasonValidationResultReason.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.

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.

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.

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:

Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_an_occupied_order_id.cs
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.

Three levels of spec check the shop, and all three pin values by name.

LevelWhat runsWhere
Model specificationsScreenplay’s in-memory evaluator, with for "…" and generated x = "…"shop.play
Rendered specificationsArc command scenarios that Stage wrote from the modelRendered/
Hand-written specificationsArc command scenarios you writeShop.Specs/

The first two come free with the model. The rest of this step is the third level, in Shop.Specs/:

Terminal window
dotnet test Shop.Specs/Shop.Specs.csproj --configuration Debug
Passed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32

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:

Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_a_pinned_order_id.cs
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:

Rendered/Shop/Orders/PlaceOrder/when_placing_an_order.cs
_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:

Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/without_a_pin.cs
[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();

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:

Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_a_fixed_clock_and_tenant.cs
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:

Shop.Specs/Orders/Placing/for_PlaceOrder/when_placing_an_order/with_a_fixed_clock_and_tenant.cs
[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.

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.