Skip to content

Tutorial: generate your first values

You are building a small shop. Customers need ids, orders need ids that carry a time, invitations need short codes you can read out loud, and integrations need API keys. Today the code is full of Guid.NewGuid(). In this tutorial you replace each one with a concept that generates its own value, and you see a result after every step.

You need the .NET 8 SDK or later. Create a folder for the solution with a console project inside it, and add the package. Every command in this tutorial runs from that shop folder:

Terminal window
mkdir shop
cd shop
dotnet new console -n Shop
dotnet add Shop package Cratis.Fundamentals

You will add the .cs files to the Shop folder and run the program with dotnet run --project Shop. The console template turns on implicit usings, so Program.cs needs no using for System, System.Linq, System.Collections.Generic or System.Threading.Tasks.

The printed values below are samples. Yours differ because the values are random, which is the point.

Step 1: a customer id that generates itself

Section titled “Step 1: a customer id that generates itself”

Add CustomerId.cs:

using Cratis.Concepts;
public record CustomerId(Guid Value) : ConceptAs<Guid>(Value), IGeneratable<CustomerId>
{
public static CustomerId New() => new(GenerateValue.Uuid());
}

Replace Shop/Program.cs with Console.WriteLine(CustomerId.New()); and run dotnet run --project Shop:

054092b2-48e9-4a54-9bc2-d2e8ccfa04e1

GenerateValue.Uuid() returns an RFC 9562 version 4 UUID from a cryptographically secure generator. IGeneratable<CustomerId> is the promise that CustomerId.New() exists, checked by the compiler. A ConceptAs<Guid> prints its inner value, which is why you see a plain UUID.

Customers will see their id in URLs and invoices, so make it self-describing and sortable. Replace CustomerId.cs:

using Cratis.Concepts;
public record CustomerId(string Value) : ConceptAs<string>(Value), IGeneratable<CustomerId>
{
public static CustomerId New() => new($"cus_{GenerateValue.Ulid().ToLowerInvariant()}");
}

Replace Program.cs to create three ids a few milliseconds apart and print them in sorted order:

var ids = new List<CustomerId>();
for (var i = 0; i < 3; i++)
{
ids.Add(CustomerId.New());
await Task.Delay(5);
}
foreach (var id in ids.OrderBy(id => id.Value, StringComparer.Ordinal))
{
Console.WriteLine(id);
}
cus_01m4tq3wcw1fn5912rd8rsmgcd
cus_01m4tq3wd2ap7q35madm22wkte
cus_01m4tq3wekqn178wa6k9xeck0x

A ULID starts with 48 bits of Unix time in milliseconds, so ids sort by creation time as plain strings. The cus_ prefix and the lower case are ordinary string operations; the result is a custom-prefixed ULID, not a TypeID. Two ids created in the same millisecond have no guaranteed order, and every id reveals when it was created.

Invitations are typed by people, so remove the characters that look alike. Add InviteCode.cs:

using Cratis.Concepts;
public record InviteCode(string Value) : ConceptAs<string>(Value), IGeneratable<InviteCode>
{
const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
public static InviteCode New() => new(GenerateValue.NanoId(10, Alphabet));
}

The alphabet has 32 characters, so each character carries 5 bits and a 10-character code carries 50. Print three codes, regenerating on a clash:

var issued = new HashSet<string>();
for (var i = 0; i < 3; i++)
{
InviteCode code;
do
{
code = InviteCode.New();
}
while (!issued.Add(code.Value));
Console.WriteLine(code);
}
SA65PT7D2G
5GPGMEGTLS
6KSESF9B9G

An API key is a secret, so use enough bytes. Add ApiKey.cs:

using Cratis.Concepts;
public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>
{
public static ApiKey New() => new(GenerateValue.Bytes(32));
}

Bytes(32) draws 256 random bits and encodes them as unpadded URL-safe base64, 43 characters:

Console.WriteLine(ApiKey.New());
gziGp2TEJPHWl6ztCXqiKMj7NVcyO3JhGD7t59Qe204

Printing a key is fine in a tutorial and a leak in production: the concept prints its value. The attribute that keeps a value out of the command causation chain comes from the Cratis.Arc.Chronicle package, not from Fundamentals, and the tutorial project does not use it. In an Arc application with Chronicle, you mark the concept like this:

using Cratis.Arc.Chronicle.Commands;
using Cratis.Concepts;
[NotAudited]
public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>
{
public static ApiKey New() => new(GenerateValue.Bytes(32));
}

[NotAudited] covers the command causation chain only. It does not protect an event that carries the key, or a log line. Secrets and tokens explains what it covers and how to handle the key.

Step 5: an order id that follows the operation

Section titled “Step 5: an order id that follows the operation”

An order id should carry the time the order was received, even if the code takes a few milliseconds to reach the point where it creates the id. Add OrderId.cs:

using Cratis.Concepts;
public record OrderId(string Value) : ConceptAs<string>(Value), IGeneratableInContext<OrderId>
{
public static OrderId New(GenerationContext context) => new(GenerateValue.TypeId("order", context));
}

This concept implements IGeneratableInContext<OrderId>, so New takes a GenerationContext holding the captured time and the tenant. Replace Shop/Program.cs to create two ids from one context and one from a context an hour later:

using Cratis.Concepts;
var received = new DateTimeOffset(2024, 6, 15, 12, 34, 56, 789, TimeSpan.Zero);
var context = new GenerationContext(received, GenerationContext.DefaultTenant);
Console.WriteLine(OrderId.New(context));
Console.WriteLine(OrderId.New(context));
Console.WriteLine(OrderId.New(context with { Now = received.AddHours(1) }));
order_01j0dy9j4nej4saxp79mmcqts7
order_01j0dy9j4ne2xvhv8rhe3n72ze
order_01j0e1qdrnfvm9xabkp66m09gw

The first ten characters after order_ encode the time. The first two ids share them because they share one Now; the third differs. The remaining characters are random, so the two ids are still different. The built-in strategies read Now only, never Tenant; a concept can use the tenant itself, as Context-dependent values shows.

Frameworks create your values without knowing their types. You can write the same shape in a few lines:

using Cratis.Concepts;
static T Create<T>()
where T : IGeneratable<T> => T.New();
static T CreateInContext<T>(GenerationContext context)
where T : IGeneratableInContext<T> => T.New(context);
var context = new GenerationContext(DateTimeOffset.UtcNow, GenerationContext.DefaultTenant);
Console.WriteLine(Create<CustomerId>());
Console.WriteLine(Create<InviteCode>());
Console.WriteLine(CreateInContext<OrderId>(context));

T.New() is a static abstract interface member, so the call is resolved at compile time with no reflection. You get back a CustomerId, an InviteCode and an OrderId, not untyped strings. Create<OrderId>() does not compile, because OrderId implements only the context interface.

Random values make poor expectations, but the time part of OrderId is yours to fix. From the shop folder, create a spec project next to Shop and reference it:

Terminal window
dotnet new xunit -n Shop.Specs
dotnet add Shop.Specs package Cratis.Specifications.XUnit
dotnet add Shop.Specs reference Shop/Shop.csproj
rm Shop.Specs/UnitTest1.cs

The rm deletes the template’s sample test, which the spec project does not need. Add Shop.Specs/when_creating_two_orders_at_a_known_time.cs:

using Cratis.Concepts;
using Cratis.Specifications;
using Xunit;
namespace Shop.Specs;
public class when_creating_two_orders_at_a_known_time : Specification
{
OrderId _first = null!;
OrderId _second = null!;
void Because()
{
var received = new DateTimeOffset(2024, 6, 15, 12, 34, 56, 789, TimeSpan.Zero);
var context = new GenerationContext(received, GenerationContext.DefaultTenant);
_first = OrderId.New(context);
_second = OrderId.New(context);
}
[Fact] void should_use_the_order_prefix() => _first.Value.StartsWith("order_").ShouldBeTrue();
[Fact] void should_encode_the_received_time() => _first.Value[6..16].ShouldEqual("01j0dy9j4n");
[Fact] void should_share_the_time_between_orders() => _second.Value[6..16].ShouldEqual(_first.Value[6..16]);
[Fact] void should_still_create_distinct_ids() => (_first == _second).ShouldBeFalse();
}

Run dotnet test Shop.Specs. All four pass on every run: the spec asserts the parts you control and does not assert the random part.

ConceptStrategyWhy
CustomerIdcus_ + lowercase Ulid()Readable, sortable public id
InviteCodeNanoId(10, alphabet)Short, typeable, needs a uniqueness check
ApiKeyBytes(32)256 random bits, a secret
OrderIdTypeId("order", context)Time follows the operation