Generated values
A shop registers a customer. The new customer needs an identity before the first event can be recorded
on it, and that identity has to come from somewhere. If the handler calls CustomerId.New() itself, a
spec cannot choose the value. If you add a CustomerId property to the command, every client can choose
it, and the identity stops being something only the server decides.
A [GeneratedValue] Handle parameter is the way out. Arc creates the value before it calls Handle(),
validates it, captures it for specs and keeps it out of the request. This page uses the shop from the
Generated values tutorial; the code is from the
Generated Values Shop sample.
The strategies themselves (UUID, ULID, TypeID, NanoID, bytes) are explained in
Generating values in Fundamentals; this page covers what Arc does
with them.
Declare a server-owned identifier
Section titled “Declare a server-owned identifier”The shop’s CustomerId is cus_ followed by a lowercase ULID. The concept owns the strategy by
implementing IGeneratable<TSelf> from Cratis.Fundamentals 7.23.0 or later, and its static New() method is
the strategy:
using Cratis.Chronicle.Events;using Cratis.Concepts;
namespace Shop.Common;
public record CustomerId(string Value) : EventSourceId<string>(Value), IGeneratable<CustomerId>{ public static implicit operator CustomerId(string value) => new(value);
public static CustomerId New() => new($"cus_{GenerateValue.Ulid().ToLowerInvariant()}");}The command then asks for it as a Handle parameter. The request carries only the customer’s name:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Concepts;using Shop.Common;
namespace Shop.Customers.Registration;
public record CustomerName(string Value) : ConceptAs<string>(Value){ public static implicit operator CustomerName(string value) => new(value);}
[Command]public record RegisterCustomer(CustomerName Name){ public (CustomerId, EventForEventSourceId) Handle([GeneratedValue("customerId")] CustomerId customerId) => (customerId, new EventForEventSourceId(customerId, new CustomerRegistered(Name)));}
[EventType]public record CustomerRegistered(CustomerName Name);Arc calls CustomerId.New() before invoking Handle(). The customerId parameter is not a command
property, so it is not in the request body and not in the generated TypeScript proxy: the proxy for
RegisterCustomer has a name property and nothing else. The handler returns the new identifier as the
command’s response, so the caller learns it, and appends CustomerRegistered to the same identifier.
This example uses Arc’s Chronicle integration for EventForEventSourceId. Generation does not depend on
it: a handler without Chronicle can return the generated value alone.
A plain Guid works too
Section titled “A plain Guid works too”An identifier that implements IGeneratable<TSelf> over a Guid follows the same shape. These
declarations allocate an identifier; they do not persist a cart or append an event, and the
migration guide reuses them:
using System;using Cratis.Arc.Commands.ModelBound;using Cratis.Concepts;
public record CartId(Guid Value) : ConceptAs<Guid>(Value), IGeneratable<CartId>{ public static CartId New() => new(GenerateValue.Uuid());}
[Command]public record AllocateCartId{ public CartId Handle([GeneratedValue("cart")] CartId cartId) => cartId;}Only non-nullable types that implement IGeneratable<TSelf> or IGeneratableInContext<TSelf> for
themselves are valid. A raw Guid, a nullable concept, duplicate names, or a generated parameter on
Provide() fails at handler construction.
Generate with the command’s time and tenant
Section titled “Generate with the command’s time and tenant”An order identifier should sort by when the command arrived. If OrderId.New() read the clock itself, an
order id, a log id and a receipt id created by one command would each carry a different creation time, and
a spec could not fix any of them. Implement IGeneratableInContext<TSelf> from Cratis.Fundamentals
7.24.0 or later and Arc hands the strategy the command’s own context:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Concepts;using Shop.Common;
namespace Shop.Orders.Placing;
public record OrderId(string Value) : EventSourceId<string>(Value), IGeneratableInContext<OrderId>{ public static OrderId New(GenerationContext context) => new(GenerateValue.TypeId("order", context));}
[Command]public record PlaceOrder(CustomerId CustomerId){ public (OrderId, EventForEventSourceId) Handle([GeneratedValue("orderId")] OrderId orderId) => (orderId, new EventForEventSourceId(orderId, new OrderPlaced(CustomerId)));}
[EventType]public record OrderPlaced(CustomerId CustomerId);Arc passes CommandContext.ReceivedAt as GenerationContext.Now, so every generated value uses the
command’s captured received time instead of a second clock read. It also maps
ITenantIdAccessor.Current to GenerationContext.Tenant: TenantId.NotSet and TenantId.Default become
GenerationContext.DefaultTenant (the zero-GUID string), and named tenants use their Value.
The shop’s OrderId takes its time from the command and has no tenant in the value. A TypeID has no
tenant slot, and GenerateValue.TypeId("order", context) reads only context.Now. When a value really
must carry the tenant, declare a separate concept, such as a TenantOrderId built as
$"{context.Tenant}-{GenerateValue.Ulid(context)}". Namespaces already isolate tenants in most
applications, so this is rarely needed. See
Context-dependent values for the contract.
Three rules to keep in mind:
- If a type implements both interfaces, Arc always calls the context form. Its plain
New()is never selected by Arc, and ARC0027 warns about it. - A default
ReceivedAtthrowsMissingGenerationTime, even when a custom strategy ignores time. A manually constructed command context must supply its received time; Arc never substitutesUtcNow. - Scenario pins bypass generation, but still pass through validation.
Keep a secret out of the causation chain
Section titled “Keep a secret out of the causation chain”The shop’s ApiKey is 32 random bytes. The secrets guidance in
Secrets and tokens covers why Bytes(32) is
the strategy; the Arc part is what happens to the value afterwards.
using Cratis.Arc.Chronicle.Commands;using Cratis.Concepts;
namespace Shop.Customers.ApiKeys;
[NotAudited]public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>{ public static ApiKey New() => new(GenerateValue.Bytes(32, ValueEncoding.Base64Url));}Chronicle records a command’s property values on the causation of every event it appends.
[NotAudited] on the concept leaves any command property of that type out of the causation chain, and
that is all it does. It does not protect event payloads, command responses or logs.
IssueApiKey generates a key and returns it once; the event it appends goes to the customer, not to the key:
[Command]public record IssueApiKey(CustomerId CustomerId){ public (ApiKey, EventForEventSourceId) Handle([GeneratedValue("apiKey")] ApiKey apiKey) => (apiKey, new EventForEventSourceId(CustomerId, new ApiKeyIssued()));}
[EventType]public record ApiKeyIssued;The key is the response, so the caller sees it exactly once, and ApiKeyIssued carries no key. The
ApiKey is never an event source id: the event is appended to the CustomerId. Keeping the key out of
the event payload and out of logs is your code’s job; use Chronicle compliance ([Encrypted] and
friends) for data that must be stored.
[NotAudited] earns its place on commands that receive the key as input. The shop’s RevokeApiKey
takes an ApiKey property, and a spec confirms its value does not appear on the causation of
ApiKeyRevoked. A generated Handle parameter is not a command property, so it never reaches the causation
chain in the first place.
Choose a strategy
Section titled “Choose a strategy”Match the value to its job, then read the linked page before shortening anything:
| Value | Strategy | Why |
|---|---|---|
CustomerId, CartId | Ulid() with a prefix, or Uuid() | Identifier; plenty of random bits |
OrderId | TypeId("order", context) | Names its type, sorts by the command’s time |
InviteCode | NanoId(10, "23456789ABCDEFGHJKLMNPQRSTUVWXYZ") | People read it aloud; only 50 random bits, so it is a generated property, never an identifier; back it with a uniqueness check where collisions matter |
ApiKey | Bytes(32) | Secret, 256 bits |
The decision flow, collision tables and entropy guidance are in Choosing a strategy.
Names and resolution
Section titled “Names and resolution”[GeneratedValue] uses the C# parameter name. [GeneratedValue("cart")] supplies an explicit name that
stays stable if you rename the parameter. Names are case-sensitive, non-empty and unique within the
command. Specs pin values by this name.
Arc generates values only after Provide() control results allow the command to continue. A value
returned by Provide(), or registered in dependency injection, never overrides a generated parameter.
Unmarked parameters keep their normal Provide and dependency injection behavior.
The Arc source generator registers a typed factory for each accessible generatable type. Commands compiled
without it can use a cached reflection fallback under the JIT. When dynamic code is unavailable, a missing
factory fails at handler construction with MissingGeneratedValueFactory, naming the command and
parameter. This factory path alone does not make the whole application NativeAOT-compatible.
Validation and captures
Section titled “Validation and captures”Arc resolves the concept’s discoverable validator from the command’s scoped provider and runs it
asynchronously with the command’s cancellation token. Error failures reject the value with
GeneratedValueRejected. This is a server defect, not invalid request input, so the command result contains
an exception rather than a 400 validation failure. Warnings and information allow execution; warning logs
contain names and types only.
CommandContext.GeneratedValues exposes validated captures in parameter resolution order. Each entry has a
zero-based Order, a Name, its declared Type, its Value, and the immutable Freshness policy.
Get<CustomerId>("customerId")
retrieves the latest matching capture and fails if none exists. Context copies retain the same captures.
When not to use it
Section titled “When not to use it”[GeneratedValue] is for new values that only the server should decide. It is the wrong tool when:
- The client chooses the identifier. A customer to update, an order to cancel and any reference the caller supplies belong on command properties. Marking them generated would discard what the caller sent.
- You need idempotency. Every execution generates a new value, so retrying a command creates a second customer. If a retry must be safe, accept a client-supplied idempotency key and check it.
- You need gapless or human-sequenced numbers. Random and time-ordered values are unique in practice, not consecutive. An invoice number with no gaps needs a counter, not a strategy.
- The value is an authorization decision. A generated value does not exist during authorization or
validation, so a rule cannot read it. A short code such as
InviteCodeis not proof of identity either: pair it with an expiry and a check. - You need a service or a read model. Inject those as ordinary Handle dependencies.
Generation also does not prove a value is absent from your storage. Time-ordered values, UUIDv7 included, reveal their creation time and are not monotonic within a millisecond.
Guard a generated append target
Section titled “Guard a generated append target”With Arc’s Chronicle integration, GeneratedValueFreshness.Guarded is the default. Returned events and
aggregate mutations targeting a generated identifier require the whole event source to be empty, regardless
of stream or event type. Occupancy produces ConcurrencyViolation with ReasonDetail = "GeneratedIdentifierCollision";
Arc does not rerun Handle().
[GeneratedValue(Freshness = GeneratedValueFreshness.Unguarded)] opts out while keeping generation and
validation. Values used only in a response or payload add no scope. Direct user calls to unit-of-work
append APIs inside Handle() are not automatically guarded. See
Fresh identifiers for supported paths, explicit-scope merging,
version requirements and the direct-write workaround.
Analyzers
Section titled “Analyzers”Arc checks generated declarations in the IDE and at compile time:
ARC0022 rejects invalid or nullable types,
ARC0023 rejects attributes outside command Handle parameters,
ARC0024 rejects empty or duplicate names, and
ARC0025 warns when Provide() supplies a generated type.
ARC0027 warns about dual generation interfaces, and
ARC0028 offers migration from inline generation to a parameter.
Startup validation remains in place for commands compiled without the analyzers.
Next steps
Section titled “Next steps”- Pin generated values in command specs so a spec can choose the value or assert its time part.
- Migrate generated identifiers when handlers still call
New()insideHandle(). - Generating a Screenplay to see which strategies Arc recovers into a model.
- Generated values, from Screenplay to running app for the whole path across products.
- Inspect captures through the command context.