Skip to content

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.

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:

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

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

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:

Shop/Carts/Starting/CartId.cs and StartCart.cs
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:

Shop/Orders/Placing/PlaceOrder.cs
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 ReceivedAt throws MissingGenerationTime, even when a custom strategy ignores time. A manually constructed command context must supply its received time; Arc never substitutes UtcNow.
  • Scenario pins bypass generation, but still pass through validation.

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.

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

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

Match the value to its job, then read the linked page before shortening anything:

ValueStrategyWhy
CustomerId, CartIdUlid() with a prefix, or Uuid()Identifier; plenty of random bits
OrderIdTypeId("order", context)Names its type, sorts by the command’s time
InviteCodeNanoId(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
ApiKeyBytes(32)Secret, 256 bits

The decision flow, collision tables and entropy guidance are in Choosing a strategy.

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

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.

[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 InviteCode is 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.

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.

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.